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:
/// 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 = 0acompanhamemory.max. Sem isso o kernel «cumpre» o limite empurrando páginas para swap, e o container nunca é morto por OOM — só fica lento.-1significamax(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.
/// `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:
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.- Verifica os controladores disponíveis (
cgroup.controllers) contra os que os limites pedem. Falta um? Erro, com o remédio. cgroup.subtree_control←+memory +pids +cpu— activa-os para os filhos.mc-<id>— o cgroup do container, e escreve-se cada limite.
E o comportamento quando o host não deixa — sem delegação:
$ 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:
$ 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:
$ 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:
$ 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):
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 emPR_CAPBSET_DROP: uma capability que o kernel não conhece (a lista0..=cap_last_cappode 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 numexec— 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.