delonix-rust tutorial

Projecto: minicontainer · 15 min de leitura

minicontainer — visão geral#

Chegou a hora de juntar tudo. O minicontainer (comando mc) é um runtime OCI rootless e sem daemon, em Rust, que:

  • lê um bundle OCI (config.json + rootfs/) e cumpre o ciclo de vida create → start → kill → delete, mais state, list, logs e run;
  • desempacota uma imagem OCI (image layout) num bundle, verificando digests e aplicando whiteouts;
  • isola com user, mount, pid, uts, ipc e net namespaces, faz pivot_root, limita com cgroups v2, reduz as capabilities ao conjunto OCI e liga no_new_privs;
  • recusa o que não implementa, em vez de ignorar;
  • passa em 22 testes e corre o mesmo bundle no runc.

Está em minicontainer/. Menos de 1 800 linhas, testes incluídos — pequeno o bastante para o leres todo, real o bastante para correr containers a sério. Tudo o que vês nestes capítulos foi corrido neste host; as saídas vêm de scripts/demo.sh.

Primeiro contacto#

cargo build --release -p minicontainer
scripts/make-rootfs.sh /tmp/b            # bundle a partir do busybox estático do host
target/release/mc spec /tmp/b -- /bin/sh -c 'echo pid=$$ host=$(hostname) uid=$(id -u)'
target/release/mc run demo -b /tmp/b
saída real · medida neste host
$ mc run demo -b bundle
pid=1 host=minicontainer uid=0
CapBnd:	00000000a80425fb
fd
full
null
random
stderr
stdin
stdout
tty
urandom
zero
[exit 0]

Um processo com PID 1, hostname próprio, uid=0 (root dentro — tu, fora), CapBnd reduzido ao conjunto OCI (a80425fb, 14 capabilities) e um /dev mínimo. Sem sudo.

O mapa de processos#

O ponto de design mais importante, e o que mais se parece com o delonix: quem é o «processo do container»? Sem daemon, ninguém a correr em permanência — mas alguém tem de ver o container morrer e gravar o código de saída. A resposta: um supervisor por container, com dono claro.

mc createCLI · volta ao terminal fork supervisorsetsid · sem terminal unshare(user,pid,uts,ipc,net)map_ids · lo up · attach cgroupwaitpid → grava «stopped»+ exit code + OOM fork init — PID 1 do container unshare(mnt) · MS_PRIVATEmounts · /dev · pivot_rootreadonly · hostname · caps · no_new_privsread(FIFO) … bloqueiadepois: exec(processo do utilizador) relatório (pipe): K<pid> | E<msg> relatório mc startoutro processo, depois escreve 1 byte start.fifomkfifo no create $MC_ROOT/<id>/ state.json · lock · start.fifo · output.logtoda a comunicação entre invocações passa por ficheiros (flock + rename atómico) — sem daemon, sem socket
  1. mc create faz fork → o supervisor (faz setsid, fica a correr sem terminal).
  2. O supervisor faz unshare de user, pid, uts, ipc, net, mapeia os ids e sobe o lo; depois faz fork → o init.
  3. O init (PID 1 do container) faz unshare(mnt), monta o rootfs, faz pivot_root, reduz privilégios — e bloqueia a ler um FIFO. Só então diz «pronto» ao supervisor (por um pipe), que responde ao mc create.
  4. mc start escreve um byte no FIFO. O init acorda e faz exec do processo do utilizador.
  5. Quando o processo sai, o supervisor faz waitpid, grava stopped + código de saída no estado em disco, remove o cgroup e termina.

Toda a comunicação entre invocações do mc passa por ficheiros (state.json, o FIFO) — não há socket nem processo residente. É a mesma escolha do delonix: «O que precisa de persistir é do systemd ou de um processo por workload com dono claro».

Âmbito: o que faz e o que recusa#

Feature minicontainer delonix-runtime
Namespaces user/mnt/pid/uts/ipc/net ✅ sempre isolados ✅
Rootless (user ns, 1 uid mapeado) ✅ ✅ (+ subuid/newuidmap)
pivot_root, rootfs só de leitura ✅ ✅
Mounts proc/tmpfs/bind ✅ ✅ + volumes, NFS/CIFS
Limites cgroup v2 (mem/pids/cpu) ✅ (recusa se sem delegação) ✅ + cpuset, io, OOM ao vivo
Capabilities OCI + no_new_privs ✅ ✅ + CDI, ambient
Imagem OCI (layout local, digests, whiteouts) ✅ ✅ + pull de registos, CAS, overlay partilhado
Estado em disco com flock ✅ ✅
seccomp ❌ recusado ✅
pty / terminal: true ❌ recusado ✅
Juntar-se a namespaces existentes ❌ recusado ✅ (pods)
Rede (veth, bridge, NAT, DNS) ❌ só lo ✅ SDN completa
exec num container a correr ❌ ✅
Vários uids (newuidmap) ❌ 1 uid ✅
CRI (servidor gRPC) ❌ (exercício) ✅

O que a coluna da direita ensina

Um runtime real é dezenas de coisas por cima deste núcleo. O valor do minicontainer é ser o núcleo, legível de uma ponta à outra: quando fores ler delonix-linux, vais reconhecer cada passo — só que com quinze mil linhas de casos difíceis à volta.

Como ler os capítulos seguintes#

Capítulo Módulo Conceito
10 · Spec e erros spec.rs, error.rs recusar em vez de ignorar; erros com classe
11 · Imagem OCI image.rs digests, layers, whiteouts
12 · Namespaces e rootfs container.rs, fsutil.rs unshare, mapa de ids, pivot_root
13 · cgroups e capabilities cgroup.rs, container.rs limites, OOM, privilégios
14 · Ciclo de vida e estado state.rs, container.rs supervisor, FIFO, flock
15 · Testes e validação tests/, runc provar, e os bugs que apanhámos
16 · Próximos passos — seccomp, rede, CRI

O Cargo.toml do crate:

minicontainer/Cargo.tomlver no GitHub ↗
[package]
name = "minicontainer"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "A minimal rootless, daemonless OCI runtime — the capstone of the delonix-rust tutorial"

[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
flate2 = "1.1.10"
hex = "0.4.3"
libc = "0.2.189"
nix = { version = "0.31.3", features = ["sched", "mount", "process", "fs", "signal", "user", "hostname"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
tar = "0.4.46"
thiserror = "2.0.20"

[dev-dependencies]
serde_json = "1.0.151"
tempfile = "3.27.0"

[[bin]]
name = "mc"
path = "src/main.rs"

[lints]
workspace = true

Repara na lista curta de dependências: nix (chamadas de sistema com tipos seguros), serde (a spec), sha2+hex (digests), flate2+tar (layers), thiserror, clap, e libc para as duas ou três chamadas que o nix não cobre. Nenhum runtime assíncrono: não há I/O concorrente aqui.