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 必須:
- 捕捉/轉發 SIGTERM(2.2 節的 signal-hook 正好上場)
- 定期
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上——Childdrop 前wait,避免容器變孤兒。
習題#
- 在範例中加上
CLONE_NEWNET後於容器內ping 127.0.0.1,為什麼不通?(提示:lo 未啟動,用ip link set lo up) - 實作 rootless 版本:先
CLONE_NEWUSER再其他 flags,用id觀察映射。 - 解釋為什麼容器內
docker exec需要 nsenter 類機制:父 namespace 如何進入既有 namespace(提示:setns(2)+/proc/<pid>/ns/)。