0%

linux网络协议栈框架

之前学习了很多与计算机网络相关的内容,虽然建立起了比较系统的概念,但是这些概念仅仅停留在模型上;而操作系统中究竟是如何对各种协议进行处理的仍然不是特别清楚。本文主要梳理 linux 内核网络模型的概念,并在此基础上探究一些常用的网络工具是如何实现的,实现在 linux 协议栈的哪个位置。

linux 内核网络模型

从功能上,linux 内核可以划分为五个部分,分别是:

  • 进程管理:主要负责 CPU 的访问,进行 CPU 调度;
  • 内存管理:主要负责控制内存的访问;
  • 文件系统:主要负责组织文件系统,实现文件操作;
  • 设备管理:主要负责控制外部设备;
  • 网络:主要负责管理网络设备,实现网络协议栈。

linux 内核的网络部分主要具备两个功能:管理网络设备、实现网络协议栈。管理网络设备对应五层协议中的物理层和数据链路层;实现网络协议栈对应五层协议中的网络层和运输层。在 linux 的网络实现体系中,linux 为了抽象与实现相分离,将内核中的网络部分划分为五个层次:

  • 系统调用接口:用户空间的访问接口,提供网络功能的统一封装。
  • 协议无关接口:使用 socket 来实现对网络通信的抽象化,实现 TCP 和 UDP 的统一封装。
  • 网络协议栈:linux 中各种网络协议的具体实现,处理各种网络协议的逻辑。
  • 设备无关接口:为网络协议栈提供操作物理设备的统一封装。
  • 设备驱动程序:负责管理物理网络设备的驱动程序。

/images/linux-network.png

其中,网络协议栈部分包括数据链路层、网络层、传输层。除南北通信外,linux 内核还提供了东西向通信。在协议栈的数据链路层,linux 提供了 bridge hook,用户可以将同一主机上的不同网卡相互桥接,实现在数据链路层的消息转发。在协议栈的网络层,linux 提供了 route 接口,用于实现 ip 包在不同 host ip 之间的传递。

/images/linux-stack.png

虽然一台物理主机上可能会有多个网络设备,但是这些网络设备会共用同一个协议。如果想要使主机上的部分进程不与其他进程共享协议栈,那么就必须借助虚拟化技术来实现网络隔离,如虚拟机技术,虚拟容器技术。这些虚拟技术通常是使用虚拟网卡来实现的。

重要数据结构

linux 网络部分中最为重要的数据结构有两个抽象net_devicesocket和一个缓冲sk_buff

net_device

net_device是所有网络设备的抽象描述,为上层协议栈提供统一的接口,主要两个方面的内容:

  • 网络设备硬件属性:端口地址、驱动函数、中断函数等;
  • 网络设备配置信息:ip 地址、子网掩码等。

这一抽象数据结构实现了协议栈和网络设备的隔离,从而使得虚拟网络设备的实现成为可能。

socket

socket 是对所有传输层协议的抽象,统一了网络 IO 与文件 IO 操作,它主要实现了应用层与系统协议栈的接口统一、网络与文件访问方式的统一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct socket {
socket_state state;

kmemcheck_bitfield_begin(type);
short type;
kmemcheck_bitfield_end(type);

unsigned long flags;

struct socket_wq __rcu *wq;

struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};

创建 struct socket 的函数 socket(3)不仅仅可以创建常规的 TCP、UDP 套接字,还可以通过控制协议族来创建 ICMP、甚至 ARP 套接字。经过 socket 的统一封装,这些位于不同层次的协议使用相同的接口被用户调用,向用户隐藏了底层系统的复杂度。struct socket的读写同样是使用了统一的接口来封装收发数据,不同的协议会使用不同的函数写入,也就是每一种 struct socket 最终调用的读写函数是不一样的,如 ICMP 协议在写入时使用的是icmp_send,因此不会经过传输层,而是直接从网络层向下进行网络通信流程。

1
2
3
4
5
// 创建一个 IMCP 套接字,SOCK_RAW 表明为原始网络协议
int icmp_fd = ::socket(AF_INET,SOCK_RAW,IPPROTO_ICMP);

// 创建一个 ARP 套接字,SOCK_PACKET 表明为链路层协议
int arp_df = :: socket(AF_PACKET,SOCK_PACKET,htons(ETH_P_ARP));

sk_buff

sk_buff结构体作用域整个数据包的处理,它承载着网络包的内容。sk_buff结构体的定义比较长,源码不贴出。主要包括以下几个方面的内容:

  • 用于实现 sk_buff的双向链表的结构;
  • 数据的各种指针:head、tail、data、end、user_data;
  • 长度标识:len、head room、tail room;
  • 协议报头:各网络协议层的报头;
  • sock*dev*:指向对应的 sock 结构体,对应的网络设备;
  • 控制缓存 char cb[40]:避免每层协议都分配内存,用于每一层临时存储信息;

linux 中,无论是写入还是读取网络包,都需要借助 sk_buff。在初始化阶段,数据并不会写入缓冲区的首部,而是会预留出一段空间,用于报文头的写入。在解析/生成报文的过程中,sk_buff中的 data 会不断移动,指向下一层级报文的开始位置。为了避免每一个层级额外分配内存,每一个sk_buff内部都预留了 40 字节大小的控制缓存,存储临时信息。这种设计能够在报文的解析和生成过程中避免频繁分配内存,大大降低了内存拷贝的需要。

更加详细的内容可以参考博客文章

网络包的读取流程

本章会从读取网络包的视角来分析 linux 网络部分的各个层级的主要工作,以及操作系统预留的一些 hook 可以用于实现的技术。

网络设备硬中断

数据帧被网卡接收前,会先检查数据的完整性,对错误数据包直接丢弃。

网卡内部具有一段内存区域,该内存区域是以 FIFO 形式来访问的,是网卡和驱动程序的共享内存。但是网卡内部是采用直接寻址的方式,并没有使用虚拟内存映射的方法来访问。当网卡中有数据帧到达时,网卡设备会将数据帧内容拷贝到该内存区域,然后向 CPU 发起一个硬中断,通知 CPU 有数据到达。

CPU 收到硬中断后,会在 skb 缓冲区分配一个 skb 数据结构,然后从网卡中将数据拷贝到 skb 中(因为网卡中的内存区域很小,很容易被占满),更新网卡状态;从数据帧中提出协议等信息,写入 skb 结构体中,然后向操作系统发出一个软中断。如果网卡的 FIFO 缓冲区或者 OS 中为网卡开辟的缓冲区被占满,那么数据包就会被直接丢弃,这是丢包的原因之一。

软中断处理入口

相较于网络设备硬中断,软中断处理是一个很长的流程,它会负责处理 OS 内存中已经到达的网络数据,并最终将其送入到对应的协议栈位置。

软中断处理会被调度到硬中断发生的 CPU 上,处理逻辑的入口函数是 net_rx_action。该函数的主要逻辑是遍历系统中的网卡设备,执行被网卡驱动注册的 poll 函数。为了限制单次软中断的运行时间,net_rx_action每次运行时都会被设置一个配额和一个截止时间,当完成配额数量或者超时后,即使没有完成所有的事件,函数也会退出,并等待被下一次中断唤醒。

另一个比较重要的函数是 netif_receive_skb,该函数是 skb 结构体的分发器,负责根据 skb 结构体的信息将其分发到对应的协议栈上,进行不同的处理。值得一提的是,虽然网络层协议目前很少,但为了考虑拓展性,函数中并没有使用分支的方式判断,而是使用了哈希表来进行查询的。netif_receive_skb同样也是抓包程序、数据链路层桥接等功能的入口。在提交至上层协议栈前,会首先根据抓包程序设置的过滤器,将符合条件的数据包复制。如 wireshark 中的 capture filter,就直接对应这一过程,如果不对其进行设置,可能会导致系统的性能下降。

1
2
3
4
5
6
7
8
//pcap逻辑,这里会将数据送入抓包点。
list_for_each_entry_rcu(ptype, &ptype_all, list) {
if (!ptype->dev || ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}

数据链路层桥则是虚拟网卡 TAP 的基础,可以在数据链路层将数据包转发给其他虚拟网卡设备,该技术是基于 hook 函数 netif_receive_skb 的。该函数会调用 rx_handler 来实现不同的功能,每一块宿主网卡都只能注册一个 rx_handler,但是网卡和网卡可以使用链路的方式来注册,从而实现一个宿主网卡上挂载多个虚拟网卡。

linux tc 是在此阶段被处理的,其中__netif_receive_skb_core处理 tc ingress,dev_xmit处理 tc egress。

网络层处理

网络层处理主要会对各种协议进行解析和处理,这一阶段的输入是经过解析后的网络层包。不同协议的包入口函数名为 prococolname_rcv,结束函数为 prococolname_rcv_finish。这一层主要进行路由、NAT、防火墙等操作。

ip 协议

ip 网络层的入口为ip_rcv,在这一函数中会对包进行检查,如果该 ip 包经过了分割,会首先将多个分片组装。在完成这些预处理工作后,ip 包会经过路由和 netfilter 的处理流程。可见,netfilter 这种内核防火墙并不会对数据包产生复制,利用其来做网络转发性能会远高于 nginx 等应用层代理。具体的处理流程如下图所示:

img

不难看到,ip 协议的处理流程和 iptables 的四链处理流程是一致的。

在 linux 的netfilter实现中,使用了ip_conntrack结构体来记录每一个连接的状态,该状态是一个协议无关的状态,主要记录连接src 和 dst 的 ip 以及端口号、连接创建时间、发送字节等信息。ip 层协议栈维护了一个 ip_conntrack 的哈希表,用于记录当前活跃的所有被追踪连接。

其他协议

除了 IP 协议,网络层处理还有 ARP 协议、ICMP 协议等,如果这些协议到达网络层后没有更高层次的协议,那么处理就会结束。以 ICMP 协议为例,数据包在到达网络层后会直接在内核态中被处理,不会再进行传递。linux 内核中处理 ICMP 应答的函数是 icmp_rcv()源码有 100 行左右,但核心功能比较简单,其主要步骤用文字表述后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int icmp_rcv(struct sk_buff *skb)
{
// 检验 ICMP 包有效性

// 获取 ICMP 报文类型

// 处理 ICMP 报文消息

// 根据 ICMP 报文类型,选择处理函数,应答处理均在内核态运行
success = icmp_pointers[icmph->type].handler(skb);

// 结束本次网络包读取
if (success) {
consume_skb(skb);
return 0;
}
}

可以看到,函数中会根据报文类型来选择合适的处理函数,处理函数会根据当前系统协议栈的状态来处理 ICMP 请求。即ICMP 的应答报文会在内核态生成,因此 ICMP 报文的读取在网络层就会结束。

传输层处理

接收数据包时,传输层处理的主要流程为:解析报文包——( 报文包进入接收队列 )——处理应答。当数据包为用户进程所需时,需要将其加入接收队列,如果接收队列已满,同样也会导致数据包被丢弃

TCP 部分

为了控制和管理每个连接,linux 内核中有使用了 TCP_SKB_CB 数据结构来描述 TCP control block,简称 TCB,用于存储单个 tcp 连接的状态。所有的 TCB 以表的形式组织起来,方便查询和修改。TCB 结构只会在 TCP 发送/写入数据、建立/关闭连接时被修改。TCB 主要记录以下部分的内容,源码较长,这里不贴出:

  • 连接四元组:TCP 双方 ip、port;
  • TCP 状态:SEQ,ACK,连接状态,SACK/FACK 重传等信息;
  • 进程号:开启 tcp 连接的进程。

TCP 的所有收发操作都会依据 TCB 的状态来进行调节,而孤儿 socket、RST 包等一些功能也都是基于 TCB 来完成的。如果 ip 层解析后的 tcp 包能够匹配具体的 process,那么就会处理该消息并将内容封装为 tcp_data ,放入 tcp_data 队列中,然后通过 tcp_read 接口来注册 poll、epoll 事件,通知被阻塞的进程。具体的处理流程如下图:

697113-20160228211142163-1933891846

TCP 中接受消息的处理路径有两条: fast path和 slow path。slow path 是当前 tcp 连接出现异常状况的处理策略,相比 fast path,它需要额外处理紧急指针、乱序数据。tcp 协议栈会使用预测标志的方法来决定使用哪种策略,当满足以下条件时,会使用 fast path:

  • 无乱序数据和紧急数据
  • 接收窗口不为 0
  • 接收缓冲区未耗尽

connect系统调用结束、收到第三次握手中 ACK时,tcp 会立刻使用 fast path;当 tcp 从异常中恢复、更新窗口后、可接收缓冲区大小变化时,都会尝试开启 fast path。

如果不存在相应的 TCB 或 TCB 中对应的进程已经退出,那么协议栈将会进行异常处理,给对端发送 RST 包。当一个进程退出时,会隐式地关闭所有 socket,但是此时连接还没有被正常关闭,因此会出现 TCB 存在,但是对应进程已经不存活的状态。

UDP 部分

由于 UDP 是一个无状态协议,相比于 TCP 协议的处理流程,UDP 协议的处理更加简单。linux 协议栈不需要为 UDP 维护状态机模型,只需要解析包后通知用户进程即可。

网络包的写入流程

上一章已经讲述了 linux 网络部分的各个模块内容,本章主要讲述的是写入流程中比较特殊的部分。具体的 write flow 则不再阐述。

传输层

TCP 包发送时必须考虑到丢包的情况,因此需要设立重传机制。linux 内核中为每一个 tcp 连接设置了一个以双向链表形式组织的重传队列,在收到对方的 ACK 前,tcp 包都会被保存在队列中,等待触发重传机制时进行调用。

TCP 重传可以分别超时重传和快速重传(SACK、DACK)两种,其中快速重传是在 TCP 包的接收中处理的,而超时重传则是由内核内部的定时器触发的。每一次 TCP 协议栈进行发包时,会设置一个超时重传定时器,触发时间为 2*RTT;若在定时器超时前收到 ACK,会撤销定时器,否则会在定时器触发时重传 TCP 报文。超时重传中有许多处理细节,可以参考博客

除超时重传定时器外,linux 内核的 TCP 协议栈为每一个 TCP 连接维护了一个定时器队列timer_list,存储在sock struct 中。定时器一共具有九种,分别是:超时重传定时器、SYNACK 定时器、Delayed ACK 定时器、Keepalive 定时器、零窗口探测定时器、FIN_WAIT2 定时器、TIME_WAIT 定时器、ER 定时器、尾部丢失探测器;这些定时器是随 sock 结构体一进行初始化的。这些定时器的触发和执行是由内核函数 update_process_times负责的,TCP 协议栈并不会单独开辟内核线程来处理这些定时器,而是采用系统软中断的方式来处理定时器。

其中 FIN_WAIT2 定时器是用于处理孤儿 socket 的相关动作的。当进程使用系统调用后close,进程不会继续持有 socket ,但是此时 TCP 协议栈尚未能够完成 TCP 的四次握手,这时 socket 会转换为 孤儿 socket,由协议栈负责完成握手;该定时器则是用于记录孤儿 socket 的存活时间的,若该定时器被触发,协议栈将会释放 socket 相关信息。

网络层

网络层中的写入流程有一个点比较特殊,那就是 IP 包在进行封装时需要使用 ARP 协议来获取 MAC 地址。由于这一步骤需要使用目标 IP 作为输入,破坏了下层协议不依赖上层协议数据的原则,所以才会产生 ARP 协议层次的争议。在 OSI 模型中,将 ARP 定义为数据链路层协议;而在 TCP/IP 模型中则将其定义为网络层协议。

数据链路层

所有的网络包在数据链路层都会通过dev_queue_xmit进入等待队列,操作系统会定期调用xmit_one函数来发送一个或多个数据包至网络设备,每一个网络设备都具有一个等待队列。该等待队列也是一个 hook,可以由用户进行调控。linux 中系统命令 tc(traffic control) 就是利用该 hook 来实现的。默认情况下,该等待队列是一个 FIFO 队列,用户也可以通过配置来实现更加复杂的功能。

/images/dev_quue.png

linux 内核在进行入队和出队操作时,使用了两个函数指针,实现了队列的抽象化。在 linux tc 实现中,允许一个抽象队列以树状组织,形成一个队列树。队列树的根节点和非叶子结点是抽象队列基类,不具备任何队列功能,但是具有两个过滤器,用于入队和出队的选择与过滤功能。每一个叶子结点都会维护一个队列实例,可以是 FIFO 队列、随机队列、优先级队列等中的一种。当链路层包需要入队时,会从根节点以入队过滤器指定的策略对链路层包进行分流,最终经过策略选择入队到某一个叶子结点的队列中。当需要出队时,则会根据过滤选择器中设置的优先级选择一个或多个链路层出队。

默认情况下, 抽象队列中根节点过滤器不设置任何规则,等待队列只设置了一个 FIFO 队列,即不实现任何功能。tc 命令中的队列规程,类别,过滤器其实就分别对应了等待队列实例,等待队列实例的类别,过滤器规则。

虚拟网络设备

linux 中许多种类的虚拟网络设备,常见的有 IFB、TUN/TAP、VETH 等,他们都是通过统一的 netif_receive_skb hook 函数来实现的。所有的虚拟网络设备都工作在数据链路层,可以选择与主机共享协议栈,或者选择使用虚拟技术实现新的协议栈。

Loopback Interface

Loopback Interface 是一个特殊的虚拟网卡,一般作为 lo 网卡挂载在 ip 127.0.0.1上。Loopback Interface 同样工作在数据链路层,发送到虚拟网卡的数据需要先经过传输层和网络层。网卡的 ip 输出函数会直接串联到 ip 输入函数中,中间没有任何缓存。

IFB

IFB(中介功能块设备)是IMQ(中介队列设备)的继任者,也是最简单的虚拟网卡,它以 bridge 的形式与其他网卡连接在一起,它不改变数据包的流向。IFB 网卡的设计初衷是为了拓展 linux tc 的功能。

使用方法可以参考博客

TUN/TAP

TAP/TUN是Linux内核实现的一对虚拟网络设备,TAP工作在二层,TUN工作在三层,Linux内核通过TAP/TUN设备向绑定该设备的用户空间程序发送数据,反之,用户空间程序也可以像操作硬件网络设备那样,通过TAP/TUN设备发送数据。

VETH

VETH 网卡是成对出现的,一对 VETH 网卡可以使用两套不同的协议栈,通常用于网络协议栈的隔离。VETH 网卡可以与物理网卡或其他虚拟网卡进行桥接,通过路由规则就可以实现网路的隔离。

NAT

NAT 可以分为 DNAT 和 SNAT 两种,由于进行 NAT 操作的主机必须记录转换前与转换后的对应关系,必须要建表来对 NAT 状态进行记录和追踪。但由于 linux 中已经具有链路追踪的功能,为了减少内存占用,linux 中的 nat 实现是依赖于 ip_conntrack 的。在前面的介绍中,提到过位于传输层协议栈,具有一个 ip_conntrack 哈希表,该哈希表就是 linux 中 nat 的实现基础。

考虑如下的一次 SNAT,源地址为 ip1:port1,目的地址为 ip2:port2,需要转换的地址为 ip3:port3。首先在正向连接中,会创建 origin tuple 和 reply tuple,插入到连接对应的 ip_contrack 结构中,随后修改 reply tuple 中的信息;将修改完成后的两个 tuple 计算哈希值,插入到连接追踪表中。

linux_nat

这样,在反向连接中,就可以通过查询连接追踪表来判断是否需要进行 de-SNAT 操作。如本次连接中,可以通过 hash_value2 来查询到对应的 tuple 信息,发现该 tuple 在所属的 ip_conntrack 为 reply tuple,然后根据 origin tuple 中的信息就可以知道如何进行 de-NAT 操作。

LVS

lvs(linux virtual server)是工作在网络层的一个虚拟服务器,工作在内核态。lvs 是利用 netfilter 的 hook 来实现的,它能够截获指定条件的 ip 包,并将其根据一定的策略转发到其他 linux 主机上,实现服务器的负载均衡。lvs 的工作位置比较特殊,它位于网络层与传输层之间。

/images/lvs_pos.png

在经过网络层的处理之后,数据包会先被 lvs 截获,如果满足 lvs 的匹配条件,那么将会由 lvs 进行处理;若果不满足 lvs 的匹配条件,数据包才会被递交给传输层。由于 lvs 的特殊位置,在下层协议栈以及其他主机的视角中,它是作为一个传输层/应用层设备工作的,所以它被称作是一个“服务器”。但是,由于 lvs 并不真实处理应用层的请求,只是将数据包转发给其他的真实服务器,所以称其是“虚拟的”。lvs 更像是一个虚拟的应用层网关。

lvs 具有一个虚拟的端口号和虚拟的网络地址,主机外可以像访问应用层服务一样访问 lvs。lvs 使用的网络地址称为 VIP(virtual ip)。由于 lvs 的位置比真实应用层更低,如果 lvs 与真实应用层使用相同的 host:ip,lvs 会截获全部的网络包。

ipvs 是 lvs 的一种实现,它利用哈希表来实现网络地址的匹配与转发,相比使用网络层的链路处理模式更加高效。ipvs 一共具有三种工作模式:

  • NAT 模式:通过 DNAT 实现转发。性能最差,但最灵活,允许 lvs 与 real server 端口号不同;
  • DR 模式:通过 MAC 地址实现转发。性能最好,只支持无 ARP 网络,要求相同的 virtual ip。
  • IPIP 模式:通过网络隧道实现转发。性能中等,灵活度中等,要求设备支持网络隧道技术,且不保证客户端一定能够收到回包。

NAT 模式的模型比较简单,不过多介绍。NAT 模式的性能较差主要源自于正向和反向网络包都需要经过 lvs 所在主机的协议栈,但也是因为经过协议栈的原因,可以使用软件来进行各种处理,有最好的灵活性。另外,NAT 模式也是最成熟,风险最小,最常用的模式,k8s 中 ipvs 代理的搭建就是使用的 NAT 模式。

DR 模式是利用数据链路层的原理来实现转发的。DR 模式要求每一个 RS 都配置一个与 LVS 相同的 VIP,并隐藏该 VIP(外部无法访问),这是为了防止被 ARP 寻址。DR 模式中的 LVS 会将收到的 ip 包中的 MAC 地址根据一定策略修改为 RS 的 MAC 地址。当数据包到达 RS 时,由于 VIP 能够匹配到 RS 中的 VIP,RS 协议栈将会接收该数据包然后处理。DR 需要回包时,由于来包中的 MAC 地址是属于数据包真实发送者的,回包并不会经过 LVS。在具体配置过程中,DR 模式非常复杂并且对网络的拓扑要求会比较高。

IPIP 模式则是在 LVS 与 RS 之间使用网络隧道进行传输,原始数据包会被包裹一层数据包,相当于原始数据包并没有经过 RS 的协议栈(原始数据包是作为包裹数据包的内容传递的)。所以 RS 的应用层看到的 SRC IP 是真实发送者的,但是网络隧道的存在,在发送回包时其实发送者与 RS 之间并没有建立起数据链路层的通道。如果网络情况比较特殊,即发送者能够与 lvs 通信,但不能与 rs 通信,就会导致返回链路无法成功建立,IPIP 模式失效。

可以参考该博客,其方法已经实验证实有效。