网络和操作系统内核,对我来说是既陌生又满是吸引,希望能够拨开层层迷雾找到背后的真相。

上一篇文章 中我深入探讨了 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
  • getsockoptsetsockopt 获取或设置 socket 层或协议层选项
  • close

通过下面的图,可以直观感受各个操作的作用:

开始讲解内核网络协议栈之前,先说下数据包在内存中的数据结构:sk_buff

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):

  1. 先分配 sk_buff
  2. 接下来开始网络协议栈的处理
  3. 设置传输层信息(这里是 TCP 头中的源和目的端口)
  4. 根据目标 IP 查找路由
  5. 设置网络层信息(源和目的 IP 地址等)
  6. 调用 netfilter(LOCAL_OUT
  7. 设置接口(interface)和协议(protocol)
  8. 调用 netfilter(POST_ROUTING
  9. 如果包过长,分段传输
  10. L2 寻址,即查找可以拥有目标 IP 地址的设备的 MAC 地址
  11. 设置链路层信息,
  12. 至此内核网络协议栈的操作完成
  13. 调用 tc(traffic control)egress(可以对包进行重定向)
  14. 进入队列 queue discipline(qdisc)
  15. 写入 NIC(network interface controler)
  16. 发送到网络

解包

NIC 收到网络发来的数据包(这里不深入讲解 direct memory access、netfilter、traffic control):

  1. 将数据包写如 DMA 中(Direct Memory Access 直接内存访问,不需要依赖 CPU,由 NIC 直接写入到内存中)
  2. 分配 sk_buff,并填充元数据,比如 protocol 为 Ethernet 类型,接收数据包的网络接口等
  3. 将链路层信息保存在 sk_buff 的 mac_header 字段中,并“移除”数据包中的链路层信息(移动指针)
  4. 接下来开始网络协议栈的处理
  5. 将网络层信息保存在 network_header 字段中
  6. 调用 tc ingress
  7. “移除”网络层信息
  8. 将传输层信息保存在 transport_header 字段中
  9. 调用 netfilter(PRE_ROUTING
  10. 查找路由
  11. 合并多个分包
  12. 调用 netfilter(LOCAL_IN
  13. “移除”传输层信息
  14. 查找监听目标端口的 socket,或者发送 reset
  15. 将数据写入 socket 的接收队列中
  16. 发信号通知有数据写入队列
  17. 至此内核网络协议栈的操作完成
  18. sk_buff 从 socket 接收队列中出队
  19. 将数据写入应用程序的缓冲区
  20. 释放 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 httpbineth0 接口中,然后开始解包的过程。

解包的过程没啥特别,确定了 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/2410.42.1.0/2410.42.2.0/24,依次类推)。通过节点的 Pod IP 网段可以判断出请求 IP 的节点,然后请求被发送到该节点。

总结

统计一下在三个场景中,经过内核网络协议栈的处理次数都是两次(包括 netfilter 的处理。),即使是同 pod 或者同节点内。而这两种情况实际都发生在同一个内核空间中。

假如同一个内核空间中的两个 socket 可以直接传输数据,是不是就可以省掉内核网络协议栈处理带来的延迟?

下篇继续。


云原生指北
25 声望7 粉丝