真实世界中的 PMTUD

上回书说到被 MTU 问题小小坑了一下,问题最后解决了,但是留了一个疑问点没有证实:为什么在 MSS 协商失败的情况下,curl https://x.com 可以,但是 curl https://accounts.google.com 不可以?

本文的实验代码都是在虚拟机中做的,所以没有隐藏 IP,直接粘贴的 tcpdump 结果。代码太宽,可以通过代码块右上角的工具栏配合阅读,比如点击 <-> 按钮来展开,或者在新窗口浏览。读本文之前,最好先读一下这篇介绍 MTU 介绍的比较好的博客:有关 MTU 和 MSS 的一切 (即本博客)。

上文中的猜想是这些网站实现了 PMTUD,这一点比较容易证明。

PMTUD 测试

TCP 握手的时候双方协商 MSS,是根据本地的网卡信息协商的。比如网卡的 MTU 是 1500,那么 MS S 就会是 1460,如果网卡 MTU 是 1450,那么 MSS 就是 1410. 这个过程,TCP 的双方都对中间网络设备的 MTU 没有概念,中间设备能转发的 MTU 很可能比两边都小(尤其是在有 VPN 或者有隧道的情况)。PMTUD 就是处理这种情况的:它的原理很简单,当有丢包的时候,我尝试发送小包,看能不能收到 ACK,如果能,说明链路 path 的 MTU 比我想的要小,等用小一点的包发送。PMTUD 的全称是 Path MTU Discovery。

验证方法很简单,我们只要创造一个环境,假设这个环境能接受的 MTU 最大是 800,超过 800 bytes 的都会直接丢包,并且不会发回去 ICMP 消息。

我们用 iptables 直接 DROP 掉超过 800 bytes 的包。实验环境我习惯将 DROP 打印出来。

然后,我们还要将 Generic Receive Offload 关闭(以及其他的 offload 也一起关了吧,方便查看)。如果不关的话,即使对方发过来小包,网卡也会帮我们合并成大包,导致被 iptables 丢弃。

最后,我们打开 tcpump,并且发送请求:curl -v https://accounts.google.com。抓包结果如下:

果然,对方一直尝试发给我们大小是 1400 的包,不断被我们丢弃,不断重发,非常锲而不舍,可惜是无用功。

还记得我们当时 MTU 设置错误,还是可以访问通 x.com,我们再拿它来试一下。

以下是 curl https://x.com 的抓包结果:

可以看到,在 server 端发送了 4 个长度为 1448 的包之后,就开始发送了一个长度为 512 的包,发现能够收到 ACK,就加大到 936 尝试扩大 MTU 发送,然后失败了,就退回到 512,可以看到后面还有大包的尝试,同样也失败了。不过最终的结果是发送成功的。

具体 PMTUD 的行为不太一样,比如 facebook.com 的第一次尝试是 1024,然后退到 512.

ICMP type 3 code 4 测试

ICMP 专门有一种消息是处理这种不可达的错误的。ICMP 的 type 3 意思是 Destination Unreachable,但是 Destination Unreachable 的原因有很多,对于每一种原因都有一种 Code,Code 4 意思就是 Fragmentation Needed and Don’t Fragment was Set。(即,包太大,需要拆成多个 IP 包,但是你有设置了不要拆包,所以我只能丢弃,并且用此 ICMP 来告知你。)

在上面的测试中,我们并没有发送任何的 ICMP 消息,而只是丢包。现在,我们添加一步,在丢包的时候,发回去一个 ICMP 消息。我们用 scapy 来做这个。代码非常简单,它抓所有超过 800 bytes 的包,对这些包的来源都发送一个 ICMP。还是 iptables 负责丢包,scapy 脚本只负责发 ICMP。

为什么 filter 是 greater 815 呢?因为 libpcapgreater 是 Ethernet 层的大小,Ethernet 的 header 是 14 bytes,所以我们要的条件是 >= 815 bytesgreater 是大于等于。(是,我也觉得很奇怪)

保存上文件为 a.py。运行方式是 python3 a.py

然后使用这台服务器进行测试。发现…… 结果和上文完全一样,我都告诉他们 next hop mtu 是 800 了,但是他们有自己的想法,从 512 开始尝试之类的。仿佛 ICMP 从来没发送到他们的服务器上。不知道是我构造包的问题,还是他们的服务器没有处理好 ICMP 的问题。比如之前看过 cloudflare 的这篇文章,就是说因为 ECMP 的问题,ICMP 消息会被路由到错误的负载均衡器上去,导致 PMTUD 失败。解决办法是将 ICMP type 3 code 4 广播到所有的负载均衡器上去。

ICMP type 3 code 4 虚拟机测试

为了试试看是不是我的脚本有问题,我在本地搭建了一个非常简单的网络环境。

抓包结构如下,可以看到,8000 端口尝试发送 1448 bytes 的包一直被忽略。当收到 ICMP 消息,server 端就立即改用 800 bytes (MSS 是 748 bytes)来发送了。所以,感觉还是公网发送 ICMP 黑洞的问题。

而且这个 path MTU 信息会在 route 的 cache 中,后续的发送会默认这个 path 的 MTU 就是 800,不会使用更高的尝试。

相关链接:

  1. nmap 有 Path MTU 探测功能:https://nmap.org/nsedoc/scripts/path-mtu.html
  2. Path MTU discovery in practice
  3. Iptables Tutorial 1.2.2 by Oskar Andreasson 一份不错的 iptables 教程
  4. RFC 5508 NAT Behavioral Requirements for ICMP
  5. Resolve IPv4 Fragmentation, MTU, MSS, and PMTUD Issues with GRE and IPsec
 

一次网络问题排查

故事起因

我们需要在一个新的环境搭建 Jenkins (一个 Java 程序)。因为我们不想自己维护 Java 运行环境,所以是将 Jenkins 运行在 Docker 里面的。

需要我去申请了 VM,然后在 VM 里面安装好 Docker,用 Jenkins 官方的 Docker 镜像启动 Docker 容器,一切正常。然后回到浏览器登录,发现这时候 Jenkins 报错了。

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。

tcpdump port 53

可以发现这个域名是 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 挂了(废话)。
在容器内访问 https://x.com 的抓包

到这里就是收集到的所有的信息了。其实答案就在本站的其他博文中,嘻嘻。读者可以推断出原因了吗?

答案

回顾 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 的包。

Docker interface 和 Host interface MTU 对比

解决办法

我们可以用 MSS clamping 来解决这个问题:通过 iptables 将 TCP 握手的包中的 MSS 值强制修改成 1450 – 40:

然后就可以在 Docker 容器中正常访问这个 HTTPS 服务了。

另一个解决办法是,让 dockerd 启动的时候,指定 interface 的 mtu

 

CVE-2024-21626 从容器内逃逸到宿主机文件系统

最近很火的一个 CVE,核心问题是 docker (runc) 在运行用户的代码之前,会 O_CLOEXEC 关闭所有的 fd——这是正确的——但是运行用户代码之前,在 setcwd(2) 的时候,fd 还没有被关闭。这就导致 docker rundocker exec 的时候,去通过 -w 参数设置 working directory, 并且设置成一个还没有关闭的 fd ,就能拿到宿主机上的文件路径,从而进入到宿主机。

这个攻击有两个依赖:

  • 能够在容器内部执行代码;
  • 能够设置容器的 working directory (docker run, docker exec, 甚至 docker build 都可以)

演示

在一个全新的 Linux 机器上复现这个攻击。

环境准备

准备一个新的 VM,需要安装的依赖有:

  1. 依赖 golang 1.22 和 libseccomp-dev 来编译指定版本的 runc;
  2. 依赖 build-essential 编译 runc;
  3. 依赖 docker engine,指定版本的 runc;

第一步:按照官方文档安装最新版本的 docker。

第二步:替换 runc (最新版已经解决这个问题了)到旧版本,这里我们使用 v1.0.0-rc10. 编译脚本如下:

安装完成旧版本的 runc 之后需要重启 docker engine:sudo systemctl restart docker.

攻击演示

创建一个 Dockerfile:

编译这个 docker image: docker build . -t test

最后运行这个 docker image: docker run --rm -ti test.

可能一次运行不会成功,多运行几次会成功。

进入 container,此时 cwd 显示 .

通过相对路径,我们可以回到 Host 上面的 / 了:

打开 Host 上面的文件

如果我们安装运行 htop,会发现只有自己的容器里面的进程:

htop 只显示自己容器的 pid

但是如果我们改变当前容器的 fs root: chroot . ,再次运行 htop,就可以看到所有的进程了。

chroot ps 可以显示所有的 pid
htop 也可以显示所有的 pid

但是试了下发送 signal 开 kill 进程是不行的,我猜是因为 pid namespace 仍然是对进程隔离的?

甚至可以在容器内运行docker 命令,看到所有的 container。因为有了 docker binary 的路径(和权限,因为容器进程也是 root)和 docker socket 的路径。

在容器内 docker ps

相关链接:

 

推荐新加坡的餐厅:Ma Maison

没想到我会专门写一篇博客来推荐一家餐厅,哈哈。今天想写的餐厅叫 Ma Maison,经常和同事朋友去,食物比较好吃,价格也相对优惠。所以想专门推荐一下。

Ma Maison 是一家日本餐厅,但是有两种风格:Tonkatsu Ma Maison, 顾名思义,专门做炸猪排;Ma Maison 洋食屋,顾名思义是西餐风格。

Tonkatsu Ma Maison, 各种各样的炸猪排,非常好吃。套餐包括饮料(茶),米饭(白米饭或者糙米饭),猪排,卷心菜沙拉,味增汤。猪排炸的恰到好处,外酥里嫩。味增汤也很好喝。除了猪排之外,其他的食物都可以 refill,管饱。最早听说这家餐厅是同事 YX 告诉我的,他的评价是:回中国之后最想念的就是这里的炸猪排,在中国还没吃到这么好吃的。

Ma Maison 洋食屋,我们最早去的就是这家。也算是比较正宗的西餐:按照前菜(一般是汤),主菜(主菜配面包 or 米饭,还有一小团意大利面),甜品的顺序上菜。我们最喜欢吃的是牛排,如果当前的 Daily Lunch Set 是牛排的话,可以透过玻璃看到厨房里面火舌四起,很有观赏性。

一餐的价格包括服务费和消费税在 20新币 – 30新币左右,水(或者咖啡,茶)免费,湿巾免费(细节很好),在新加坡算是非常实惠的价格了。

全岛现在有很多分店了,樟宜机场也有。除了新加坡,马来西亚新山也有一家,在 Southkey: Tonkatsu by Ma Maison – The Mall Mid Valley Southkey。

Westgate 门店,图片来自官网 (但是其他图片是我自己拍的)
Tonkatsu 门店
新加坡 Ma Maison 位置

食物

洋食屋
Tonkatsu 猪扒饭

优惠

优惠策略很有意思,有好几种:

  • Daily Lunch Set: 工作日午餐可以以比较便宜的价格吃到一份套餐,每日的套餐不一样,可以从官网查看,比如 AnchorPoint 分店的每日午餐列表可见这里
  • 盖章:注册会员之后会有两张卡片和一个 $10 off 的优惠券。卡片使用来集章的,分成午餐和晚餐两种:
    • 午餐:每吃一顿午餐可以得到一个章,收集齐 10 个章可以兑换一张免费午餐券;很多店有 Double Chop Day,消费一顿午餐可以获得两个章。这样的话,相当于买5送1.
    • 晚餐:每吃一顿晚餐可以得到一个章,收集齐 10 个章可以兑换 10 张 $10 off 的券。
  • Lady’s Night: Group 里面有一位女生,可以享受 20% off 优惠;
  • Early Bird 优惠:午餐去的比较早可以享受。

每一家分店的政策可能不同。我觉得最核心的就是 Daily Lunch 和集章,已经兑换了 30 张 $10 off 晚餐券以及 4 张午餐券了(说明我光顾太多次啦,哈哈)。

集章的卡片和优惠券
兑换的免费午餐券
还是优惠券
过生日的时候邮寄给我们的贺卡,很贴心,其实也是一张优惠券
 

Python 复用装饰器代码

前几天同事问我一个问题:Python 代码中,两个函数装饰器部分的代码太多了,而且有很多重复的,能否复用?这个问题我一开始也没完全听明白需求是啥,不过看了他的代码就明白了。

这里,我将他的代码简化如下:

这里,hello_woldhello_world2 的装饰器部分几乎相同,唯一不同的部分是 @add_args("bar", "bar1") 的第二个参数不同。所以他想要服用装饰器部分的代码。想要达到的效果如下面这个写法,希望能和上面的代码完全等同。

这个需求是用 click 这个库定义子命令的时候,子命令之间有很多重复的。在 golang 中,使用 cobra 库可以将一部分参数都抽象出来,复用这部分代码。在 Python 中,click 库看起来不太容易做到这一点。我觉得也许可以抽象出来命令 Group 来解决这个问题,但是同事听了直摇头,觉得 command sub1 sub2 这种敲下去两层子命令不太好,一层子命令是能接受的极限了。

有没有一种方法,能够复用这部分重复的代码,还不影响命令的 UI(怎么,Cli 也是一种 UI!)

回来试了一下,发现是完全可以做到的。

如果了解装饰器基础知识,可以直接跳到文末看答案。上面没有列出源代码的四个装饰器,源代码如下:

Python 中的函数是一等公民

这句话的意思是,Python 中的函数和其他变量一样,可以被创建,修改,赋值,可以作为参数。

下面是一个 decorating_bark 函数,这里面什么也没有做,传进来一个函数,拿到一个函数,只是为了证明函数可以作为一个参数一样传递和返回。

输出结果是:

当然,也可以做点什么,这段代码中的 bark 拿到函数之后,返回了一个新的函数,新的函数先是 print 了一下,然后调用原来的函数:

注意在 cat_say = bark(cat_say) 这一行,bark 所返回的新的函数赋值给了原来的 cat_say。运行的结果如下:

两行内容都是打印在 cat_say() 调用的时候发生了,说明它的行为被 bark(cat)的返回值给替换了。

这两个函数 decorating_barkbark 就是上面的装饰器,但是这一段内容中我们没提起过装饰器,都是在讲函数。

Python 中装饰器是什么?

cat_say = bark(cat_say) 这一行,我们也可以这么写:@bark。但是这一行必须要写在 def 的上方:

这就是装饰器了。所以,装饰器只是一个语法糖。它没有给 Python 添加新的功能,只是让代码看起来更漂亮简洁了一些。

可以传入参数的装饰器

下面这个例子复杂了一些:

但是我们只用上面学过的内容,不需要任何新的知识,就可以理解它。

you_decide_what_to_say("oh!") 只是一个函数调用,我们将 @ 这个语法糖去掉,就变成了下面这样:

看起来还是有一些复杂,我这么写,就简单了:

对照最初的语法糖,可以看到所谓“带有参数的装饰器”,本质其实就是一个函数调用,这个函数调用会返回一个函数,返回的函数才是装饰器,用来装饰 def 的函数。

换句话说,“带有参数的装饰器” 的本质是一个装饰器制造器(decorator maker,我发明的叫法)。

由于这个例子中,实际的装饰器什么也没做,所以看起来还相对简单。有了这些知识,我们可以来看最后一个装饰器,它比上一个增加的内容,就是对函数本身做了修改。

其中,add_args 是一个制造装饰器的函数,function_wrapper 是它制造出来的装饰器,real_func 是真正会返回的函数,会去替换原来的函数,它的内部调用了原来的函数,不过调用之前,它先修改了入参。

我还专门画了一个直观的图:

Python 带有参数的装饰器分解

答案

到这里,可以发现装饰器的代码也是可以复用的,因为我们可以将其当作函数来调用:

注意,包装的顺序很重要,因为装饰器是有顺序的,最里面的会先执行,最外面的后执行。读者可以复制到前面的代码中,会发现输出完全一样。这样做我们只是删除了语法糖,只使用了最原始的函数。(其实,代码中没有地方在定义装饰器,而只是在定义函数!装饰器是函数调用的语法糖)

但是,我们是否可以继续使用语法糖来复用这部分代码呢?答案是可以的。

因为 @ 必须在 def 的上方使用,所以我们必须要有 def 才行。那要 def 什么呢?我们只是想组合起来已有的装饰器,并不想改变原来的函数的行为。那就随便 def 一个新函数好了,只是新函数的内部啥也不用做,原原本本将原来的函数返回即可。

最后,上文中的 general_decorator 的实现可以如下:

读者若有兴趣,可以看下之前写过的另一篇有关装饰器的内容:Python装饰器兼容加括号与不加括号的写法。之后,相信如果看到装饰器的代码,就可以信心满满地说:“哈,我知道,这只不过是函数而已!”

不过,装饰器切不可滥用。一般定义装饰器的场景是制作框架,比如像 Flask 这种 web 框架,或者 Celery 这种异步框架。框架的制作者将装饰器定义好,用户就可以使用这些装饰器,好处是,用户看起来是在写普普通通的函数,但是确能通过装饰器告诉框架一些额外的信息,和框架配合工作地很好。

作为一个语法糖,装饰器可以很好的标记出来函数一些特殊的属性。它的目的是提高代码的可读性。可惜的是,笔者遇到过很多使用装饰器的代码,解决的问题确是普通的显式函数调用就可以完成的,使用装饰器反而让代码看起来更加复杂,语义上也说不过去,降低了代码的可读性。

什么时候该用函数调用,什么时候该用装饰器?这其实是需要在朝着写好代码的漫漫长路上不断练习的。但是我有一个捷径:如果你要标记这个函数的属性,比如标记它是异步任务,标记它失败需要自动重试(@retry),标记它和某一个 @api_route 关联,那么几句设计成装饰器;如果这个是函数本身的逻辑,比如需要先干这个再干那个,这三个函数都需要先干这个再干其他的。“这个”就是显式函数调用的场景。