OCI runtime-spec 与 Linux 内核接口映射

oci runtime-spec 定义了一组 JSON schema,容器运行时必须将其语义翻译为 Linux 内核系统调用。runC、crun、youki 等实现的核心工作就是将 spec 中的每个字段转化为内核接口的正确调用顺序与参数。以下从内核源码视角逐层拆解这份映射关系。

容器的进程树从 clone(2) 开始。runtime-spec 的 `linux.namespaces` 数组直接映射到 clone flags:

```c
/* kernel/fork.c:sys_clone() -> copy_process() -> copy_namespaces() */
/* include/uapi/linux/sched.h */
#define CLONE_NEWNS 0x00020000 /* mount namespace */
#define CLONE_NEWUTS 0x04000000 /* UTS namespace */
#define CLONE_NEWIPC 0x08000000 /* IPC namespace */
#define CLONE_NEWPID 0x20000000 /* PID namespace */
#define CLONE_NEWNET 0x40000000 /* network namespace */
#define CLONE_NEWCGROUP 0x02000000 /* cgroup namespace (v2 only) */
#define CLONE_NEWTIME 0x00000080 /* time namespace */
```

runtime 在 `create` 阶段调用 clone(2) 时组合这些 flags,内核在 `kernel/nsproxy.c:create_new_namespaces()` 中为每个 namespace 创建独立的 `struct nsproxy`。对于已经运行的进程(如 `exec` 操作),runtime 改用 `unshare(2)` 进入目标 namespace,此时通过 `/proc//ns/{pid,net,mnt,...}` 提供的 `nsfd` 调用 `setns(2)`,内核在 `kernel/nsproxy.c:copy_namespaces()` 中做 `struct nsproxy` 的引用计数与切换。

边界条件:PID namespace 的 `pid_t` 回绕。当 PID namespace 内的 pid 号达到 `pid_max`(/proc/sys/kernel/pid_max,默认 32768),内核必须回绕并从 300 开始重新分配。runtime 在 `state.json` 中记录 `pid` 字段,但该值在 PID namespace 内可能因 recycle 而指向不同进程。内核不保证 `pid_t` 的单向增长,runtime 应使用 `pidfd_open(2)` + `pidfd_send_signal(2)` 而非 `kill(pid, sig)`,前者通过 `struct pid` 的引用计数保证目标确定。

cgroups 路径映射在 `linux.cgroupsPath` 字段。内核 v1/v2 的接口差异显著。v2 统一层级下,runtime 通过 cgroupfs 操作:

```c
/* 内核接口:kernel/cgroup/cgroup.c:cgroup_mkdir() */
/* 对应 sysfs 操作为 mkdir /sys/fs/cgroup/ */

char cg_path[PATH_MAX];
snprintf(cg_path, sizeof(cg_path),
"/sys/fs/cgroup%s", spec->linux->cgroupsPath);

mkdir(cg_path, 0755);
/* 写入 subtree_control 启用控制器 */
int fd = open(cg_path, O_DIRECTORY | O_RDONLY);
write(fd, "+cpu +memory +io", strlen("+cpu +memory +io"));

/* 将容器进程迁移到 cgroup */
snprintf(procs, sizeof(procs), "/sys/fs/cgroup%s/cgroup.procs", spec->linux->cgroupsPath);
```

内核在 `kernel/cgroup/cgroup.c:cgroup_attach_task()` 中执行线程分派,其中涉及 `cgroup_migrate_prepare_dst()` 的线程组排他锁与 `css_task_iter` 的遍历。竞态场景:OOM killer 可能在对 `memory.current` 执行写回时触发回收,若 runtime 在 `memory.max` 写入之前完成 `cgroup.procs` 迁移,容器进程可能在限制生效前吃掉宿主机内存。正确的顺序是:先创建子 cgroup,写入 `memory.max`,再写入 `cgroup.procs`。

用户命名空间映射通过 `/proc/self/uid_map` 与 `/proc/self/gid_map` 实现,对应 runtime-spec 的 `linux.uidMappings` 与 `linux.gidMappings`:

```c
/* kernel/user_namespace.c:map_write() */
/* 内核接收格式: " \n" */

static int uid_map_write(struct file *file, const char __user *buf,
size_t size, loff_t *ppos)
{
struct seq_file *seq = file->private_data;
struct user_namespace *ns = seq->private;
/* 需要 CAP_SYS_ADMIN 在当前 user_ns */
/* 只允许写入一次,除非 parent_ns 设置了新映射 */
/* 写入后立即生效,无需 remount */
}
```

关键约束:`write(uid_map_fd, buf, len)` 之前必须先写入 `/proc/self/setgroups` 为 `"deny"`。这是内核 commit 9cc46516a186 引入的安全加固,防止无特权的 user namespace 通过 newgidmap 构造额外的组权限。runtime 在创建 user namespace 后必须按以下顺序写入:

```
write(setgroups_fd, "deny", 4) -> /proc//setgroups
write(gid_map_fd, buf, len) -> /proc//gid_map
write(uid_map_fd, buf, len) -> /proc//uid_map
```

若顺序错误,`newgidmap` 系统调用将失败返回 `-EPERM`。

seccomp 配置映射为 `seccomp(2)` + `SECCOMP_SET_MODE_FILTER`。runtime-spec 的 `process.seccomp` 中的每个 `Syscall` 条目转换为 `struct sock_fprog` 中的 BPF 指令:

```c
/* kernel/seccomp.c:seccomp_set_mode_filter() */
/* 每个 syscall 映射为一条 BPF 语句 */
struct sock_filter filter[] = {
/* 架构检查 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
/* syscall 号检查 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
/* default action */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = { .len = ARRAY_SIZE(filter), .filter = filter };
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, &prog);
```

性能影响:`SECCOMP_FILTER_FLAG_NEW_LISTENER` (Linux 5.0) 将 seccomp 过滤从 `struct task_struct` 的同步执行改为通过 `seccomp_notif` 用户的异步通知,但容器场景中大多数 runtime 仍使用同步 filter 模式,因为异步模式引入调度延迟。每条 syscall 进入时,`arch/x86/entry/entry_64.S` 的 `do_syscall_64` 会调用 `__secure_computing()`,其内部遍历 BPF 指令树。若 filter 包含大量规则,`seccomp_data` 的 `nr` + `arch` + `instruction_pointer` 查找退化为 O(n) 扫描。实测 200 条规则的 filter 在 `perf top` 中占 2-3% 的 syscall 开销。

noNewPrivileges 字段直接映射为 `prctl(PR_SET_NO_NEW_PRIVS, 1)`,对应 `kernel/sys.c:__prctl_set_no_new_privs()`。内核在该调用之后设置 `current->no_new_privs` 标志,后续所有 `execve(2)` 中的 LSMs 调用 `security_bprm_creds_for_exec()` 时跳过 setuid/setgid 和 capability 的 elevation。runtime 必须在 seccomp 加载之前调用此 prctl,否则 seccomp filter 可能被 `execve(2)` 后的新程序覆盖?实际情况是:如果 seccomp filter 在 no_new_privs 之前被加载且 filter 本身允许 `prctl(PR_SET_NO_NEW_PRIVS)`,则不会出现问题。但若 filter 拦截 `prctl`,则 no_new_privs 无法设置,子进程将保留提升特权的通道。

capabilities 映射到 `capset(2)`(实际通过 `security/commoncap.c:cap_set_proc()` 实现):

```c
/* security/commoncap.c:cap_set_proc() */
/* runtime-spec 的 process.capabilities 转换为 cap_user_header_t + cap_user_data_t */
struct __user_cap_header_struct header = {
.version = _LINUX_CAPABILITY_VERSION_3,
.pid = 0,
};
struct __user_cap_data_struct data[2] = {
[0] = { .effective = eff, .permitted = perm, .inheritable = inh },
[1] = { .effective = eff >> 32, .permitted = perm >> 32, .inheritable = inh >> 32 },
};
capset(&header, &data);
```

内核内部调用 `security_capset()` 钩子链。`cap_set_proc()` 执行 `cap_validate_masked()` 检查:`permitted` 必须是 `ambient` + `inheritable` + `cap_bset` 交集;`effective` 必须是 `permitted` 的子集;`cap_bset`(/proc/self/status 中的 CapBnd)在 `commoncap_capset()` 中被验证。若设置了 no_new_privs,`ambient` capabilities 的计算跳过 `inheritable` 继承链但保留 `cap_bset` 限制。

rlimits 映射为 `setrlimit(2)` 或 `prlimit(2)`。`kernel/sys.c:do_prlimit()` 的实现依赖 `rcu_read_lock()` 保护 `tsk->signal->rlim`。两种系统调用的区别:`setrlimit` 操作 `current` 的 `struct rlimit`,而 `prlimit(pid, ...)` 操作指定进程,需要 `CAP_SYS_RESOURCE`。runtime 在 `create` 阶段应使用 `setrlimit` 在 exec 前设置,因为此时子进程尚未执行。部分 runtime 使用 `prlimit(pid, ...)` 在 fork 后 exec 前通过 PID 修改,但这引入了 TOCTOU 竞态:目标进程可能已经在 exec 中,新资源限制未覆盖加载的程序。

```c
/* kernel/sys.c:do_prlimit() */
int do_prlimit(struct task_struct *tsk, unsigned int resource,
struct rlimit *new_rlim, struct rlimit *old_rlim)
{
struct rlimit *rlim;
/* RCU 保护 tsk->signal 的生命周期 */
rlim = rcu_dereference(tsk->signal)->rlim + resource;
/* RLIM_INFINITY 检查 */
if (new_rlim->rlim_cur > new_rlim->rlim_max)
return -EINVAL;
/* 对 RLIMIT_NOFILE 做额外校验 */
if (resource == RLIMIT_NOFILE && new_rlim->rlim_cur > sysctl_nr_open)
return -EPERM;
}
```

mount 处理是大多数 runtime 最复杂的部分。`root.path` 通过 `pivot_root(".", ".")` 或 `MS_MOVE` 切换。`pivot_root(2)` 在内核 `fs/namespace.c:do_pivot_root()` 中执行传播检查:

```c
/* fs/namespace.c:do_pivot_root() */
/* 内核要求:old_root 和 new_root 必须在同一个挂载命名空间 */
/* new_root 不能是 MS_SHARED 传播源 */
/* 若 new_root 的父挂载是 MS_SHARED,pivot_root 返回 -EBUSY */
int do_pivot_root(const char *new_root, const char *put_old)
{
struct mount *new_mnt, *old_mnt, *root;
LIST_HEAD(umount_list);

/* ... 路径查找 ... */
/* 关键检查:new_mnt 不能有子挂载的传播 */
if (propagation_mount_busy(new_mnt, MNT_PROPAGATION_BUSY))
return -EBUSY;
/* ... attach_recursive_mnt ... */
}
```

如果父挂载点是 `MS_SHARED`(systemd 默认设置),runtime 必须先执行 `mount("none", "/", NULL, MS_PRIVATE | MS_REC, NULL)` 将根挂载改为 private 传播,否则 `pivot_root(2)` 返回 `-EBUSY`。runtime-spec 的 `linux.rootfsPropagation` 就是在此阶段处理,值可以是 `rshared`、`rslave`、`rprivate`。

对于 `linux.maskedPaths` 和 `linux.readonlyPaths`,runtime 对路径列表执行 bind mount:

```c
/* 每个 masked path 用 /dev/null 覆盖 */
for (char **p = spec->linux->maskedPaths; *p; p++) {
/* 先创建挂载点 */
struct stat st;
if (stat(*p, &st) == 0) {
mount("/dev/null", *p, NULL, MS_BIND | MS_REC, NULL);
/* 子挂载点可能传播,需 MS_SLAVE 隔离 */
mount("none", *p, NULL, MS_PRIVATE, NULL); /* 可选 */
}
}
/* readonlyPaths 用 bind + remount 实现 */
for (char **p = spec->linux->readonlyPaths; *p; p++) {
mount(*p, *p, NULL, MS_BIND | MS_REC, NULL);
mount("none", *p, NULL, MS_BIND | MS_RDONLY | MS_REMOUNT, NULL);
}
```

竞态场景:这些挂载操作发生在 `pivot_root` 之后,但 `/sys` 或 `/proc` 内的路径可能在挂载过程中被并发访问。`mount(2)` 系统调用在内核持有 `namespace_sem`(`fs/namespace.c` 中的全局读写信号量),这对多容器并发创建形成锁竞争。实测在 64 核机器上同时创建 32 个容器时,`namespace_sem` 的等待时间可达 200ms 以上。youki(Rust 实现)通过 batch mount 减少 `mount(2)` 调用次数缓解此问题。

生命周期管理中的状态机严格映射到内核进程状态。`create` 阶段使进程进入 `TASK_TRACED`(通过 ptrace 或 cgroup freezer)。`start` 阶段 `kill(pid, SIGCHLD)` 或 freezer 的 `cgroup.freeze` 写入 `0`。`delete` 阶段等待 `waitid(P_PID, pid, ...)` 返回 `ECHILD`,对应内核 `kernel/exit.c:release_task()` 将 `struct task_struct` 回收到 slab 缓存。

sysctl 的 `linux.sysctl` 映射直接写入 `/proc/sys/`:

```c
/* 内核 net/ipv4/sysctl_net_ipv4.c 注册 /proc/sys/net/ipv4/ 下的每个条目 */
/* runtime 逐项写入,注意路径安全检查 */
void apply_sysctls(spec_linux_sysctl_t *sysctls)
{
for (size_t i = 0; sysctls[i].key; i++) {
/* 检查 key 不在拒绝列表中:如 kernel.sched_*, vm.overcommit_* */
if (is_blocked_key(sysctls[i].key))
continue; /* 返回 EPERM */
char path[PATH_MAX];
snprintf(path, sizeof(path), "/proc/sys/%s", sysctls[i].key);
int fd = open(path, O_WRONLY);
if (fd < 0) {
/* 内核可能因 !net_ns->sysctl_permissions 拒绝 */
continue;
}
write(fd, sysctls[i].value, strlen(sysctls[i].value));
close(fd);
}
}
```

内核通过 `static_branch_enable(&sysctl_write_hook)` 控制 sysctl 权限。某些参数(如 `kernel.sched_rt_runtime_us`)需要 `CAP_SYS_NICE`,在 user namespace 中即使 root 也无法写入。runtime 必须在创建 container 之前获取此能力。

最后,`struct seccomp` 在进程 exec 后的继承问题。seccomp filter 通过 `fork(2)` / `clone(2)` 继承到子进程的 `task_struct->seccomp`,内核在 `copy_process()` 中调用 `copy_seccomp()` 执行 `refcount_inc(¤t->seccomp->count)`。但在 `execve(2)` 时,`kernel/seccomp.c:seccomp_after_exec()` 检查 `current->no_new_privs` 标志,若未设置且新程序有 setuid 位,filter 将被丢弃。这是 runtime 必须在 `prctl(PR_SET_NO_NEW_PRIVS)` 之后设置 filter 的根本原因。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐