退群「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
方法在性能和效率上都有显著提升。
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.