最近发现的一个 Linux 内核 bug,会造成使用 veth 设备进行路由的容器(例如 Docker on IPv6、Kubernetes、Google Container Engine 和 Mesos)不检查 TCP 校验码(checksum),这会造成应用在某些场合下,例如坏的网络设备,接收错误数据。这个 bug 可以在我们测试过的三年内的任何一个内核版本中发现。
这个问题的补丁已经被整合进核心代码,正在回迁入3.14之前的多个发行版中(例如SuSE,Canonical)。如果在自己环境中使用容器,强烈建议打上此补丁,或者等发布后,部署已经打上补丁的核心版本。
注:Docker 默认的 NAT 网络并不受影响,而实际上,Google Container Engine 也通过自己的虚拟网络防止了硬件错误。
编者:Jake Bower 指出这个 bug 跟一段时间前发现的另外一个 bug 很相似。有趣!
起因
十一月的某个周末,一组 Twitter 负责服务的工程师收到值班通知,每个受影响的应用都报出 “impossible” 错误,看起来像奇怪的字符出现在字符串中,或者丢失了必须的字段。这些错误之间的联系并不很明确指向 Twitter 分布式架构。问题加剧表现为:任何分布式系统,数据,一旦出问题,将会引起更长的字段出问题(他们都存在缓存中,写入日志磁盘中,等等...)。经过一整天应用层的排错,团队可以将问题定位到某几个机柜内的设备。团队继续深入调查,发现在第一次影响开始前,进入)的 TCP纠错码错误大幅度升高;这个调查结果将应用从问题中摘了出来,因为应用只可能引起网络拥塞而不会引起底层包问题。
编者:用“团队”这个词可能费解,是不是很多人来解决这个问题。公司内部很多工程师参与排错,很难列出每个人名字,但是主要贡献者包括:Brian Martin、David Robinson、Ken Kawamoto、Mahak Patidar、Manuel Cabalquinto、Sandy Strong、Zach Kiehl、Will Campbell、Ramin Khatibi、Yao Yue、Berk Demir、David Barr、Gopal Rajpurohit、Joseph Smith、Rohith Menon、Alex Lambert and Ian Downes、Cong Wang。
一旦机柜被移走,应用失效问题就解决了。当然,很多因素可以造成网络层失效,例如各种奇怪的硬件故障,线缆问题,电源问题,等....;TCP/IP 纠错码就是为保护这些错误而设计的,而且实际上,从这些设备的统计证据表明错误都可以检测到---那么为什么这些应用也开始失效呢?
隔离特定交换机后,尝试减少这些错误(大部分复杂的工作是由 SRE Brain Martin 完成的)。通过发送大量数据到这些机柜可以很容易复现失效数据被接收。在某些交换机,大约~10%的包失效。然而,失效总是由于核心的 TCP 纠错码造成的(通过netstat -a 返回的 TcpInCsumError 参数报告),而并不发送给应用。(在 Linux 中,IPv4 UDP 包可以通过设置隐含参数 SO_NO_CHECK,以禁止纠错码方式发送;用这种方式,我们可以发现数据失效)。
Evan Jones(@epcjones)有一个理论,说的是假如两个 bit 刚好各自翻转(例如0->1和1->0)失效数据有可能有有效的纠错码,对于16位序字节,TCP 纠错码会刚好抵消各自的错误(TCP 纠错码是逐位求和)。当失效数据一直在消息体固定的位置(对32位求模),事实是附着码(0->1)消除了这种可能性。因为纠错码在存储之前就无效了,一个纠错码 bit 翻转外加一个数据 bit 翻转,也会抵消各自的错误。然而,我们发现出问题的 bit 并不在 TCP 纠错码内,因此这种解释不可能发生。
很快,团队意识到测试是在正常的 linux 系统上进行的,许多 Twitter 服务是运行在 Mesos 上,使用 Linux 容器隔离不同应用。特别的,Twitter 的配置创建了 veth(虚拟以太网(virtual ethernet))设备,然后将应用的包转发到设备中。可以很确定,当把测试应用跑在 Mesos 容器内后,立即发现不管 TCP 纠错码是否有效(通过 TcpInCsumErrors 增多来确认),TCP 链接都会有很多失效数据。有人建议激活 veth 以太设备上的 “checksum offloading” 配置,通过这种方法解决了问题,失效数据被正确的丢弃了。
到这儿,有了一个临时解决办法,Twitter Mesos 团队很快就将解决办法作为 fix 推给了 Mesos 项目组,将新配置部署到所有 Twiter 的生产容器中。
排错
当 Evan 和我讨论这个 bug 时,我们觉得由于 TCP/IP 是在 OS 层出现问题,不可能是 Mesos 不正确配置造成的,一定应该是核心内部网络栈未被发现 bug 的问题。
为了继续查找 bug,我们设计了最简单的测试流程:
可以在一个台式机上运行客户端,服务器在另外一个台式机上。通过以太交换机连接两台设备,如果不用容器运行,结果和我们预想一致,并没有失效数据被接收到,也就是10秒内没有失效包被接收到。客户端停止修改包后,所有10条报文会立刻发出;这确认Linux端TCP栈,如果没有容器,工作是正常的,失效包会被丢弃并重新发送直到被正确接收为止。
这样是工作的:错误的数据不会接收,TCP转发数据
Linux 和容器
现在让我们快速回顾一下 Linux 网络栈如何在容器环境下工作会很有帮助。容器技术使得用户空间(user-space)应用可以在机器内共存,因此带来了虚拟环境下的很多益处(减少或者消除应用之间的干扰,允许多应用运行在不同环境,或者不同库)而不需要虚拟化环境下的消耗。理想地,任何资源之间竞争都应该被隔离,场景包括磁盘请求队列,缓存和网络。
Linux 下,veth 设备用于隔离同一设备中运行的容器。Linux 网络栈很复杂,但是一个 veth 设备本质上应该是用户角度看来的一个标准以太设备。
为了构建一个拥有虚拟以太设备的容器,必须:
为什么是虚拟造成了问题
我们重建了如上测试场景,除了服务端运行于容器中。然后,当开始运行时,我们发现了很多不同:失效数据并未被丢弃,而是被转递给应用!通过一个简单测试(两个台式机,和非常简单的程序)就重现了错误。
失效数据被转递给应用,参见左侧窗口。
我们可以在云平台重建此测试环境。k8s 的默认配置触发了此问题(也就是说,跟 Google Container Engine 中使用的一样),Docker 的默认配置(NAT)是安全的,但是 Docker 的 IPv6 配置不是。
修复问题
重新检查 Linux 核心网络代码,很明显 bug 是在 veth 核心模块中。在核心中,从硬件设备中接收的包有 ip_summed 字段,如果硬件检测纠错码,就会被设置为 CHECKSUM_UNNECESSARY,如果包失效或者不能验证,者会被设置为 CHECKSUM_NONE。
veth.c 中的代码用 CHECKSUM_UNNECESSARY 代替了 CHECKSUM_NONE,这造成了应该由软件验证或者拒绝的纠错码被默认忽略了。移除此代码后,包从一个栈转发到另外一个(如预期,tcpdump 在两端都显示无效纠错码),然后被正确传递(或者丢弃)给应用层。我们不想测试每个不同的网络配置,但是可以尝试不少通用项,例如桥接容器,在容器和主机之间使用 NAT,从硬件设备到容器见路由。我们在 Twitter 生产系统中部署了这些配置(通过在每个 veth 设备上禁止 RX checksum offloading)。
还不确定为什么代码会这样设计,但是我们相信这是优化设计的一个尝试。很多时候,veth 设备用于连接统一物理机器中的不同容器。
逻辑上,包在同一物理主机不同容器间传递(或者在虚机之间)不需要计算或者验证纠错码:唯一可能失效的是主机的 RAM,因为包并未经过线缆传递。不幸的是,这项优化并不像预想的那样工作:本地产生的包,ip_summed 字段会被预设为CHECKSUM_PARTIAL,而不是 CHECKSUM_NONE。
这段代码可以回溯到该驱动程序第一次提交(commit e314dbdc1c0dc6a548ecf [NET]: Virtual ethernet device driver)。 Commit 0b7967503dc97864f283a net/veth: Fix packet checksumming (in December 2010)修复了本地产生,然后发往硬件设备的包,默认不改变CHECKSUM_PARTIAL的问题。然而,此问题仍然对进入硬件设备的包生效。
核心修复补丁如下:
diff - git a/drivers/net/veth.c b/drivers/net/veth.c
index 0ef4a5a..ba21d07 100644
- - a/drivers/net/veth.c
+++ b/drivers/net/veth.c
@@ -117,12 +117,6 @@ static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
kfree_skb(skb);
goto drop;
}
- /* don’t change ip_summed == CHECKSUM_PARTIAL, as that
- * will cause bad checksum on forwarded packets
- */
- if (skb->ip_summed == CHECKSUM_NONE &&
- rcv->features & NETIF_F_RXCSUM)
- skb->ip_summed = CHECKSUM_UNNECESSARY;
if (likely(dev_forward_skb(rcv, skb) == NET_RX_SUCCESS)) {
struct pcpu_vstats *stats = this_cpu_ptr(dev->vstats);
结论
我对 Linux netdev 和核心维护团队的工作很钦佩;代码确认非常迅速,不到几个星期补丁就被整合,不到一个月就被回溯到以前的(3.14+)稳定发行版本中(Canonical,SuSE)。在容器化环境占优势的今天,很惊讶这样的bug居然存在了很久而没被发现。很难想象因为这个 bug 引发多少应用崩溃和不可知的行为!如果确信由此引发问题请及时跟我联系。