☰
docker-ce 中 runc libcontainer nsenter 包解析:在 Go 运行时启动前完成容器命名空间引导
2026/10/14 16:17:29 网站建设 项目流程
  • 容器运行时
  • 云原生

【免费下载链接】docker-ce

:warning: This repository is deprecated and will be archived (Docker CE itself is NOT deprecated) see the https://github.com/docker/docker-ce/blob/master/README.md :warning:

项目地址:https://gitcode.com/gh_mirrors/do/docker-ce
点击查看免费下载

nsenter是 runc 的 libcontainer 库中一个"特殊"的 Go 包:它通过 cgo 与 C 语言的构造函数机制,在 Go 运行时正式启动之前就执行nsexec(),完成加入既有命名空间、创建新命名空间、配置用户/组 ID 映射等关键工作,从而绕开 Go 运行时多线程模型与setns(2)之间的固有冲突。本文以 nsenter/README.md 为主线,结合 docker-ce 中 vendored 的 runc 源码实现,讲解该包的注册机制、nsexec()三阶段执行流程、命名空间加入与创建原理,以及它在容器启动链路中承担的安全防护职责。

nsenter 包的设计初衷:为什么必须在 Go 运行时启动前介入

libcontainer 是 runc 用于创建容器的底层库,它依赖 Linux 内核的 namespaces、cgroups、capabilities 与文件系统访问控制能力。其中,"加入既有命名空间"依赖setns(2)系统调用,而 Go 运行时是多线程的(runtime 会自行创建线程、迁移 goroutine)。Linux 内核的setns(2)只作用于调用它的那个线程,Go 运行时一旦启动,其内部线程模型就会导致命名空间切换不彻底、难以保证所有线程都在目标命名空间中运行。

nsenter包的设计正是为了解决这个问题:它注册了一个特殊的 init 构造函数,在 Go 运行时获得机会启动之前就被调用。正如 README.md 所述:

Thensenterpackage registers a special init constructor that is called before the Go runtime has a chance to boot. This provides us the ability tosetnson existing namespaces and avoid the issues that the Go runtime has with multiple threads.

也就是说,容器进程被 exec 之后,第一条执行的"业务代码"就是 C 语言层的nsexec(),它在一个可控的、单线程的、尚未被 Go runtime 干扰的环境里完成全部命名空间设置,随后才把控制权交还给 Go 运行时去执行容器 init 流程。

触发链路:一次空导入如何驱动 C 代码执行

nsenter包使用 cgo,其核心注册逻辑位于 nsenter.go:

// +build linux,!gccgo package nsenter /* #cgo CFLAGS: -Wall extern void nsexec(); void __attribute__((constructor)) init(void) { nsexec(); } */ import "C"

这段代码的关键点在于:

  1. 包内执行了import "C",启用 cgo;
  2. 紧邻import "C"之前的注释被称为preamble(前导头),会被当作 C 代码编译进本包;
  3. preamble 中定义了一个带有__attribute__((constructor))属性的init()函数,这意味着只要包被链接进最终二进制,该构造函数就会在main()(即 Go runtime 启动)之前自动执行;
  4. 构造函数内部调用nsexec(),从而完成了"导入包 → 执行 C 构造函数 → 调用 nsexec()"的触发链路。

README 特别强调:由于 Go 语言中只有真正被引用的包才会被链接,如果只是把nsenter放在代码里却不导入,编译器会将其裁掉,构造函数也就不会执行。因此必须使用**空导入(blank import)**强制其生效:

import _ "github.com/opencontainers/runc/libcontainer/nsenter"

nsenter包只在 init 引导文件中被导入,因此每次 runc 的init命令被调用时,对应的 C 代码都会执行一次——这就是"re-exec(重新执行自身二进制并传入 init 参数)"能够正确进入命名空间的前提。

不同编译器的适配实现

仓库中针对不同编译环境提供了三种实现变体:

  • nsenter.go:构建标签为linux,!gccgo,面向标准 gc 编译器;
  • nsenter_gccgo.go:构建标签为linux,gccgo,面向 gccgo 编译器。gccgo 对 cgo 构造函数的链接处理存在缺陷(见 GCC bug 65134),因此这里通过一个导出的AlwaysFalse变量配合init()中的死代码引用C.init(),强制编译器把 C 构造函数链接进最终程序;
  • nsenter_unsupported.go:构建标签为!linux !cgo,在非 Linux 或未启用 cgo 的环境下提供一个空包占位,避免编译失败。

nsexec() 核心流程:init pipe 与引导数据读取

nsexec()的实现位于 nsexec.c,其执行可以概括为以下几步(与 README 描述一致):

  1. 获取 init pipe 的文件描述符:从环境变量_LIBCONTAINER_INITPIPE中读取管道号。这个管道由父进程打开,并在 fork-exec 子进程时保持打开状态,从而跨越 fork-exec 传递到nsexec()进程中。源码中对应initpipe()函数,位于 nsexec.c:若环境变量缺失或无法解析,则直接返回 -1(此时nsexec()不做任何操作,直接回到 Go 流程)。
  2. 通过 init pipe 读取引导(bootstrap)数据:父进程通过 netlink 消息发送的引导数据包含:命名空间路径(namespace paths)、clone flags、uid/gid 映射以及控制台路径。nsexec()通过nl_parse()解析这些数据到struct nlconfig_t(见 nsexec.c)。
  3. 执行命名空间设置:对引导数据中提供的命名空间调用setns(2)加入;随后用给定的 clone flags 通过clone(2)派生子进程。
  4. 更新用户/组 ID 映射:向父进程协商后写入/proc/<pid>/uid_map与/proc/<pid>/gid_map。
  5. 回传 PID:将子进程的 PID 通过 init pipe 发回给nsexec()的"调用者"(即 runc 主进程)。
  6. 交接控制权:父侧nsexec()退出,子侧nsexec()返回,Go 运行时接管,继续执行容器 init 流程。

README 中有一个值得特别注意的说明:

NOTE: We do bothsetns(2)andclone(2)even if we don't have anyCLONE_NEW*clone flags because we must fork a new process in order to enter the PID namespace.

也就是说,即使不需要创建任何新命名空间,nsexec()也必然执行setns(2)与clone(2)——因为PID namespace 只对其子进程生效,调用进程自身的 PID namespace 无法通过setns(2)或unshare(2)改变(改变后getpid()的返回值会与调用者自身的认知冲突,破坏大量应用程序和库)。因此必须 fork 一个新进程才能真正进入新的 PID namespace。

三阶段执行模型:stage-0 / stage-1 / stage-2 与同步状态机

nsexec()最精妙的部分是它采用setjmp/longjmp实现的三阶段执行模型(见 nsexec.c),配合两个 socketpair 管道实现父子进程间同步:

阶段常量角色定位主要职责
stage-0STAGE_PARENTrunc:[0:PARENT]创建 stage-1 子进程,在收到请求后写/proc/<pid>/uid_map、gid_map,转发 stage-2 的 PID 给 runc,最后退出
stage-1STAGE_CHILDrunc:[1:CHILD]setns(2)加入既有命名空间,unshare(2)创建新命名空间,请求父进程配置 userns 映射,再创建 stage-2 子进程
stage-2STAGE_INITrunc:[2:INIT]等待SYNC_GRANDCHILD信号后执行setsid()、setuid(0)、setgid(0)、按需unshare(CLONE_NEWCGROUP),最后返回让 Go 运行时接管

同步协议(sync_t 状态值)

父子进程通过 socketpair 交换enum sync_t状态值完成握手(见 nsexec.c):

状态值含义
SYNC_USERMAP_PLS(0x40)stage-1 请求父进程映射用户命名空间
SYNC_USERMAP_ACK(0x41)父进程确认映射完成
SYNC_RECVPID_PLS(0x42)stage-1 通知父进程即将发送 PID
SYNC_RECVPID_ACK(0x43)父进程确认收到 PID
SYNC_GRANDCHILD(0x44)父进程通知 stage-2 可以运行
SYNC_CHILD_FINISH(0x45)子进程(或孙进程)已完成阶段工作

另有CREATECGROUPNS(0x80),专门用于 cgroup namespace 的创建同步,它与process_linux.go中定义在 Go 侧的常量createCgroupns保持一致(见源码注释,nsexec.c)。

引导数据的 netlink 属性

nsexec()与 runc 主进程之间通过 netlink 消息交换引导数据,消息类型为INIT_MSG(62000),属性编号与含义如下(nsexec.c):

属性编号内容
CLONE_FLAGS_ATTR27281clone flags(32 位整数)
NS_PATHS_ATTR27282需要 join 的命名空间路径列表
UIDMAP_ATTR27283uid 映射
GIDMAP_ATTR27284gid 映射
SETGROUP_ATTR27285setgroups 策略
OOM_SCORE_ADJ_ATTR27286oom_score_adj 调整值
ROOTLESS_EUID_ATTR27287是否为 rootless(euid 非 0)容器
UIDMAPPATH_ATTR27288映射工具路径(如 newuidmap)
GIDMAPPATH_ATTR27289映射工具路径(如 newgidmap)

这些属性由父进程(runc 的start或exec路径)写入 init pipe,nsexec()侧通过nl_parse()读取并填充配置结构。

命名空间加入与创建:join 顺序与 unshare 的取舍

setns(2):加入既有命名空间

join_namespaces()处理引导数据中给出的命名空间列表(格式为type:path,以逗号分隔),其实现见 nsexec.c。这里有三个工程细节:

  1. 先打开所有 fd 再逐个 setns:因为一旦加入了 mnt namespace,进程可能无法再访问原来的路径,所以必须先一次性open()所有 namespace 文件;
  2. join 顺序必须遵循用户命名空间优先:代码注释明确指出 "We should always join the user namespacefirst",该顺序由 Go 侧container_linux.go保证,nsexec()只是忠实地按给定顺序执行;
  3. 命名空间名称到CLONE_NEW*标志的映射由nsflag()完成(nsexec.c),各 flag 取值集中定义在 namespace.h。

unshare(2) + clone(2):创建新命名空间

在 stage-1 中,命名空间的创建遵循一套非常讲究的顺序(见 nsexec.c 内的大段注释,出自 runc 维护者 Aleksa Sarai):

  • 用户命名空间必须最先处理:如果 cloneflags 中包含CLONE_NEWUSER,先在 stage-1 中单独unshare(CLONE_NEWUSER),然后请求 stage-0 父进程写入 uid/gid 映射,映射完成后用setresuid(0, 0, 0)在命名空间内成为 root;
  • 其余命名空间通过一次 unshare 批量创建:unshare(config.cloneflags & ~CLONE_NEWCGROUP),cgroup namespace 被单独留到 stage-2 处理(因为需要等待 cgroup 资源约束由p.manager.Apply()完成后再创建);
  • 必须再 fork 一次(stage-2):正如前文所述,setns(2)和unshare(2)都无法改变调用进程自身的 PID namespace,只有 fork 出的子进程才会落在新的 PID namespace 中。

选择unshare而非把全部 flag 塞进clone(),是为了解决历史上 SELinux 相关的内核缺陷:在某些内核上一次性 clone 所有命名空间会导致 namespace 对象归属错误(例如 mqueue 内部内核挂载的 SELinux 标签在 UTS namespace 先于 USER namespace 映射时出错);而如果先unshare(CLONE_NEWUSER)再 clone 又会因为 unshare 丢弃旧命名空间中的全部 capabilities 导致父进程失去配置权限。此外,老内核(Linux 3.12 及更早)中CLONE_PARENT与CLONE_NEWPID组合存在 bug,也是这套"先 unshare、后双 fork"设计的诱因之一(相关修复见内核上游 commit 1f7f4dde5c945f41a7abc2285be43d918029ecc5)。这些考量都在源码注释中得到了完整记录,是理解整个流程设计的重要背景。

用户命名空间映射与 rootless 容器支持

nsexec()在 stage-0 中负责完成 uid/gid 映射写入,这是 rootless 容器得以实现的基础:

  • 常规路径:直接向/proc/<pid>/uid_map、/proc/<pid>/gid_map写入映射字符串(update_uidmap/update_gidmap,见 nsexec.c);
  • EPERM 回退路径:如果直接写入因权限不足返回-EPERM,则 fork 并 execnewuidmap/newgidmap(由UIDMAPPATH_ATTR/GIDMAPPATH_ATTR指定的工具路径),这是 rootless 多条目映射场景下的标准做法(try_mapping_tool,nsexec.c);
  • setgroups 策略:update_setgroups()在写 gid_map 之前处理/proc/<pid>/setgroups,写"allow"或"deny"(Linux 3.19 起内核要求在 gid_map 写入前必须显式处理)。rootless 单条目映射场景下显式 deny,多条目场景下配合 newuidmap/newgidmap 使用 allow;
  • rootless 标识:ROOTLESS_EUID_ATTR标记是否 rootless,stage-2 中据此跳过setgroups(0, NULL)(nsexec.c)。

另外,nsexec()在 stage-1 中会先处理OOM_SCORE_ADJ_ATTR更新/proc/self/oom_score_adj,并调用prctl(PR_SET_DUMPABLE, 0)将进程设为不可转储(non-dumpable),以避免加入的命名空间中的进程借由可转储进程访问宿主机资源;仅在无命名空间可 join(如 rootless 场景)时跳过此设置,这与内核社区的建议一致。

安全防护:ensure_cloned_binary 与 CVE-2019-5736

nsexec()在解析引导数据之前会调用ensure_cloned_binary()(声明见 nsexec.c,实现位于 cloned_binary.c)。其目的是防止容器通过/proc/self/exe访问并篡改宿主机的 runc 二进制,这正是 CVE-2019-5736 的漏洞形态。

具体机制如下:

  1. is_self_cloned()检查当前程序是否已经是"自我克隆"版本:优先通过fcntl(fd, F_GET_SEALS)判断/proc/self/exe是否是一个完全 sealed 的 memfd(RUNC_MEMFD_SEALS=F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE),否则检查_LIBCONTAINER_CLONED_BINARY环境变量、只读文件系统挂载(MS_RDONLY)以及硬链接数为 0 等特征;
  2. 如果不是,则把自身二进制复制到一个安全位置:首选memfd_create(内存文件,可加 seal),退而求其次用O_TMPFILE匿名文件,再不行用mkostemp创建临时文件并立即 unlink;
  3. 对复制的二进制加 seal(F_ADD_SEALS),防止其内容被修改;对于普通文件类型,还会先fchmod(0100)再通过/proc/self/fd/<fd>以O_PATH重新打开以避免 execve 返回-ETXTBSY;
  4. 设置_LIBCONTAINER_CLONED_BINARY=1环境变量后通过fexecve()重新执行自己。

这样容器进程看到的/proc/self/exe是一个只读、不可写、sealed 的自我副本,从根源上杜绝了容器内进程覆盖宿主机 runc 可执行文件的攻击路径。

日志通道与 JSON 转义

为了在 C 层排障,nsexec()支持通过_LIBCONTAINER_LOGPIPE环境变量获得日志管道(setup_logpipe()),并以 JSON 格式输出带 level 与阶段标签的日志(如{"level":"debug", "msg": "nsexec-1[123]: ..."},见write_log(),nsexec.c)。由于日志消息可能包含不可见字符,仓库还专门提供了 JSON 字符串转义实现 escape.c 及其单元测试 test/escape.c 与 test/escape.go。测试覆盖了"、\、\n、\r、\b、\t、\f以及 DEL(0x7f)和 0x01–0x1f 控制字符的转义行为,并验证 UTF-8 多字节字符(如Barney Bär)不被错误转义。

在应用中使用 nsenter:完整的 init 引导示例

要在自己的程序中直接使用 libcontainer,必须完成两件事:一是空导入nsenter,二是在init()中处理init参数。docker-ce 中 vendored 的 libcontainer/README.md 给出了完整的引导示例:

import ( _ "github.com/opencontainers/runc/libcontainer/nsenter" ) func init() { if len(os.Args) > 1 && os.Args[1] == "init" { runtime.GOMAXPROCS(1) runtime.LockOSThread() factory, _ := libcontainer.New("") if err := factory.StartInitialization(); err != nil { logrus.Fatal(err) } panic("--this line should have never been executed, congratulations--") } }

这段代码揭示了容器启动的"两步走"模型:容器创建时,runc 以init参数重新执行自身二进制(bootstrap 进程)。此时:

  1. Go 链接器已经因为空导入把nsenter包纳入二进制,__attribute__((constructor))构造函数先于 Go runtime 执行nsexec();
  2. nsexec()从_LIBCONTAINER_INITPIPE管道读取引导数据、加入/创建命名空间、完成映射配置;
  3. 控制权交还 Go 运行时,此时init()已被调用,识别到init参数后执行StartInitialization()完成容器初始化。

注意runtime.GOMAXPROCS(1)与runtime.LockOSThread()的配合——它把 Go 运行时收缩到单线程并锁定当前 OS 线程,进一步规避多线程下的命名空间一致性问题。初始化成功后panic兜底(理论上永远不应执行到这一行)。

小结

nsenter包虽然代码量不大,却是 runc/libcontainer 乃至 docker-ce 容器引擎正确性、安全性的关键一环:

  • 时机即一切:通过 C 构造函数在 Go 运行时启动前执行,绕开多线程模型与setns(2)的冲突;
  • 双通道通信:_LIBCONTAINER_INITPIPE(引导数据 + PID 回传)与_LIBCONTAINER_LOGPIPE(调试日志)两条管道构成了 nsexec 与 runc 主进程的完整交互协议;
  • 三阶段 + 双 fork:stage-0/1/2 通过 socketpair 与setjmp/longjmp协同,解决了用户命名空间映射、PID namespace 进入、cgroup namespace 时机等一连串内核层面的难题;
  • 安全加固:memfd 自我克隆机制从攻击面上封堵了 CVE-2019-5736。

对希望在 Go 程序中直接使用 libcontainer 的开发者而言,import _ "github.com/opencontainers/runc/libcontainer/nsenter"是必须牢记的"第一行代码"——它决定了你的容器进程能否正确进入目标命名空间。更多实现细节可深入阅读本仓库中的 nsexec.c、cloned_binary.c 与 libcontainer/README.md。

  • 容器运行时
  • 云原生

【免费下载链接】docker-ce

:warning: This repository is deprecated and will be archived (Docker CE itself is NOT deprecated) see the https://github.com/docker/docker-ce/blob/master/README.md :warning:

项目地址:https://gitcode.com/gh_mirrors/do/docker-ce
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询