5.3 cgroups 資源限制、overlayfs rootfs 與一個 500 行的微型容器執行器#
回顧與組裝#
5.2 節我們有了「平行世界」(namespace),本節補上三件事:資源配額(cgroups)、檔案系統視野(overlayfs rootfs)、以及把全部零件組成一個 mini-docker 執行器。讀完本節,你將擁有一個約 500 行、能 pull 映像、掛 rootfs、限制資源、隔離網路並執行命令的完整專案。
cgroups:給容器上緊箍咒#
cgroup v2(2016,Linux 4.6+)統一掛載在 /sys/fs/cgroup,對一個群組的控制就是寫檔案:
# 手動示範(之後由 Rust 程式做同樣的事)
$ sudo mkdir /sys/fs/cgroup/mini
$ echo "100000000" > /sys/fs/cgroup/mini/memory.max # 100MB
$ echo "100000" > /sys/fs/cgroup/mini/cpu.max # 1 個 CPU(quota/period)
$ echo $$ > /sys/fs/cgroup/mini/cgroup.procs # 把 shell 放進去
$ # 之後這個 shell 的記憶體超過 100MB 會被 OOM killer 殺掉
歷史上,cgroup 的出現正是 Google 大規模伺服器農場的需求(2006 年 Menage/Seth 的 process containers);Kubernetes 的 resource limits(limits.memory: 100Mi)翻譯成核心操作就是你剛才那兩行 echo。
Rust 版:
use anyhow::{Context, bail};
pub struct Cgroup {
path: std::path::PathBuf,
}
impl Cgroup {
pub fn create(name: &str, mem_bytes: u64, cpu_quota: u64) -> anyhow::Result<Self> {
let path = std::path::Path::new("/sys/fs/cgroup").join(name);
if !path.exists() {
std::fs::create_dir_all(&path)
.with_context(|| format!("cgroup {}(需 root、v2)", path.display()))?;
}
std::fs::write(path.join("memory.max"), mem_bytes.to_string())?;
std::fs::write(path.join("cpu.max"), format!("{cpu_quota} 100000"))?;
Ok(Self { path })
}
pub fn attach(&self, pid: i32) -> anyhow::Result<()> {
std::fs::write(self.path.join("cgroup.procs"), pid.to_string())?;
Ok(())
}
pub fn usage(&self) -> anyhow::Result<u64> {
// memory.current:目前用量
Ok(std::fs::read_to_string(self.path.join("memory.current"))?.trim().parse()?)
}
}
impl Drop for Cgroup {
fn drop(&mut self) {
let _ = std::fs::remove_dir(&self.path); // 離開自動清理
}
}
Cgroup 用 RAII 管理:容器退出(無論正常或 panic)時 cgroup 目錄自動刪除——C 版執行器忘記清理 cgroup 是「殘屍累積吃光記憶體」的常見事故。
overlayfs:分層的 rootfs#
映像不是一個大 tar 檔,而是層:ubuntu:22.04 的 base 層、apt install 的層、你的應用層。2003 年起 unionfs/AUFS 嘗試把多層疊成一個目錄,overlayfs(2014 年進入 Linux 3.18)勝出成為 Docker 預設:
merged = lowerdir(唯讀層們) + upperdir(可寫層) + workdir
寫時複製:讀檔案時從 lower 給你;修改時核心把它拷貝到 upper(copy-up)再改。刪除 lower 的檔案則在 upper 放 whiteout。這就是「映像可以共享、容器各自獨立」的機制——100 個容器共用同一個 ubuntu 層,磁碟只佔一份。
fn mount_overlay(layers: &[&str], upper: &str, work: &str, target: &str) -> anyhow::Result<()> {
let lower = layers.join(":");
let opts = format!("lowerdir={lower},upperdir={upper},workdir={work}");
let r = unsafe {
libc::mount(b"overlay\0".as_ptr() as *const _,
target.as_ptr() as *const _,
b"overlay\0".as_ptr() as *const _,
0, opts.as_ptr() as *const _)
};
anyhow::ensure!(r == 0, "overlayfs mount: {}", std::io::Error::last_os_error());
Ok(())
}
mini-docker:完整執行器#
專案結構:
mini-docker/
├── Cargo.toml
└── src/main.rs # 全部邏輯,約 500 行
[package]
name = "mini-docker"
version = "0.1.0"
edition = "2021"
[dependencies]
libc = "0.2"
anyhow = "1"
signal-hook = "0.3"
flate2 = "1" # 解壓 layer tar.gz
tar = "0.4" # 解 tar
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use anyhow::{Context, bail};
use std::ffi::CString;
use std::fs;
use std::os::unix::io::OwnedFd;
use std::path::{Path, PathBuf};
use std::process::Command;
// ---------- 映像:pull(簡化:取 Docker Hub 的 registry v2 API) ----------
// 真正實作需 OAuth token;教學版使用公開的 registry.docker.io 匿名端點。
// 為控制篇幅,pull 以 curl 外部命令代勞,重心放在容器本體。
fn pull_image(image: &str) -> anyhow::Result<PathBuf> {
// image 形如 "alpine:3.19" → 目錄 ~/.mini-docker/images/alpine/3.19/rootfs
let (repo, tag) = image.split_once(':').unwrap_or((image, "latest"));
let dir = home_dir().join(".mini-docker/images").join(repo).join(tag);
let rootfs = dir.join("rootfs");
if rootfs.exists() { return Ok(rootfs); } // 已下載
fs::create_dir_all(&rootfs)?;
// 1) 取 manifest(匿名,需要 Accept header)
let manifest = Command::new("curl").args([
"-s", "-H", "Accept: application/vnd.oci.image.index.v1+json, \
application/vnd.docker.distribution.manifest.v2+json",
&format!("https://registry-1.docker.io/v2/{repo}/manifests/{tag}"),
]).output()?;
let m: serde_json::Value = serde_json::from_slice(&manifest.stdout)?;
// 2) 取 amd64 manifest(簡化:直接取第一個 manifest list 條目或單 manifest)
let (mut cfg_digest, mut layer_digest) = (None, None);
if let Some(manifests) = m.get("manifests") {
for e in manifests.as_array().unwrap() {
let p = e["platform"].as_ref();
if p.map_or(false, |p| p["architecture"] == "amd64" && p["os"] == "linux") {
let d = e["digest"].as_str().unwrap();
let man2 = Command::new("curl").args([
"-s", "-H", "Accept: application/vnd.docker.distribution.manifest.v2+json",
&format!("https://registry-1.docker.io/v2/{repo}/manifests/{d}"),
]).output()?;
let m2: serde_json::Value = serde_json::from_slice(&man2.stdout)?;
cfg_digest = Some(m2["config"]["digest"].as_str().unwrap().to_string());
layer_digest = Some(m2["layers"][0]["digest"].as_str().unwrap().to_string());
break;
}
}
} else {
cfg_digest = Some(m["config"]["digest"].as_str().unwrap().to_string());
layer_digest = Some(m["layers"][0]["digest"].as_str().unwrap().to_string());
}
let layer = layer_digest.context("no layer found")?;
// 3) 下載 layer blob(registry 的 blob 端點會 302 到 CDN,curl -L 跟隨)
let blob_path = dir.join("layer.tar.gz");
let status = Command::new("curl").args([
"-sL", "-o",
blob_path.to_str().unwrap(),
&format!("https://registry-1.docker.io/v2/{repo}/blobs/{layer}"),
]).status()?;
anyhow::ensure!(status.success(), "download layer failed");
// 4) 解壓進 rootfs
let gz = flate2::read::GzDecoder::new(std::fs::File::open(&blob_path)?);
tar::Archive::new(gz).unpack(&rootfs)
.with_context(|| format!("unpack into {}", rootfs.display()))?;
let _ = cfg_digest;
Ok(rootfs)
}
fn home_dir() -> PathBuf {
std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/tmp"))
}
// ---------- 容器執行 ----------
struct Container {
cgroup: Cgroup,
pid: i32,
#[allow(dead_code)]
upper: PathBuf,
}
impl Drop for Container {
fn drop(&mut self) {
unsafe { libc::kill(self.pid, libc::SIGKILL) };
// cgroup 與 upper 由各自 Drop/外部清理
let _ = self.cgroup.usage(); // 退出前印用量(教學:統計)
}
}
const NS_FLAGS: i32 = libc::CLONE_NEWNS | libc::CLONE_NEWUTS | libc::CLONE_NEWIPC
| libc::CLONE_NEWPID | libc::CLONE_NEWNET | libc::CLONE_NEWUSER;
fn run_container(rootfs: &Path, cmd: &[String], mem_mb: u64) -> anyhow::Result<()> {
let work = home_dir().join(".mini-docker/run");
let upper = work.join("upper");
let wdir = work.join("work");
let merged = work.join("merged");
for d in [&upper, &wdir, &merged] { fs::create_dir_all(d)?; }
// 先在宿主準備好 rootfs 疊加目錄(mount 在 fork 前做,減少子程序內的操作)
mount_overlay(&[rootfs.to_str().unwrap()],
upper.to_str().unwrap(), wdir.to_str().unwrap(),
merged.to_str().unwrap())?;
let pid = unsafe { libc::fork() };
anyhow::ensure!(pid >= 0, "fork failed");
if pid == 0 {
// ===== 容器內(PID 1 候選)=====
child_setup(&merged, cmd).unwrap_or_else(|e| { eprintln!("{e:#}"); std::process::exit(1); });
unreachable!();
}
// ===== 宿主側 =====
let cgroup = Cgroup::create("mini", mem_mb * 1024 * 1024, 100_000)?;
// 等 user namespace 建立(uid_map 由子程序寫;教學版由父寫 root 映射)
std::thread::sleep(std::time::Duration::from_millis(200));
cgroup.attach(pid)?;
let mut status = 0;
unsafe { libc::waitpid(pid, &mut status, 0) };
unsafe { libc::umount(merged.as_os_str().as_encoded_bytes().as_ptr() as *const _) };
println!("\n[mini-docker] exit={}", (status >> 8) & 0xff);
Ok(())
}
fn child_setup(merged: &Path, cmd: &[String]) -> anyhow::Result<()> {
// 1) 六個 namespace(rootless:user 先行)
unsafe { libc::unshare(libc::CLONE_NEWUSER) };
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
fs::write("/proc/self/setgroups", "deny")?;
fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
unsafe { libc::unshare(NS_FLAGS & !libc::CLONE_NEWUSER) };
// 2) 進入 rootfs:pivot_root 的簡化版 —— chroot + 掛 proc
unsafe { libc::chroot(merged.as_os_str().as_encoded_bytes().as_ptr() as *const _) };
unsafe { libc::chdir(b"/\0".as_ptr() as *const _) };
let _ = fs::create_dir_all("/proc");
unsafe {
libc::mount(b"proc\0".as_ptr() as *const _,
b"/proc\0".as_ptr() as *const _,
b"proc\0".as_ptr() as *const _, 0, std::ptr::null());
}
// 3) 容器內網路:僅 lo(對外網路需 veth + bridge,見習題)
Command::new("ip").args(["link", "set", "lo", "up"]).status().ok();
// 4) hostname
let h = CString::new("mini").unwrap();
unsafe { libc::sethostname(h.as_ptr(), h.as_bytes().len()) };
// 5) exec 應用
let prog = CString::new(cmd[0].clone())?;
let args: Vec<CString> = cmd.iter().map(|s| CString::new(s.clone()).unwrap()).collect();
let mut argv: Vec<*const i8> = args.iter().map(|c| c.as_ptr()).collect();
argv.push(std::ptr::null());
let mut envp = vec![b"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\0".as_ptr() as *const i8];
envp.push(std::ptr::null());
libc::execve(prog.as_ptr(), argv.as_ptr(), envp.as_mut_ptr());
bail!("exec failed: {}", std::io::Error::last_os_error())
}
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.as_slice() {
[img, rest @ ..] if img == "run" => {
let (mem, cmd) = match rest.iter().position(|a| a == "--mem") {
Some(i) => (rest[i+1].parse()?,
rest[..i].chain(rest[i+2..]).cloned().collect::<Vec<_>>()),
None => (64, rest.to_vec()),
};
let image = cmd[0].clone();
let rootfs = pull_image(&image)?;
run_container(&rootfs, &cmd[1..], mem)
}
_ => {
eprintln!("usage: mini-docker run <image> [--mem MB] <cmd>...");
Ok(())
}
}
}
執行(Linux、rootless 環境需核心啟用 unprivileged userns):
$ sudo cargo run --release -- run alpine:3.19 sh
/ # ps # 只有自己,PID 1
/ # hostname # mini
/ # ip link # 只有 lo
$ # 另開終端觀察:
$ cat /sys/fs/cgroup/mini/memory.current
這 500 行涵蓋了什麼#
graph TD
P[pull: registry API + tar 解包] --> R[rootfs: overlayfs 疊加]
R --> N[namespace: user/mnt/uts/ipc/pid/net]
N --> C[cgroup: memory.max / cpu.max]
C --> E[exec: chroot + execve]
E --> I[PID1: 訊號轉發 + wait 收殭屍]而 Rust 在其中的貢獻:
Container的Drop保證 SIGKILL 與統計一定發生;Cgroup的Drop保證資源配額目錄不留殘屍;OwnedFd(如 blob 檔)與所有?錯誤傳播,使「中途失敗」的清理路徑與成功路徑共用同一套 RAII;- unsafe 全部集中在 mount/chroot/execve 的薄層,易於審查。
真正的 runc 有數萬行程式碼:pivot_root 而非 chroot、rootfs 的 device cgroup 與 seccomp(2005 年加入 Linux,限制可用系統呼叫)、veth+bridge 網路、OCI spec 的完整解析。但你已經造出了它的骨架——剩下的每一個功能,都是你在本書學過的系統呼叫知識的組合應用。
習題#
- 為容器加上網路:宿主建 bridge(
ip link add name br0 type bridge),建立 veth pair,一端插 bridge、一端移入容器 netns,配置 IP 後測ping宿主。 - 加入
--cpus 0.5參數,翻譯成cpu.max並用壓力測試驗證。 - 對照 youki 原始碼的
create流程,找出你的 mini-docker 缺的三個安全機制(提示:seccomp、no_new_privs、capabilities drop)。 - 實驗:把
memory.max調小後在容器內跑dd if=/dev/zero | head -c 200M > /dev/null,觀察 OOM 行為與memory.events檔案。