数据包如何从物理网卡到达云主机的应用程序?

笔者工作后从事的行业是linux2.6内核态的负载均衡研发,但是当时采用的mips CPU且整个架构重构了;对于通用的服务器的网络数据包运行流程,有了解,但是缺少大规模的总结和深刻的认识

因为笔者在之前工作中解决了云主机tap口丢包问题,就借此机会详细阐述下数据包处理流程吧,一为总结,二为兴趣,笔者不太喜欢看黑盒的东西。

cc.png

如上图示,数据包从物理HOST外侧,经过物理网卡,发给ovs bridge,最后经过linux 安全组交给guest云主机1,最后交给guest1云主机应用程序;后文就从接近代码级别阐述此过程的详细步骤;笔者讲述的这个过程只能算是通用流程,可以用来参考,因为不同物理网卡会存在差异性。希望你通过这篇文章了解整个数据包转发流程,更深入了解底层网络实现。

粗略的转发逻辑

其实下图中有linux bridge设备,用来实现安全组功能,现在大部分云计算实现,已经去除linux bridge,采用ovs 流表实现。但是笔者定位tap口丢包问题时候,就是这种转发路径

所以就按照这种转发路径进行流程分析,对于想了解ovs情况读者来说,内容也只多不少吧

xx.png

如上图,一个数据包从物理网卡到云主机内部粗略转发逻辑可以概括如下四大部分,后文分别对该四大部分进行较详细的解析

  1. 数据包经过物理口到达ovs 桥(br-int)
  2. 通过虚拟机网络中的veth pair重新传送给 linux bridge
  3. linux bridge 将数据包发给tap口,送给云主机
  4. 云主机收到该数据包并最终将数据包交给云主机内部程序

数据包经过物理口到达ovs 桥(br-int)

数据包经过物理服务器物理网卡送给br-int,虽然描述起来很简单,但是其实质过程是相当复杂的;这个过程涉及到物理网卡收数据包,驱动将数据包给内核协议栈,协议栈将数据包发给ovs三大部分;

因为过程较为复杂,我们在此继续将该过程拆分成三大部分进行描述,详细部分如下:

  1. 物理网卡处理
  2. 中断下半部分软中断处理
  3. 将数据包交给内核的ovs bridge处理

物理网卡处理

cc.png

物理网卡收到数据包的处理流程如上图所示,详细步骤如下:

  1. 网卡收到数据包,先将高低电平转换到网卡fifo存储,网卡申请ring buffer的描述,根据描述找到具体的物理地址,从fifo队列物理网卡会使用DMA将数据包写到了该物理地址写到了,其实就是skb_buffer中
  2. 这个时候数据包已经被转移到skb_buffer中,因为是DMA写入,内核并没有监控数据包写入情况,这时候NIC触发一个硬中断,每一个硬件中断会对应一个中断号,且指定一个vCPU来处理,如上图vcpu2收到了该硬件中断
  3. 硬件中断的中断处理程序,调用驱动程序完成,a.启动软中断
  4. 硬中断触发的驱动程序会禁用网卡硬中断,其实这时候意思是告诉NIC,再来数据不用触发硬中断了,把数据DMA拷入系统内存即可
  5. 硬中断触发的驱动程序会启动软中断,启用软中断目的是将数据包后续处理流程交给软中断慢慢处理,这个时候退出硬件中断了,但是注意和网络有关的硬中断,要等到后续开启硬中断后,才有机会再次被触发
  6. NAPI触发软中断,触发napi系统
  7. 消耗ringbuffer指向的skb_buffer
  8. NAPI循环处理ringbuffer数据,处理完成
  9. 启动网络硬件中断,有数据来时候就可以继续触发硬件中断,继续通知CPU来消耗数据包

其实上述过程过程简单描述为:网卡收到数据包,DMA到内核内存,中断通知内核数据有了,内核按轮次处理消耗数据包,一轮处理完成后,开启硬中断。其核心就是网卡和内核其实是生产和消费模型,网卡生产,内核负责消费,生产者需要通知消费者消费;如果生产过快会产生丢包,如果消费过慢也会产生问题。也就说在高流量压力情况下,只有生产消费优化后,消费能力够快,此生产消费关系才可以正常维持,所以如果物理接口有丢包计数时候,未必是网卡存在问题,也可能是内核消费的太慢。

在介绍完整体流程后,也许明白很多,其实也迷惑很多,我的疑惑是,网卡数据如何写到内核内存?

如何将网卡收到的数据写入到内核内存?

引用:https://tech.meituan.com/Redis_High_Concurrency_Optimization.html

NIC在接收到数据包之后,首先需要将数据同步到内核中,这中间的桥梁是rx ring buffer。它是由NIC和驱动程序共享的一片区域,事实上,rx ring buffer存储的并不是实际的packet数据,而是一个描述符,这个描述符指向了它真正的存储地址,具体流程如下:

驱动在内存中分配一片缓冲区用来接收数据包,叫做sk_buffer;

将上述缓冲区的地址和大小(即接收描述符),加入到rx ring buffer。描述符中的缓冲区地址是DMA使用的物理地址;

驱动通知网卡有一个新的描述符;

网卡从rx ring buffer中取出描述符,从而获知缓冲区的地址和大小;

网卡收到新的数据包;

网卡将新数据包通过DMA直接写到sk_buffer中。

当驱动处理速度跟不上网卡收包速度时,驱动来不及分配缓冲区,NIC接收到的数据包无法及时写到sk_buffer,就会产生堆积,当NIC内部缓冲区写满后,就会丢弃部分数据,引起丢包。这部分丢包为rx_fifo_errors,在 /proc/net/dev中体现为fifo字段增长,在ifconfig中体现为overruns指标增长。

在理解完物理网卡处理流程后,后面理解中断下本部分中断处理详细过程。

中断下半部分软中断处理

见上图,在上述第7步,主要流程是一个一个的消费数据包,那么具体流程如何?

7.1 因为igb_ckean_rx_irq会循环消耗数据包,但是需要有个度,否则就会一直在消耗数据包,整个CPU一只停留在处理部分,用户态程序就没有机会真正消费数据包了,所以这循环有次数限制,内核参数是net.core.netdev_budget = 300,其含义是最大300个,无论如何

都应该退出软中断部分,空出CPU时间片,就有机会调度应用程序了。

7.2 取出skb,调用napi_gro_receive,这个函数先做一些GRO包合并动作,然后根据是否开启RPS执行如下流程

7.2.1: 开启了RPS,将会将数据包放到hash到的vCPU队列中,默认大小由net.core.netdev_max_backlog = 1000 控制,数据包挂入这个队列后,本包处理就结束了,继续处理下一个包。也就是数据包如果超过1000个,就会被丢弃,所以用户态消耗还是要跟上才行。 当然此处涉及到到数据包挂到cpu3队列后,后续执行流程是cpu3中断触发时候,会从取出数据调用__netif_receive_skb_core处理消耗该数据包

7.2.2:没有开启RPS,直接使用vcpu2调用__netif_receive_skb_core进行处理消耗该数据包

rps

由上述描述可以知道,开启rps后,接收硬件中断vCPU2 和处理下半步中断的vcpu2就有机会把大量的数据包简短处理,直接挂到其它vcpu队列上,这样就能减少该vcpu2压力,vcpu2就能处理更大流量。RPS适合单网卡队列,多vcpu的使用场景

__netif_receive_skb_core

__netif_receive_skb_core 是协议栈处理数据包的入口函数,你使用tcpdump抓包就是在此起作用,也就是说如果你tcpdump抓到数据包,代表该数据包已经到达协议栈入口了。

软中断处理结尾是调用__netif_receive_skb_core进行消耗数据包,后文的ovs处理流程的入口也在该函数中调用

将数据包交给内核的ovs bridge处理

cc.png

上图所示是数据包在ovs内转发流程,其中netdev_frame_hook是ovs入口,被__netif_receive_skb_core调用用来处理收到的数据包;数据流量进入ovs bridge

根据数据包信息查找流表,该数据包主要有如下两种处理情况

  1. 流量发给控制器
  2. 流量在内核态直接转发

流量发给控制器

内核流量通过netlink将数据包传给用户态的ovs-vswitchd进程,该进程会对流量解析,根据解析和预制处理逻辑处理(一般是下发流表)

流量在内核态直接转发

以当前分析情况为例说明,数据包转发到linux veth口的详细过程,以及最后如何发给linux bridge的?

根据流表的动作执行发送动作,最终会调用dev_hard_star_xmit,该函数会调用xmit_one , 最后调用ndo_start_xmit发送数据包(当前采用的linux 虚拟口,所以调用veth_xmit)。

其中tcpdump工作在xmit_one

其中虚拟口发包计数统计工作在veth_xmit

所以抓包是在包个数统计之前的,这一点要注意

linux虚拟机口工作原理描述

一边用于收包收到数据包后立刻将该数据包发出,所谓的发出是指: “将数据包收取到后立刻再次注入到协议栈,交给另一个处理对象”,具体处理方法如下:

  1. veth_xmit将数据包发出
  2. 调用dev_forward_sk,调用协议栈收包入口netif_rx_internal, 我们当前情况很可能netif_rx_internal将数据直接放入CPU队列中,该数据包就被注入了协议栈
  3. 另一个处理对象,会经过软中断触发时候,立刻处理该CPU队列的数据包,协议栈会再次收到数据包,相当于连接linux bridge的口收到了数据包,协议栈会将该数据包送给linux bridge

linux bridge 将数据包发给tap口

cc.png

连接该linux bridge的是linux veth pair口,由之前的描述可以知道,数据包会经过linux veth pair被注入到协议栈,协议栈会再次调用_netif_reveive_skb处理该数据包;

而因该函数是veth口收到的数据,该接口连接的是linux bridge,协议栈会调用br_handle_frame处理该数据包。linux bridge处理数据包会经过如下几个情况:

  1. 协议栈收到该数据包会先经过NF_BR_PRE_ROUTING钩子,该钩子在处理之前,你可以通过iptabls加PRE ROUTING策略,加的策略会通过该钩子函数工作
  2. 经过NF_BR_PRE_ROUTING钩子后,会判断是将数据包当成本机数据包处理,还是继续转发(后文就以转发为例进行说明)
  3. 数据包转发时候会经过NF_BR_FORWARD钩子函数,该函数是所有转发的数据包都会经过的,安全组策略就是在此钩子中work的
  4. 转发数据包处理完成后,最后将调用br_forward_finish将数据包发出去,NF_BR_POST_ROUTING钩子存在此处,数据包发出前最后经历过的处理
  5. 最后会调用dev_hard_start_xmit函数将数据包发出

经过linux bridge转发处理后,linux最后将数据包调用dev_hard_start_xmit发给tap口了,后文描述tap详细处理过程

tap口详细处理过程

按照我们本次研究的场景,linux bridge连接的是tap口,linux bridge会将数据包交给tap口;终极目标是将数据包将给用户态进程qume进程的云主机进行消耗;

所以需要将数据包从内核态转发到用户态,且触发中断告知云主机数据包来了,让云主机work起来,处理消耗数据包。很显然tap口自己是完成不了,接口紧紧能处理接收数据包

而数据内核态和用户态穿越以及中断触发,就需要通过另一个层级的内核线程驱动了,在此研究场景下使用的vhost-net技术;也就是说tap口和vhost配合完成将数据包交给云主机。

后文先说明tap口如何处理,以及tap口和vhost如何配合的

cc.png

tap口如何处理数据包

linux bridge最后会调用 dev_hard_start_xmit,因为连接的是tap口,所以会调用tap口的xmit_one将数据包送给tap口入口函数。

如上图,tcpdump抓取数据包点在xmit_one调用,经过抓包点后会最后将数据包调用tun_net_xmit发出去,tun_net_xmit这个函数很重要,承载了tap数据的逻辑,具体点介绍是:

  1. 发包统计计数在此统计、drop丢包计数在此统计
  2. tun_net_xmit会将数据包放入socket.sk->sk_receive_queue,socket队列中,且该队列有长度限制,默认长度大小为tap口的tx_queuelen:一般为1000个数据包,如果长度大于1000后数据包会被丢弃,遇到这种情况,说明云主机处理的慢了,你就想办法优化云主机处理速度吧
  3. 数据包放入socket队列中,需要唤醒vhost线程工作,具体工作见tap口和vhost线程如何配合

如下:txqueuelen:1000 为tap口socket队列长度,单位是包个数

1
2
3
4
5
6
7
8
root@compute-001:~# ifconfig tapc3072bad-18
tapc3072bad-18 Link encap:Ethernet HWaddr fe:16:3e:09:6f:46
inet6 addr: fe80::fc16:3eff:fe09:6f46/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1450 Metric:1
RX packets:3569 errors:0 dropped:0 overruns:0 frame:0
TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:294046 (294.0 KB) TX bytes:26034 (26.0 KB)

tap口和vhost线程如何配合

其实tap口和vhost配合很简单,tap口接收到数据包后放入挂到socket队列后,调用tun_net_xmit后,唤醒vhost线程让起工作即可,

这个时候vhost线程会将数据包考入云主机的ring buffer中,且触发中断告诉云主机数据包已经来了,让云主机去处理消耗数据包,详细见vhost线程工作过程

vhost线程工作过程

vhost线程,是云主机vhost-net后,建立云主机时候一并建立的线程,线程名称组成为vhost-26735,其中26735为qume进程号

说到这里获取设计常用的接口工作技术,按照器性能高低来个排序, vhost-user > vhost >virtio ,在此不多做解释了

如下图:

vhost线程是个死循环,它被唤醒后,循环做两件事情

事情1:注入中断触发gust主机活动起来

事情2:循环从tap口的socket队列中取出数据,直接将数据包考入用户态云主机的ringbuffer中,注意ringbuffer是vhost线程内核和用户态共享内存

做完这两件事情,后续的事情就交给kvm触发中断物理主机vcpu如何将cpu让给gust主机的CPU了,这些事情后文讲述

cc.png

kvm如何中断触发云主机协议栈?

如下图vhost线程会调用kvm一些接口触发中断,为了触发中断主要做了两件事情

事情1:判断物理cpu运行中,让cpu运行先退出,为了有机会注入中断

事情2:想目标cpu添加一个请求,这个请求vcpu死循环会去检查,如果发现了这个请求,就真正注入中断

也就是说,这两件事情其实只是让cpu退出,方便cpu注入,和提前写入注入中断标记,其过程还需要vcpu死循环来完成

同样如下图:vcpu死循环主要干了什么?

vcpu在运行的时候一直处于循环状态,在循环体中,会有环节检查中断注入标记,如果发现该标记就调用kvm_x86_ops->run触发中断

该触发会最终调用中断处理函数vp_interrupt -> vp_ring_interrupt-> 最终触发协议栈的运行,让协议栈调用__netif_receive_core处理数据包

cc.png

云主机收到该数据包并最终将数据包交给云主机内部程序

其处理就是正常linux协议栈收包处理是一致的,这里就不多做说明了

总结

到此叙述算是完成了,这还仅仅是针对于特定场景下收包过程,虽然描述完成了,其实我也还是有很多疑问点,因此这就当作你一个总结吧,后续根据不同的环境你可以自己跟踪下运行过程。

在跟踪此流程过程中,借用了网上一些总结、同时也借用systemtap探究了调用栈、并且配合linux kernel源代码,但是其过程介绍的还是相对分散了,我们对这个流程再来一次汇总吧,如下图

cc.png