delonix-rust tutorial

Projecto: minicontainer · 25 min de leitura

4 · cgroups e capabilities#

Depois de isolar o que o processo vê, limitamos o que ele gasta e o que pode fazer.

Traduzir a spec para ficheiros do cgroup#

O linux.resources da spec vira escritas em ficheiros do cgroup v2. Isto é lógica pura, separada do I/O — testável sem tocar em /sys:

minicontainer/src/cgroup.rs · limitsver no GitHub ↗
/// Limites já traduzidos para os ficheiros do cgroup v2 (função pura → testável).
pub fn limit_files(res: &Resources) -> Vec<(&'static str, String)> {
    let mut out = Vec::new();
    if let Some(limit) = res.memory.as_ref().and_then(|m| m.limit) {
        out.push(("memory.max", if limit < 0 { "max".to_owned() } else { limit.to_string() }));
        // Sem isto o kernel «cumpre» o limite empurrando páginas para swap.
        out.push(("memory.swap.max", "0".to_owned()));
    }
    if let Some(p) = &res.pids {
        out.push(("pids.max", if p.limit < 0 { "max".to_owned() } else { p.limit.to_string() }));
    }
    if let Some(quota) = res.cpu.as_ref().and_then(|c| c.quota.filter(|q| *q > 0)) {
        let period = res.cpu.as_ref().and_then(|c| c.period).unwrap_or(100_000);
        out.push(("cpu.max", format!("{quota} {period}")));
    }
    out
}

Duas escolhas a notar:

  • memory.swap.max = 0 acompanha memory.max. Sem isso o kernel «cumpre» o limite empurrando páginas para swap, e o container nunca é morto por OOM — só fica lento.
  • -1 significa max (sem limite), tal como na spec.

E o teste, sem cgroup nenhum:

let res = Resources {
    memory: Some(Memory { limit: Some(64 << 20) }),
    pids: Some(Pids { limit: 32 }),
    cpu: Some(Cpu { quota: Some(50_000), period: Some(100_000) }),
};
let f = limit_files(&res);
assert!(f.contains(&("memory.max", "67108864".to_owned())));
assert!(f.contains(&("cpu.max", "50000 100000".to_owned())));

Criar o cgroup — e recusar se não der#

Aqui está a parte que dá dor de cabeça a toda a gente que faz rootless. Já vimos no capítulo do Linux: só numa subárvore delegada ao teu utilizador é que se criam filhos com controladores.

minicontainer/src/cgroup.rs · cg-createver no GitHub ↗
    /// `Ok(None)` quando o bundle não pede limites — não se mexe em cgroups sem necessidade.
    pub fn create(id: &str, res: Option<&Resources>) -> Result<Option<Self>> {
        let files = res.map(limit_files).unwrap_or_default();
        if files.is_empty() {
            return Ok(None);
        }
        let hint = "run under a delegated scope: systemd-run --user --scope -p Delegate=yes -- mc ...";
        let parent = own_cgroup()?;

        // Regra «no internal processes»: para activar controladores em `parent` ele não pode
        // ter processos directos. Mudamo-nos para um filho `mc-mgr` (o delonix chama-lhe `dlx-mgr`).
        let mgr = parent.join("mc-mgr");
        fs::create_dir_all(&mgr)
            .map_err(|e| Error::Cgroup(format!("cannot create {}: {e} ({hint})", mgr.display())))?;
        fs::write(mgr.join("cgroup.procs"), b"0")
            .map_err(|e| Error::Cgroup(format!("cannot move into {}: {e} ({hint})", mgr.display())))?;

        let available = fs::read_to_string(parent.join("cgroup.controllers")).unwrap_or_default();
        let enable: Vec<String> = wanted_controllers(&files)
            .into_iter()
            .filter(|c| available.split_whitespace().any(|a| a == *c))
            .map(|c| format!("+{c}"))
            .collect();
        let missing = wanted_controllers(&files).len() - enable.len();
        if missing > 0 {
            return Err(Error::Cgroup(format!(
                "a requested controller is not delegated here (have: {available:?}); {hint}"
            )));
        }
        fs::write(parent.join("cgroup.subtree_control"), enable.join(" "))
            .map_err(|e| Error::Cgroup(format!("cannot enable controllers: {e} ({hint})")))?;

        let leaf = parent.join(format!("mc-{id}"));
        fs::create_dir(&leaf).map_err(|e| Error::Cgroup(format!("creating {}: {e}", leaf.display())))?;
        for (name, value) in &files {
            fs::write(leaf.join(name), value)
                .map_err(|e| Error::Cgroup(format!("writing {name}={value}: {e}")))?;
        }
        Ok(Some(Self { path: leaf }))
    }

Os passos, pela ordem que o kernel exige:

  1. mc-mgr — cria um cgroup filho «gestor» e move-se para lá (cgroup.procs ← 0 = «eu próprio»). É a regra no internal processes: só assim o pai fica sem processos directos e pode activar controladores.
  2. Verifica os controladores disponíveis (cgroup.controllers) contra os que os limites pedem. Falta um? Erro, com o remédio.
  3. cgroup.subtree_control ← +memory +pids +cpu — activa-os para os filhos.
  4. mc-<id> — o cgroup do container, e escreve-se cada limite.

E o comportamento quando o host não deixa — sem delegação:

saída real · medida neste host
$ mc run cg -b bundle   # limites pedidos, SEM scope delegado
mc: cgroup: cannot enable controllers: Device or resource busy (os error 16) (run under a delegated scope: systemd-run --user --scope -p Delegate=yes -- mc ...)
[exit 1]

O EBUSY (Device or resource busy) é o kernel a dizer «este cgroup ainda tem processos». A mensagem não se fica pelo erro cru: diz o remédio. Comparação com uma sessão delegada:

saída real · medida neste host
$ systemd-run --user --scope -p Delegate=yes mc run cg -b bundle
0::/user.slice/user-1000.slice/user@1000.service/app.slice/run-r794cad54fa1443259ab5bf59cbefe413.scope/mc-cg2
[exit 0]

O container está agora em .../mc-cg2 — o cgroup que criámos.

Fail-closed, outra vez

A alternativa fácil era «se não conseguir, segue sem limites». Não: o operador pediu memory: 32M, o container corre sem tecto, e ninguém sabe. O delonix mediu isto numa VM limpa: -m 128M --cpus 0.5 inertes numa sessão SSH normal. Recusar com o remédio é a única resposta honesta — e é a regra de ouro do capítulo 10 aplicada a recursos.

Os limites a funcionar#

Numa sessão com cgroup delegado, um processo que enche a memória contra memory.max = 32 MiB:

saída real · medida neste host
$ systemd-run --user --scope -p Delegate=yes mc run oom -b bundle   # memory.max=32M; o processo enche a memória
mc: container was OOM-killed
[exit 137]

Saída 137 = 128 + 9 (SIGKILL) — a convenção de shell para «morto por sinal 9». E o mc diz porquê: «OOM-killed». Um 137 sozinho lê-se igual para um OOM e para um kill -9, e os remédios são opostos. E o limite de processos:

saída real · medida neste host
$ systemd-run --user --scope -p Delegate=yes mc run pids -b bundle   # pids.max=16; o processo faz fork de 20
/bin/sh: can't fork: Resource temporarily unavailable

can't fork: Resource temporarily unavailable — o EAGAIN que o kernel devolve quando pids.max está atingido.

Detectar o OOM: o cgroup desaparece com o container#

Aqui está uma armadilha que o delonix mediu com atenção, e o minicontainer respeita. O cgroup de um container desaparece no instante em que ele morre. Depois disso, memory.events (onde o kernel conta oom_kill) já não é legível. Portanto não há detecção post-mortem possível: tem de ser lida ao vivo, por quem vive tanto quanto o container — o supervisor, antes de remover o cgroup:

// no supervisor, depois do waitpid e ANTES de remover o cgroup:
let oom = cg.as_ref().is_some_and(|c| c.oom_kills() > 0);
store.update(id, |st| { st.status = Status::Stopped; st.exit_code = Some(code); st.oom_killed = oom; Ok(()) })?;

E a leitura em si:

pub fn oom_kills(&self) -> u64 {
    fs::read_to_string(self.path.join("memory.events"))
        .ok()
        .and_then(|t| t.lines().find_map(|l| l.strip_prefix("oom_kill ")?.trim().parse().ok()))
        .unwrap_or(0)
}

Limitação conhecida

oom_kills() > 0 é suficiente aqui porque o cgroup é novo e só tem este container. O delonix compara uma subida do contador contra uma linha de base lida antes do waitpid — um cgroup reutilizado traz a contagem antiga, e > 0 chamaria OOM a um kill -9. Lê também memory.events.local (não o hierárquico), porque o cgroup de um nó Kubernetes-em-container contém uma árvore systemd inteira.

Capabilities: reduzir o tecto#

Dentro do user namespace, o init tem todas as capabilities. Para a carga do utilizador queremos as 14 da OCI e mais nenhuma. Retira-se do bounding set (o tecto herdado por qualquer processo filho):

minicontainer/src/container.rs · capsver no GitHub ↗
fn drop_capabilities() -> Result<()> {
    let last: u32 = fs::read_to_string("/proc/sys/kernel/cap_last_cap")
        .ok()
        .and_then(|s| s.trim().parse().ok())
        .unwrap_or(40);
    for cap in (0..=last).filter(|c| !KEEP_CAPS.contains(c)) {
        // SAFETY: prctl com inteiros; EINVAL para caps desconhecidas é tolerado.
        let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, libc::c_ulong::from(cap), 0, 0, 0) };
        if rc != 0 && nix::Error::last() != nix::Error::EINVAL {
            return Err(Error::Sys(nix::Error::last()));
        }
    }
    Ok(())
}

Porque o bounding set e não as capabilities efectivas? Quando um processo de uid 0 faz exec, as suas capabilities permitidas ficam = bounding set. Ao reduzir o tecto antes do exec, o processo do utilizador nasce já limitado — e nunca as pode reconquistar. O teste verifica o valor exacto:

assert_eq!(bnd, "00000000a80425fb", "bounding set != OCI default");

a80425fb são as 14 do capsh --decode: chown, dac_override, fowner, fsetid, kill, setgid, setuid, setpcap, net_bind_service, net_raw, sys_chroot, mknod, audit_write, setfcap.

Duas notas de precisão:

  • EINVAL é tolerado em PR_CAPBSET_DROP: uma capability que o kernel não conhece (a lista 0..=cap_last_cap pode incluir números que esta versão não tem) não é erro.
  • A seguir vem PR_SET_NO_NEW_PRIVS: o processo (e os seus filhos) nunca ganha privilégios num exec — nem por setuid, nem por capabilities de ficheiro.

O que falta (e o delonix faz)#

minicontainer delonix
cpuset, io.weight, cpu.weight ❌ ✅ (best-effort: só se delegados)
hugepages, unified ❌ ✅
Hierarquia do kubelet (scope transitório pelo systemd) ❌ ✅ (StartTransientUnit)
oom_score_adj ❌ ✅
ambient/inheritable caps, CDI de GPU ❌ ✅
seccomp ❌ recusa ✅ allowlist + clone3→ENOSYS

Verifica#

cargo test -p minicontainer cgroup::                     # tradução de limites
cargo test -p minicontainer --test e2e drops_capabilities
systemd-run --user --scope -p Delegate=yes target/release/mc run oom -b /tmp/b   # com limites no config.json

Falta a peça que junta tudo: o ciclo de vida e o estado.