O que é um container#
Um container não existe no kernel Linux. Não há uma chamada
create_container(). Existe um processo normal a que se aplicaram, em conjunto, mecanismos independentes de isolamento e de limitação. O «runtime» é o programa que os aplica pela ordem certa.
Guarda esta frase: todo o resto do tutorial é a sua consequência. Quatro mecanismos, cada um respondendo a uma pergunta diferente:
| Mecanismo | Pergunta | Exemplo |
|---|---|---|
| Namespaces | O que é que o processo vê? | o seu próprio /, PIDs, hostname, rede |
| cgroups v2 | Quanto é que pode gastar? | memória, CPU, nº de processos |
| Capabilities / seccomp | O que é que pode fazer? | chown, montar, mknod — ou não |
pivot_root |
Qual é o seu /? |
o rootfs da imagem, não o do host |
Namespaces: o que o processo vê#
Cada processo pertence a um namespace de cada tipo. Vê-los é ler /proc/self/ns — esta é a saída de um shell neste host:
$ ls -l /proc/self/ns | tail -n +2 | awk "{print \$9, \$10, \$11}" cgroup -> cgroup:[4026531835] ipc -> ipc:[4026531839] mnt -> mnt:[4026531832] net -> net:[4026531833] pid -> pid:[4026531836] pid_for_children -> pid:[4026531836] time -> time:[4026531834] time_for_children -> time:[4026531834] user -> user:[4026531837] uts -> uts:[4026531838]
Oito tipos (o time chegou no kernel 5.6):
| Namespace | Isola | Sintoma de o teres |
|---|---|---|
mnt |
pontos de montagem | o container tem o seu próprio / |
pid |
numeração de processos | o teu processo é o PID 1 |
uts |
hostname e domínio | hostname diferente do host |
ipc |
filas/memória partilhada SysV | sem ipcs do host |
net |
interfaces, rotas, firewall | só um lo, em baixo |
user |
uids/gids | root dentro, ninguém fora |
cgroup |
vista da hierarquia de cgroups | /sys/fs/cgroup próprio |
time |
relógios monotónicos | raro |
Sem escrever uma linha de código, o unshare(1) mostra três deles a funcionar — user, pid e uts:
$ unshare --user --map-root-user --pid --fork --mount-proc --uts sh -c "hostname dentro; echo hostname=\$(hostname) pid=\$\$; id -u; ps -o pid,comm" hostname=dentro pid=1 0 PID COMMAND 1 sh 5 ps $ hostname # o do host, intacto kaeso-sys-01
Repara: dentro, pid=1 e hostname=dentro; fora, o hostname do host não mudou. O mesmo em Rust — com um ponto subtil que já enganou muita gente — está em examples/src/bin/ns_demo.rs, e o teste de integração confirma de fora que o host ficou intacto.
Armadilha: o pid namespace só vale para os filhos
unshare(CLONE_NEWPID) não move o processo que chama — só os que ele criar depois. Por isso o código faz unshare e depois fork: o filho é o PID 1. Já o uts e o user aplicam-se logo a quem chama (e os filhos herdam), o que faz com que o «pai» do demo veja o hostname que o filho pôs: estão no mesmo namespace novo.
User namespace: a chave do rootless#
O user namespace é o que permite tudo o resto sem ser root. Um processo sem privilégio pode criar um user namespace novo e, lá dentro, ter todas as capabilities — sobre esse namespace apenas. O preço: os uids têm de ser mapeados. Sem mapa, és ninguém:
$ unshare --user sh -c "cat /proc/self/uid_map; echo ---; id" --- uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup) $ unshare --user --map-root-user sh -c "cat /proc/self/uid_map; id -u" 0 1000 1 0
Sem mapeamento vês uid=65534(nobody) (o uid de overflow). Com --map-root-user, o uid_map diz 0 1000 1: «o uid 0 lá dentro é o uid 1000 cá fora, num intervalo de 1». Um mapeamento de um só uid é o que se consegue sem newuidmap//etc/subuid; para vários uids (um container com utilizadores diferentes) precisas do helper setuid newuidmap.
Armadilha que custou um EPERM misterioso
Ao escrever o minicontainer, o uid_map falhava com «Operation not permitted» — mesmo escrevendo 0 <uid> 1. Causa: eu chamava getuid() depois do unshare, e aí devolve o uid de overflow (65534). Lê os ids antes de entrares no namespace. Está comentado no código: map_ids.
cgroups v2: quanto pode gastar#
Os namespaces limitam o que se vê; não limitam o que se gasta. Um container sem cgroup pode comer toda a RAM do nó. Os control groups v2 formam uma só hierarquia em /sys/fs/cgroup:
$ stat -fc %T /sys/fs/cgroup cgroup2fs $ cat /sys/fs/cgroup/cgroup.controllers cpuset cpu io memory hugetlb pids rdma misc dmem $ cut -d: -f3 /proc/self/cgroup | sed "s#/app.slice/.*#/app.slice/…#" /user.slice/user-1000.slice/user@1000.service/app.slice/…
Cada directório é um cgroup; ficheiros como memory.max, pids.max, cpu.max são os limites. Para limitar, escreves num ficheiro — não há API.
O problema: o cgroup tem de ser teu#
Numa sessão normal, não podes criar filhos com controladores activos: o scope da sessão pertence ao systemd. Só numa subárvore delegada ao teu utilizador é que o kernel deixa:
$ systemd-run --user --scope -q -p Delegate=yes sh -c "echo controllers-disponiveis: \$(cat /sys/fs/cgroup\$(cut -d: -f3 /proc/self/cgroup)/cgroup.controllers); echo subtree_control=\"\$(cat /sys/fs/cgroup\$(cut -d: -f3 /proc/self/cgroup)/cgroup.subtree_control)\"" controllers-disponiveis: cpu memory pids subtree_control=
O Delegate=yes fez o systemd entregar-nos um cgroup próprio, com cpu memory pids disponíveis. Note-se o que não aparece: cpuset e io — o user.slice, que é do root, não os passa para baixo. É o estado normal de um host rootless.
Lição de produção: limites que não se aplicam
O delonix mediu isto numa VM limpa acedida por SSH: -m 128M --cpus 0.5 ficavam inertes — memory.max=max. Não é bug: o scope de sessão SSH é irmão de user@UID.service, não filho. A resposta do motor (e do nosso minicontainer) é recusar um limite que não consegue aplicar, com o remédio na mensagem — em vez de fingir que o aplicou. Ver cgroups no mini-projecto.
Regra a saber, porque morde toda a gente: «no internal processes». Um cgroup só pode activar controladores para os filhos (cgroup.subtree_control) se não tiver processos directos. Por isso o runtime move-se primeiro para um cgroup «gestor» (mc-mgr; o delonix chama-lhe dlx-mgr) e só depois activa +memory +pids +cpu.
Capabilities: o que pode fazer#
O root clássico é tudo-ou-nada. As capabilities dividem-no em ~40 poderes (CAP_CHOWN, CAP_NET_ADMIN, CAP_SYS_ADMIN…). Um container não precisa da maioria. O que importa é o conjunto limitador (bounding set): o tecto do que qualquer processo filho pode alguma vez ter.
$ grep -E "^Cap(Inh|Prm|Eff|Bnd)" /proc/self/status CapInh: 0000000000000000 CapPrm: 0000000000000000 CapEff: 0000000000000000 CapBnd: 000001ffffffffff $ unshare -Ur sh -c "grep -E \"^Cap(Prm|Eff|Bnd)\" /proc/self/status" CapPrm: 000001ffffffffff CapEff: 000001ffffffffff CapBnd: 000001ffffffffff
Repara na segunda medição: dentro de um user namespace criado por nós, CapEff = 1ffffffffff — todas as capabilities. São sobre o namespace, não sobre o host, mas continuam a ser demasiado poder para uma carga qualquer. A OCI define um conjunto por omissão de 14, e é o que o runtime deve deixar:
$ capsh --decode=a80425fb 0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap $ capsh --decode=000001ffffffffff | head -c 300 0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrac
a80425fb são exactamente essas 14. Vais ver o minicontainer reduzir o bounding set a este valor — e um teste a verificar CapBnd == a80425fb.
pivot_root: o / do container#
Um container tem o seu próprio sistema de ficheiros raiz (o rootfs da imagem). Duas formas de o trocar:
chroot |
pivot_root |
|
|---|---|---|
| O que faz | muda a raiz para um processo | troca a raiz do mount namespace |
O antigo / |
continua acessível (há fugas conhecidas) | pode ser desmontado |
| Usa-se em | scripts, jails simples | todos os runtimes |
A sequência, que o minicontainer implementa em container_init:
unshare(NEWNS)emount(/, MS_REC|MS_PRIVATE)— para os mounts não propagarem ao host;- bind mount do rootfs sobre si próprio (o
pivot_rootexige que a nova raiz seja um ponto de montagem); chdir(rootfs),pivot_root(".", ".")— a raiz nova e a antiga ficam empilhadas no mesmo sítio;umount2(".", MNT_DETACH)— a antiga desaparece;chdir("/").
Seccomp: o que pode pedir ao kernel#
O último eixo é o filtro de syscalls (seccomp-BPF): uma lista de chamadas permitidas, com o resto a devolver erro ou a matar o processo. O minicontainer não o implementa — e o config.json que peça linux.seccomp é recusado com erro claro, não ignorado. O delonix implementa-o e tem um allowlist embutido, mais uma regra que apanhámos a custo: instalar sempre o filtro que faz clone3 devolver ENOSYS, porque um clone3 deixaria contornar o filtro dos flags de clone.
Resumo: o que cada peça fecha
Sem namespaces vês tudo; sem cgroups gastas tudo; sem capabilities podes tudo; sem pivot_root o teu / é o do host. Um container é a soma dos quatro — e uma falha em qualquer um é uma fuga.
Rootless-first#
O delonix corre sem root por omissão. Não é um detalhe de conveniência, é uma decisão de segurança: se o motor for comprometido, o atacante tem os poderes de um utilizador normal, não os do sistema. O que isto custa (e o motor documenta): um só uid mapeado sem newuidmap, sem mknod (os /dev/null, /dev/zero… são bind mounts dos do host), portas < 1024 dependem de ip_unprivileged_port_start, e limites de cgroup exigem delegação.
Próximo passo#
Já sabes o que um runtime aplica. Falta saber o quê aplicar: qual é o formato da imagem e da configuração? É a norma OCI — próximo capítulo.