退群「Hall of shame」

September 3, 2024

在 GO 1.23 版本中,GO Linker 将来会限制 //go:linkname 直接引用标准库中不可访问的对象。 看到这个消息后,我立刻用 GO 1.23.0 编译了 containerd,发现即使不加 -checklinkname=0 也能正常工作。 之前为了支持 ID-mapped mount, 我在 containerd 项目里使用了 GO runtime 的隐藏功能,算是语言层面的灵活运用。 现在可倒好,GO 团队直接在代码里把 containerd 列为 Hall of shame 成员。 这种强行拉入群的形式实在无力吐槽,只能想想如何退群了。

为什么要使用 go:linkname

Kubernetes 社区之所以能落地 User Namespace 特性,是因为 Linux 社区在 2022 年引入了 ID-mapped mount 的概念。 原先,文件属性中的 UID 和 GID 是固定的,只有特定的用户或用户组才能访问相应的文件。 容器镜像中的文件大多是由 root 用户创建的,如果要让非特权用户访问镜像中的所有内容,过去唯一的办法是使用 chown -R 来物理改变所有文件的 UID 和 GID。

Image Size Inodes overlayfs w/ metacopy overlayfs w/o metacopy
tensorflow/tensorflow:latest 1489MiB 32596 1.29s 54.80s
library/node:latest 1425MiB 33385 1.18s 52.86s
library/ubuntu:22.04 83.4MiB 3517 0.15s 5.32s

现在有了 ID-mapped mount 的功能后,原本只有 UID=0/GID=0 用户可以访问的目录,只需几次系统调用就可以让 UID=1001/GID=1001 的用户访问。 ID-mapped mount 在内核层面实现 ID 映射,比传统的 chown -R 方法在性能和效率上都有显著提升。

mount-idmapped-bind

NOTE: 具体细节可以查看之前的文章 - containerd 1.7: UserNamespace Stateless Pod

不过,ID-mapped mount 需要提供 user_namespace(7) 的句柄信息,而这种信息只能通过创建新进程(使用 CLONE_USER)来获取(/proc/$PID/ns/user 句柄)。 由于 GO 对多进程编程的支持有限,标准库中只有 os.StartProcess 接口,而它采用了 fork 和 execve 组合的方式。 换句话说,多进程的 官方 方法只能通过调用命令行来实现,这要求开发者为每个进程逻辑准备一个独立的二进制文件。 为了简化运维,常见的做法是将每个独立逻辑封装成子命令,并通过调用自身的 /proc/self/exe 来完成,这种方法被称为 re-exec

然而,这种方法的成本太高。相当于每个引用了 ID-mapped mount 功能的程序都需要一个专门的命令行来创建新的 user_namespace。 因此,在 containerd 的 GetUsernsFD 中,模拟了 os.StartProcess 的 fork 流程,并将最后的 execve 替换为 syscall.RawSyscall(syscall.SYS_PPOLL, 0, 0, 0),使子进程进入无限期的睡眠状态。 这样,父进程 containerd 在 fork 返回时,子进程已经运行在新的 user_namespace 中,父进程可以安全地获取子进程的 user_namespace 句柄,而无需担心进程间的保活问题。

在整个 fork 过程中,子进程处于单线程模式,不具有完整的 GO runtime 逻辑。因此,需要使用 GO runtime 中的三个隐藏功能来屏蔽信号处理,并防止父子进程间发生栈大小的变化。这些隐藏功能只能通过 //go:linkname 指令来激活。 在我看来,这本属于灵活使用,本身 GO 标准库都是这么玩的 :)。

//go:linkname beforeFork syscall.runtime_BeforeFork
func beforeFork()

//go:linkname afterFork syscall.runtime_AfterFork
func afterFork()

//go:linkname afterForkInChild syscall.runtime_AfterForkInChild
func afterForkInChild()

当然,子进程也只能使用 RawSyscall 来完成初始化工作。这是为了避免进入不应该进入的流程,例如释放当前的执行线程。

替换方案 ptrace

我并不想为了 fork 而引入一个子命令,所以给的选择也就只有 ptrace,如下面的代码所示。

os.StartProcess("/proc/self/exe", []string{"containerd[getUsernsFD]"}, &os.ProcAttr{
	Sys: &syscall.SysProcAttr{
		Cloneflags:  unix.CLONE_NEWUSER,
		UidMappings: uidMaps,
		GidMappings: gidMaps,
		// NOTE: It's reexec but it's not heavy because subprocess
		// be in PTRACE_TRACEME mode before performing execve.
		Ptrace:    true,
		Pdeathsig: syscall.SIGKILL,
	},
})

根据 ptrace(2) 的文档说明,在替换完程序入口指令后,子进程在返回用户态前会进入停止状态,即 tracing stop。 以下是命令 retsnoop -e '*_send_signal_locked*' --comm exe -S 的输出示例。 在执行 exec_binrpm 时,会触发 PTRACE_EVENT_EXEC 事件,然后在 exit_to_user_mode_loop 中处理掉该信号,使得进程主动进入 ptrace_stop 状态。

07:42:51.340953 -> 07:42:51.340956 TID/PID 2854060/2854060 (exe/exe):

              entry_SYSCALL_64_after_hwframe+0x73
              do_syscall_64+0x56
              x64_sys_call+0x680
              __x64_sys_execve+0x37
              do_execveat_common.isra.0+0x198
              bprm_execve+0x5e
              bprm_execve.part.0+0x17b
              exec_binprm+0x15a
              send_sig+0x29
              do_send_sig_info+0x63
              send_signal_locked+0xdd
!    2us [0]  __send_signal_locked


07:42:51.341038 -> 07:42:51.341043 TID/PID 2854060/2854060 (exe/exe):

              entry_SYSCALL_64_after_hwframe+0x73
              do_syscall_64+0x63
              syscall_exit_to_user_mode+0x17
              exit_to_user_mode_prepare+0xf4
              exit_to_user_mode_loop+0xaa
              arch_do_signal_or_restart+0x2a
              get_signal+0x5c1
              ptrace_signal+0x4e
              ptrace_stop.part.0+0x246
              do_notify_parent_cldstop+0x17d
              send_signal_locked+0xdd
!    4us [0]  __send_signal_locked

这与之前使用 poll 进入睡眠状态的本质是一样的,因为子进程并不需要执行具体的任务。 有了这种能力后,可以直接使用 re-exec,利用现成的二进制文件。所以在 os.StartProcess 中,第一个参数直接指定为 /proc/self/exe

os.StartProcess 通过配置一个带有 CLOEXEC 标志的匿名管道的写端来与子进程通信,该函数会在子进程进入 execve 系统调用的 do_close_on_exec 阶段后才返回。 也就是说,当 os.StartProcess 返回时,子进程已经运行在了新的 user_namespace 中。

相比之前的方法,os.StartProcess 需要执行 execve 系统调用,这意味着它会读取程序的内容并解析入口指令的位置。虽然这种方法在成本上略高,但依然在可以接受的范围内。

goos: linux
goarch: amd64
pkg: github.com/containerd/containerd/v2/core/mount
cpu: AMD Ryzen 7 5800H with Radeon Graphics
                                    │ go122-golinkname │             go122-ptrace              │             go123-ptrace              │
                                    │      sec/op      │    sec/op     vs base                 │    sec/op     vs base                 │
BatchRunGetUsernsFD_Concurrent1-16        533.3µ ± ∞ ¹   739.6µ ± ∞ ¹        ~ (p=1.000 n=1) ²   723.3µ ± ∞ ¹        ~ (p=1.000 n=1) ²
BatchRunGetUsernsFD_Concurrent10-16       3.662m ± ∞ ¹   4.024m ± ∞ ¹        ~ (p=1.000 n=1) ²   3.957m ± ∞ ¹        ~ (p=1.000 n=1) ²
geomean                                   1.397m         1.725m        +23.45%                   1.692m        +21.06%
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

                                    │ go122-golinkname │              go122-ptrace               │              go123-ptrace               │
                                    │       B/op       │     B/op       vs base                  │     B/op       vs base                  │
BatchRunGetUsernsFD_Concurrent1-16       1.118Ki ± ∞ ¹   3.855Ki ± ∞ ¹         ~ (p=1.000 n=1) ²   4.121Ki ± ∞ ¹         ~ (p=1.000 n=1) ²
BatchRunGetUsernsFD_Concurrent10-16      11.29Ki ± ∞ ¹   38.67Ki ± ∞ ¹         ~ (p=1.000 n=1) ²   41.36Ki ± ∞ ¹         ~ (p=1.000 n=1) ²
geomean                                  3.553Ki         12.21Ki        +243.65%                   13.06Ki        +267.43%
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

                                    │ go122-golinkname │             go122-ptrace             │             go123-ptrace             │
                                    │    allocs/op     │  allocs/op   vs base                 │  allocs/op   vs base                 │
BatchRunGetUsernsFD_Concurrent1-16         43.00 ± ∞ ¹   68.00 ± ∞ ¹        ~ (p=1.000 n=1) ²   69.00 ± ∞ ¹        ~ (p=1.000 n=1) ²
BatchRunGetUsernsFD_Concurrent10-16        421.0 ± ∞ ¹   671.0 ± ∞ ¹        ~ (p=1.000 n=1) ²   682.0 ± ∞ ¹        ~ (p=1.000 n=1) ²
geomean                                    134.5         213.6        +58.76%                   216.9        +61.23%
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

最后

core/mount: use ptrace instead of go:linkname 合并后,我第一时间给 GO 社区发起了退群请求 - 609996: runtime: update comment for golinkname.