故事起因
我们需要在一个新的环境搭建 Jenkins (一个 Java 程序)。因为我们不想自己维护 Java 运行环境,所以是将 Jenkins 运行在 Docker 里面的。
需要我去申请了 VM,然后在 VM 里面安装好 Docker,用 Jenkins 官方的 Docker 镜像启动 Docker 容器,一切正常。然后回到浏览器登录,发现这时候 Jenkins 报错了。
查看 Jenkins 的日志,错误是:java.net.SocketTimeoutException: Read timed out
。但是不知道具体是要访问什么服务报错的。
收集信息
首先,我们先找到具体是访问哪一个服务不通。在 Jenkins 启动的过程中进行 tcpdump
可以发现,确实是访问一个 IP 的 443 端口会卡住。但是这个 IP 是可以 ping 通的,说明 3 层没问题,问题是出在 4 层及以上。tcpdump 发现对这个 IP 的 443 端口建立 TCP 连接是没有问题的,但是在数据交换的过程中会卡住。那很可能是应用层的问题。
要知道这个服务是干啥的,我们要找到这个 IP 对应的 domain。那这个 IP 是怎么拿到的呢——对了,是 DNS,用 tcpdump 对 DNS 协议抓包,可以发现这个 IP 对应的 domain。
可以发现这个域名是 accounts.google.com
。
我在容器中做了一些简单的测试:
- 在容器里面是可以 ping 通
accounts.google.com
的。 - 在容器里面可以正常
curl http://accounts.google.com
并且拿到 response。 - 但是
curl https://accounts.google.com
必定会超时。 - 问题出在
https
? 容器内可以访问其他的一些 https 网站,比如curl https://x.com
,但是速度很慢 - 我去 VM(容器所在的宿主机)
curl https://accounts.google.com
发现是正常的。说明 Host 网络是 OK 的,也说明不是 Google 挂了(废话)。
到这里就是收集到的所有的信息了。其实答案就在本站的其他博文中,嘻嘻。读者可以推断出原因了吗?
答案
回顾 Docker (容器) 的原理 一文中网络的部分,容器发送包到 WAN 大致的路径是:容器内的 eth0 -> Host 对应的 veth 的另一头 -> docker0
bridge -> Host eth0 -> WAN。而因为在 Host 上的网络没有问题,所以最后一段 Host eth0 -> WAN 是没问题的。
通过抓包的现象可以看到,在 TCP 正常建立连接之后,如果是 HTTP 就很顺喜,如果是 HTTPS,在建立连接之后会卡住。从上图抓包可以看到(虽然 IP 部分打了码,但是可以通过 Flags 前面的字看出来是谁发回来的),从 443 端口发回来的包,只有 length = 0 的。所以猜测(只能猜吗?),从 443 端口发回来的包都因为 MTU 太大的原因被丢弃了。参考 有关 MTU 和 MSS 的一切。
只能猜测吗?因为如果超过了 MTU 1500 的大小,那么丢包的可能是任何中间设备,所以我们看不到被丢弃的包的,现象就是对方的 443 端口没有发送任何东西回来导致超时。但是我们可以有以下理由这么猜:
- 因为 HTTP 访问相同的 IP port 可以拿到正常响应,说明 4 层网络是通的,至少不是因为防火墙之类的问题;
- HTTP 响应相对较小,HTTPS 和 HTTP 相对于 4 层来说有啥不同呢?只是中间多了一层 TLS 而已,TLS 在握手的过程中要交换很多信息,包括证书等。
- 访问某些 HTTPS 网站是可以的,但是从抓包可以看出,中间也卡了很久,过了一段时间,对方才从 443 端口发回来数据。而且奇怪的是,明明对方要发送一连串的数据,却没有用 length 很长的 segment 发回来,而是发了几个很小的 segment。说明这些网站可能实现了 PMTUD.
证明
为什么会导致 MTU 太大,进而导致丢包呢?肯定是 TCP 的 MSS 协商出了问题。
既然 Host 上的网络没有问题,我就对比了 Docker 中的 interface 配置和 Host 上的 interface 配置。发现 Host 上的 MTU 设置为 1450,而 Docker 里面是默认的 1500. 于是就明白了:我们的 Host (即 virtual machine)运行在一个 Overlay 网络中。简单可以理解为,Host 收发的网络包,中间的网络设备要在上面添加额外的信息,添加之后,MTU 就会超过 1500,为了避免这个问题,就调小 interface 上的 MTU 值,这样,为“额外信息”预留出来空间,保证网络中的任何包大小都不超过 1500 bytes。但是我们自己搭建的 docker,没有单独去配置 interface 的 MTU,于是就会让 Docker 内的程序在建立 TCP 连接的时候,错误地认为自己的 MTU 是 1500,导致最终产生 MTU 大于 1500 bytes 的包。
解决办法
我们可以用 MSS clamping 来解决这个问题:通过 iptables 将 TCP 握手的包中的 MSS 值强制修改成 1450 – 40:
1 |
iptables -I FORWARD -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1410 |
然后就可以在 Docker 容器中正常访问这个 HTTPS 服务了。
另一个解决办法是,让 dockerd
启动的时候,指定 interface 的 mtu:
1 |
dockerd --default-network-opt=bridge=com.docker.network.driver.mtu=1234 |
这一个 case 实际上可以用 cilium 他们家 pwru 来定位具体在内核中哪个函数丢弃了具体的 skb
嗯,也是一个好办法。