网络和操作系统内核,对我来说是既陌生又满是吸引,希望能够拨开层层迷雾找到背后的真相。
在 上一篇文章 中我深入探讨了 Kubernetes 网络模型,这次我想更深入一点:了解数据包在 Kubernetes 中的传输,为学习 Kubernetes 的 eBPF 网络加速做准备,加深对网络和操作系统内核的理解。 文中可能有疏漏之处,还望大家赐教。
在开始之前,我可以用一句话来总结我的学习成果:数据包的流转其实就是一个网络套接字描述符(Socket File Descriptor,中文有点冗长,以下简称 socket fd)的寻址过程。 它不是简单的指 socket fd 的内存地址,还包括它的网络地址。
在 Unix 和类 Unix 系统中,一切皆文件,也可以通过文件描述符来操作 socket。
基础知识
数据包
既然要讨论数据包的流转,先看看什么是数据包。
网络数据包(network packet),也称为网络数据报(network datagram)或网络帧(Network frame),是通过计算机网络传输的数据单位。拿最常见的 TCP 数据包来看包含如下几个部分:
- Ethernet header:链路层信息,主要包括目的 MAC 地址和源 MAC 地址,以及报文的格式,这里是 IP 包。
- IP header:网络层信息,主要包括长度、源 IP 地址和目的 IP 地址以及报文的格式,当然这里必须是 TCP 包。
- TCP header:传输层信息,包括源端口和目的端口。
- 数据:一般是第 7 层的数据,比如 HTTP 等。
这里没有介绍的 checksum 和 FCS 通常是用来检查数据包在传输过程中是否被篡改或者发生了错误。
应用程序使用 socket 向网络发送数据的过程可以简单理解为使用头信息封装数据的过程:TCP 数据包、IP 数据包、Ethernet 数据包;反过来,从网络接收以太网数据包到应用程序可以处理的数据,就是解包的过程。封包和解包的过程是由内核网络协议栈来完成的。
下面分别说一下 socket 和内核网络协议栈的处理。
socket 套接字
Socket 是一种在计算机网络中使用的编程接口,位于用户空间(用户应用程序运行的空间)和内核网络协议栈(内核中对数据进行封包和解包的组件)之间。
作为编程接口,socket 提供了如下操作(只列出部分):
- socket
- connect
- bind
- listen
- accept
- 数据传输
- send
- sendto
- sendmsg
- recv
- recvfrom
- recvmsg
- getsockname
- getpeername
- getsockopt 、setsockopt 获取或设置 socket 层或协议层选项
- close
通过下面的图,可以直观感受各个操作的作用:
开始讲解内核网络协议栈之前,先说下数据包在内存中的数据结构:sk_buff[1]。
sk_buff
sk_buff 是 Linux 内核中用于管理网络数据包的数据结构,它包含了接收和发送的网络数据包的各种信息和属性,如数据包的协议、数据长度、源和目标地址等。sk_buff 是一种可以在网络层和数据链路层之间传递的数据结构,可以被用于所有类型的网络协议栈,例如 TCP/IP、UDP、ICMP 等。
sk_buff 在 Linux 内核中广泛应用于网络协议栈的各个层级,如数据链路层、网络层、传输层等。sk_buff 数据结构的字段很多,有 4 个重要的字段且都是指针类型。sk_buff 在不同层的使用,就是通过修改这些指针来完成的:加 header (封包)和移除 header(解包)。
这个过程操作做的是指针,数据是零拷贝的,可以极大地提升效率。
内核网络协议栈
封包
应用程序使用 socket 的 sendmsg 操作发送数据(这里不深入讲解 netfilter、traffic control、queue discipline):
- 先分配 sk_buff
- 接下来开始网络协议栈的处理
- 设置传输层信息(这里是 TCP 头中的源和目的端口)
- 根据目标 IP 查找路由
- 设置网络层信息(源和目的 IP 地址等)
- 调用 netfilter(LOCAL_OUT)
- 设置接口(interface)和协议(protocol)
- 调用 netfilter(POST_ROUTING)
- 如果包过长,分段传输
- L2 寻址,即查找可以拥有目标 IP 地址的设备的 MAC 地址
- 设置链路层信息,
- 至此内核网络协议栈的操作完成
- 调用 tc(traffic control)egress(可以对包进行重定向)
- 进入队列 queue discipline(qdisc)
- 写入 NIC(network interface controler)
- 发送到网络
解包
NIC 收到网络发来的数据包(这里不深入讲解 direct memory access、netfilter、traffic control):
- 将数据包写如 DMA 中(Direct Memory Access 直接内存访问,不需要依赖 CPU,由 NIC 直接写入到内存中)
- 分配 sk_buff,并填充元数据,比如 protocol 为 Ethernet 类型,接收数据包的网络接口等
- 将链路层信息保存在 sk_buff 的 mac_header 字段中,并“移除”数据包中的链路层信息(移动指针)
- 接下来开始网络协议栈的处理
- 将网络层信息保存在 network_header 字段中
- 调用 tc ingress
- “移除”网络层信息
- 将传输层信息保存在 transport_header 字段中
- 调用 netfilter(PRE_ROUTING)
- 查找路由
- 合并多个分包
- 调用 netfilter(LOCAL_IN)
- “移除”传输层信息
- 查找监听目标端口的 socket,或者发送 reset
- 将数据写入 socket 的接收队列中
- 发信号通知有数据写入队列
- 至此内核网络协议栈的操作完成
- sk_buff 从 socket 接收队列中出队
- 将数据写入应用程序的缓冲区
- 释放 sk_buff
Kubernetes 的网络模型
另一部分的基础知识就是 Kubernetes 的网络模型了,可以参考之前的那篇 深入探索 Kubernetes 网络模型和网络通信。
Kubernetes 中的数据包流转
这里继续讨论之前文章中的三种通信场景,pod 间的通信使用 pod IP 地址。如果要讨论通过 Service 来访问,则要加入 netfilter 的讨论篇幅会增加不少。
同 pod 内的容器间通信
pod 内两个容器间的方式通常使用回环地址 127.0.0.1,在封包的 #4 路由过程中确定了使用回环网卡 lo进行传输。
同节点上的 pod 间通信
curl 发出的请求在封包 #4 过程中确定使用 eth0 接口。然后通过与 eth0 相连的隧道 veth1 到达节点的根网络空间。
veth1 通过网桥 cni0 与其他 pod 相连虚拟以太接口 vethX 相连。在封包 #10 L2 寻址中,ARP 请求通过网桥发送给所有相连的接口是否拥有原始请求中的目的 IP 地址(这里是 10.42.1.9)
拿到了 veth0 的 MAC 地址后,在封包 #11 中设置数据包的链路层信息。数据包发出后,经过 veth0 隧道进入 pod httpbin 的 eth0 接口中,然后开始解包的过程。
解包的过程没啥特别,确定了 httpbin 使用的 socket。
不同节点的 pod 间通信
这里稍微不同,就是在通过 cni0 发送 ARP 请求没有收到应答,使用根命名空间也就是主机的路由表,确定了目标主机 IP 地址后,然后通过主机的 eth0 放 ARP 请求并收到目标主机的响应。将其 MAC 地址在封包 #11 中写入。
数据包发送到目标主机后,开始解包的过程,最终进入目标 pod。
在集群层面有一张路由表,里面存储着每个节点的 Pod IP 网段(节点加入到集群时会分配一个 Pod 网段(Pod CIDR),比如在 k3s 中默认的 Pod CIDR 是 10.42.0.0/16,节点获取到的网段是 10.42.0.0/24、10.42.1.0/24、10.42.2.0/24,依次类推)。通过节点的 Pod IP 网段可以判断出请求 IP 的节点,然后请求被发送到该节点。
总结
统计一下在三个场景中,经过内核网络协议栈的处理次数都是两次(包括 netfilter 的处理。),即使是同 pod 或者同节点内。而这两种情况实际都发生在同一个内核空间中。
假如同一个内核空间中的两个 socket 可以直接传输数据,是不是就可以省掉内核网络协议栈处理带来的延迟?
下篇继续。
参考资料
[1] sk_buff: https://elixir.bootlin.com/linux/latest/source/include/linux/skbuff.h#L843