Spegel 镜像分发介绍

网络系列文章许久不更新了,因为最近的工作比较忙,有很多其他的问题要解决。上个周花了很多时间研究 P2P,(和 web3 区块链无关的),(和民间 P2P 借贷也无关),(和下载盗版电影也无关,好吧,算是有点关系)。而且着实被「云原生」坑了一把,所以这篇文章介绍一下我们要解决什么问题,Spegel 这个项目是什么,怎么解决问题的。最重要的是…… 我会写一下如何从 0 启动并运行这个项目。读者可能会问,这不是按照项目的文档就能跑起来的吗?不是!这项目是云原生的,它居然没有文档,只有一个 Helm Charts1,命令行的参数看的云里雾里,如果不在 K8S 中运行这个程序,就只能结合它的 Charts 以及源代码来弄懂参数的含义。好在我功力深厚,已经完全掌握命令行的启动方式了,接下来就把它传授给读者。

问题

在部署容器的时候,node 要从 image registry 来 pull image。如果要部署的规模非常大,比如 2000 个 container,那么就要 pull 2000 次,这样对 image registry 造成的压力就非常大。

在规模比较大的软件分发问题中,自然而然会想到 P2P 的方式来分发。如果要下载 1000 次,可以先让 10 个节点下载完成,然后后续 100 个节点从这 10 个节点下载,每一个并发是 10,然后其余节点从这 110 个节点下载。这样,每一个节点的最大并发都不会超过 10,就解决了中心点的性能瓶颈问题。

我们尝试过的 P2P 方案是 Dragonfly2, 这个工具以透明代理的方式工作3,透明代理会首先通过 P2P 查找资源,如果找到,优先从 P2P 网络进行下载。下载完成后,会另外保存一份「缓存」,在后续一段时间内(可配置,默认 6 小时,时间越长,文件生效的时间越长,但是占用的磁盘空间越大),会给其他请求下载的节点来 serve 文件。如果整个 P2P 网络都没有目标文件,那么就会回源下载。而且文件支持分段下载,比如一个文件 1G,就可以并行地从 10 个节点分别下载 100M。

透明代理的设计非常好,不光是镜像文件,像 Python 的 pip 下载 tar 包,apt 下载 deb 文件,部署其他的 binary 文件,都可以通过这个 P2P 网络来加速下载。

但是这个设计也有一些弊端,尤其是在镜像下载的场景。

比如进行故障切换的时候,要对目标 IDC 的服务进行扩容,需要同时扩容 100 个服务,那么这 100 个服务都需要请求到镜像中心,因为这 100 个服务都是不同的镜像,P2P 网络中现在没有缓存。或者对一组服务进行紧急扩容的时候,也会因为缓存中没有镜像而全部请求到镜像中心。

在多个数据中心部署的时候,由于无法事先得知一个 IDC 需要部署哪些服务,就得把所有的镜像都同步到这个 IDC,极其浪费带宽。如果不同步的话,那么每次部署都要跨 IDC 来拉取镜像, 延迟比较大。今天部署新的服务,跨 IDC 拉取镜像部署完成。明天一个机器挂了,容器要部署到另一个机器上去,但这时候 P2P 网络中的缓存已经删除了,所以又需要跨 IDC 拉取镜像部署。

以上的问题,按照 dragonfly 的缓存设计,不太好解决。

可能的方案

这个问题的本质,其实是镜像太大了,假设一个服务的镜像只有 10M4,算了,算 100M 吧,那么这些问题都不是问题,1000 个节点全部去镜像中心拉取,也才 10G,跨 IDC 也都能接受。

但是很多人打的 image 什么都往里放,golang 编译器也放进去,gcc 也放进去。甚至做基础镜像的团队也把很多不用的东西都放进去了。导致 image 最终就是 1G 的正常大小,甚至还有 10G 的5

Image lazy loading

一个很新奇的想法是,既然 image 很多的文件都用不到,那么就只下载用得到的文件,其他的文件等读到的时候再去下载。比如 Nydus6,把 image 进行文件级别的索引和分析,启动的时候只下载必要文件,其他的文件等 access 的时候再通过 P2P 网络下载7

从 containerd 来 serve

既然 image 在其他部署的机器上已经存在了,那能不能直接从其他的机器来下载呢?

通过 containerd 把已经存在的 image 读出来是可能的8。于是我们就想尝试写一个程序:

  • 可以把本地 containerd 的 image 暴露出来;
  • 需要下载 image 的时候,优先从其他的机器上直接下载;
  • 需要做一个服务发现服务让不同的节点知道 image 的 blob 都存在于哪些机器上。

然后就发现了 Spegel 这个项目9,简直和我们想做的事情一模一样!

Spegel 做了上面的三件事:

  • 暴露一个 HTTP 服务可以提供下载本机的 image;
  • 对 containerd 做 image mirror,containerd 想要 pull image 的时候,会被 spegel 代理到去其他机器下载;
  • 如何发现谁有什么镜像呢?服务发现是用的 P2P 网络,但是并没有用 P2P 来存储数据,本质上,只是用 P2P 来做了服务发现。Spegel 会定期把本地的 image 广播到 P2P 网络中,需要 image 的话,也会从 P2P 网络中寻找 Provider。

运行 Spegel

环境准备

首先需要准备一个 containerd 的运行环境。也可以直接安装 docker。

然后需要安装 golang 用来编译项目的 binary,直接安装 golang 的官方文档进行编译即可。

下载项目进行编译。

这里编译的是版本是 v0.0.28,因为最新的版本 v0.0.30 我没成功运行起来,会遇到错误:failed to negotiate security protocol: remote error: tls: bad certificate。有一个 issue 说是因为 IPv4 和 IPv6 dual stack 的问题10,但是尝试关闭 IPv6 也于事无补。

软件的 binary 准备好了,就可以准备运行起来了。

Containerd mirror 配置

Spegel 启动的时候会检查连接的 containerd 是否已经配置自己为 mirror,如果没有配置,会拒绝启动。Containerd registry config path needs to be set for mirror configuration to take effect

首先配置 CRI 的 config path。

然后用 Spegel 提供的配置命令来创建 registry 的 mirror 配置。这个命令只是修改配置文件,你也可以自己修改。

其中:

--containerd-registry-config-path:是要写入配置的目录,因为上面配置中 containerd 也是使用了这个目录,所以在这里 Spegel 就要修改这个目录。

--registries:是对什么域名配置 mirror。

--mirror-registries:Spegel 提供 image mirror 服务的地址。这里写什么地址,接下来启动 Spegel 的时候就使用什么地址。

--resolve-tags=true:标记 Spegel 的地址有 resolve tag 的能力。

这个命令执行过后,会看到多了一个文件:/etc/containerd/certs.d/registry-1.docker.io/hosts.toml,内容是:

启动项目

下一步就可以启动项目了。

启动的命令是:

参数的含义如下:

  • registry 表示启动 registry 服务。spegel binary 只支持两个子命令,另一个就是上面用到的 configuration
  • --mirror-resolve-retries 表示 Spegel 在解析 image 的时候最多重试几次;
  • --mirror-resolve-timeout 表示解析 image 的时候多久会超时;
  • --registry-addr 指定 Spegel 在本地提供 image 下载服务的地址,containerd 会从这个地址来下载镜像,如果失败,就 fallback 到镜像中心;
  • --router-addr Spegel 会 listen 这个地址来接收来自 P2P 网络的请求;
  • --metrics-addr 这个地址可以访问 /metrics 以及 golang 的 pprof 文件;
  • --containerd-sock containerd 的客户端通过这个 socket 文件访问 containerd,本地已经存在的 image 也是通过这种方式访问到的;
  • --containerd-registry-config-path 和上面一样,但是这个值在代码中并没有实际用到;
  • --bootstrap-kind 加入一个 P2P 网络,至少需要认识一个已经存在 P2P 网络中的节点,通过已经存在于 P2P 的节点来加入网络。这里是指定发现节点的方式,是 HTTP;
  • --http-bootstrap-addr Spegel 启动之后会 listen 这个地址,提供 HTTP 服务。只有一个 path /id,访问这个 path 会返回自己的 multiaddr11。即,其他节点可以把本 node 的 --http-bootstrap-addr 来当作 bootstrap http 地址,这个配置是让本节点对其他节点提供服务;
  • --http-bootstrap-peer 指定其他节点的 --http-bootstrap-addr,来加入 P2P 网络;
  • --resolve-latest-tag 是否解析 latest tag,因为 Spegel 本质上是 image 缓存分发,如果 latest 修改了,那么 Spegel 解析到的 latest tag 可能是过时的;
  • --registries 对什么镜像地址进行 mirror;
  • --local-addr 本机的地址,实际上没有 listen,在代码中只是在获取到其他节点地址的时候,用这个配置项来比较是不是自己,过滤掉自己的地址,(用于有 NAT 等的复杂环境);

启动一个节点之后,再去另一个节点修改一下地址相关的参数,启动,就可以得到一个 2 节点的 Spegel 网络了。

使用 crictl 来测试

好像使用 docker 不会走到 contianerd 的 image 下载逻辑,所以我们下载一个 crictl 直接来操作 containerd。

安装 crictl:

在第一个节点下载:

耗时 3m 左右。

然后再去另一个节点执行同样的命令,会看到耗时 20s 左右,提升已经很明显了,而且会极大减少镜像中心的压力。

  1. Spegel 的 Charts:https://github.com/spegel-org/spegel/tree/main/charts/spegel ↩︎
  2. Dragonfly https://d7y.io/ ↩︎
  3. 架构图:https://d7y.io/docs/#architecture ↩︎
  4. 一个 Redis 都几 M 就够了 Build 一个最小的 Redis Docker Image ↩︎
  5. 这就是去年年终总结说过的问题,如果人人都是高级工程师,那么问题就不存在了 ↩︎
  6. 项目主页:https://nydus.dev/,也是 dragonfly 的一个项目 ↩︎
  7. AWS 等云厂商也有类似的技术 Under the hood: Lazy Loading Container Images with Seekable OCI and AWS Fargate | Containers ↩︎
  8. Containerd API:https://pkg.go.dev/github.com/containerd/[email protected]#Client.ContentStore ↩︎
  9. 项目代码:https://github.com/spegel-org/spegel,项目主页:https://spegel.dev/ ↩︎
  10. “could not get peer id” and timeouts since 0.0.29 · Issue #709 · spegel-org/spegel ↩︎
  11. https://docs.libp2p.io/concepts/introduction/overview/ ↩︎
 

pwru 工具介绍和案例一则

pwru1 是排查 Linux 网络问题最好的工具,全称是 Packet where are you?

它是怎么工作的呢?

eBFP 可以让我们往 kernel 的函数上添加 hook,当这个 kernel 函数执行的时候,我们可以通过 eBPF 定义一些额外的动作,比如把这个函数和它的参数记录下来然后打印出来。

Linux 启动的时候,会生成 /proc/kallsyms,pwru 通过读取这个文件,找到所有和 skb (网络包在内核中的数据结构)相关的函数,然后 hook 这些函数。这样,在这个包在内核栈经过的路径,就可以用 eBPF 追踪到了2 3

通过这种方法,pwru 几乎可以解决在 Linux 上遇到的任何网络不通的问题,因为通过函数路径可以很快确定这个包经过了哪些函数处理,没有走到哪些函数。对照函数查看源代码,就可以知道原因了。

它的安装方法很简单:apt install pwru 即可4

然后通过 pwru icmp and dest host 1.1.1.1 就可以开始追踪了。包的过滤语法和 tcpdump 一样。

案例分享

今天遇到的问题,还是网络在 Linux 上网络不通了,网络结构很简单,就是默认路由到一个 vxlan driver 的 interface,经过这个 interface 封装,然后通过物理 interface 发送出去。这种网络不通的问题最适合用 pwru 定位了,这个工具可以直接告诉我们包在 Linux 网络栈经过的代码路径。

我们用以下命令来抓这个包,pwru --filter-track-skb --all-kmods dst 10.1.1.100,得到的输出如下:

pwru 抓包的输出

可以看到数据包的确被 Drop 了,虽然原因是 SKB_DROP_REASON_NOT_SPECIFIED,但是不要紧,我们可以看到 drop 之前的函数是 vxlan_get_route。然后就可以去查看对应的 Linux 源代码5

从源码中可以确认,这个 vxlan_get_route 函数返回错误的原因有 2 个:

  • 一个是 vxlan 封包之后使用的 dev 和现在一样,那就是出现环路了,会导致一直封包一直循环;
  • 另一个就是没有路由;

按照包的封装过程查看 ip route,发现确认有一条多加的路由导致环路了,删除即可恢复。

如果没有 pwru 的话,就得依靠网络的经验逐个地方检查,然后判断出来是路由表配置问题。但是有了 pwru,就可以顺藤摸瓜得排查到根因。

  1. https://github.com/cilium/pwru ↩︎
  2. https://cleveruptime.com/docs/files/proc-kallsyms ↩︎
  3. https://github.com/cilium/pwru/blob/db786876d10bc104be1a7908e13902c890e548d0/internal/pwru/ksym.go#L45 ↩︎
  4. 这个是我司在内网打包的,公网不能这么安装,用户需要按照 Github 的文档来安装。 ↩︎
  5. 相应的代码地址:https://elixir.bootlin.com/linux/v5.15.178/source/drivers/net/vxlan/vxlan_core.c#L2428 ↩︎
 

一个由 BGP Route Aggregation 引发的问题

上周遇到的一个问题很有意思,后来搜索相关的资料,找到的也比较少,感觉有必要记录一下。

问题的场景很简单:我们有两个路由设备同时发布了 10.81.0.0/16 的网段做 ECMP1,网络一切正常。拓扑如下图。现在,有一个新的 IP,只存在于 Router A 上,所以 Router A 宣告网段 10.81.100.100/32,而 Router B 不宣告。这样,由于在路由表中,/32 的 prefix 比 /16 要长,所以 Router X 在从路由表选路的时候,10.81.100.100 会优先选择去 Router A,而对于其他的 10.81.0.0/16 的网段,会负载均衡到 A 和 B 两台路由器上。

简化的拓扑图

理论上,一切看似合理并且正常。但是 /32 的网段一经宣告,10.81.0.0/16 的网络都挂了。

事后我们得知,在 Router X 上有一条路由聚合配置。但是这条合理的路由聚合,怎么会让整个网段挂掉呢?

BGP 路由聚合

为什么需要路由聚合呢?

Router A 每次宣告一个网段给 Router X,Router X 的 BGP 路由就会多一个。Router B 每次宣告一个网段,X 上也会多一个。可想而知,Router X 上的路由是它的下游的总和。同理,Router X 上游的路由器的路由将会更多。路由的条目越多,对路由器的性能要求就越高。所以,核心路由器要想处理所有的路由条目,就需要性能非常高。性能是有上限的,假设性能再搞也无法处理这么多路由,怎么办呢?我们可以优化另一个变量——路由条目2

如何减少路由条目呢? 考虑下面 3 个网段:

  • 10.81.2.0/24
  • 10.81.3.0/24
  • 10.83.4.4/26

其实都可以汇聚成一个网段:10.81.0.0/16。把这个网段宣告出去,收到的流量可以在 X 这里根据自己的路由表进行转发。

这里产生了一个问题:就是我们宣告了自己没有路由的网段出去,比如我们的路由中并不存在 10.81.5.0/24 这个段,但是被我们的 10.81.0.0/16 宣告了出去。

由此,会产生两个问题。第一个问题,假设其他路由器有到 10.81.5.0/24 的路由,那么会不会走到我们的 10.81.0.0/16 这里来呢?答案是不会的。因为路由表的匹配规则是最长前缀匹配/24 比我们的 /16 优先级更高。

第二个问题更加严重一些,路由的聚合可能导致环路3

考虑下面这个拓扑图,两个路由器都存在路由聚合的配置。

路由聚合导致环路产生的例子

这里的问题是,10.81.4.1 这个 IP 不存在于 A 也不存在于 B,但是由于路由聚合的配置,A 认为在 B 上,B 认为在 A 上,导致在转发的时候会出现环路。虽然 IP 层有 TTL 机制,会让这个包最终被丢弃,但是也会让两个路由器在某些网段的转发上浪费一些计算资源。

如何避免在转发「不存在的网段」的时候出现的环路呢?一个思路是我们精确的控制聚合的配置,不配置出来可能产生环路的聚合,但是这几乎是不可能的。(就像用静态路由配置替代动态路由一样不可能)。

另一个思路是,在 10.81.4.1 这种本地没有路由的包出现的时候,直接「黑洞」掉。方法很简单,就是在每次聚合的时候,创建一条路由,终点是 Null0,即直接丢弃。

具体来说,在上图的 Router A 中,聚合本地的三条路由到 /16,我们应该这么做:

  • 向外宣告路由 10.81.0.0/16,以达到减少路由条目的目的4
  • 在本地插入一条 Null0 的路由,使得本地的路由最终如下。

注意,路由表的顺序没有意义,因为用的是最长前缀匹配。转发包的时候,对于 10.81.1.0/24 这种本地存在的段,因为它们的前缀比 /16 长,所以正常转发;对于不存在的段,比如 10.81.4.1,会命中 10.81.0.0/16 -> Null0 的路由,直接在本地丢弃。这样,就可以阻止环路的产生。由聚合而自动产生的 /16 是一个防环的兜底路由,正常情况下,不应该使用这条路由,如果命中这条路由,说明无法转发的包到达了路由器,直接丢弃即可。

回到本文开头的问题上,为什么宣告一条 /32 会导致整个网段挂掉呢?Null0 不是说只是兜底而已吗?回答这个问题,还要补充一点知识。

BGP 和路由表

路由设备按照路由表(叫做 RIB, Routing Information Base)进行转发(实际上还有一层加速用的 FIB,但是 FIB 的 source of the truth 是 RIB,所以这里先忽略)。RIB 转发的逻辑是最长前缀匹配。

RIB 是怎么生成的呢?一种是静态配置,即静态路由。另一种是动态路由协议。路由协议之间交换路由信息,然后负责动态修改 RIB。在有多条可达路由的时候,怎么决定把哪一条路由写入到 RIB 呢?这就是不同的路由协议来决定的了。比如,BGP 有 13 条选路原则5;OSPF 和 IS-IS 这种协议也有自己的路径选择算法。

路由协议和 RIB 的关系6

这张图比较好,不同的路由协议可以同时运行,不同的路由协议可以根据自己的算法来操作路由表,决定转发路径。

路由的聚合也是路由协议的一部分。像 OSPF, EIGRP, BGP 这些协议,都有关于路由聚合的定义和支持。重申一下:路由聚合是路由协议的 feature,而不是路由表 RIB 的。

这也就是说,路由聚合中产生的 Null0 黑洞条目首先出现在 BGP 中,然后 BGP 根据自己的选路原则,放到路由表中。

回到本文最先开始讨论的问题,现在就可以用上面的知识来解释这个问题了。

首先,Router X 会收到 3 条路由。

到达 Router X,经过聚合之后,在 BGP 里面,会有 4 条路,多出来的一条是聚合产生的 Null0 黑洞路由。

到达 10.81.0.0/16 的路由有 3 个

BGP 会按照自己的选路原则,在 10.81.0.0/16 的 3 条路径中选择一条放到 RIB 中。这 3 条路径中,Null0 这条可是本地路由,Weight 是最高的。所以,Null0 由于其他两条真实存在的路由,进入了 RIB。

show ip route

可以看到路由表中,只有 10.81.100.100 明细路由和 10.81.0.0/16 到 Null0 的黑洞路由,其他两条路由被刷下去了。

到这里,真相就大白了。10.81.100.100 在没有发布的时候,10.81.0.0/16 工作正常。但是一旦发布,10.81.0.0/16 的正常路由就被路由聚合产生的 Null0 给刷下去了。

  1. 数据中心网络高可用技术:ECMP ↩︎
  2. Understand Route Aggregation in BGP – Cisco ↩︎
  3. 网络中的环路和防环技术 ↩︎
  4. 确认了下没有写错,这里的意思是 Tiao Mu De Mu Di,博大精深的中文! ↩︎
  5. Select BGP Best Path Algorithm – Cisco ↩︎
  6. 来源:FIB表与RIB表的区别与联系 – &Yhao – 博客园 ↩︎
 

2024 年的总结

今年依旧是平静而充实的一年。

特别喜欢 Armin1 的这一段话:

Whether it’s working on a project, solving a difficult problem, or even refining soft skills like communication, the act of showing up and putting in the hours is essential. Practice makes perfect, but more so it’s all about progress rather than perfection. Each hour you spend iterating, refining, failing and retrying brings you closer to excellence. It doesn’t always feel that way in the moment but when you look back at what you did before, you will see your progress. And that act of looking back, and seeing how you improved, is immensely rewarding and in turn makes you enjoy your work.

「注重进步,而非完美」。当知道自己每天在朝着自己的目标进步的时候,每天都有一种充实的感觉。

生活上

年初体验了邮轮2,今年旅行去了东京3。然后去了马六甲和吉隆坡,马六甲和吉隆坡的旅行比较随意,说去就去了,路途上也没有特别惊艳的地方,就连博客都没有写。今年去了很多次新山,新马通道支持自助过关了,方便了很多。

工作

今年有意控制自己喜欢创造新的项目的冲动,所以新的项目开启的不多,自己写了一个有意思的玩具,只不过从来没有公开过,属于自娱自乐,除此之外就没有什么新的项目了,Github 空空,只有偶尔维护一些已有的项目。

大部分的精力花在了工作上,处理了非常多的问题,有意思的一些已经纪录在了博客上,所以今年博客产量稍微多一些。

一如即往,今年在公司里也有一些新项目的尝试。比较自豪的是写了一个 IP 信息查询系统。可以查询一个 IP 对应的设备,机器,容器,虚拟机,VIP,等等,集成了 20 多个系统,并且用了并发加载,后端只用了 500 行左右,虽然小,但是用户体验很不错,个人很满意。

也有一些想法,比如经常在讨论某某技术的时候,一些大公司的人会评价说「这种开源的方案不适用我们 XX 公司的规模,规模一大就不行了。」然后发明一种新的技术宣称能解决超大规模的问题。我发现很多时候规模大是有用户滥用的本质问题的。比如监控系统的 metrics,遇到过好几次,低质量的 metrics 占用了 50% 的存储资源,只要用户优化一下4,就可以节省很多资源;还有镜像存储服务,我们的镜像存储服务的压力越来愈大,但是用户的 docker 镜像的优化空间还很大5,甚至很多镜像中存储了很多不需要的内容。去解决技术问题无法从根本解决滥用问题,规模扩大到多大总有一天还是会遇到规模问题,不如从根本解决用户计费问题来的根本。现在认为,即使是内部产品,也要像公有云那样的思路去做,不能提供免费的服务。另一个思路也能解决问题,就是只招聘专家,每一个人都知道自己在做什么,不做出来浪费的事情。

另一个想法是关于运维系统的透明,即我们做操作所使用平台。我越来越喜欢 Jenkins 了,因为它足够透明和简单,当运维操作失败的时候,我只看 Job 的日志就可以了,从来不需要去排查 Jenkins 自己内部的问题。但是最近公司总是冒出来一个又一个的团队,想要用代码开发出来一个运维平台来给 SRE 使用。然后遇到问题总是得去排查平台的代码问题,最终变得很复杂,得明白运维平台的代码是怎么运行的,才能用的好。怀念 Jenkins 和 Ansible 的操作。

业余的爱好

读书方面,印象比较深的是读了莫言的《丰乳肥臀》,非常震撼。读了很多和网络相关的书。读了一本 The Manager’s Path: A Guide for Tech Leaders Navigating Growth and Change,写的很好,也许后面有空单独写一篇博客。

今年也看了很多影视作品,年末上映的《好东西》非常好看;偶然发现 Disenchantment 已经更新完了,之前只看过第一季,所以今年一口气全部看完了,连着看居然有一些审美疲劳;印象比较深的还有《奥本海默》,《沙丘2》(我今年最喜欢的电影了),Shameless 年初跟了一段时间,感觉烂尾了,已经弃剧。

今年玩的令人印象深刻的游戏是《黑神话·悟空》,已经期待了四年,今年终于玩上了。非常好玩,一点也没有让人失望。下半年腾讯宣布 Switch 游戏机服务器停止维护,作为国服勇士,我可以领四个游戏,最喜欢的是超级马里奥兄弟,和欣一块玩,不亦乐乎,已经通关了,现在再玩第二遍,寻找所有的隐藏金币。

欣对星星比较着迷,跟着普及了一下天文学的知识,能看到星星的夜晚都不会浪费掉,会去公园的躺椅上辨认一颗颗星星。

新的一年

  • 打算继续锻炼身体,2024 年锻炼的还是不够多,今年要多花点时间锻炼才行;
  • 打算带爸妈去新加坡和泰国旅行,已经订好酒店了,期待;
  • 继续在网络方面的研究,还有很多想要学习的内容在排队;

其他的年终总结列表:

  1. 2013年
  2. 2014年
  3. 2015年
  4. 2016年
  5. 2017年
  6. 2018年
  7. 2019年
  8. 2020年
  9. 2021年
  10. 2022年
  11. 2023年
  12. 2024年
  1. Armin 写的人生反思:https://lucumr.pocoo.org/2024/12/26/reflecting-on-life/ ↩︎
  2. 去远航 ↩︎
  3. 去伊豆去东京 ↩︎
  4. 程序的 Metrics 优化——Prometheus 文档缺失的一章 ↩︎
  5. Docker 镜像构建的一些技巧 ↩︎
 

用 LD_PRELOAD 写魔法程序

我和我的同事们排查网路问题非常喜欢 MTR,它是 traceroute 和 ping 的结合,可以快速告诉我们一个网络包的路径。是哪一跳丢包,或者延迟太高。

这些路径使用 IP 地址的形式表示的。没有人能记住这么多 IP 地址,所以我们需要有意义的名字。我在公司里写了一个平台,集成了其他的二十多个系统,给一个 IP,能查询出来这个 IP 对应的网络设备,容器,物理机,虚拟机等等。

复制 IP 到这个系统中查看结果,还是有些不方便,于是就想能不能让 MTR 直接展示设备的名字。

最终效果如图,可以展示 mtr 路径中所有的网络设备的名字。敏感信息已经隐藏。

MTR 支持 DNS PTR 反查,如果查到记录,会优先展示名字。这些名字在公网上通常没有什么意义。我们的内网 DNS 没有支持 PTR,所以这个 PTR 记录在默认的情况下也没有什么用。如果通过 DNS 系统来支持 PTR 记录的话,成本就有些大了,得对 DNS 做一些改造,DNS 又是一个比较重要的系统。那能不能有一个影响比较小的旁路系统来做到这个 feature 呢?

看了下 MTR 的代码,MTR 对 DNS PTR 的支持是通过 libc 的函数 getnameinfo(3) 来实现的。那么我就可以用 LD_PRELOAD 这个 hack,自己写一个 getnameinfo(3) 编译成 so,告诉 MTR 在寻找 getnameinfo(3) 的时候,先寻找我的 so 文件。这样,我就可以自己定义 getnameinfo(3) 的行为了。(就像魔法一样)

其实,proxychains1 程序也是用这种方式工作的,你只要在运行的命令前面添加 proxychains,proxychains 就会对后面运行的命令注入 LD_PRELOAD 环境变量,从而让程序调用的 socket API 是 proxychains 定义的,然后 proxychains 就会对 socket 做一些代理转发。

POC

可以写一个最简单的程序验证这样是否可行。

我们写一个最简单的函数,声明和 getnameinfo(3) 一模一样。

不过,这个函数无论对于什么 ip,都会返回 kawabangga.com. 然后编译,运行 traceroute 程序。

编译命令:gcc -shared -fPIC -o libmylib.so mylib.c -ldl

可以看到,traceroute 显示每一跳的名字都是 kawabangga.com 了。

用 Go 语言 POC

我比较倾向于用 Go 语言来实现逻辑,而不是用 C 语言。

Go 语言也是支持编译到 shared lib 的2。hello world 代码如下:

编译命令是:go build -o libmylib.so -buildmode=c-shared mylib.go

程序运行的命令一样,也可以看到 getnameinfo(3) 被成功 hook 了。

剩下的只需要在程序里面写逻辑代码就可以了,应该很简单(实际发现不简单)。

遇到问题 1: log 不打印

因为 traceroute 和 mtr 这种程序都是往 stdout 打印的,我开发代码又需要用 print 来调试,所以为了不干扰正常输出,就把日志打印到一个文件,通过 ENV 来控制日志是否需要打印,以及打印的日志路径。

结果就遇到了问题:日志打印在 traceroute 中是正常的,但是在 mtr 中看不到日志。

因为程序是「寄生」在 mtr 的代码中的,而且在 traceroute 中没有问题,所以应该和 mtr 的代码有关。

去看了一下代码,发现 mtr 和 traceroute 不一样的地方是:mtr 是用了异步的方式来执行 getnameinfo 函数,因为这个函数可能使用 DNS PTR 记录,涉及到网络请求,耗时可能很长。所以在调用的时候,mtr 会 fork 一个进程,专门执行这个函数。fork 出来的进程使用 PIPE 和主进程通信,并且 fork 之后就把除了 stdin, stdout, stderr 和 PIPE 之外的 fd 都关闭了。跟着关闭的,也包括我们的日志文件 fd。

解决办法就是修改了一下程序,不写日志到文件了,而是写到 stderr。在 debug 的时候,就 mtr 2>/tmp/stderr.log 这样,就可以了。

问题2: Golang 程序卡住

之前的 POC 代码运行正常,我把它改成通过 HTTP 请求 IP 信息服务的时候,居然就出问题了。mtr 显示的是 IP 而不是名字。从现象看,是函数执行失败了。

但是失败在哪里呢?

经过一段时间的排查,发现了这么几个现象:

  1. 每次程序运行之后,都有一些 mtr 进程残留在系统中没有结束;
  2. traceroute 还是正常的,但是 mtr 每次都会出问题;
  3. 不断加 print 来 debug,发现程序的问题出现在发送 HTTP 请求的地方,但是把这个地方的代码改成直接返回固定字符串,程序就正常了;

使用 gdb 去 debug,backtrace 如下,也看不出什么信息。

Golang 程序的 backtrace

看起来这个线程好像没有什么事情可以做。

花了一天排查无果,问了朋友,最后发现这个问题:Goroutines cause deadlocks after fork() when run in shared library #155383,而且开发人员的回复是:This is to be expected. It’s almost impossible for multithreaded Go runtime to handle arbitrary forks.

而 mtr 正好执行了 fork,所以这算是一个 Golang 的 runtime 问题——如果以 shared-lib 的方式运行,那么主程序是不能 fork 的,如果 fork,Go runtime 中的 goroutine 管理与多线程模型,fork 后线程状态的不一致可能会导致无法正常恢复,从而触发死锁。

最后,我把程序的逻辑用 C 语言实现了一下,就没有问题了。把它打包成 deb 包发布到了内网中。打包推荐用 nfpm4,非常方便,传统的用 apt 工具链打包太复杂了。

  1. Proxychains 项目:https://github.com/rofl0r/proxychains-ng,我的博客:编译安装proxychains4 ↩︎
  2. Fun building shared libraries in Go, https://medium.com/@walkert/fun-building-shared-libraries-in-go-639500a6a669 ↩︎
  3. https://github.com/golang/go/issues/15538 ↩︎
  4. https://github.com/goreleaser/nfpm ↩︎