从零用 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 更难操作,原因有二:

  1. new_root 必须是挂载点(不能是目录);
  2. 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 行,但已经覆盖了容器运行时的核心机制。然而,距离生产就绪还有一段距离:

  1. OCI 镜像规范:需要支持从 registry 下载镜像(通常是分层的 tar+gzip),并联合挂载(overlayfs)到 rootfs。
  2. OCI 运行时规范:需要解析 config.json 配置,支持预启动(pre-start)和后启动(post-start)hooks。
  3. Rootless 容器:不使用 root 权限创建容器,依赖 user namespace 和 fuse-overlayfs 等技术。
  4. cgroup 委托:让容器内的进程能管理自己的 cgroup 子树(需要 v2 的 delegation 功能)。
  5. 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

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.384471s