从 Docker 网络流量控制的坑中爬出……


最近很幸运地中了特等奖“无锡两日游”,去客户现场调试 Docker 网络通信中断问题,并确定其具体原因。从客户现场回来后,在网上没有搜到关于该问题真实原因的详细解析,而个人在探索和研究的过程中也获得了对 Linux 网络的更深入的理解。因此决定写一篇博客来分享该经历,以飨读者。

据客户描述,当启动我们产品的进程后,两个 Docker 容器之间的通信就会突然中断,导致容器内进程一直报告 No route to host 并退出,容器不断自动重启;而关闭我们的产品之后,其通信又会马上恢复正常。到客户现场后,通过简单地对网络相关功能进行选择性地关停,很快确认了问题来自产品中的流量模块对 Docker 网络接口进行的流量控制。

这一问题可以简单地依靠执行指令进行复现:我们搭建了一个 SUSE12 SP3 发行版(内核版本 4.4.73-5-default)的复现环境,其包含一个 IP 为 10.2.19.78 的以太网口,并运行着 ID 为 2c179189c210、网络模式为桥接模式的 Docker 容器。

通过 tc qdisc add dev docker0 root htb 为 Docker 网桥添加分层令牌桶(Hierarchical Token Bucket, 以下简称 HTB)流量控制策略。此时通过 docker exec -it 2c179819c210 ping -v 10.2.19.78 尝试在容器网络命名空间内 ping 主机以太网 IP 地址,发现其一直报告 Destination Host Unreachable 的错误,提示主机的以太网 IP 不可达。

而只要通过 tc qdisc delete dev docker root0 删除先前添加的流量控制策略,再次在容器网络命名空间内 ping 主机以太网 IP 地址,就会发现容器内外的网络连通性马上恢复了正常。

为非 Docker 网络接口添加流量控制策略,同样的事情却不会发生。譬如通过 tc qdisc add dev eth0 root htb 为主机上的以太网口添加 HTB 流量控制策略,并 ping 同一网段内的另一存活主机 10.2.19.80,可以发现跨主机通信的连通性未受到任何影响。

看似简单的一个添加流量控制策略的操作,却带来了很多的值得思考的疑点:

  1. 为何该异常只针对 Docker 网络接口的通信,对于真实以太网接口的通信却不产生任何影响?
  2. 我们并没有修改路由表,添加流量控制规则为何会影响路由(依据 Destination Host Unreachable 的报错推断),或者说目标主机为何变得不可达了?
  3. 在 2 的基础上,为何只需要删除流量控制策略就能让容器内外的网络通信几乎瞬间恢复正常?
  4. 若要为 Docker 网络接口添加流量控制规则,正确的添加方式是什么,以及 Linux 内核为何要引入这种差异性?是用户所必须处理的差异还是内核的 Bug?

对于制定针对该问题的修复方案而言,若不对以上疑点进行充分研究,理解其前因后果,显然无法给出一个能够工作的修复方案。

因此本文将依据现场调试经历,采用层层深入的方式对 Linux 网络模块的相关运作机理进行探究,以此对上述疑点进行分析和解答。

此路不通

在桥接模式的 Docker 网络下,Docker 会为每个容器创建独立的虚拟以太网接口和网络命名空间,并将 Docker 容器进程置于网络命名空间中执行。为了简化后续的操作,在进行进一步调试前我们不妨先通过 nsenter 指令进入具有网络中断问题的容器的网络命名空间。

进入容器的网络命名空间后,可以发现其包含一个回环设备 lo 和一个 IP 地址为 172.17.0.2 虚拟网卡 eth0@if13。执行 ping 指令检查测试容器内与宿主机以太网口的连通性,可以收到来自 eth0@if13 发出的“远程主机不可达”消息,与我们在宿主机上观察到的现象一致。

值得注意的是 eth0@if13 是当前容器网络命名空间内的一个出口的网络接口。也就是说在调整 Docker 网络接口的流量控制策略以后,ping 发出的 ICMP 报文还没走出当前网络命名空间,eth0@if13 就对该报文作出响应,告知 ping 指令“此路不通”。

使用 strace 跟踪 ping 指令的系统调用。可见 ping 通过 socket 系统调用创建了一个 ICMP 协议报文的收发的 raw socket,在创建的 raw socket 上通过 sendmsg 向目标地址 10.2.19.78 发起 ICMP 请求,最后通过 recvmsg 收到了由 eth0@if13 产生的 EHOSTUNREACH 错误。作为对内核进行分析的起始点,从 ping 发起的 sendmsgrecvmsg 为两个独立的系统调用,并分别包含了请求和响应,不太利于进行跟踪和说明。

因此我们决定以 TCP 套接字的 connect 系统调用作为分析的起始点。当应用程序通过 socket 创建阻塞模式的 TCP 套接字,并通过 connect 向远程主机发起连接时,就会被系统调用阻塞,直到与远程主机完成 TCP 三次握手并建立连接,或者因为超时和网络异常等原因产生具体错误。

// gcc conntest.c -o conntest
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

int main() {
	int fd = socket(AF_INET, SOCK_STREAM, 0);
	if(fd < 0) {
		perror("socket");
		return -errno;
	}

	struct sockaddr_in addr;
	memset(&addr, 0, sizeof(addr));
	addr.sin_family = AF_INET;
	addr.sin_port = htons(443);
	addr.sin_addr.s_addr = inet_addr("10.2.19.78");
	if(connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
		perror("connect");
		return -errno;
	}

	close(fd);
	return 0;
}

编写一段简单的测试程序与宿主机上的 10.2.19.78:443 建立 TCP 连接。将上述代码编译成可执行文件 conntest 并执行,可以发现在阻塞约 3 秒后其返回 -EHOSTUNREACH 错误,并由 perror 打出 No route to host 消息。这与先前观察到容器内产生的错误相吻合。

通过 cat /proc/$(pidof conntest)/stackconntest 进程执行 connect 系统调用时获取其内核栈追迹,结合对内核的反汇编可知当前 conntest 进程在 net/ipv4/af_inet.c:618__inet_stream_connect 调用 inet_wait_for_connect 时进行等待。

static inline int sock_error(struct sock *sk)
{
	...
	err = xchg(&sk->sk_err, 0);
	return -err;
}

int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,
			  int addr_len, int flags)
{
	struct sock *sk = sock->sk;
	...

	switch (sock->state) {
	...
	case SS_UNCONNECTED:
	...
		err = sk->sk_prot->connect(sk, uaddr, addr_len);
		if (err < 0)
			goto out;
		sock->state = SS_CONNECTING;
	...
	}

	... // if (...) {
		if (!timeo || !inet_wait_for_connect(sk, timeo, writebias))
			goto out;

		err = sock_intr_errno(timeo);
		if (signal_pending(current))
			goto out;
	}

	if (sk->sk_state == TCP_CLOSE)
		goto sock_error;

	sock->state = SS_CONNECTED;
	err = 0;
out:
	return err;

sock_error:
	err = sock_error(sk) ? : -ECONNABORTED;
	sock->state = SS_UNCONNECTED;
	if (sk->sk_prot->disconnect(sk, flags))
		sock->state = SS_DISCONNECTING;
	goto out;
}

在不影响理解的前提下,截取 __inet_stream_connect 的部分代码进行说明。可知在返回 -EHOSTUNREACH 时其关键逻辑如下:

  1. 调用 sk->sk_prot->connect 向目标地址发起 TCP 套接字的握手;
  2. 调用 inet_wait_for_connect 将当前进程加入等待队列中,等待当前 TCP 握手完成,TCP 握手发生错误和超时,以及当前进程收到信号等;
  3. TCP 套接字握手产生错误并设置 sk->sk_err 字段为 -EHOSTUNREACH,随后唤醒正在等待的进程;
  4. 通过 sock_error 获取 sk->sk_err 的值 -EHOSTUNREACH 并将其清零,最后返回该错误给应用程序。

因此只需要找出步骤 3 中修改 sk->sk_err 字段的调用者及其调用栈,即可追溯 -EHOSTUNREACH 错误的来源。

变量 sksock 对象的 sock->sk 字段。首先 sock 对象及其 sk 对象由 socket 系统调用创建和初始化,并关联文件描述符;然后 connect 系统调用会从文件描述符获取到先前创建的 sock 对象,再由 sock 对象通过访问 sock->sk 字段获取到 sk 对象。因此只需要在 socket 系统调用中获取到 sk 的地址,通过 perf 插入内存写硬件断点,监控 sk->sk_err 字段的修改,即可获取到我们需要的调用栈信息。

在我们进行调试的同时,宿主机上其他进程也会创建各种类型的套接字,进行大量的 socket 的系统调用。为了方便地定向跟踪 conntest 进程的 socket 系统调用,还需要获取启动的 conntest 进程的 PID。

综上所述,我们在 conntest 源代码的 socketconnect 系统调用之前插入了等待用户输入(如按下回车键)的源代码,以便在进行 socket 系统调用前获取进程的 PID,以及在进行 connect 系统调用前插入针对 sk->sk_err 的硬件断点。

通过对内核的反汇编可知 net/socket.c:1234 处调用 sock_create 后返回的指令地址为 ffffffff814f57ae。创建后 sock 对象的指针被存放到相对于当前栈帧基地址 +0x8 处,而 sock->sk 相对于 sock 对象的偏移为 +0x32。挂载 KProbe 跟踪点,抓取到 sock->sk 对象的地址为 ffff880096ae4800

通过对内核中 __inet_stream_connect 函数的反汇编,可知 sk->sk_err 相对于 sk 对象的偏移为 +0x1b0,因此应该在 ffff880096ae49b0 处插入针对内存写入的硬件断点以监控对 sk->sk_err 的赋值操作。

该硬件断点捕获了三个采样,其中如图所示的调用栈为 sk->sk_err 赋值了 EHOSTUNREACH。调用栈包含在一个 NAPI 接收处理栈中,由调用栈中包含的 icmp_rcv 函数可知,当前有一个 ICMP 包被接收并处理,该 ICMP 包中包含了目标宿主机不可达的错误消息,并最终中断了当前在进行的 TCP 握手。

目标 IP 是当前宿主机(而非网络上的其他主机)上的虚拟以太网接口,对应的 ICMP 包也应该在当前宿主机上产生,因此可以尝试抓取 icmp_send 函数的调用栈。

在函数 icmp_send 入口处插入硬件断点并执行 conntest 程序,可以捕获到唯一的调用 icmp_send 的采样。调用栈包含在一个时钟中断处理栈中,稍微观察下函数名,可知产生了某种超时使得函数 neigh_timer_handler 认为目标链路不可达,并通过函数 icmp_send 报告了该链路上的错误,中断了其上所有正在进行的通信。

至此,应用程序收到“目标主机不可达”错误的原因已经水落石出:函数 neigh_timer_handlerneigh_invalidate 均来自邻居子系统,进行 TCP 握手时应用程序通过某种方式将自己添加到邻居子系统的某种通知列表中,并触发了某个计时;先前为 Docker 网络接口添加流量控制的操作会造成邻居子系统的计时超时,最终邻居子系统通过 ICMP 报文通知在通知列表中的应用程序“目标主机不可达”消息。

坏邻居

至此事情就变得有趣了起来:并不是因为我们的操作修改了路由表造成了目标主机不可达,而是添加的流量控制策略无形间影响了邻居子系统的工作;当我们删除添加的流量控制策略之后,邻居子系统就能通过某种方式瞬间恢复到正常工作状态。

相信大多数读者可能对邻居子系统不是那么熟悉,特别是不清楚其在 Linux 网络中的作用是什么,为何发送一个 TCP 报文要经过邻居子系统。那么不妨先来弄清 TCP 报文发送和邻居子系统的联系,而发掘这一联系最简单的方法还是从调用栈中的函数 neigh_timer_handlerneigh_invalidate 入手。

static void neigh_invalidate(struct neighbour *neigh)
	__releases(neigh->lock)
	__acquires(neigh->lock)
{
	...
	while (neigh->nud_state == NUD_FAILED &&
	       (skb = __skb_dequeue(&neigh->arp_queue)) != NULL) {
		write_unlock(&neigh->lock);
		neigh->ops->error_report(neigh, skb);
		write_lock(&neigh->lock);
	}
	...
}

static void neigh_timer_handler(unsigned long arg)
{
	...
	if ((neigh->nud_state & (NUD_INCOMPLETE | NUD_PROBE)) &&
	    atomic_read(&neigh->probes) >= neigh_max_probes(neigh)) {
		neigh->nud_state = NUD_FAILED;
		notify = 1;
		neigh_invalidate(neigh);
		goto out;
	}
	...
}

待分析的两个函数均定义于 net/core/neighbour.c 文件,在不影响理解的前提下截取代码进行分析。其中 neigh_timer_handler 会检查 neigh->nud_state 字段的 NUD_INCOMPLETENUD_PROBE 标志位,若包含标志位且满足 atomic_read(neigh->probes) >= neigh_max_probes(neigh) 条件,则进入该分支并调用 neigh_invalidate,发出 ICMP 报文通知目标主机不可达。

那么谁会去设置 neigh->nud_stateNUD_INCOMPLETENUD_PROBE 标志位,以触发这样的超时检查呢?来试着找找 neigh->nud_state 都被哪些函数修改了吧。

在函数 neigh_invalidate 入口处插入 KProbe 并抓取当前被操作的 neigh 内核对象的地址,可知被操作的 neigh 对象的地址一直为 ffff8801b106cc00

通过 neigh_invalidate 的反汇编可知 neigh->nud_state 相对于 neigh 的偏移为 +0xb5,并占用 1 字节空间。在 ffff8801b106ccb5 处插入针对内存写入的硬件断点,可以监控到对 neigh->nud_state 的赋值操作。

该硬件断点捕获了三个采样,抽取其中显然与 TCP 报文发送有关的调用栈如图所示。该调用栈从 connect 系统调用开始,一直穿过传输层的 tcp_transmit_skb 函数和网络层的 ip_finish_output2 函数,最终到达 neigh_resolve_output 函数。

static int ip_finish_output2(struct net *net, struct sock *sk, struct sk_buff *skb)
{
	...
	rcu_read_lock_bh();
	nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);
	neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
	if (unlikely(!neigh))
		neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
	if (!IS_ERR(neigh)) {
		int res = dst_neigh_output(dst, neigh, skb);

		rcu_read_unlock_bh();
		return res;
	}
	...
}

函数 ip_finish_output2 先在路由表 rt 中查找到了能到达报文目标 IP 地址 ip_hdr(skb)->daddr 的下一跳 nexthop,然后根据下一跳找到了对应的邻居对象 neigh,最后调用函数 dst_neigh_output 让邻居子系统将报文发出。函数 dst_neigh_output 是一个内联函数,经过层层代理后调用到 neigh_resolve_output

int neigh_resolve_output(struct neighbour *neigh, struct sk_buff *skb)
{
	int rc = 0;

	if (!neigh_event_send(neigh, skb)) {
		int err;
		struct net_device *dev = neigh->dev;
		unsigned int seq;

		if (dev->header_ops->cache && !neigh->hh.hh_len)
			neigh_hh_init(neigh);

		do {
			__skb_pull(skb, skb_network_offset(skb));
			seq = read_seqbegin(&neigh->ha_lock);
			err = dev_hard_header(skb, dev, ntohs(skb->protocol),
					      neigh->ha, NULL, skb->len);
		} while (read_seqretry(&neigh->ha_lock, seq));

		if (err >= 0)
			rc = dev_queue_xmit(skb);
		else
			goto out_kfree_skb;
	}
out:
	return rc;
out_kfree_skb:
	rc = -EINVAL;
	kfree_skb(skb);
	goto out;
}

先暂且不论函数 neigh_event_send 的功能,若其返回 0 的话,则进入函数 neigh_resolve_output 的主体。其函数主体的关键逻辑无非是从 neigh->ha 取出目标设备的硬件地址(如 MAC 地址),使用其填充 skb 的数据链路层 PDU,并调用函数 dev_queue_xmit 将填充后的 skb 加入设备的发送队列。

显然无法取得硬件地址 neigh->ha 的话就无法填充为有效的数据链路层 PDU,进而无法发送给定的 skb。我们知道 IPv4 中要取得目标设备的 MAC 地址,可以通过 ARP 协议指定已知 IPv4 地址询问网段中的其他设备进行反查。因此 neigh_event_send 做的事情应该就是判断当前 neigh 条目的 neigh->ha 是否已经处于可用状态,若不可用则触发获取硬件地址的操作,通过 ARP 等协议获取当前 neigh 对象对应的目标设备的硬件地址。

至此,相信邻居子系统与 TCP 报文发送的联系变得相对清晰一些了:邻居子系统存放了路由表下一跳设备的硬件地址信息,以供填充有效的数据链路层 PDU(如以太网帧)。由先前对 neigh_resolve_output 的分析及 __neigh_event_send 出现在了 perf 捕获的采样得知,目前 Docker 网络接口的硬件信息是缺失的,无法发送来自 TCP 层的报文。

static inline int neigh_event_send(struct neighbour *neigh, struct sk_buff *skb)
{
	unsigned long now = jiffies;
	
	if (neigh->used != now)
		neigh->used = now;
	if (!(neigh->nud_state&(NUD_CONNECTED|NUD_DELAY|NUD_PROBE)))
		return __neigh_event_send(neigh, skb);
	return 0;
}

int __neigh_event_send(struct neighbour *neigh, struct sk_buff *skb)
{
	...
	bool immediate_probe = false;
	...

	if (!(neigh->nud_state & (NUD_STALE | NUD_INCOMPLETE))) {
		if (NEIGH_VAR(neigh->parms, MCAST_PROBES) +
		    NEIGH_VAR(neigh->parms, APP_PROBES)) {
			unsigned long next, now = jiffies;

			atomic_set(&neigh->probes,
				   NEIGH_VAR(neigh->parms, UCAST_PROBES));
			neigh->nud_state     = NUD_INCOMPLETE;
			neigh->updated = now;
			next = now + max(NEIGH_VAR(neigh->parms, RETRANS_TIME),
					 HZ/2);
			neigh_add_timer(neigh, next);
			immediate_probe = true;
		... // } else { ... }
	... // }
out_unlock_bh:
	if (immediate_probe)
		neigh_probe(neigh);
	...
}

函数 neigh_event_send 为一个内联函数,当对应条目不可用时会调用函数 __neigh_event_send 以触发对应 neigh 条目目标设备硬件地址的获取。此时函数的主要执行流程为,将 neigh->nud_state 置为 NUD_INCOMPLETE,并启动计时器以调用 neigh_timer_handler 进行重试等处理,同时通过 neigh_probe 获取目标设备的硬件地址。

static const struct neigh_ops arp_generic_ops = {
	.family =		AF_INET,
	.solicit =		arp_solicit,
	...
};

static const struct neigh_ops ndisc_generic_ops = {
	.family =		AF_INET6,
	.solicit =		ndisc_solicit,
	...
};

static void neigh_probe(struct neighbour *neigh)
	__releases(neigh->lock)
{
	...
	if (neigh->ops->solicit)
		neigh->ops->solicit(neigh, skb);
	...
}

函数 neigh_probe 的核心逻辑为调用 neigh->ops->solicit 函数指针,依据邻居网络的网络层协议族,选择该协议族下的硬件地址解析协议进行邻居网络地址的探测。如在 IPv4 协议族下通过 ARP 协议的 arp_solicit 进行获取,在 IPv6 协议族下通过 NDP 协议的函数 ndisc_solicit 进行获取。由于我们连接的宿主机接口的地址是 IPv4 地址,下一步应该分析的函数为 arp_solicit

在函数 arp_solicit 入口处使用 perf 插入硬件断点抓取采样,可以获得如图所示的调用栈。可以看到从系统调用 connect 开始,路经我们先前分析过的所有函数,最后调用了 arp_solicit。这也印证了我们前文的分析是符合事实的。

static void arp_send_dst(int type, int ptype, __be32 dest_ip,
			 struct net_device *dev, __be32 src_ip,
			 const unsigned char *dest_hw,
			 const unsigned char *src_hw,
			 const unsigned char *target_hw,
			 struct dst_entry *dst)
{
	struct sk_buff *skb;

	/* arp on this interface. */
	if (dev->flags & IFF_NOARP)
		return;

	skb = arp_create(type, ptype, dest_ip, dev, src_ip,
			 dest_hw, src_hw, target_hw);
	if (!skb)
		return;

	skb_dst_set(skb, dst_clone(dst));
	arp_xmit(skb);
}

static void arp_solicit(struct neighbour *neigh, struct sk_buff *skb)
{
	...
	arp_send_dst(ARPOP_REQUEST, ETH_P_ARP, target, dev, saddr,
		     dst_hw, dev->dev_addr, NULL, dst);
}

函数 arp_solicitarp_send_dst 定义于 net/ipv4/arp.c,截取其关键逻辑如上所示。函数 arp_solicit 确认 ARP 报文的源 IP 地址等信息后,调用 arp_send_dst 进行 ARP 请求报文(ARP 操作号为 ARPOP_REQUEST)的发送;函数 arp_send_dst 则通过 arp_create 创建 ARP 协议报文的 skb 后,通过 arp_xmit 进行发送。

static int arp_xmit_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
	return dev_queue_xmit(skb);
}

void arp_xmit(struct sk_buff *skb)
{
	/* Send it off, maybe filter it using firewalling first.  */
	NF_HOOK(NFPROTO_ARP, NF_ARP_OUT,
		dev_net(skb->dev), NULL, skb, NULL, skb->dev,
		arp_xmit_finish);
}

函数 arp_xmit 调用 Netfilter 钩子并走过 ARP 协议的 Netfilter 链,经过对报文的过滤和修改后,调用函数 dev_queue_xmit 将其加入当前设备的发送队列以发往下一跳设备。

在函数 arp_xmit 的开头使用 perf 插入硬件断点,可以捕获如图所示的调用栈。其中除了预期的包含函数 arp_solicit 的调用栈外,还有一个包含 arp_process 的调用栈。显然包含了函数 arp_process 调用栈就是 Docker 网络接口接收了 ARP 请求报文并通过函数 arp_xmit 发回包含其硬件地址的 ARP 响应报文(ARP 操作号为 ARPOP_REPLY)的调用栈。

通过抓取的采样,我们可以断定从 Docker 容器内部发出的 ARP 报文已经被 Docker 网络接口接收到,并且 Docker 网络接口作出了响应。若 Docker 网络接口进行响应的 ARP 报文能够被容器内的网络接口接收到,则邻居表的对应条目应该会被填充。而不会出现我们观察到的邻居探测超时,通过 arp_error_report 向上层报告链路状态异常,并中断 TCP 的握手过程的现象。

通过阅读函数 __dev_queue_xmit 的编写于 net/core/dev.c:3074 的文档,可知若报文成功发送,则该函数返回 0;当加入设备发送队列失败时,该函数返回负数;某些流量控制算法会在丢弃包时返回正数。注意到调用函数 dev_queue_xmit 等价于调用 __dev_queue_xmit(skb, NULL),因此我们可以尝试跟踪 arp_process 调用链中的 dev_queue_xmit 是否返回了非零值。

通过内核反汇编可知在地址 ffffffff8157b235 处函数 arp_xmit 调用 dev_queue_xmit 后返回。在此处插入 KProbe 捕获其返回值,并设置过滤条件为返回值非零,可以获取到如图所示包含了函数 arp_process 的采样。而返回值恰好为 1NET_XMIT_DROP,意味着 Docker 网络接口进行响应的 ARP 报文全部被流量控制策略所丢弃。

static void neigh_probe(struct neighbour *neigh)
	__releases(neigh->lock)
{
	...
	atomic_inc(&neigh->probes);
	...
}

static void neigh_timer_handler(unsigned long arg)
{
	... // if (...) { ... } else {
		/* NUD_PROBE|NUD_INCOMPLETE */
		next = now + NEIGH_VAR(neigh->parms, RETRANS_TIME);
	... // }

	if ((neigh->nud_state & (NUD_INCOMPLETE | NUD_PROBE)) &&
	    atomic_read(&neigh->probes) >= neigh_max_probes(neigh)) {
		neigh->nud_state = NUD_FAILED;
		notify = 1;
		neigh_invalidate(neigh);
		goto out;
	}

	... // if (...) {
		if (!mod_timer(&neigh->timer, next))
			neigh_hold(neigh);
	... // }

	if (neigh->nud_state & (NUD_INCOMPLETE | NUD_PROBE)) {
		neigh_probe(neigh);
	... // }
	...
}

最后让我们重新关注邻居子系统是如何判断“超时”并向上层协议报告通信链路不可达的。每个邻居表项的 neigh->probes 字段维护了当前已经尝试过的邻居探测的计数(在 IPv4 协议下即 ARP 请求次数),并在每次执行函数 neigh_probe 进行邻居探测后将该计数加 1。

在函数 __neigh_send_event 激活计时器并达到重传超时执行 neigh_timer_handler 后,若当前尝试进行的邻居探测次数未达到最大允许的探测次数 neigh_max_probes(neigh),则会重置计时器超时并重试一次邻居探测;否则则会在累积了一定次数的重试后,neigh_timer_handler 调用 neigh_invalidate 报告通信链路不可达。

至此,邻居子系统一直无法发送 TCP 报文的原因已经水落石出了:容器内的网络接口的 ARP 请求报文可以到达 Docker 网络接口,但 Docker 网络接口上设置的流量控制策略将 ARP 响应报文丢弃,导致容器内的邻居表条目一直无法填上 Docker 网络接口的 MAC 地址,并最终在重试了一定次数的 ARP 请求后,向上层协议报告前往 Docker 网络接口的链路不可达。

开放的陷阱

既然已经明确了 Docker 网络接口上设置的流量控制策略导致了 Docker 网络接口发往容器内的 ARP 响应报文被丢弃,导致容器内无法获取到 Docker 网络接口的 MAC 地址,那么此时剩下的最大疑问就是:为何在 Docker 网络接口上添加的流量控制策略会导致所有流量都被丢弃,且相同的事情不会发生在真实的以太网设备上。

值得注意的是我们添加流量控制策略时使用的是默认的配置,这就意味着无论这一异常是有意还是无意引入的,它都是留给 Linux 用户的一个开放的陷阱。

我们添加的流量控制策略为 HTB,因此相对应的将报文加入流量控制队列发送函数的为函数 htb_enqueue。通过往函数 htb_enqueue 添加 KRetProbe,并过滤返回值为非零值的情况,便可以印证函数 htb_enqueue 实际丢弃了待发送报文并返回了 NET_XMIT_DROP

static int htb_enqueue(struct sk_buff *skb, struct Qdisc *sch)
{
	...
	struct htb_class *cl = htb_classify(skb, sch, &ret);

	if (cl == HTB_DIRECT) {
		if (q->direct_queue.qlen < q->direct_qlen) {
			__skb_queue_tail(&q->direct_queue, skb);
			...
		} else {
			return qdisc_drop(skb, sch);
		}
#ifdef CONFIG_NET_CLS_ACT
	} else if (!cl) {
		...
		return ret;
#endif
	} else if ((ret = qdisc_enqueue(skb, cl->un.leaf.q)) != NET_XMIT_SUCCESS) {
		...
		return ret;
	// } else { ... }

	...
	return NET_XMIT_SUCCESS;
}

函数 htb_enqueue 所有返回值的情况如图所示。进入 htb_enqueue 的报文首先会被 htb_classify 分类,并且依据其类别进行发送和丢弃等各种处理。将分类后的报文进行的处理重新组织如下所示:

  1. 若报文分类为 HTB_DIRECT,视乎 Qdisc 对象的直接发送队列 direct_queue 中已有报文的数目与限制 direct_qlen 的关系,将报文插入直接发送队列或进行丢弃;
  2. 若报文无法进行分类(即分类类别为 NULL),则 htb_classify 会将错误写入 ret,此时直接返回该错误;
  3. 尝试将报文通过函数 qdisc_enqueue 加入 Qdisc 对象的流量控制发送队列,若插入失败则直接返回函数 qdisc_enqueue 报告的错误;
  4. 其他情况下报文成功加入 Qdisc 对象任一发送队列,此时返回 0(即 NET_XMIT_SUCCESS)。

通过内核中 htb_enqueue 的反汇编得知函数 htb_classify 被内联到了函数 htb_enqueue 中。因此我们无法直接获得函数 htb_classify 的返回值(即分类结果),取而代之的是根据内联代码随机应变,插入相应的跟踪点进行跟踪。

首先考虑报文被分类为 HTB_DIRECT 的情况,通过内核反汇编得知当报文被分类为 HTB_DIRECT 时必定会经过 ffffffffa075125b 处的指令。在此处插入 KProbe 可以捕获到相对应的采样,并且调用栈就是我们先前分析的 arp_process 所在的调用栈。可知原来 ARP 的响应报文被分类为了 HTB_DIRECT

而进入此分支后,能返回 NET_XMIT_DROP 的情况就只有一种,那就是 Qdisc 对象直接发送队列 direct_queue 中已有报文的数目大于等于 direct_qlen

通过内核反汇编可知字段 direct_queue.qlen 相对于 Qdisc 对象的偏移为 +0x1a0direct_qlen 相对于 Qdisc 对象的偏移为 +0x16c。重设 ffffffffa075125b 处的 KProbe 的采集指令并采集数据,可以得知 Qdisc 对象的 direct_queue.qlendirect_qlen 均为 0

显然 direct_qlen0 是一个不太正常的情况,这使得只要采用 HTB 作为流量控制策略,所有被归类为 HTB_DIRECT 的报文都将被丢弃。

static int htb_init(struct Qdisc *sch, struct nlattr *opt)
{
	...
	if (tb[TCA_HTB_DIRECT_QLEN])
		q->direct_qlen = nla_get_u32(tb[TCA_HTB_DIRECT_QLEN]);
	else
		q->direct_qlen = qdisc_dev(sch)->tx_queue_len;
	...
}

函数 htb_init 包含了 HTB 流量控制策略的 Qdisc 对象初始化的代码,节选与 direct_qlen 属性初始化有关的代码如上所示。流量控制策略 HTB 初始化时接收一个 RTNetlink 消息作为参数,视乎 RTNetlink 消息中是否设置了 TCA_HTB_DIRECT_QLEN 属性对 direct_qlen 进行初始化。若 RTNetlink 消息显式指定了具体的 direct_qlen 则采用指定的数值,否则采用与 Qdisc 相关的网络设备的 tx_queue_len 属性。

显然我们为 Docker 网络接口使用 tc qdisc add dev docker0 root htb 添加流量控制策略时没有显式指定对应的 direct_qlen 的值,因此不妨猜测是因为 Docker 网络设备的 tx_queue_len 属性为 0,导致 direct_qlen 被赋值为了 0

通过内核的反汇编我们得知地址 ffffffffa074f1bc 处的指令为 htb_init 中使用网络接口的 tx_queue_len 属性为 direct_qlen 赋值的分支。在此处插入 KProbe 并捕获当前网络接口的 tx_queue_len 属性,可以看到为 Docker 网络接口添加 HTB 流量控制策略时,Docker 网络接口的 tx_queue_len 属性为 0

我相信看到这里,肯定有人会说“既然是虚拟网络接口,那么其 tx_queue_len 属性为 0 是理所当然的,添加 Qdisc 策略时就应该为虚拟网络接口设置 direct_qlen”。

那么看到 Linux 4.3 主线引入的 net: sched: drop all special handling of tx_queue_len == 0 提交。在 Linux 4.3 版本之前,为网络接口添加 HTB 流量控制策略时,若接口的 tx_queue_len0,则初始化时会自动将 direct_qlen 赋值为 2。我不知道这一修改是否符合 Linux 中网络子系统的后续规划,但是在 Linux 4.4.73 或至少 SUSE12 SP3 上,对于我而言它更像是一个 Breaking Change。

编后语

事实上在处理这个问题的时候,通过逐个关停功能我很快就确认了问题来自添加的 HTB 流量控制策略。因此即使我对 Linux 的网络模块相当不熟悉,也很快完成了排查并确定了具体原因。但事后想了想,若添加 HTB 流量控制策略后所有 TCP 报文均被丢弃,难道不应该观察到“连接超时”而不是“目标主机不可达”吗?

带着这一疑点我进行了研究,在此过程中也逐步发掘了整个问题的复杂性。发现 Linux 的网络模块各部件之间的协作竟是如此复杂后,也真切地感受到了带着想当然的心态去为一个问题归因并给出所谓的解决方案,是多么危险的一件事。

因此我相信在定位 HTB 流量控制策略中的引入异常的根本原因以外,添加与其相互联系的邻居子系统和 connect 系统调用的相关分析,并以之作为铺垫,会让人更真切地感受到 Linux 网络模块各组件之间的相互联系,并且在发生异常时也是“牵一发而动全身”。而不管是我还是读者,都能从这样一个分析中获益,对 Linux 网络有更深入的了解,并且能为 Linux 网络相关问题的排查提供启发。

September 21, 2021