上一篇我们分享了《eBPF 完美搭档:连接云原生网络的 Cilium》,介绍了Cilium作为第一个实现kube所有功能的网络插件诞生的背景-通过eBPF代理,发展演变的过程,以及具体的使用例子。本文将围绕Cilium网络的相关知识点,详细介绍Cilium如何拦截网络流路的原理和过程。网络分层的宏观视角Linux网络协议栈Linux接收网络包的过程这个问题在校招的时候经常被问到。这个过程很复杂,恐怕一天一夜都做不完。这里长话短说,简述一下大概的过程,建立一个宏观的视角。首先,让我们回顾一下网络分层模型。如下图,左图为OSI的标准七层网络模型。这个模型只是概念性的,实施起来太复杂了。右边是业界标准的TCP/IP模型,以及Linux系统中按照TCP/IP模型开发的网络协议栈。接下来回到上面的问题,输入URL到收到请求响应之间发生了什么?这里简单介绍一下流程,限于篇幅就不一一展开了。当然,如果你对其中的一些知识点感兴趣,可以自行搜索相关资料继续深入研究。客户端发起网络请求,用户态应用程序(浏览器)会生成HTTP请求报文,通过DNS协议找到对应的远程IP地址。用户态应用程序(浏览器)会委托操作系统内核协议栈的上半部分,即TCP/UDP协议来发起连接请求。TCP头(或UDP头)被封装在这里。然后由IP协议封装在协议栈的下半部分,交给下层协议。IP头被封装在这里。在MAC层处理后,找到接收者的目标MAC地址。MAC头被封装在这里。最后,数据包通过网卡转换成电信号,通过交换机和路由器发送到服务器。服务端处理后得到数据,然后依次通过各种网络协议对封装的头部进行解封装,响应数据给客户端。客户端获取渲染数据。02Linux网络协议栈以上介绍了网络分层的原理和每一层的数据包拆包过程。下面介绍Linux网络协议栈。其实Linux网络协议栈类似于TCP/IP的四层结构:图片摘自《你不好奇 Linux 网络发包过程吗?》([3])从上图可以看出:应用程序需要交换数据通过系统调用与Socket层;Socket层以下是传输层、网络层和网络接口层;最底层,则是网卡驱动和硬件网卡设备;03Linux中接收网络包的过程也是一样的,先从宏观的角度来看,再一一介绍,以免一开始就卡在细节上。图片截取自《你不好奇 Linux 网络发包过程吗?》([3])可以看到上图下面网卡相关的内容比之前介绍的网络包解包要多。是的,因为我们要介绍Cilium相关的网络基础,我们需要了解数据包如何通过网络数据路径:从硬件到内核再到用户空间。图中有Cilium标识的地方是Cilium在datapath上大量使用BPF程序的地方。下面逐层介绍。声明,下图参考:[1]UnderstandingandTroubleshootingtheeBPFDatapathinCilium-NathanSweet,DigitalOceanhttps://kccncna19.sched.com/e...2深入理解Cilium的eBPF收发包路径(datapath)(KubeCon,2019)http://arthurchiao.art/blog/u...3.1L1->L2(physicallayer->datalinklayer)\网卡收包简要流程:网卡驱动初始化。网卡获得一块物理内存,作为发送和接收数据包的缓冲区(ring-buffer)。这种方法称为DMA(直接内存访问)。驱动程序向内核NAPI(新API)注册一个轮询方法。网卡从网络上接收到一个数据包,通过DMA将数据包放入RingBuffer中,RingBuffer就是一个环形缓冲区。如果此时NAPI没有执行,网卡会触发一个硬件中断(HWIRQ),告诉处理器DMA区有数据包等待处理。处理器接收到硬中断信号后,开始执行NAPI。NAPI执行网卡注册的poll方法开始收包。关于NAPIpoll机制:Linux内核在2.6版本引入了NAPI机制。它是一种混合的“中断和轮询”方法来接收网络数据包。其核心概念不是使用中断读取数据,而是先使用Interrupt唤醒服务程序接收数据,然后使用poll方法轮询数据。驱动程序注册的轮询是主动轮询(activepoll)。poll方法由运行在一个或所有CPU上的内核线程(kernelthread)执行。一旦执行,就会继续处理,直到没有数据可以处理为止。然后进入空闲状态。例如,当一个网络数据包到达时,网卡发起一个硬件中断,然后执行网卡的硬件中断处理函数。中断处理函数处理完后,需要“暂时屏蔽中断”,然后唤醒“软中断”轮询处理数据。在DMA区接收数据包,直到没有新数据后才恢复中断,这样一个中断处理多个网络包,从而减少网卡中断带来的性能开销。之所以存在这种机制,是因为硬件中断非常昂贵,因为它们比系统上几乎所有的东西都具有更高的优先级。NAPI驱动的轮询机制从DMA区读取数据,对数据做一些准备,然后交给上层内核协议栈。3.2L2数据链路层这里就不过多展示driver层做了什么,重点介绍Cilium中涉及到的进程,即kernel及以上的进程,主要包括:allocatingsocketbuffers(skb)BPFiptablestosend数据包到网络栈(networkstack)和用户空间Step1:NAPIpoll首先,NAPIpoll机制不断调用驱动实现的poll方法,处理RX队列中的数据包,最后将数据包发送到正确的程序.第二步:XDP程序处理XDP全称为eXpressDataPath,是Linux内核网络栈的最底层。它仅存在于RX(接收数据)路径上,允许在网络设备驱动程序的内部网络堆栈中最早的数据源处进行数据包处理。在某些模式下,处理可以在操作系统分配内存(skb)之前完成。XDP的工作模式插曲:XDP有三种工作模式,默认的是native(本机)模式,在讨论XDP时通常会隐含这种模式。本机XDP:XDP程序挂钩到网络设备的驱动程序。它是XDP最原始的模式,因为它还是先于操作系统进行数据处理,其执行性能还是很高的。当然,这需要网卡驱动的支持。大多数广泛使用的10G和更高速度的网卡已经支持这种模式。OffloadedXDP:XDP程序直接挂接到可编程网卡硬件设备。与其他两种模式相比,它具有最强的处理性能;因为处于数据链路的最前端,所以过滤效率也是最高的。如果需要使用这种模式,需要在加载程序时显式声明。GenericXDP:对于尚未实现native或offloadedXDP的驱动程序,内核提供了一个genericXDP选项,这是操作系统内核提供的一种通用XDP兼容模式,可以在没有硬件或驱动程序支持的主机上执行XDP程序。在这种模式下,XDP的执行是由操作系统自己来模拟native模式执行的。优点是只要核心够高,谁都可以玩XDP;缺点是由于模拟执行,需要分配额外的socketbuffer(SKB),导致处理性能下降,比native模式低10倍左右。对于在生产环境中使用XDP,建议选择本机或卸载模式。这两种模式都需要网卡驱动的支持。对于那些不支持XDP的驱动,内核提供了GenericXDP,这是一种性能较低的软件实现的XDP。在实现方面,XDP的执行上移到核心网络堆栈。.继续回来介绍,分两种情况:native/offloaded模式,general模式。(1)native/offloaded模式:XDP在内核接收函数receive_skb()之前。(2)GenericXDP模式:XDP在内核接收函数receive_skb()之后。XDP程序向驱动程序返回一个结论,可以是PASS、TRANSMIT或DROP。TRANSMIT非常有用。使用此功能,可以使用XDP实现TCP/IP负载平衡器。XDP仅适用于对包进行较小的修改。如果是大规模修改,这样的XDP程序性能可能不会很高,因为这些操作会降低poll函数处理DMAring-buffer的能力。如果返回的是DROP,则可以直接就地丢弃数据包,不用经过后面复杂的协议栈再丢弃到某个地方,从而节省了大量的资源。业界最著名的应用场景之一就是Facebook基于XDP的高效抗DDoS攻击。其本质是在不消耗系统资源的情况下,尽早实现“丢包”,建立完整的网络堆栈链路,即“earlydrop”。如果返回为PASS,内核将继续沿默认路径处理数据包。如果是native/offloaded模式,则到达clean_rx()方法;如果是GenericXDP模式,会跳转到check_taps()下的Step6继续解释。第三步:clean_rx():创建skb如果XDP返回PASS,内核会继续沿着默认路径处理数据包,到达clean_rx()方法。此方法创建一个套接字缓冲区(skb)对象,可能会更新一些统计信息,执行skb的硬件校验和,并将其交给gro_receive()方法。第四步:gro_receive()GRO([4])(GenericReceiveOffload)是硬件特性的软件实现,可以简单理解为:在将数据包交给网络协议栈之前,将相关的小数据包合并为一个大数据包包。目标是减少传递到网络堆栈的数据包数量,这有助于降低CPU使用率并提高吞吐量。如果GRO的缓冲区对于数据包来说太小,它可能会选择什么都不做。如果当前数据包属于一个较大数据包的一个片段,则调用enqueue_backlog将这个片段放入一个CPU的数据包队列中。当包重组完成后,会交给receive_skb()方法处理。如果当前包不是分片包,则直接调用receive_skb()进行网络栈的一些底层处理。第五步:receive_skb()receive_skb()后,会再次进入XDP程序点。3.3L2->L3(数据链路层->网络层)Step6:GeneralXDPprocessing(gXDP)如前所述,如果不支持Native或Offloaded模式下的XDP,则由GeneralXDP处理,即这里(g)XDP。这里XDP的行为与步骤2相同,这里不再赘述。第七步:tap设备处理图中有一个check_taps框,但是没有这个方法:receive_skb()会轮询所有的sockettap,将数据包放到正确的tap设备的buffer中。tap设备监控第2层协议(L2协议)。如果tap设备存在,就可以操作skb。Tun/Tap设备:Tun设备是一个三层设备。它从/dev/net/tun字符设备读取IP数据包,只写IP数据包。因此无法进行二层操作,如发送ARP请求、以太网广播等。Tap设备是三层设备,处理二层MAC层数据帧。它从/dev/net/tun字符设备读取MAC层数据帧,只写入MAC层数据帧。从这一点来看,Tap虚拟设备和真实物理网卡的能力更接近。第八步:tc(流分类器)处理\\接下来就是TC(TrafficControl),即流量控制。TC更专注于packetscheduler,即所谓的网络包调度器,对延迟、丢失、传输顺序和速度控制进行调度。与XDP一样,TC的输出表示数据包如何处理的一个动作。最新的Linux内核([5])中定义了9个动作:\#defineTC_ACT_OK0#defineTC_ACT_RECLASSIFY1#defineTC_ACT_SHOT2#defineTC_ACT_PIPE3#defineTC_ACT_STOLEN4#defineTC_ACT_QUEUED5#defineTC_ACT_REPEAT6#defineTC_ACT_REDIRECT7#defineTC_ACT_TRAP8注意:Cilium控制的网络设备必须至少加载一个tceBPF程序。第九步:Netfilter处理如果tcBPF返回OK,数据包将再次进入Netfilter。Netfilter还将处理传入的数据包,包括nftables和iptables模块。def_dev_protocol框是第2层过滤器(L2网络过滤器)。由于Cilium没有使用任何L2过滤器,因此这里不再展开。Step10:L3协议层处理:ip_rcv()最后,如果数据包之前没有被丢弃,就会通过网络设备的ip_rcv()方法进入协议栈的第三层(L3)——也就是IP层——用于处理。接下来看ip_rcv(),不过这??里需要提醒的是,Linux内核除了支持IP外,还支持其他三层协议,它们的datapaths会和这个有些不同。3.3L3->L4(网络层->传输层)第十一步:NetfilterL4处理ip_rcv()首先要做的是再次进行Netfilter过滤,因为我们现在是从四层(L4)的角度来处理socketbuffer),因此,Netfilter中的任何四层规则(L4规则)都会在这里执行。Step12:ip_rcv_finish()处理完Netfilter的执行后,调用回调函数ip_rcv_finish()。ip_rcv_finish()立即调用ip_routing()判断数据包的路由。Step13:ip_routing()处理ip_routing()判断数据包的路由,比如检查是否在lookback设备上,是否可以路由出去(egress),或者是否可以路由,是否可以unmangled到其他设备,等等。在Cilium中,如果不使用隧道,则会使用这里的路由功能。与隧道模式相比,路由模式的数据路径更短,因此性能更高。第14步:目的地为本机:ip_local_deliver()处理根据路由判断的结果。如果数据包的目的地是本地机器,将调用ip_local_deliver()方法。ip_local_deliver()调用xfrm4_policy()。Step15:xfrm4_policy()处理xfrm4_policy()完成数据包的封装、解封装、加密和解密。比如IPSec就是在这里完成的。最后,ip_local_deliver()将根据四层协议将最终数据包投递到TCP或UDP协议栈。这里必须是这两种协议中的一种,否则设备会向源IP地址返回一个ICMP目标不可达报文。这里以UDP协议为例,因为TCP状态机过于复杂,这里无法用它来理解datapath和数据流。但是不代表TCP不重要,LinuxTCP状态机还是非常值得学习的。3.4L4传输层,以UDP为例步骤16:udp_rcv()处理udp_rcv()验证数据包的有效性,检查UDP校验和。然后,数据包再次发送到xfrm4_policy()进行处理。Step17:xfrm4_policy()再次处理这里再次对数据包执行变换策略,因为有些规则可以指定具体的四层协议,所以这些策略只有到达协议层后才能执行。第18步:将数据包放入socket_receive_queue这一步会找到对应端口的socket,然后将skb放入一个名为socket_receive_queue的链表中。第19步:通知socket接收数据:sk_data_ready()最后udp_rcv()调用sk_data_ready()方法来标记socket有数据要接收。本质上,socket是Linux中的一个文件描述符,这个描述符有一组相关的文件操作抽象,比如读、写等。上面的Step1~19就是Linux网络栈下半部分的全部内容。接下来介绍几个内核函数,都是和进程上下文相关的。3.5L4UserSpace下图左边是一个socket监听程序,这里省略了错误检查,epoll本质上是不需要的,因为UDP的recv方法已经在执行poll操作了。实际上,当我们调用recvmsg()方法时,内核所做的事情与上面的代码类似。对比右图:首先初始化一个epoll实例和一个UDPsocket,然后告诉epoll实例我们要监听这个socket上的receive事件,然后等待事件的到来。当socketbuffer接收到数据时,它的等待队列会被上一节中的sk_data_ready()方法设置(标记)。epoll监听等待队列,所以epoll收到事件通知后,提取事件内容返回给用户空间。用户空间程序调用recv方法,后者又调用udp_recv_msg方法,后者又调用cgroupeBPF程序——本文介绍的第三种BPF程序。Cilium使用cgroupeBPF实现socket级别的负载均衡:一般的客户端负载均衡对客户端是不透明的,即客户端应用程序必须在应用程序中内置负载均衡逻辑。使用cgroupBPF,客户端根本感知不到负载均衡的存在。本文介绍的最后一种BPF程序是sock_opsBPF,它用于套接字级别的流量整形,这对于某些功能至关重要,例如客户端级别的速率限制。最后,我们有一个用户空间缓冲区,用于存储接收到的数据。以上就是Cilium在内核中涉及网络包流转的过程。事实上,内核数据路径比这里描述的要复杂得多。上一节只是简单介绍了协议栈的各个位置(Netfilter、iptables、eBPF、XDP)可以执行的动作。这些位置提供的处理能力是不同的。例如:XDP可能是最受限制的,因为它仅设计用于快速丢弃和非本地重定向;但另一方面,它是最快的程序,因为它位于整个数据路径的最前端,所以它有能力将整个数据路径短路。tc和iptables程序可以很容易地破坏数据包,而不会显着影响原始转发过程。理解这些东西非常重要,因为这是Cilium乃至广义数据路径中非常核心的东西。如果遇到底层网络问题,或者需要做Cilium/kerneltuning,一定要了解数据包的发送和接收/转发路径,希望本文的分享能够对大家有所帮助。参考资料:[1]UnderstandingandTroubleshootingtheeBPFDatapathinCilium-NathanSweet,DigitalOcean:https://kccncna19.sched.com/e...[2]【翻译】深入理解Cilium的eBPF收发包路径(数据路径)(KubeCon,2019):http://arthurchiao.art/blog/u...[3]是不是很好奇Linux网络承包流程:https://xie.infoq.cn/article/...[4]GRO:https://zhuanlan.zhihu.com/p/...[5]Linux内核:https://elixir.bootlin.com/li...[6]25张图,10000字,拆解Linux网络包发送过程[7]图解Linux网络包接收过程
