丰富的线上&线下活动,深入探索云世界
做任务,得社区积分和周边
最真实的开发者用云体验
让每位学生受益于普惠算力
让创作激发创新
资深技术专家手把手带教
遇见技术追梦人
技术交流,直击现场
海量开发者使用工具、手册,免费下载
极速、全面、稳定、安全的开源镜像
开发手册、白皮书、案例集等实战精华
为开发者定制的Chrome浏览器插件
eBPF是革命性技术,起源于linux内核,能够在操作系统内核中执行沙盒程序。旨在不改变内核源码或加载内核模块的前提下安全便捷的扩展内核能力。
demo程序如下:
#include#defineSEC(NAME)__attribute__((section(NAME),used))SEC("xdp")intxdp_drop_the_world(structxdp_md*ctx){returnXDP_DROP;}char_license[]SEC("license")="GPL";执行步骤:
clang-O2-targetbpf-cdemo.c-odemo.osudoiplinksetdevloxdpobjdemo.osecxdpverbosesudoiplinksetdevloxdpoff效果:
终端执行ping程序,不断有输出,执行sudoiplinksetdevloxdpobjdemo.osecxdpverbose停止输出,执行sudoiplinksetdevloxdpoff后恢复输出。
举个例子,在分析网络问题时,我们会使用tcpdump获取网络报文,tcpdump支持通过-d选项可显示tcpdump的过滤规则转换后的特殊指令,如下所示:
而这些特殊的指令就是BPF(BerkeleyPacketFilter,伯克利包过滤器),这种特殊指令通过libpcap接口传递进入内核,当网卡收到了数据包后会执行注册的AF_PACK协议中的packet_rcv函数,执行用户态传入的BPF指令,如果满足过滤规则就clone到用户态。通过不断优化这些指令来提高用户过滤获取数据包的性能。大体流程如下:
BPF历史:
当前BPF的应用场景:
思考:BPF解决了什么问题?
eBPF(extenedBerkeleyPacketFilter)是一种内核技术,它允许开发人员在不修改内核代码的情况下运行特定的功能。
出于对更好的Linux跟踪工具的需求,eBPF从dtrace中汲取灵感,dtrace是一种主要用于Solaris和BSD操作系统的动态跟踪工具。与dtrace不同,Linux无法全面了解正在运行的系统,因为它仅限于系统调用、库调用和函数的特定框架。在BerkeleyPacketFilter(BPF)(一种使用内核VM编写打包过滤代码的工具)的基础上,一小群工程师开始扩展BPF后端以提供与dtrace类似的功能集,eBPF诞生了。发展历史如下:
eBPF比起传统的BPF来说,传统的BPF只能用于网络过滤,而eBPF则可以用于更多的应用场景,包括网络监控、安全过滤和性能分析等。另外,eBPF允许常规用户空间应用程序将要在Linux内核中执行的逻辑打包为字节码,当某些事件(称为挂钩)发生时,内核会调用eBPF程序。此类挂钩的示例包括系统调用、网络事件等。
后面会做详细介绍。
思考:eBPF和内核版本关系是什么?
使用高版本内核编写内核程序,如果使用到新实现的eBPF特性,那么该程序在低版本内核可能就无法运行。目前阿里云售卖的ecs是4.19版本内核。
$uname-aLinuxiZt4nehxuneswo3c2bvol2Z4.19.91-23.al7.x86_64#1SMPTueMar2318:02:34CST2021x86_64x86_64x86_64GNU/Linux思考:写eBPF程序和写内核程序区别是什么?
思考:cBPF和eBPF区别是什么?
eBPF整个技术栈如下:
当我们编写好eBPF程序后,内核通过事件驱动的方式执行程序逻辑。因此我们先介绍事件,然后介绍eBPF的编写、加载、验证等内容。
eBPF程序是事件驱动的,当事件发生后执行程序,从上图右边可以看到,事件可以是用户态,也可以是内核态。一些事件例子如下:
事件体系本身也比较复杂,需要对内核知识有一定的了解。以tracepoint事件为例,通过perflisttracepoint可以看到内核tracepoint:
直接编写eBPF程序比较困难,初学者可以利用现有的一些项目,如:
这些项目让编写eBPF程序更加简单,也有很多示例程序。但这些项目对eBPF程序进行了封装,隐藏了很多底层细节,深入学习还需要从linux源码入手。
基于内核源代码编写eBPF程序
不同版本编译内核源代码方式有一定区别,主要解决工具依赖、头文件依赖和库依赖等问题。高版本遇到的问题相对少一些。编译内核会遇到各种问题,该系列让编译简单很多,也符合阿里云上内核4.19要求。现在网上很多资料都是基于5.x内核,在生产环境使用会遇到一些问题。
内核代码samples/bpf中也有很多示例程序可以学习参考。
熟悉c/c++的人对eBPF的编译过程会比较熟悉,修改Makefile便可以修改编译过程。
编译完成后,就会生成对应的字节码。对于内核程序,通过加载字节码运行eBPF程序。字节码(bytecode)是一种包含执行程序、由一序列OP代码(操作码)/数据对组成的二进制文件。因此需要将eBPF程序编译成字节码,最常用的方法是通过类似LLVM工具链编写,目前支持python、go、c和rust等语言编写eBPF程序。
上面提到了字节码,我们介绍下字节码和机器码区别。
机器码(machinecode),学名机器语言指令,有时也被称为原生码(NativeCode),是电脑的CPU可直接解读的数据(计算机只认识0和1)。
字节码(bytecode)是一种包含执行程序、由一序列OP代码(操作码)/数据对组成的二进制文件。
字节码是一种中间码,它比机器码更抽象,需要直译器转译后才能成为机器码的中间代码。
通常情况下它是已经经过编译,但与特定机器码无关。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令等构成的序列。
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
当eBPF程序被编译成字节码以后,然后使用加载程序Loader通过bpf()系统调用将字节码加载至内核。加载到linux内核后,会对程序进行验证。验证是非常重要的一步,验证时会对程序进行很多检查。如果没有这一步,攻击者就可以任意修改内核行为,导致系统被破坏。内核使用验证器(Verfier)组件保证执行字节码的安全性,验证过程:
为了实现安全检查,对eBPF程序有如下限制:
验证完成后,进行JIT编译,将字节码转换成CPU可运行的机器码。JIT编译器可以极大加速BPF程序的执行,因为与解释器相比,它们可以降低每个指令的开销(reducetheperinstructioncost)。通常,指令可以1:1映射到底层架构的原生指令。另外,这也会减少生成的可执行镜像的大小,因此对CPU的指令缓存更友好。特别地,对于CISC指令集(例如x86),JIT做了很多特殊优化,目的是为给定的指令产生可能的最短操作码(emittingtheshortestpossibleopcodes),以降低程序翻译过程所需的空间。
整体流程如下图:
思考:如何找出所有的eBPF程序?
所有的eBPF都是通过bpfcall加载,因此在这上面挂载一个eBPF程序就可以知道所有加载的eBPF程序了。
思考:eBPF会将内核整崩溃吗,比如程序会core吗?
根据前面验证器介绍可以防止让内核崩溃。具体到eBPF程序会coredump吗?在网上没有搜索到答案,自己写个程序验证行为,构造一个访问空指针情况,验证器检查出来。
程序代码第14行将指针改为空:
SEC("xdp")intxdp_drop_the_world(structxdp_md*ctx){//dropeverything//意思是无论什么网络数据包,都drop丢弃掉void*data=(void*)(long)ctx->data;void*data_end=(void*)(long)ctx->data_end;structethhdr*eth=data;if((void*)eth+sizeof(*eth)<=data_end){structiphdr*ip=data+sizeof(*eth);if((void*)ip+sizeof(*ip)<=data_end){if(ip->protocol==IPPROTO_UDP){structudphdr*udp=(void*)ip+sizeof(*ip);if((void*)udp+sizeof(*udp)<=data_end){udp=0;if(udp->dest==__builtin_bswap16(7999)){udp->dest=__builtin_bswap16(7998);}}}}}returnXDP_PASS;}加载报错信息:
lemon@lemon-server:~$sudoiplinksetdevloxdpobjxdp-example.osecxdpverbose~libbpf:loadbpfprogramfailed:Permissiondeniedlibbpf:--BEGINDUMPLOG---libbpf:0:(61)r2=*(u32*)(r1+0)1:(61)r1=*(u32*)(r1+4)2:(bf)r3=r23:(07)r3+=144:(2d)ifr3>r1gotopc+12R1_w=pkt_end(id=0,off=0,imm=0)R2_w=pkt(id=0,off=0,r=14,imm=0)R3_w=pkt(id=0,off=14,r=14,imm=0)R10=fp05:(bf)r3=r26:(07)r3+=347:(2d)ifr3>r1gotopc+9R1_w=pkt_end(id=0,off=0,imm=0)R2_w=pkt(id=0,off=0,r=34,imm=0)R3_w=pkt(id=0,off=34,r=34,imm=0)R10=fp08:(71)r3=*(u8*)(r2+23)9:(55)ifr3!=0x11gotopc+7R1=pkt_end(id=0,off=0,imm=0)R2=pkt(id=0,off=0,r=34,imm=0)R3=inv17R10=fp010:(07)r2+=4211:(2d)ifr2>r1gotopc+5R1=pkt_end(id=0,off=0,imm=0)R2_w=pkt(id=0,off=42,r=42,imm=0)R3=inv17R10=fp012:(b7)r1=213:(69)r2=*(u16*)(r1+0)R1invalidmemaccess'inv'processed14insns(limit1000000)max_states_per_insn0total_states1peak_states1mark_read1libbpf:--ENDLOG--libbpf:failedtoloadprogram'xdp_drop_the_world'libbpf:failedtoloadobject'xdp-example.o'思考:eBPF对内核性能有影响吗?
验证器对eBPF程序做了限制,防止对内核产生很大影响。比如性能方面限制指令数量和禁止while循环。所以不可能出现一个eBPF程序运行1s影响内核。根据一些实践经验介绍,对内核性能影响较小,不用过于担心。
eBPF的存储模块由11个64位寄存器、一个程序计数器和一个512字节的栈组成。这个模块用于控制eBPF程序的执行。其中,R0寄存器用于存储函数调用和eBPF程序的返回值,这意味着函数调用最多只能有一个返回值;R1-R5寄存器用于函数调用的参数,因此函数调用的参数最多不能超过5个;而R10则是一个只读寄存器,用于从栈中读取数据。当使用较大的内存,需要用map。
Map是eBPF的核心功能。内核运行的代码与加载其的用户空间程序可以通过map机制实现双向实时通信,这个特性非常有用,让内核态程序和用户态程序相互影响,有点类似go语言中的channel。
BPFMap是驻留在内核中的以键/值方式存储的数据结构,可以被任何知道它们的eBPF程序访问。在用户空间运行的程序也可以通过使用文件描述符来访问eBPFMap。可以在eBPFMap中存储任何类型的数据,但需要指定个数和大小。在内核中,键和值都被视为二进制的方式来存储。
eBPFMap用于用户空间和内核空间之间进行双向的数据交换、信息传递。彼此共享MAP的BPF程序不需要具有相同的程序类型。
Map类型有:
eBPFMap也有自己的CRUD,主要操作如下:
eBPF程序并不能随意调用内核函数,因此,内核定义了一系列的辅助函数,用于eBPF程序与内核其他模块进行交互。限制对内核函数调用也是保证eBPF程序安全性的重要部分。
从内核5.13版本开始,部分内核函数(如tcp_slow_start()、tcp_reno_ssthresh()等)也可以被BPF程序直接调用了。不过,这些函数只能在TCP拥塞控制算法的BPF程序中调用。
不同类型的eBPF程序所支持的辅助函数是不同的。比如,对于kprobe类型的eBPF程序,可以在命令行中执行bpftoolfeatureprobe,来查询当前系统支持的辅助函数列表:
辅助函数类型包括:
关于eBPF的程序类型,我们后面做介绍。
通过Tail&FunctionCalls,我们可以使用一个eBPF程序调用另一个eBPF程序。这样组合起来就可以实现复杂的功能。如下图:
有了尾调用,就可以把复杂的逻辑拆分到多个eBPF程序中,减少代码规模,更加易于维护。
eBPF程序类型决定了一个eBPF程序可以挂载的事件类型和事件参数,这也就意味着,内核中不同事件会触发不同类型的eBPF程序。程序类型根据挂载的事件确定。
根据内核头文件bpf.h中bpf_prog_type的定义,Linux内核v5.13已经支持30种不同类型的eBPF程序(注意,BPF_PROG_TYPE_UNSPEC表示未定义):
enumbpf_prog_type{BPF_PROG_TYPE_UNSPEC,/*Reserve0asinvalidprogramtype*/BPF_PROG_TYPE_SOCKET_FILTER,BPF_PROG_TYPE_KPROBE,BPF_PROG_TYPE_SCHED_CLS,BPF_PROG_TYPE_SCHED_ACT,BPF_PROG_TYPE_TRACEPOINT,BPF_PROG_TYPE_XDP,BPF_PROG_TYPE_PERF_EVENT,BPF_PROG_TYPE_CGROUP_SKB,BPF_PROG_TYPE_CGROUP_SOCK,BPF_PROG_TYPE_LWT_IN,BPF_PROG_TYPE_LWT_OUT,BPF_PROG_TYPE_LWT_XMIT,BPF_PROG_TYPE_SOCK_OPS,BPF_PROG_TYPE_SK_SKB,BPF_PROG_TYPE_CGROUP_DEVICE,BPF_PROG_TYPE_SK_MSG,BPF_PROG_TYPE_RAW_TRACEPOINT,BPF_PROG_TYPE_CGROUP_SOCK_ADDR,BPF_PROG_TYPE_LWT_SEG6LOCAL,BPF_PROG_TYPE_LIRC_MODE2,BPF_PROG_TYPE_SK_REUSEPORT,BPF_PROG_TYPE_FLOW_DISSECTOR,BPF_PROG_TYPE_CGROUP_SYSCTL,BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE,BPF_PROG_TYPE_CGROUP_SOCKOPT,BPF_PROG_TYPE_TRACING,BPF_PROG_TYPE_STRUCT_OPS,BPF_PROG_TYPE_EXT,BPF_PROG_TYPE_LSM,BPF_PROG_TYPE_SK_LOOKUP,};对于具体的内核来说,因为不同内核的版本和编译配置选项不同,一个内核并不会支持所有的程序类型。你可以在命令行中执行下面的命令,来查询当前系统支持的程序类型:
bpftoolfeatureprobe|grepprogram_type根据具体功能和应用场景的不同,这些程序类型大致可以划分为三类
我们以一个程序为例说明程序类型转换过程。
#include#defineSEC(NAME)__attribute__((section(NAME),used))staticint(*bpf_trace_printk)(constchar*fmt,intfmt_size,...)=(void*)BPF_FUNC_trace_printk;SEC("tracepoint/syscalls/sys_enter_execve")//这个名字是程序类型吗?intbpf_prog(void*ctx){charmsg[]="Hello,BPFWorld!";bpf_trace_printk(msg,sizeof(msg));return0;}char__license[]SEC("license")="GPL";思考:上面哪段代码表示了eBPF的程序类型?
程序类型信息通过SEC("tracepoint/syscalls/sys_enter_execve")标识,SEC表示在ELF格式文件中的段名。但这行代码如何转成内核识别的类型呢?首先查看生成的二进制文件:
查看内核代码tools/lib/bpf/libbpf.c:
除了上面内核态部分程序,eBPF也要求有用户态部分程序,名字如**_kern.c和**_user.c。对应上面的**_user.c代码如下:
#include#include#include#include#include#includeintmain(intargc,char**argv){structperf_buffer_optspb_opts={};structbpf_link*link=NULL;structbpf_program*prog;structperf_buffer*pb;structbpf_object*obj;intmap_fd,ret=0;charfilename[256];FILE*f;snprintf(filename,sizeof(filename),"%s_kern.o",argv[0]);obj=bpf_object__open_file(filename,NULL);if(libbpf_get_error(obj)){fprintf(stderr,"ERROR:openingBPFobjectfilefailed\n");return0;}/*loadBPFprogram*/if(bpf_object__load(obj)){fprintf(stderr,"ERROR:loadingBPFobjectfilefailed\n");gotocleanup;}prog=bpf_object__find_program_by_name(obj,"bpf_prog");if(libbpf_get_error(prog)){fprintf(stderr,"ERROR:findingaproginobjfilefailed\n");gotocleanup;}link=bpf_program__attach(prog);if(libbpf_get_error(link)){fprintf(stderr,"ERROR:bpf_program__attachfailed\n");link=NULL;gotocleanup;}cleanup:bpf_link__destroy(link);bpf_object__close(obj);returnret;}程序中的bpf_object__open_file、bpf_object__load等函数就对应了加载等逻辑,在libbpf.c文件中都有代码实现。
eBPF程序能的使用的C语言库数量有限,并且不支持调用外部库,想要调试程序比较困难,比如printf函数无法直接使用。
为了克服这个限制,最常用的一种方法是定义和使用BPF辅助函数,即helperfunction。比如可以使用bpf_trace_printk()辅助函数,这个函数可以根据用户定义的输出,将BPF程序产生的对应日志消息保存在用来跟踪内核的文件夹(/sys/kernel/debug/tracing/),这样,我们就可以通过这些日志信息,分析和发现BPF程序执行过程中可能出现的错误。
typedefunsignedintu32;#definebpfprint(fmt,...)\({\char____fmt[]=fmt;\bpf_trace_printk(____fmt,sizeof(____fmt),\##__VA_ARGS__);\})//使用bpfprint("srcipaddr2:.%d\n",(ip_src>>24)&0xFF);2.9.2生产问题排查bpf_trace_printk程序有如下问题:
为了简化BPF程序开发,社区创建了BCC项目:其为编写、加载和运行eBPF程序提供了一个易于使用的框架,除了“限制性C”之外,还可以通过编写简单的Python或Lua脚本来实现。
BCC是个工具库,里面有很多有用的程序可以使用。
内存检测:
examples:./tcpaccept#traceallTCPaccept()s./tcpaccept-t#includetimestamps./tcpaccept-P80,81#onlytraceport80and81./tcpaccept-p181#onlytracePID181./tcpaccept--cgroupmapmappath#onlytracecgroupsinthisBPFmap./tcpaccept--mntnsmapmappath#onlytracemountnamespacesinthemap./tcpaccept-4#traceIPv4familyonly./tcpaccept-6#traceIPv6familyonly效果如下:
如果你文本处理经常使用awk工具,应该可以体会到其中的好处。
我们从RX(报文到达)和TX(报文发送)两个链路说明网络程序。主要模块是XDP和TC模块。如下图:
此处我们主要介绍XDP模块。XDP对应RX链路,在报文到达时触发。在讲XDP之前,我们说明下现在高性能网络的一些问题:
为了实现高性能网络,一种思路是绕过内核,全部在用户态处理,如Intel的DPDK项目。另一种思路就是使用XDP。XDP处理报文行为如下:
性能数据:
Cilium是一款开源软件,也是CNCF的孵化项目,目前已有公司提供商业化支持,还有基于Cilium实现的服务网格解决方案。最初它仅是作为一个Kubernetes网络组件。Cilium底层是基于Linux内核的新技术eBPF,可以在Linux系统中动态注入强大的安全性、可见性和网络控制逻辑。Cilium基于eBPF提供了多集群路由、替代kube-proxy实现负载均衡、透明加密以及网络和服务安全等诸多功能。Cilium底层是基于Linux内核的新技术eBPF,可以在Linux系统中动态注入强大的安全性、可见性和网络控制逻辑。Cilium基于eBPF提供了多集群路由、替代kube-proxy实现负载均衡、透明加密以及网络和服务安全等诸多功能。
注意:Cilium要求Linuxkernel版本在4.8.0以上,Cilium官方建议kernel版本至少在4.9.17以上。
基于eBPF,也可以加速k8s的网络,现有网络的调用路径如下,每一步都用标号标识:
通过eBPF可以大大减少调用,效果如下,黄色部分的逻辑都可以省略:
国外公司使用如下:
eBPF主要应用在观测诊断、网络和安全三个方向。我们从这三个方向介绍我们的工作。
PolarDB团队发布过TcpRT:阿里云RDS智能诊断系统(发表于SIGMOD2018),其中也涉及到内核指标采集:
这些指标也可以在对应的内核函数运行eBPF程序采集。
OB中有异常处理的代码,如right_to_die_or_duty_to_live发生异常才会调用,整个线程因此会hang住,因此可以在用户太代码挂载eBPF程序,打印对于排查问题有用的信息,如函数调用堆栈。因为对应用代码无侵入性,也不需要发布新的版本。
思考:小明说连接OBProxy,内存增加了xx字节,请问哪些逻辑出发了内存分配?
内存分配都会走到内核的函数中,在对应函数上挂载eBPF程序,打印出调用堆栈。
主要流程如下:
BMC处理也被叫做Pre-stackprocessing技术。
此处抛砖引玉一下,假设OBProxy和OBServer部署在一台机器:
其它:基于DPDK优化TCP协议栈也是一个性能优化方向。用户态代码相对eBPF技术门槛可能会低一些。
基于eBPF也可以做很多安全方面事情。
linux自带的工具iptables/netfilter都可以提供白名单功能,iptables常常和ipset结合使用,设置一些IP地址黑名单,防御DDOS(distributeddenial-of-service)网络攻击。对于DDOS这样的网络攻击,更早地丢包,就能更好地缓解CPU的损耗。但是用iptables作为防DDOS攻击的手段,效果往往很差。是因为iptables基于netfilter框架实现,即便是攻击报文在netfilter框架PREROUTING的hook点(收包路径的最早hook点)丢弃,也已经走过了很多Linux网络协议栈的处理流程。网上有比较数据,利用XDP技术的丢包速率要比iptables高4倍左右。
字节跳动基于eBPF技术实现了高性能ACL,原理如下:
这里也可以看到程序实现思路:用户态程序实现控制面逻辑,eBPF实现数据面逻辑。
基于eBPF程序,我们可以记录所有访问特定端口如2883的连接。bcc工具集合中的tcpaccpt实现了该功能。
使用eBPF程序,我们可以记录特定IP的访问流量,当流量异常时,我们可以选择丢弃报文、延迟发送等,基于tc类型程序可以很好的做好网络控制。
根据前面介绍,eBPF核心优势如下:
eBPF是一种非常强大的技术,近几年也在快速发展,并有很多的最佳实践和明星项目出现,未来会发挥更大作用。我们需要利用eBPF,在观测、网络等部分发力,享受技术红利。