- 容器运行时
- 云原生
【免费下载链接】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:
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 所述:
The
nsenterpackage 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"这段代码的关键点在于:
- 包内执行了
import "C",启用 cgo; - 紧邻
import "C"之前的注释被称为preamble(前导头),会被当作 C 代码编译进本包; - preamble 中定义了一个带有
__attribute__((constructor))属性的init()函数,这意味着只要包被链接进最终二进制,该构造函数就会在main()(即 Go runtime 启动)之前自动执行; - 构造函数内部调用
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 描述一致):
- 获取 init pipe 的文件描述符:从环境变量
_LIBCONTAINER_INITPIPE中读取管道号。这个管道由父进程打开,并在 fork-exec 子进程时保持打开状态,从而跨越 fork-exec 传递到nsexec()进程中。源码中对应initpipe()函数,位于 nsexec.c:若环境变量缺失或无法解析,则直接返回 -1(此时nsexec()不做任何操作,直接回到 Go 流程)。 - 通过 init pipe 读取引导(bootstrap)数据:父进程通过 netlink 消息发送的引导数据包含:命名空间路径(namespace paths)、clone flags、uid/gid 映射以及控制台路径。
nsexec()通过nl_parse()解析这些数据到struct nlconfig_t(见 nsexec.c)。 - 执行命名空间设置:对引导数据中提供的命名空间调用
setns(2)加入;随后用给定的 clone flags 通过clone(2)派生子进程。 - 更新用户/组 ID 映射:向父进程协商后写入
/proc/<pid>/uid_map与/proc/<pid>/gid_map。 - 回传 PID:将子进程的 PID 通过 init pipe 发回给
nsexec()的"调用者"(即 runc 主进程)。 - 交接控制权:父侧
nsexec()退出,子侧nsexec()返回,Go 运行时接管,继续执行容器 init 流程。
README 中有一个值得特别注意的说明:
NOTE: We do both
setns(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-0 | STAGE_PARENT | runc:[0:PARENT] | 创建 stage-1 子进程,在收到请求后写/proc/<pid>/uid_map、gid_map,转发 stage-2 的 PID 给 runc,最后退出 |
| stage-1 | STAGE_CHILD | runc:[1:CHILD] | setns(2)加入既有命名空间,unshare(2)创建新命名空间,请求父进程配置 userns 映射,再创建 stage-2 子进程 |
| stage-2 | STAGE_INIT | runc:[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_ATTR | 27281 | clone flags(32 位整数) |
NS_PATHS_ATTR | 27282 | 需要 join 的命名空间路径列表 |
UIDMAP_ATTR | 27283 | uid 映射 |
GIDMAP_ATTR | 27284 | gid 映射 |
SETGROUP_ATTR | 27285 | setgroups 策略 |
OOM_SCORE_ADJ_ATTR | 27286 | oom_score_adj 调整值 |
ROOTLESS_EUID_ATTR | 27287 | 是否为 rootless(euid 非 0)容器 |
UIDMAPPATH_ATTR | 27288 | 映射工具路径(如 newuidmap) |
GIDMAPPATH_ATTR | 27289 | 映射工具路径(如 newgidmap) |
这些属性由父进程(runc 的start或exec路径)写入 init pipe,nsexec()侧通过nl_parse()读取并填充配置结构。
命名空间加入与创建:join 顺序与 unshare 的取舍
setns(2):加入既有命名空间
join_namespaces()处理引导数据中给出的命名空间列表(格式为type:path,以逗号分隔),其实现见 nsexec.c。这里有三个工程细节:
- 先打开所有 fd 再逐个 setns:因为一旦加入了 mnt namespace,进程可能无法再访问原来的路径,所以必须先一次性
open()所有 namespace 文件; - join 顺序必须遵循用户命名空间优先:代码注释明确指出 "We should always join the user namespacefirst",该顺序由 Go 侧
container_linux.go保证,nsexec()只是忠实地按给定顺序执行; - 命名空间名称到
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 的漏洞形态。
具体机制如下:
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 等特征;- 如果不是,则把自身二进制复制到一个安全位置:首选
memfd_create(内存文件,可加 seal),退而求其次用O_TMPFILE匿名文件,再不行用mkostemp创建临时文件并立即 unlink; - 对复制的二进制加 seal(
F_ADD_SEALS),防止其内容被修改;对于普通文件类型,还会先fchmod(0100)再通过/proc/self/fd/<fd>以O_PATH重新打开以避免 execve 返回-ETXTBSY; - 设置
_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 进程)。此时:
- Go 链接器已经因为空导入把
nsenter包纳入二进制,__attribute__((constructor))构造函数先于 Go runtime 执行nsexec(); nsexec()从_LIBCONTAINER_INITPIPE管道读取引导数据、加入/创建命名空间、完成映射配置;- 控制权交还 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:
相关推荐
docker-ce 中的 runc nsenter 包:Go 运行时启动前的命名空间引导机制
docker ce 中的 runc nsenter 包:Go 运行时启动前的命名空间引导机制 nsenter 是 runc 中一个特殊的 cgo 包,它通过注册
容器运行时云原生runc/libcontainer nsenter 包深度解析:如何在 Go 运行时启动前完成 Linux 命名空间初始化
runc/libcontainer nsenter 包深度解析:如何在 Go 运行时启动前完成 Linux 命名空间初始化 导读 本文以开源仓库中 vendor
开发者工具runc nsenter 包深度解析:在 Go 运行时启动前完成命名空间初始化
runc nsenter 包深度解析:在 Go 运行时启动前完成命名空间初始化 导读 libcontainer/nsenter 是 runc 中一个极为特殊的包
云原生容器运行时CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考