从零用 Rust 实现一个最小容器运行时:深入 Namespace、cgroups 与 Seccomp 核心机制
容器技术改变了现代软件部署的方式,然而大多数人只停留在
docker run的表面用法。本文将深入 Linux 容器的底层本质,从零用 Rust 语言编写一个最小但完整的容器运行时,亲手触摸 namespace、cgroups、pivot_root 与 seccomp 这些塑造当代云原生基石的系统调用。
引言:容器到底是什么
容器不是轻量级虚拟机。准确地说,容器只是一个被 Linux namespace 隔离、被 cgroup 限制资源、被 seccomp 约束系统调用的普通进程。它共享宿主机的内核,没有硬件虚拟化开销。当你执行 docker run -it alpine 时,本质上是 Docker 守护进程 fork 了一个子进程,通过一系列 namespace 调用让这个子进程认为自己是 PID 1、拥有独立的文件系统视图和网络栈,然后通过 cgroup 限制它能占用的 CPU 和内存。
这种"组合式隔离"的设计哲学意味着任何语言只要能够调用 Linux 系统调用,就能构建容器运行时。Rust 凭借零成本抽象、所有权系统和对 unsafe 代码的显式标记,成为编写容器运行时的理想选择。诸如 Youki(受 runc 启发)、Krata 等项目已经在用 Rust 重写 OCI 运行时。
一、Namespace:容器的隔离斗篷
Namespace 是容器隔离的核心机制,它将全局系统资源封装在抽象层中,使 namespace 内的进程认为自己是独立存在的。Linux 提供了 7 种 namespace:
| Namespace | 命令符 | 隔离对象 |
|---|---|---|
| Mount | CLONE_NEWNS | 挂载点文件系统视图 |
| PID | CLONE_NEWPID | PID 编号空间 |
| Network | CLONE_NEWNET | 网络设备、端口、路由表 |
| IPC | CLONE_NEWIPC | System V IPC、POSIX 消息队列 |
| UTS | CLONE_NEWUTS | 主机名和域名 |
| User | CLONE_NEWUSER | 用户和组 ID 映射 |
| Cgroup | CLONE_NEWCGROUP | cgroup 根目录视图 |
| Time | CLONE_NEWTIME | 系统时间(Linux 5.6+) |
在 Rust 中调用 namespace 系统调用非常简单。下面是最基本的代码:
use nix::sched::{clone, CloneFlags};
fn create_sandbox() {
let stack_size = 1024 * 1024; // 1MB 栈空间
let mut stack = vec![0u8; stack_size];
let callback = Box::new(|| {
// 在子进程(容器)中执行
println!("容器进程的 PID: {}", nix::unistd::getpid());
// 执行用户指定的命令
});
let flags = CloneFlags::CLONE_NEWUTS // 独立的 UTS(主机名)
| CloneFlags::CLONE_NEWNS // 独立的 Mount
| CloneFlags::CLONE_NEWPID // 独立的 PID
| CloneFlags::CLONE_NEWNET // 独立的网络
| CloneFlags::CLONE_NEWIPC // 独立的 IPC
| CloneFlags::CLONE_NEWUSER; // 独立的用户命名空间
clone(callback, &mut stack, flags, Some(libc::SIGCHLD))
.expect("clone failed");
}
PID Namespace 的特殊性
PID namespace 有一个微妙之处:它只在"进入"之后才对子进程生效。也就是说,clone 出来的子进程在其新 PID namespace 中仍然看到自己为主机 PID。真正让子进程变成 PID 1 的方式是在子进程中再次 clone 孙进程——这模拟了真实的 init 进程行为。
// 孙进程才是容器内的 "PID 1"
let grandchild = clone(
Box::new(|| {
// 此时 PID 为 1
println!("容器内 PID: {}", nix::unistd::getpid()); // 输出 1
run_container_init();
}),
&mut grandchild_stack,
CloneFlags::CLONE_NEWPID | CloneFlags::CLONE_NEWNS,
Some(libc::SIGCHLD),
).unwrap();
在容器启动时,挂载 /proc 是 PID namespace 生效后的第一步。如果不重新挂载 procfs,容器内的 ps、top 等命令仍然会显示宿主机的进程列表:
use nix::mount::{mount, MsFlags, MountFlags};
fn mount_proc() {
// 确保 /proc 挂载点存在(在 pivot_root 后由新 rootfs 提供)
std::fs::create_dir_all("/proc").ok();
mount(
Some("proc"),
"/proc",
Some("proc"),
MsFlags::empty(),
None::<&str>,
).expect("Failed to mount /proc");
}
二、Pivot Root:切换根文件系统
在容器中获得独立文件系统视图通常有两种方案:chroot 和 pivot_root。chroot 只是将进程的根目录更改进程树,但存在逃逸风险(对 root 进程多次 chroot 可逃逸)。pivot_root 则通过将当前根目录移动到新位置并挂载新根文件系统到 /,从根本上解决了这个问题。
pivot_root 比 chroot 更难操作,原因有二:
new_root必须是挂载点(不能是目录);old_root必须位于new_root下的某个位置。
use std::path::Path;
fn pivot_root(new_root: &Path, old_root: &Path) {
// Step 1: 将 new_root 挂载为挂载点(mount --bind)
mount(
Some(new_root),
new_root,
None::<&str>,
MsFlags::MS_BIND | MsFlags::MS_REC,
None::<&str>,
).expect("bind-mount new_root failed");
// Step 2: 创建 old_root 存放点
std::fs::create_dir_all(old_root).ok();
// Step 3: 执行 pivot_root
nix::unistd::pivot_root(new_root, old_root)
.expect("pivot_root failed");
// Step 4: cd / 切换工作目录
std::env::set_current_dir("/").expect("chdir to / failed");
// Step 5: umount old_root(lazy unmount)
nix::mount::umount2("/", MntFlags::MNT_DETACH)
.expect("unmount old_root failed");
// Step 6: 删除旧的 rootfs
std::fs::remove_dir("/old_root").ok();
}
为什么不能用 chroot? 对于 root 权限的容器进程,chroot 不是真正的隔离屏障。如果你的容器可能运行 root 用户,必须使用 pivot_root,否则攻击者可以通过创建 ../ 目录链逃逸到主机文件系统。
三、Cgroups v2:资源治理的精密天平
Namespace 解决了"看见什么"的问题,而 cgroup 回答了"能用多少"的问题。现代 Linux 系统已经全面转向 cgroups v2,它在资源控制颗粒度和一致性方面远优于 v1。
核心概念
- cgroup:一组进程的集合,可以对其施加规则
- controller:实际的资源控制器(cpu, memory, io, pids, hugetlb 等)
- subtree:cgroup 的子树继承父级规则并可进一步细化
- domain:v2 区分"thread domain"和"domain",解决 v1 中 CFS 带宽控制的困难
Rust 操作 cgroups v2
cgroups v2 通过文件系统接口(通常挂载在 /sys/fs/cgroup)。对习惯 Rust 文件操作的开发者来说,这是一种自然的 API:
use std::fs;
use std::path::Path;
/// 写入 cgroup 子树的控制文件
fn write_cgroup_file(cgroup_path: &str, filename: &str, content: &str) -> std::io::Result<()> {
let path = Path::new("/sys/fs/cgroup").join(cgroup_path).join(filename);
fs::write(path, content)
}
/// 添加进程到指定 cgroup
fn assign_to_cgroup(cgroup_path: &str, pid: i32) -> std::io::Result<()> {
let procs_file = Path::new("/sys/fs/cgroup").join(cgroup_path).join("cgroup.procs");
fs::write(procs_file, format!("{}", pid))
}
fn setup_cgroups(container_name: &str, pid: i32) -> std::io::Result<()> {
let cgroup_path = format!("mycontainer/{}", container_name);
// 1. 创建容器 cgroup 子树
fs::create_dir_all(format!("/sys/fs/cgroup/{}", cgroup_path))?;
// 2. 启用需要控制的 controllers(在父级授权)
write_cgroup_file(
&format!("mycontainer", ),
"cgroup.subtree_control",
"+cpu +memory +io +pids"
)?;
// 3. 设置资源限制
write_cgroup_file(&cgroup_path, "cpu.max", "50000 100000")?; // 单核 50% CPU
write_cgroup_file(&cgroup_path, "memory.max", "256M")?; // 256MB 内存
write_cgroup_file(&cgroup_path, "pids.max", "64")?; // 最多 64 个进程
write_cgroup_file(&cgroup_path, "io.max", "8:0 rbps=10485760 wbps=10485760")?; // 10MB/s IO
// 4. 将容器进程加入 cgroup
assign_to_cgroup(&cgroup_path, pid)?;
Ok(())
}
cgroups v2 的 "no internal processes" 规则
v2 引入了一个重要约束:非 root cgroup 不能同时包含进程并管理委托(即开启 subtree_control 的 cgroup 下不能直接添加进程)。这是为了防止资源竞争的死锁。实际操作中必须形成分层结构:
/sys/fs/cgroup/mycontainer/ (subtree_control = +cpu +memory)
/sys/fs/cgroup/mycontainer/container-abc/ (无 subtree_control, 包含实际进程)
四、Seccomp:切断危险系统调用
Namespace 和 cgroup 提供了隔离和资源限制,但容器进程仍然能看到完整的 Linux syscall 接口(约 340+ 个)。让我们思考一下这意味着什么:容器内的一个 root 进程可以调用 mount() 重新挂载 cgroup、调用 ptrace() 调试任意进程、调用 kexec_load() 加载新内核。这些操作即使在容器内也是应该被禁止的。
Seccomp(Secure Computing)通过 BPF 过滤器在内核层面限制进程可执行的系统调用。
Seccomp BPF 的经典用法——只允许 read/write/exit
下面的例子使用 libseccomp 库(Rust 绑定为 libseccomp crate)创建一个极简的 seccomp 规则:只允许 exit、read 和 write syscall:
use libseccomp::*;
fn setup_seccomp() -> Result<(), Box<dyn std::error::Error>> {
// 创建过滤器:默认动作 KILL(未匹配的系统调用直接杀死进程)
let mut filter = ScmpFilterContext::new(ScmpAction::KillProcess)?;
// 添加允许的系统调用
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("read")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("write")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("exit_group")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("exit")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("close")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("fstat")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("mmap")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("mprotect")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("munmap")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("brk")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("rt_sigaction")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("rt_sigprocmask")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("ioctl")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("access")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("pipe")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("sched_yield")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("dup")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("nanosleep")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("getpid")?);
filter.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("getppid")?);
// ... 根据具体应用需求添加
// 加载到内核
filter.load()?;
Ok(())
}
Docker 的默认 Seccomp Profile
Docker 提供了一个 默认 seccomp profile,禁用了约 44 个危险 syscall(如 swapon、reboot、kexec_load、open_by_handle_at、init_module、finit_module 等),同时允许约 300+ 个常见 syscall。对于容器运行时而言,复用 Docker 的 profile 是最稳妥的做法。
User Namespace 作为纵深防御
Seccomp 并非万能。某些 syscall 即使被过滤,攻击者仍可通过组合其他 syscall 绕过。User namespace 提供了额外的隔离层——它允许容器进程以 root 身份运行,但被映射到主机上的一个非 root 用户。这一层映射使得容器发起的许多特权操作(如 mount 文件系统)真正无效,因为容器内的 root 对主机而言只是一个普通用户。
fn setup_user_namespace(child_pid: libc::pid_t) -> std::io::Result<()> {
// /proc/<pid>/uid_map: "0 100000 65536"
// 表示容器内 UID 0 映射到主机 UID 100000
fs::write(
format!("/proc/{}/uid_map", child_pid),
"0 100000 65536"
)?;
// 需要写入 setgroups .allow 后才能写 gid_map
fs::write(format!("/proc/{}/setgroups", child_pid), "deny")?;
fs::write(
format!("/proc/{}/gid_map", child_pid),
"0 100000 65536"
)?;
Ok(())
}
五、组装完整运行时:将所有拼图组合
现在我们把上面讨论的所有部分组装成一个完整的 Rust 容器运行时。为了便于理解,下面是核心逻辑的伪代码:
use nix::sched::clone;
use nix::sys::wait::waitpid;
use nix::unistd::Pid;
fn container_main(command: &str, args: &[&str]) -> isize {
// 1. 设置主机名(UTS namespace 已隔离)
nix::unistd::sethostname("mycontainer").ok();
// 2. 切换根文件系统
let new_root = Path::new("/tmp/container_rootfs");
let old_root = Path::new("/tmp/container_rootfs/.old_root");
pivot_root(new_root, old_root);
// 3. 挂载 proc
std::fs::create_dir_all("/proc").ok();
mount(Some("proc"), "/proc", Some("proc"), MsFlags::empty(), None::<&str>).ok();
// 4. 设置 Seccomp(必须在 pivot_root 之后,避免破坏系统调用)
setup_seccomp().expect("seccomp setup failed");
// 5. 执行容器命令
nix::unistd::execvp(command, args).ok();
0
}
fn run_container() {
let stack_size = 1024 * 1024;
let mut stack = vec![0u8; stack_size];
let callback = Box::new(|| -> isize {
container_main("/bin/sh", &["/bin/sh"])
});
let flags = CloneFlags::CLONE_NEWUTS
| CloneFlags::CLONE_NEWNS
| CloneFlags::CLONE_NEWPID
| CloneFlags::CLONE_NEWNET
| CloneFlags::CLONE_NEWIPC
| CloneFlags::CLONE_NEWUSER;
let child_pid = clone(callback, &mut stack, flags, Some(libc::SIGCHLD))
.expect("clone failed");
// 主机侧:配置 user namespace 映射
setup_user_namespace(child_pid.as_raw()).unwrap();
// 主机侧:配置 cgroup
setup_cgroups("mycontainer", child_pid.as_raw()).unwrap();
// 等待容器进程结束
waitpid(child_pid, None).unwrap();
}
六、进一步:从玩具到生产级 OCI 运行时
上面的代码虽然只有约 100 行,但已经覆盖了容器运行时的核心机制。然而,距离生产就绪还有一段距离:
- OCI 镜像规范:需要支持从 registry 下载镜像(通常是分层的 tar+gzip),并联合挂载(overlayfs)到 rootfs。
- OCI 运行时规范:需要解析
config.json配置,支持预启动(pre-start)和后启动(post-start)hooks。 - Rootless 容器:不使用 root 权限创建容器,依赖 user namespace 和 fuse-overlayfs 等技术。
- cgroup 委托:让容器内的进程能管理自己的 cgroup 子树(需要 v2 的 delegation 功能)。
- Checkpoint/Restore:使用 CRIU 实现容器状态的持久化和恢复。
如果你对这些进阶主题感兴趣,可以参考以下优秀开源项目: - Youki:受 runc 启发的 Rust OCI 运行时,代码质量极高 - crun:用 C 打造的超快运行时,是 Podman 的默认引擎 - runsc (gVisor):Google 的用户态内核实现,提供额外的安全边界
结语
容器技术不是魔法,而是 Linux 内核数十年系统编程智慧的集中体现。Namespace 提供了视角隔离,cgroup 提供了资源治理,secprog 提供了攻击面收敛,而 pivot_root 提供了文件系统沙箱。正是这些抽象的组合,让一个普通进程变成了"容器"。
用 Rust 实现一个小容器运行时,不仅可以巩固对 Linux 系统调用的理解,还能深刻理解当我们执行 docker run 时究竟发生了什么。这种理解在排查容器逃逸、优化容器性能、或仅仅是构建更好的云原生工具时,都会成为你最宝贵的经验。
参考资源: - OCI Runtime Specification - Linux Namespaces in Operation (LWN) - Kernel Recipes 2019 — Cgroups v2 - Youki: A Rust Implementation of OCI Runtime - Linux seccomp Documentation

发表评论 取消回复