Fundamentos
Fundações de Linux
Antes de leres: IaaS e cloud native, para saberes porque é que um motor de nó precisa destes primitivos. Só precisas de uma shell Linux, nada mais.
Toda página depois desta assume que consegues responder, com um comando, a perguntas como «em que network namespace está este processo?», «porque é que este limite não se aplicou?» ou «quem ainda segura este pipe aberto?». Esta página ensina esses primitivos à mão, do ponto de vista de alguém que escreve código de sistema Linux e tem de o operar às 3 da manhã. No fim vais conseguir responder a cada uma dessas perguntas a partir de uma shell, e prever as falhas que as páginas seguintes descrevem no vocabulário do motor (um limite que não se aplica, um pipe que nunca chega a EOF, um PID que nomeia outro processo).
Não explica como o motor os usa — esse mapeamento, com ficheiros e símbolos, é a Introdução ao cloud native. Cada secção aqui termina com um apontador para a parte correspondente dela.
Como usar esta página. Abre um terminal e escreve ao mesmo tempo. Todo comando marcado sem privilégio foi corrido como utilizador comum num host Ubuntu com um kernel 7.0, util-linux 2.39 e systemd, e a saída mostrada é o que ele imprimiu (cortada, com caminhos específicos do host substituídos por placeholders). Comandos marcados exige root — corre numa VM descartável não foram executados para esta página: mudam estado de todo o host, e nunca os devias tentar numa máquina que corre algo com que te importas. Configuração de microVMs mostra como obter uma VM descartável a partir do próprio motor.
Trabalha num directório de rascunho para nada do que crias aterrar no repositório:
mkdir -p ~/scratch/linux-lab && cd ~/scratch/linux-lab
Processos, o kernel e /proc#
Um processo é um programa a correr com o seu próprio espaço de endereçamento, um PID numérico, um pai (o seu PPID) e um conjunto de atributos pertencentes ao kernel: credenciais, namespaces, pertença a um cgroup, descritores de ficheiro abertos, disposições de sinal e limites de recurso. Todo processo excepto o PID 1 tem um pai; quando um pai morre primeiro, o órfão é re-adoptado pelo subreaper mais próximo ou pelo PID 1.
- O
forkduplica o processo que o chama. O filho recebe uma cópia do espaço de endereçamento (copy-on-write) e uma cópia da tabela de descritores de ficheiro — os mesmos ficheiros abertos, partilhados, não reabertos. Só a thread que chamou é copiada, o que é a razão pela qual fazer fork de um programa multi-thread e depois fazer algo não trivial antes doexecé perigoso (um lock segurado por outra thread fica segurado para sempre no filho). - O
execvesubstitui o programa a correr num processo: mesmo PID, mesmo pai, mesmos namespaces e cgroup, código novo. Os descritores de ficheiro sobrevivem aoexeca não ser que estejam marcados close-on-exec — mais sobre isto em Descritores de ficheiro. - O
cloneé a forma geral por trás tanto doforkcomo da criação de threads. As suas flags escolhem o que o filho partilha com o pai e, crucialmente aqui, em que namespaces novos arranca (CLONE_NEWUSER,CLONE_NEWNS,CLONE_NEWPID,CLONE_NEWNET, …). Um container nasce de umclonecom essas flags.
O kernel expõe cada processo como um directório debaixo de /proc. Os ficheiros que vais usar
mais:
| Caminho | O que te diz |
|---|---|
/ |
nome, estado, PPid, uid/gid, NSpid (o PID em cada namespace de PID aninhado), conjuntos de capabilities, threads |
/ |
o argv, separado por NUL |
/proc/<pid>/ns/ |
um symlink por namespace; o número de inode é a identidade do namespace |
/ |
o caminho de cgroup v2 (0::/…) |
/proc/<pid>/fd/, / |
descritores de ficheiro abertos e o seu offset/flags |
/proc/<pid>/stat |
o campo 22 é a hora de arranque, que distingue um processo de um posterior que reutilizou o seu PID |
Experimenta na tua própria shell (sem privilégio):
grep -E '^(State|PPid|Threads|NSpid|CapEff)' /proc/$$/status
tr '\0' ' ' < /proc/$$/cmdline; echo
cat /proc/self/cgroup
State: S (sleeping)
PPid: 4033620
NSpid: 953496
Threads: 1
CapEff: 0000000000000000
0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-….scope
Repara que $$ é a tua shell, enquanto self é qualquer processo que abrir o ficheiro — para
cat /proc/self/cgroup esse é o cat. Repara também que um PID é um número, não um nome: uma
vez colhido um processo, o kernel pode dar o mesmo número a um processo sem relação nenhuma. Código
que guarda um PID e o sinaliza mais tarde tem de verificar a hora de arranque, ou melhor, segurar um
pidfd (ver abaixo).
Porque é que o motor lê /proc. É a única vista autoritativa e sem lock de um processo vivo: o
cgroup real de um container a correr, se um PID registado ainda nomeia o mesmo processo, que
namespaces juntar para um exec. → Como o Delonix usa isto: namespaces e funcionamento
rootless.
Ler mais: proc(5),
fork(2),
execve(2),
clone(2).
Namespaces#
Um namespace envolve um tipo de recurso global para que os processos lá dentro vejam a sua própria instância. O Linux tem oito:
| Namespace | Flag | Isola |
|---|---|---|
| mount | CLONE_NEWNS |
a tabela de mounts: o que está montado onde |
| UTS | CLONE_NEWUTS |
o hostname e o nome de domínio NIS |
| IPC | CLONE_NEWIPC |
objectos IPC do System V e filas de mensagens POSIX |
| PID | CLONE_NEWPID |
a numeração de processos; o primeiro processo lá dentro é o PID 1 |
| network | CLONE_NEWNET |
interfaces, endereços, rotas, tabelas de firewall, sockets, /proc/sys/net |
| user | CLONE_NEWUSER |
uids/gids e capabilities; o dono de todos os outros namespaces |
| cgroup | CLONE_NEWCGROUP |
a vista da árvore de cgroups (o processo vê o seu cgroup como /) |
| time | CLONE_NEWTIME |
os offsets de CLOCK_MONOTONIC e CLOCK_BOOTTIME |
Identidade: o inode por trás de /proc//ns#
Cada entrada em /proc/<pid>/ns é um symlink cujo alvo codifica o tipo do namespace e um número de
inode. Dois processos estão no mesmo namespace exactamente quando esses inodes são iguais — é
assim que se compara, não pelos nomes (sem privilégio):
ls -l /proc/self/ns
lrwxrwxrwx 1 you you 0 … cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 you you 0 … ipc -> ipc:[4026531839]
lrwxrwxrwx 1 you you 0 … mnt -> mnt:[4026531832]
lrwxrwxrwx 1 you you 0 … net -> net:[4026531833]
lrwxrwxrwx 1 you you 0 … pid -> pid:[4026531836]
lrwxrwxrwx 1 you you 0 … pid_for_children -> pid:[4026531836]
lrwxrwxrwx 1 you you 0 … time -> time:[4026531834]
lrwxrwxrwx 1 you you 0 … time_for_children -> time:[4026531834]
lrwxrwxrwx 1 you you 0 … user -> user:[4026531837]
lrwxrwxrwx 1 you you 0 … uts -> uts:[4026531838]
O pid_for_children e o time_for_children existem porque um processo nunca muda o seu próprio
namespace de PID ou de tempo: unshare/setns nesses afecta só os filhos que criar a seguir.
O lsns lista namespaces em todo o sistema. No host usado para esta página, o util-linux 2.39.3
num kernel 7.0 falha com lsns: Unsupported ioctl NS_GET_USERNS e não imprime nada. Se o teu
fizer o mesmo, compara os inodes directamente: readlink /proc/<pid>/ns/net para os processos que
te interessam.
À mão: um namespace de user + mount + UTS + network, sem root#
Um utilizador sem privilégio não consegue criar a maioria dos namespaces sozinho…
unshare --net true
unshare: unshare failed: Operation not permitted
…mas consegue criar um user namespace, e lá dentro torna-se root sobre os namespaces que esse
user namespace possui. O --map-root-user (-r) mapeia o teu uid para 0 lá dentro
(sem privilégio):
unshare --user --map-root-user --mount --uts --net sh -c '
hostname lab; hostname; id
cat /proc/self/uid_map
ip link
readlink /proc/self/ns/net'
hostname; readlink /proc/self/ns/net # back outside
lab
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
0 1000 1
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
net:[4026534483]
<your-host>
net:[4026531833]
Três coisas a ver: o hostname mudou só lá dentro; o network namespace novo tem só lo, e está
down; e o inode do namespace difere do do host. O grupo 65534(nogroup) é um grupo do host sem
mapeamento lá dentro — ids não mapeados aparecem sempre como o id de overflow.
Um mount namespace funciona da mesma forma: mounts feitos lá dentro são invisíveis fora (sem privilégio):
mkdir -p mnt
unshare -r -m sh -c "mount -t tmpfs scratch $PWD/mnt && findmnt -n -o SOURCE,FSTYPE $PWD/mnt && touch $PWD/mnt/only-here && ls $PWD/mnt"
ls mnt; findmnt -n mnt; echo "findmnt rc=$?"
scratch tmpfs
only-here
findmnt rc=1
Um namespace de PID precisa de --fork, porque o próprio chamador fica no seu namespace de PID
antigo; só o seu filho se torna PID 1. O --mount-proc volta a montar o /proc para ferramentas
como ps verem a numeração nova (sem privilégio):
unshare -r --pid --fork --mount-proc sh -c 'echo $$; ps -o pid,ppid,comm'
1
PID PPID COMMAND
1 0 sh
2 1 ps
Visto de fora, o mesmo processo tem dois PIDs — o NSpid lista-os do namespace mais externo para
dentro (sem privilégio):
unshare -r -p -f sleep 3 & U=$!; sleep 0.4
grep -E '^(Name|NSpid)' /proc/$(pgrep -P $U)/status; wait
Name: sleep
NSpid: 953619 1
Os namespaces de cgroup e de tempo podem ser experimentados da mesma forma (unshare -r --cgroup
cat /proc/self/cgroup imprime 0::/).
User namespaces e mapeamento de uid#
O mapeamento vive em /proc/<pid>/uid_map e gid_map, uma linha por intervalo:
<primeiro id lá dentro> <primeiro id fora> <contagem>. As regras que moldam os containers
rootless:
- Os mapas são escritos uma vez, por um processo com o privilégio certo sobre o namespace novo — normalmente o pai, enquanto o filho espera.
- Um utilizador sem privilégio pode escrever um mapa de uma linha do seu próprio uid (o que o
-rfez acima:0 1000 1). Um container cuja imagem corre como uid 101 ou muda a dona de ficheiros para uids de serviço precisa de um intervalo. - Os intervalos vêm de
/etc/subuide/etc/subgide são escritos pelos helpers setuidnewuidmap/newgidmap, que verificam que o intervalo te pertence.
Sem privilégio (precisa de uma entrada para o teu utilizador em /etc/subuid//etc/subgid):
grep "^$(id -un):" /etc/subuid /etc/subgid
unshare --user --map-auto --map-root-user cat /proc/self/uid_map
/etc/subgid:you:100000:65536
/etc/subuid:you:100000:65536
0 1000 1
1 100000 65536
O uid 0 lá dentro és tu; os uids 1–65536 lá dentro são os uids do host 100000–165535, que não pertencem a ninguém no host. Esse último ponto é a razão pela qual um ficheiro escrito por um container como uid 999 não pode ser removido por ti de fora — ver a nota sobre a leitura desses ficheiros em Ambiente.
No Ubuntu 23.10 e posteriores, o kernel.apparmor_restrict_unprivileged_userns=1 pode recusar user
namespaces a binários sem perfil AppArmor. O /usr/bin/unshare tem um; um binário acabado de
construir num directório arbitrário pode não ter. O sintoma é EPERM no primeiro unshare, que
parece um bug do programa. O Ambiente cobre a correcção.
Criar versus juntar; manter um namespace vivo#
- Criar:
unshare(2)(o processo actual move-se para namespaces novos) ouclone(2)comCLONE_NEW*(o filho arranca lá dentro). - Juntar:
setns(2)sobre um descritor de ficheiro aberto a partir de/proc/<pid>/ns/<tipo>. A ferramentansenter(1)embrulha-o.
Um namespace vive enquanto algo o referenciar: um processo lá dentro, um descritor de ficheiro
aberto para o seu ficheiro /proc/<pid>/ns/*, ou um bind mount desse ficheiro (que é o que o
ip netns add cria debaixo de /run/netns). Quando a última referência desaparece, um network
namespace e toda interface lá dentro desaparecem.
Por isso o padrão rootless é um holder: um processo pequeno que dorme dentro do namespace para o manter vivo, e a que outros se juntam. Consegues fazer isto sem root, porque possuis o user namespace que o holder criou (sem privilégio):
unshare --user --map-root-user --net sleep 60 & # the holder; unshare execs sleep
H=$!; sleep 0.5
nsenter --target $H --user --net --preserve-credentials sh -c 'ip link add dummy0 type dummy; ip -br link'
nsenter --target $H --user --net --preserve-credentials ip -br link # a second visitor sees it
lo DOWN 00:00:00:00:00:00 <LOOPBACK>
dummy0 DOWN ae:1a:b3:4b:88:4c <BROADCAST,NOARP>
lo DOWN 00:00:00:00:00:00 <LOOPBACK>
dummy0 DOWN ae:1a:b3:4b:88:4c <BROADCAST,NOARP>
Quando o sleep termina, o namespace e o dummy0 vão com ele.
Os equivalentes privilegiados, exige root — corre numa VM descartável (não executados nesta revisão):
ip netns add lab # a named netns, pinned by a bind mount in /run/netns
ip netns exec lab ip link # run a command in it
nsenter --target <pid> --net --mount ip addr # join another user's process's namespaces
ip netns del lab
Boas práticas#
- Em rootless, empareja sempre o network namespace com um user namespace. Sem ele não tens
CAP_NET_ADMINsobre o namespace novo; com ele tens, e nada vaza para o host. - Cria o user namespace primeiro (ou no mesmo
clone): todo outro namespace pertence ao user namespace em que foi criado, e essa posse decide quem o pode configurar. - Mantém um holder para tudo o que tenha de sobreviver a um comando, e trata o holder como um processo com dono e pidfile, não como um acidente.
- Junta-te, não recries. Recriar um namespace que ainda tem membros vivos corta-os fora.
- Compara inodes, nunca nomes ou PIDs, para decidires «o mesmo namespace».
- Limpa o que nomeares:
ip netns del, desmonta bind mounts, e deixa os holders sair.
→ Como o Delonix usa isto: namespaces do Linux e funcionamento rootless e Rede de containers.
Ler mais: namespaces(7),
user_namespaces(7),
pid_namespaces(7),
network_namespaces(7),
unshare(1),
nsenter(1),
setns(2),
newuidmap(1),
subuid(5).
cgroups v2#
Um control group é um conjunto de processos ao qual se aplicam limites de recurso e
contabilidade. O cgroup v2 é uma árvore unificada montada em /sys/fs/cgroup: um directório é
um cgroup, um processo pertence a exactamente um, e os ficheiros no directório são a interface.
cgroup.controllers— os controladores disponíveis neste cgroup (concedidos pelo pai).cgroup.subtree_control— os controladores activados para os filhos deste cgroup. Escrever+memorylá cria ficheirosmemory.*em cada filho.cgroup.procs— os PIDs neste cgroup. Escrever um PID move esse processo (só esse processo; os seus filhos existentes ficam onde estão).- Ficheiros de controlador:
memory.max,memory.high,memory.events,memory.peak,cpu.max(<quota> <período>em microsegundos, oumax),cpu.weight,cpu.stat,pids.max, e os ficheiros de pressãocpu.pressure,memory.pressure,io.pressure(PSI).
A regra «sem processos internos»#
Um cgroup que tem processos não pode activar controladores para os seus filhos, e um cgroup que
distribui recursos a filhos mantém os seus processos em folhas. Na prática: os processos vivem em
folhas, e um gestor que queira criar filhos para os seus próprios processos tem primeiro de se
mover para uma folha. O kernel reporta uma violação como EBUSY.
Delegação a utilizadores#
Só o root consegue escrever na árvore de cgroups por omissão. O systemd delega uma subárvore a
um utilizador ao mudar o dono dela: na maioria dos hosts o user@<uid>.service pertence-te e
delega alguns controladores. Olha primeiro para a tua própria posição (sem privilégio):
CG=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup); echo "$CG"
cat "$CG/cgroup.controllers"
U=/sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service
stat -c '%U %n' "$U/cgroup.subtree_control"; cat "$U/cgroup.subtree_control"
/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-….scope
memory pids
you /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/cgroup.subtree_control
cpu memory pids
Dois factos que vale a pena reparar neste host: o scope da shell não tem controlador cpu (por
isso cpu.max não existe lá), e o cpuset/io não são delegados ao utilizador de todo — o slice
raiz não os passa para baixo.
Porque é que uma sessão SSH não consegue definir limites. Um login por SSH aterra em
session-<n>.scope, que é um irmão de user@<uid>.service, não um filho. Mover um PID entre
dois cgroups exige acesso de escrita ao cgroup.procs do seu antepassado comum; aqui isso é
user-<uid>.slice, propriedade do root. Por isso um programa arrancado por SSH não se consegue pôr
debaixo da subárvore delegada, e os limites que tenta definir não têm para onde ir. A correcção é
pedir ao systemd um scope delegado.
À mão: um comando limitado num scope de utilizador#
O systemd-run --user --scope corre um comando num scope transitório novo debaixo do teu gestor de
utilizador, com propriedades de controlo de recurso aplicadas (sem privilégio):
systemd-run --user --scope -q -p MemoryMax=64M -p CPUQuota=20% sh -c '
C=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup); echo "$C"
cat "$C/memory.max" "$C/cpu.max"
cat "$C/cpu.pressure"
head -3 "$C/cpu.stat"'
/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/run-r4b0….scope
67108864
20000 100000
some avg10=0.00 avg60=0.00 avg300=0.00 total=35
full avg10=0.00 avg60=0.00 avg300=0.00 total=35
usage_usec 6398
user_usec 1066
system_usec 5331
O CPUQuota=20% tornou-se cpu.max = 20000 100000: 20 ms de CPU por período de 100 ms.
Agora provoca um OOM kill e lê a evidência antes de o cgroup desaparecer. Um scope transitório é
removido assim que o seu último processo sai, por isso a leitura tem de acontecer de dentro dele.
Dois detalhes importam: MemorySwapMax=0 (senão a alocação simplesmente vai para swap), e
OOMPolicy=continue (a omissão do systemd para um scope é parar o scope inteiro quando um
processo é morto por OOM — o leitor também morreria; sem isto este comando só imprimia
Terminated) (sem privilégio):
systemd-run --user --scope -q -p MemoryMax=32M -p MemorySwapMax=0 -p OOMPolicy=continue sh -c '
python3 -c "b = bytearray(128 * 1024 * 1024)"; echo "python exit=$?"
C=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup); cat "$C/memory.events"'
Killed
python exit=137
low 0
high 0
max 51
oom 1
oom_kill 1
oom_group_kill 0
sock_throttled 0
O código de saída 137 é 128 + 9 (SIGKILL). O único sítio que diz «isto foi um OOM kill e não um
kill -9» é o oom_kill em memory.events — e desaparece assim que o cgroup é removido.
Por fim, vê a regra «sem processos internos» e a delegação de uma só vez, dentro de um scope que o
systemd te delega (Delegate=yes) (sem privilégio):
systemd-run --user --scope -q -p Delegate=yes sh -c '
C=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)
mkdir "$C/leaf"
env printf "+memory" > "$C/cgroup.subtree_control" || echo "refused: this cgroup still has processes"
echo $$ > "$C/leaf/cgroup.procs" && echo "moved self into leaf"
echo "+memory +pids" > "$C/cgroup.subtree_control" && echo "controllers enabled for children"
echo 16M > "$C/leaf/memory.max"; cat "$C/leaf/memory.max"'
printf: write error: Device or resource busy
refused: this cgroup still has processes
moved self into leaf
controllers enabled for children
16777216
Os mesmos passos sem systemd, exige root — corre numa VM descartável (não executados nesta revisão):
mkdir /sys/fs/cgroup/lab
echo "+memory +pids" > /sys/fs/cgroup/cgroup.subtree_control # usually already enabled at the root
mkdir /sys/fs/cgroup/lab/work
echo 64M > /sys/fs/cgroup/lab/work/memory.max
echo <pid> > /sys/fs/cgroup/lab/work/cgroup.procs
cat /sys/fs/cgroup/lab/work/memory.events
# cleanup: the cgroup must be empty before rmdir
echo <pid> > /sys/fs/cgroup/cgroup.procs; rmdir /sys/fs/cgroup/lab/work /sys/fs/cgroup/lab
Boas práticas#
- Uma folha por workload. Os limites, a contabilidade e a evidência de OOM pertencem então a exactamente uma coisa.
- Define os limites e move o processo para dentro antes de ele começar a correr o seu programa. Uma migração move um processo, nunca os seus descendentes; qualquer coisa que tenha feito fork antes da mudança fica fora do limite para sempre.
- Lê o
memory.eventspara OOM, e lê-o enquanto o cgroup ainda existe — a partir do processo que espera pelo workload, não depois. - Não escrevas em controladores ou cgroups que não possuis. Um cgroup delegado a ti é teu; o
seu pai não é. Num host partilhado, nunca toques em
/sys/fs/cgroupfora da tua própria subárvore. - Verifica o dono do
cgroup.subtree_control, não a presença de um nome de controlador, para saberes se tens mesmo delegação. - Usa o PSI (
*.pressure) para ver contenção antes de se tornar um OOM ou um incidente de latência.
→ Como o Delonix usa isto: cgroups v2 e delegação.
Ler mais: kernel.org — Control Group v2
(cgroup-v2.rst),
cgroups(7),
systemd.resource-control(5),
systemd-run(1),
systemd — Control Group APIs and Delegation,
kernel — PSI.
Descritores de ficheiro#
Um descritor de ficheiro é um pequeno inteiro que indexa uma tabela por-processo. Cada
entrada aponta para uma descrição de ficheiro aberto no kernel — que guarda o offset do
ficheiro e as flags de estado (O_APPEND, O_NONBLOCK, …) — e essa descrição aponta para o objecto
subjacente: um inode para um ficheiro normal, ou um pipe, um socket, um contador de eventos, um
processo.
process fd table kernel object
3 ─────────────┐
├──► open file description ──────────► inode / pipe / socket / …
7 (dup of 3) ──┘ (offset, O_APPEND, …)
Consequências que mordem em código real:
dup/dup2eforkpartilham a descrição de ficheiro aberto: dois fds (ou dois processos) movem o mesmo offset. Abrir o mesmo caminho duas vezes dá duas descrições com offsets independentes.- A flag close-on-exec (
FD_CLOEXEC) é por descritor, não por descrição: vive na entrada da tabela e define-se comO_CLOEXECnoopen,SOCK_CLOEXECnosocket,pipe2(…, O_CLOEXEC), oufcntl(fd, F_SETFD, FD_CLOEXEC)depois. A janela entreopenefcntlé uma corrida num programa multi-thread; usa a flag atómica. - Os fds 0, 1, 2 são stdin, stdout e stderr só por convenção; são herdados como qualquer outro fd.
- Tudo com que um processo fala é um fd: ficheiros, pipes (
pipe2), sockets incluindo sockets unix, pidfds (uma referência estável a um processo,pidfd_open), memfds (memória anónima com uma interface de ficheiro,memfd_create), eventfds (um contador para wakeups), instâncias epoll, referências de namespace abertas de/proc/<pid>/ns. - Um pipe só chega a EOF quando toda cópia da sua ponta de escrita estiver fechada, em todo processo. Uma cópia esquecida num filho de vida longa e o leitor bloqueia para sempre.
À mão em bash#
Abre, escreve, inspecciona e fecha um descritor (sem privilégio):
bash -c '
exec 3<>notes.txt # open read-write as fd 3
echo hello >&3
ls -l /proc/$$/fd | tail -n +2
cat /proc/$$/fdinfo/3
exec 3>&- # close fd 3
ls /proc/$$/fd
cat notes.txt'
lrwx------ 1 you you 64 … 0 -> socket:[464860423]
l-wx------ 1 you you 64 … 1 -> …
l-wx------ 1 you you 64 … 2 -> …
lrwx------ 1 you you 64 … 3 -> /home/you/scratch/linux-lab/notes.txt
pos: 6
flags: 0100002
mnt_id: 34
ino: 21761577
0
1
2
hello
flags está em octal: 02 é O_RDWR, 0100000 é O_LARGEFILE. Não há 02000000
(O_CLOEXEC): os fds abertos pela shell são herdados por todo comando que ela corre. Consegues
vê-lo (sem privilégio):
bash -c 'exec 3>inherited.txt; ls -l /proc/self/fd | awk "NR>1{print \$9,\$10,\$11}"'
0 -> socket:[464878621]
1 -> pipe:[464854925]
2 -> …
3 -> /home/you/scratch/linux-lab/inherited.txt
4 -> /proc/953134/fd
O ls recebeu o fd 3 da shell sem o pedir (o fd 4 é o directório que o próprio ls abriu).
A ordem do redirecionamento importa, porque cada redirecionamento é um dup2 aplicado da
esquerda para a direita (sem privilégio):
( echo out; echo err >&2 ) >both.log 2>&1 # stdout → file, then stderr → where stdout is now
cat both.log
( echo out; echo err >&2 ) 2>&1 >only-out.log # stderr → where stdout is NOW (the terminal), then stdout → file
cat only-out.log
out
err
err
out
A primeira forma põe as duas linhas no ficheiro. Na segunda, o err foi para o terminal (a linha
solitária err) e só o out chegou ao ficheiro.
Um pipe como um fd numerado, usando substituição de processo e um número de fd escolhido automaticamente (sem privilégio):
bash -c '
exec {fd}< <(printf "line1\nline2\n")
echo "fd=$fd"; readlink /proc/$$/fd/$fd
read -r first <&$fd; echo "$first"
exec {fd}<&-'
fd=10
pipe:[464865935]
line1
Named pipes e sockets unix a partir da linha de comandos (sem privilégio; o nc aqui é o
netcat do OpenBSD, onde -U significa socket unix e -N fecha a ligação no fim do input):
mkfifo pipe.fifo
( echo "through the fifo" > pipe.fifo & ); cat pipe.fifo; rm pipe.fifo
nc -lU s.sock > got.txt & sleep 0.3
printf 'ping\n' | nc -NU s.sock; wait; cat got.txt; rm -f s.sock got.txt
through the fifo
ping
O socat oferece o mesmo e mais (socat - UNIX-CONNECT:s.sock); não estava instalado no host
usado para esta página, por isso essa forma não é verificada aqui.
Os outros tipos de fd, e close-on-exec por omissão. O Python abre tudo com O_CLOEXEC a não
ser que lhe digam o contrário, o que o torna um laboratório conveniente (sem privilégio):
python3 - <<'EOF'
import os, subprocess
a = os.open("cloexec.txt", os.O_WRONLY | os.O_CREAT | os.O_CLOEXEC, 0o600)
b = os.open("inherit.txt", os.O_WRONLY | os.O_CREAT, 0o600); os.set_inheritable(b, True)
print("parent:", a, "cloexec.txt |", b, "inherit.txt")
print(subprocess.run(["sh", "-c", "ls -l /proc/$$/fd | awk 'NR>1{print $9, $11}'"],
capture_output=True, text=True, close_fds=False).stdout)
for name, fd in [("pidfd", os.pidfd_open(os.getpid())), ("memfd", os.memfd_create("scratch")),
("eventfd", os.eventfd(0))]:
print(name, "->", os.readlink(f"/proc/self/fd/{fd}"))
EOF
parent: 3 cloexec.txt | 4 inherit.txt
0 pipe:[464869577]
1 pipe:[464879797]
2 pipe:[464879798]
4 …/inherit.txt
pidfd -> anon_inode:[pidfd]
memfd -> /memfd:scratch (deleted)
eventfd -> anon_inode:[eventfd]
O shell filho recebeu o fd 4 e não o fd 3: o close-on-exec fez o seu trabalho no execve.
Para inspeccionar os descritores de outro processo, usa /proc/<pid>/fd e
/proc/<pid>/fdinfo/<fd>, ou lsof -p <pid> (sem privilégio, para os teus próprios processos):
lsof -p $$ | head -4
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
bash 953131 you 0u unix 0x0000000000000000 0t0 464878621 type=STREAM (CONNECTED)
bash 953131 you 1w REG 259,4 827 21672530 …
bash 953131 you 2w REG 259,4 827 21672530 …
Rastrear que fds um programa abre e fecha é strace -f -e trace=openat,close,dup2,pipe2,execve
<cmd> (não exercitado para esta página).
Limites#
(sem privilégio)
ulimit -n; ulimit -Hn
cat /proc/sys/fs/file-nr /proc/sys/fs/file-max /proc/sys/fs/nr_open
1048576
1048576
66730 0 9223372036854775807
9223372036854775807
1048576
- O
ulimit -né oRLIMIT_NOFILEpara este processo: o brando, depois o duro. É herdado através defork/exec; as units do systemd definem-no comLimitNOFILE=. Muitos hosts têm o limite brando por omissão a 1024 — este não, por isso não assumas que os teus números coincidem. - O
fs.nr_opené o tecto até onde o limite duro de qualquer processo pode ser subido. - O
file-nré handles alocados, não usados, máximo de todo o sistema. EMFILEsignifica que o teu processo esgotou;ENFILEsignifica que o sistema esgotou.
Boas práticas para código do motor#
- CLOEXEC em todo o lado. Abre com
O_CLOEXEC, cria pipes compipe2(…, O_CLOEXEC), sockets comSOCK_CLOEXEC. A biblioteca padrão do Rust já o faz para o que abre; chamadaslibccruas não. No motor, vê os pipes de prontidão e de exec nospawn(crates/adapters/delonix-linux/src/lib.rs), cujo comentário explica que oO_CLOEXECna ponta de escrita é o que transforma «o filho morreu, ou fez exec sem escrever» num EOF a que o pai consegue reagir; o mesmopipe2(…, O_CLOEXEC)aparece nopipeemcrates/adapters/delonix-sdn/src/pin_userns.rs, e oOFlag::O_CLOEXECnoexec_withe noopen_container_nsnodelonix-linux. - Um filho que faz fork mas nunca exec tem de fechar o que herdou. O CLOEXEC só actua no
execve. O shim de logs do motor é exactamente esse filho: fecha tudo excepto os fds de que precisa comclose_rangelogo a seguir ao fork — verclose_range_rawe o seu ponto de chamada nospawn(crates/adapters/delonix-linux/src/lib.rs). Oclose_range_rawchama a syscall pelo número porque o wrapperlibcsó existe para alvos glibc. - Nunca deixes vazar um pipe ou o stdio do chamador para um filho de vida longa. Dois
incidentes reais estão registados no
AGENTS.md: o shim de logs a segurar outras ligações HTTP de um servidor de longa duração abertas (secção «CLI (delonix)», a entradadelonix serve docker-api), e o pin de rede (o processo holder de vida longa do network namespace rootless do motor — o padrão holder mostrado em Namespaces) a herdar o stderr do chamador, por issoout=$(delonix …)nunca via EOF — corrigido escrevendo parapin.log(secção «A classe «X não é Y» — varredura de 2026-08-05»; código:start_pinepin_log_pathemcrates/adapters/delonix-sdn/src/infra.rs). - Sinaliza um processo através de um pidfd, não de um PID. Um PID colhido pode ser reutilizado;
um pidfd refere-se a um processo durante toda a sua vida. Ver
ADR-0027 e
ChildHandle(open,kill) emcrates/interfaces/delonix-cri/src/child_handle.rs. Onde só existe um PID guardado, compara primeiro a hora de arranque:safe_to_signalemcrates/contexts/delonix-node/src/host.rs. - Limita os fds sob carga. Um servidor que abre um descritor por pedido tem de o fechar em
todo caminho, incluindo erros e timeouts, e tem de tratar
EMFILEcomo contra-pressão, não como um crash. - Depois de um
forknum processo multi-thread, só trabalho async-signal-safe (fechar fds,dup2,execve,_exit) — nada de alocação, nada de locks.
→ Como o Delonix usa isto: Capabilities, seccomp, AppArmor, caminhos
mascarados (a lista do
filtro de syscalls inclui close_range, memfd_create e eventfd2), e Daemonless, num
parágrafo para porque é que os processos
por-workload não podem segurar o que não possuem.
Ler mais: open(2),
fcntl(2),
dup(2),
pipe(2),
close_range(2),
pidfd_open(2),
memfd_create(2),
eventfd(2),
unix(7),
getrlimit(2),
manual do bash — Redirecionamentos.
Sinais e o tempo de vida de um processo#
- O
SIGTERMpede a um processo para sair; pode ser apanhado, e um serviço bem-comportado limpa-se. OSIGKILLnão pode ser apanhado nem ignorado. Uma paragem graciosa é «SIGTERM, espera um tempo limitado, depoisSIGKILL» — ostopemcrates/adapters/delonix-linux/src/lib.rsfaz exactamente isso. - O PID 1 num namespace de PID é especial: sinais enviados a ele de dentro do seu namespace
são ignorados a não ser que ele tenha instalado um handler, e até um
SIGKILLde dentro não faz nada (sem privilégio):
unshare -r -p -f --mount-proc sh -c 'kill -TERM 1; kill -KILL 1; echo "pid $$ survived its own SIGTERM and SIGKILL"'
sh -c 'kill -TERM $$; echo not reached'; echo "rc=$?"
pid 1 survived its own SIGTERM and SIGKILL
Terminated
rc=143
Por isso um container cujo PID 1 não tem handler de SIGTERM não pára com SIGTERM, e a
paragem acaba em SIGKILL. Quando o PID 1 de um namespace sai, o kernel mata todo outro
processo lá dentro.
- Zombies. Um filho que saiu fica como zombie até o seu pai colher o estado com
wait/waitpid/waitid. Um pai que nunca espera acumula zombies (sem privilégio):
sh -c 'sleep 0.2 & exec sleep 2' & P=$!; sleep 1
ps -o pid,ppid,stat,comm --ppid $P; wait
PID PPID STAT COMMAND
953503 953501 Z sleep
A shell fez fork de um sleep, depois fez exec para outro sleep que nunca espera: o filho
fica no estado Z até o seu pai sair.
- Só o pai consegue esperar. É por isso que um motor sem daemon continua a precisar de um
pequeno supervisor por workload destacado: o processo que fez fork do workload é o único
que consegue ler o seu estado de saída real e, para OOM, ler o memory.events antes de o
cgroup ser removido. Ver run_supervised em
crates/adapters/delonix-linux/src/supervise.rs e wait_and_record em
crates/adapters/delonix-linux/src/lib.rs.
- Um servidor de longa duração tem de colher os seus filhos, e não pode colher filhos que
outra coisa esteja à espera. O reaper do shim da API Docker espreita com WNOWAIT por essa
razão (spawn_zombie_reaper em bins/delonix-runtime-bin/src/cmd/dockerapi.rs); o histórico
está no AGENTS.md, secções «CLI (delonix)» e «Auditoria de segurança
#3 (2026-08-10)».
Ler mais: signal(7),
pid_namespaces(7),
wait(2),
pidfd_send_signal(2).
Juntando tudo#
Um container rootless é os primitivos acima, aplicados numa ordem estrita. A sequência abaixo
segue o spawn e o container_init em crates/adapters/delonix-linux/src/lib.rs; a ordem não é
cosmética, e os comentários lá explicam a corrida que cada passo fecha.
Legenda — os participantes são processos (e o kernel); as setas sólidas são chamadas de sistema ou escritas; as setas tracejadas são respostas ou eventos de pipe; as notas marcam estado que se torna verdadeiro nesse ponto.
A figura mostra que o pai configura a identidade, o cgroup e a rede enquanto o filho está bloqueado, e que o filho só reporta «pronto» depois de o seu sistema de ficheiros estar final.
sequenceDiagram
participant P as delonix (parent, spawn)
participant K as Linux kernel
participant C as container init (container_init)
P->>K: pipe2(O_CLOEXEC) x2 (readiness, exec) + sync pipe
P->>K: clone(CLONE_NEWUSER | NEWNS | NEWPID | NEWNET | NEWUTS | NEWIPC ...)
K-->>C: child starts in new namespaces, blocked on sync pipe
P->>K: write_userns_maps: uid_map/gid_map (newuidmap for a subuid range)
P->>K: setup_cgroup: create leaf, set limits, write pid to cgroup.procs
Note over P,C: limits apply before any program code runs
P->>C: network hook, then "GO" byte on sync pipe
C->>K: setup_rootfs: overlay mount, binds, pivot_root
C-->>P: readiness byte (mounts are final)
C->>K: drop capabilities, seccomp filter
C->>K: execvp(entrypoint), CLOEXEC fds close
K-->>P: EOF on exec pipe = program started
Passo a passo, no vocabulário desta página:
- Descritores de ficheiro primeiro. Os pipes que coordenam pai e filho são criados
close-on-exec, por isso as cópias do filho desaparecem no
execvpe um filho morto lê-se como EOF, nunca como um bloqueio. - User namespace, criado no mesmo
cloneque os outros, para o possuir. - Mapas de uid/gid, escritos pelo pai enquanto o filho espera (um processo não se consegue mapear a si próprio com utilidade).
- Folha de cgroup com limites, e o PID movido para dentro antes de o programa correr — uma mudança posterior deixaria os filhos iniciais fora do limite.
- Mount namespace →
pivot_root: o filho constrói a sua raiz e troca-a, depois sinaliza prontidão, para nada conseguir fazersetnspara dentro de um sistema de ficheiros a meio construir. - Privilégios largados, depois
exec, sem nenhum descritor herdado excepto o stdio.
→ Ver Arquitectura — Nível 2: executáveis e
processos, Arquitectura — Nível 4:
dois fluxos, como sequências, e o crate
delonix-linux.
Exercícios de auto-verificação#
Faz estes no teu directório de rascunho, como o teu utilizador normal.
- Igual ou diferente? Arranca
unshare -r -n sleep 30 &, depois comparareadlink /proc/$!/ns/netcomreadlink /proc/self/ns/net, e…/ns/mntpara os dois. Esperado: os inodes denetdiferem; os inodes demntsão iguais (não pediste um mount namespace). - Junta-te ao holder. Com o mesmo
sleepa correr, corrensenter --target $! --user --net --preserve-credentials ip -br link. Esperado: sólo,DOWN. Depois de osleepsair, o mesmonsenterfalha porque o processo, e com ele o namespace, desapareceu. - Para onde foi o limite? Corre
systemd-run --user --scope -q -p MemoryMax=48M sh -c 'cat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.max', depoiscat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.maxna tua shell normal. Esperado:50331648dentro do scope; fora,max(ou "No such file" se o teu cgroup não tiver controlador de memória). - Herança em bash. Corre
bash -c 'exec 5>five.txt; ls /proc/self/fd', depoisbash -c 'exec 5>five.txt; exec 5>&-; ls /proc/self/fd'. Esperado: o5aparece na primeira listagem (herdado pelols, porque a shell não define close-on-exec) e não na segunda; o3nos dois é o directório que o própriolsabriu. - Quem segura a ponta de escrita? Compara os dois comandos e os seus tempos:
bash -c 'exec {w}> >(cat >/dev/null; echo "reader got EOF at ${SECONDS}s" >&2)
sleep 3 & exec {w}>&-; echo "writer closed at ${SECONDS}s"; wait'
bash -c 'exec {w}> >(cat >/dev/null; echo "reader got EOF at ${SECONDS}s" >&2)
{ exec {w}>&-; sleep 3; } & exec {w}>&-; echo "writer closed at ${SECONDS}s"; wait'
Esperado: no primeiro, writer closed at 0s e reader got EOF at 3s — o sleep em segundo
plano herdou uma cópia da ponta de escrita do pipe, por isso o EOF espera por ele; no segundo,
o filho fecha a sua cópia primeiro e o leitor recebe o EOF em 0s. É a mesma forma do
incidente do pin/stderr. (Não tires o exec {w}>&- no pai: o wait também espera pelo leitor,
e um leitor que nunca vê EOF faz o comando pendurar.)
Seguinte: Introdução ao cloud native — onde o motor usa cada um destes primitivos, e as especificações abertas (OCI, CNI, CRI, KVM) construídas por cima deles.