一次网络问题排查

故事起因

我们需要在一个新的环境搭建 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 关联,那么几句设计成装饰器;如果这个是函数本身的逻辑,比如需要先干这个再干那个,这三个函数都需要先干这个再干其他的。“这个”就是显式函数调用的场景。

 

Prometheus & Grafana No Data 排查手册

经常有同事来问我为什么从 Grafana 面板上看不到数据,其实排查思路是基本固定的,按照查询的链路一步一步看每一个组件里面是否有这个数据。之前写了一个排查文档,但是发现没有人阅读,遇到问题还是直接找我。今天画了一个图片版本的,更加清晰一些。在这里也贴一下。点击图片可以查看大图。