跳轉到

5.2 用 Rust 實作容器隔離:clone/unshare 與六種 namespace#

目標#

本章開始動手:用 Rust 建立一個「活在平行世界」的子程序——它有自己的 PID 空間、網路、掛載表、hostname、IPC 與使用者映射。這正是 runc 的 runc create 所做的事。

clone(2):一個系統呼叫,多種世界#

fork 只能複製一切;2003 年 Linux 2.5/2.6 強化的 clone 則用 flags 逐項選擇「要共享什麼、要新開什麼」:

CLONE_NEWNS    → 新 mount namespace
CLONE_NEWUTS   → 新 UTS(hostname)
CLONE_NEWIPC   → 新 IPC
CLONE_NEWPID   → 新 PID namespace
CLONE_NEWNET   → 新 network namespace
CLONE_NEWUSER  → 新 user namespace

unshare(2)(2005 年核心 2.6.16 提供對應的獨立工具)則是讓自己脫離現有 namespace。psmisc/util-linux 的 unshare 命令就是包這個。

用 Rust 呼叫 clone#

libc::clone 的包裝在 Rust 裡需要處理「子程序從函式指標開始執行」的 ABI 細節,比較務實的教學路徑是 fork + unshare(效果等價,程式更清楚):

[dependencies]
libc = "0.2"
use std::ffi::CString;
use std::fs;
use std::process::Command;

const FLAGS: i32 = libc::CLONE_NEWNS | libc::CLONE_NEWUTS | libc::CLONE_NEWIPC
    | libc::CLONE_NEWPID | libc::CLONE_NEWNET;

fn main() -> anyhow::Result<()> {
    if std::env::args().nth(1).is_none() {
        // 父程序:fork 出進入新 namespace 的子程序
        let pid = unsafe { libc::fork() };
        anyhow::ensure!(pid >= 0, "fork failed");
        if pid == 0 {
            child()?;
            unreachable!();
        }
        let mut status = 0;
        unsafe { libc::waitpid(pid, &mut status, 0) };
        return Ok(());
    }
    // 子程序繼續往下(exec 模式)
    unreachable!()
}

fn child() -> anyhow::Result<()> {
    // 需要 root(或 user namespace,見下)
    let r = unsafe { libc::unshare(FLAGS) };
    anyhow::ensure!(r == 0, "unshare failed: {}", std::io::Error::last_os_error());

    // 新 UTS:改 hostname(只影響本 namespace)
    let h = CString::new("mini-container").unwrap();
    unsafe { libc::sethostname(h.as_ptr(), h.as_bytes().len()) };

    // 新 mount namespace:把 /proc 重新掛上(否則 ps 看到的還是宿主程序)
    let proc_dir = CString::new("/proc").unwrap();
    unsafe {
        libc::mount(b"proc\0".as_ptr() as *const _,
                    proc_dir.as_ptr(),
                    b"proc\0".as_ptr() as *const _,
                    0, std::ptr::null());
    }

    // 新 PID namespace 裡,本程序是 1 號
    println!("hostname: {}", fs::read_to_string("/etc/hostname")?.trim());
    println!("pid: {}", std::process::id());   // 印出 1
    Command::new("sh").arg("-i").status()?;
    Ok(())
}

執行(需 root):

$ sudo cargo run
hostname: mini-container
pid: 1
# 裡面 ps aux 幾乎看不到宿主程序

六個 namespace 的效果逐一驗證:

測試 命令 觀察
PID 容器內 ps 只有自己(PID 1)
UTS 容器內 hostname mini-container
NET 容器內 ip link 只剩 lo(要建 veth 才有對外網路,見 5.3)
MNT 容器內 mount 自己的掛載表,可以 chroot/rootfs
IPC ipcs 空
USER 見下節 uid 映射

user namespace 與 rootless 容器#

CLONE_NEWUSER(Linux 3.8, 2013 年)是容器安全的分水嶺:容器內的 root 可以映射成宿主的普通使用者。這催生了 rootless container(2019 年 podman 1.0、Docker rootless mode)——docker daemon 不必再以 root 跑,容器逃逸的爆炸半徑大幅縮小。

// user namespace:先 unshare(CLONE_NEWUSER),再寫映射檔
unsafe { libc::unshare(libc::CLONE_NEWUSER) };
fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))?;   // 容器root → 宿主uid
fs::write("/proc/self/setgroups", "deny")?;
fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))?;

細節:寫 uid_map 前通常要先 setgroups deny;且新 user namespace 開始時只有一個 capability——正是靠它取得 CAP_SYS_ADMIN 完成其他 namespace 的 unshare。所以 rootless 容器的標準順序是:先開 user namespace,再開其餘五個。

為什麼「PID 1」很危險#

新 PID namespace 裡第一個程序是 PID 1,扮演「init」角色:孤兒程序會被它收養;它若死掉,核心會對整個 namespace 發 SIGKILL。C 寫的容器常見慘案:PID 1 沒裝 SIGTERM handler → docker stop 等十秒後被 SIGKILL。歷史教訓(systemd 之前,SysV init 的 zombie reaping 職責)提醒我們 PID 1 必須:

  1. 捕捉/轉發 SIGTERM(2.2 節的 signal-hook 正好上場)
  2. 定期 waitpid(-1, ...) 收殭屍

Rust 版 PID 1 的骨架:

use signal_hook::iterator::Signals;
use signal_hook::consts::signal::*;

fn init_loop(child_pid: i32) -> anyhow::Result<i32> {
    let mut sigs = Signals::new([SIGTERM, SIGINT])?;
    loop {
        for sig in sigs.pending() {
            unsafe { libc::kill(child_pid, sig) };   // 轉發給應用
        }
        let mut st = 0;
        let r = unsafe { libc::waitpid(-1, &mut st, libc::WNOHANG) };
        if r == child_pid {
            return Ok((st >> 8) & 0xff);             // 應用結束,容器退出
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
}

所有權如何幫忙#

容器執行器要管理大量「成對」的資源:veth pair(一端在容器一端在宿主)、pipe、cgroup fd、rootfs 掛載。C 版 runc 的歷史 CVE 多與「清理路徑漏掉一步」有關(例如中途錯誤時已建立的 namespace/掛載沒有復原)。Rust 的策略:

  • fd/掛載點包成 OwnedFd/RAII guard,錯誤傳播(?)時自動逆序清理;
  • veth 的兩端用兩個 OwnedFd(或名字)結構體持有,drop 時刪除介面;
  • PID namespace 的生命週期綁在 PID 1 的 Child 上——Child drop 前 wait,避免容器變孤兒。

習題#

  1. 在範例中加上 CLONE_NEWNET 後於容器內 ping 127.0.0.1,為什麼不通?(提示:lo 未啟動,用 ip link set lo up)
  2. 實作 rootless 版本:先 CLONE_NEWUSER 再其他 flags,用 id 觀察映射。
  3. 解釋為什麼容器內 docker exec 需要 nsenter 類機制:父 namespace 如何進入既有 namespace(提示:setns(2) + /proc/<pid>/ns/)。