Arquitectura
System Design Interview — o Delonix Engine
Antes de leres: Arquitectura e As crates — esta página defende porque é que a estrutura que elas descrevem tem esta forma.
EntrevistadorDesenha um motor de containers e de microVMs para um único nó Linux. Tem de correr sem root por omissão, sem um daemon residente, e não pode saber quem o está a chamar.
Esta página responde a esse enunciado como um candidato forte responderia, e depois confronta cada resposta com o que o Delonix Engine faz de facto. Cada aprofundamento termina com Onde vive no código, que lista os ficheiros e símbolos lidos para esta página. Para o mapa estrutural (camadas, grafo de crates, processos, caminhos de estado) lê primeiro Arquitectura; esta página é sobre porque é que o desenho tem esta forma. Depois dela consegues defender — ou desafiar com evidência — as principais escolhas de desenho do motor: criação de processos rootless, o holder de rede, camadas de imagem partilhadas, a porta de VM e o reconciliador sem ficheiro de estado.
Os números citados abaixo são medições registadas no repositório com a sua data ou release, não factos intemporais. Volta a medir antes de confiares num deles.
1. Requisitos#
CandidatoAntes de desenhar caixas, quero fixar o que significa «feito».
Funcionais#
- Correr containers a partir de imagens OCI: pull, descompactar, criar, arrancar, parar, exec, logs, remover.
- Correr microVMs a partir de imagens de disco, em mais do que um hypervisor.
- Redes entre cargas: bridges privadas, portas publicadas, firewall, nomes DNS, isolamento por namespace.
- Armazenamento: volumes nomeados, bind mounts, partilhas de rede.
- Operação declarativa: um manifesto de Kinds,
plan,apply, detecção de deriva, poda. - Servir o kubelet através do CRI, para que o motor possa ser o runtime de um nó Kubernetes.
- Expor as mesmas operações a programas locais (uma API, um protocolo de ferramentas de IA), e não só a uma shell.
Não funcionais#
- Rootless-first. O caminho normal corre como um utilizador sem privilégio; o privilégio é opt-in e dito.
- Daemonless. Nenhum processo corre «só por via das dúvidas». A persistência pertence ao systemd ou a um processo por carga com dono.
- Nenhum conhecimento do consumidor. Nenhum inquilino, conta, plano ou faturação; o motor valida o seu próprio contrato em vez de confiar num chamador para recusar o que ele não consegue fazer.
- Observável através de padrões abertos (OpenTelemetry, Prometheus) e de um registo de eventos.
- Falha honesta. Uma operação recusada ou feita a meio é reportada como tal, com uma razão e uma
classe de saída estável — nunca
0por cima de uma falha. - Sobrevive a reinícios dos seus próprios processos de controlo sem perturbar as cargas que estão a correr.
2. Restrições de fundo: o que um utilizador Linux sem privilégio pode fazer#
CandidatoO rootless muda o desenho mais do que qualquer outro requisito, por isso deixa-me listar as regras do kernel com que tenho de viver.
| O kernel deixa um utilizador sem privilégio… | …mas não | Consequência para o desenho |
|---|---|---|
criar um user namespace e ser uid 0 dentro dele (CLONE_NEWUSER) |
mapear uids arbitrários do host | um mapa de uid único, a menos que newuidmap/newgidmap e /etc/subuid concedam um intervalo; uma imagem que faça chown para o uid 101 precisa do intervalo |
criar namespaces de rede, mount, PID, IPC e UTS pertencentes a esse user namespace, com CAP_NET_ADMIN/CAP_SYS_ADMIN dentro deles |
mexer no network namespace inicial do host | a rede é construída dentro de um namespace de que o motor é dono; chegar ao host precisa de uma bridge em user space (slirp4netns) |
fazer mount de overlayfs, tmpfs e binds dentro do seu próprio mount namespace |
montar na vista do host | o próprio init do container faz o mount do overlay depois do clone |
| escrever limites numa subárvore cgroup v2 delegada | escrever em cgroups que não lhe foram delegados | os limites só se aplicam onde o systemd os delegou (systemd-run --user --scope -p Delegate=yes) |
fazer setns para um namespace pertencente ao seu próprio user namespace |
fazer setns para um namespace pertencente ao user namespace de outro processo |
juntar-se a uma rede criada noutro sítio significa entrar primeiro no user namespace desse dono |
correr clone em segurança num processo com uma só thread |
assumir que clone é seguro num processo com várias threads (o clone não corre os handlers pthread_atfork) |
servidores construídos sobre tokio têm de entregar a criação de processos a um processo novo |
Duas políticas do host aparecem constantemente e parecem bugs do motor: o Ubuntu 23.10+ restringe os user namespaces sem privilégio através do AppArmor (um perfil é associado ao caminho do executável que cria o namespace), e uma sessão SSH simples não é um scope de cgroup delegado. Cada linha da tabela é um primitivo ensinado à mão em Fundações de Linux; as duas políticas do host estão em Preparar o teu ambiente — Armadilhas conhecidas do host.
3. API#
CandidatoUm conjunto de operações, várias portas de entrada — e uma delas é o contrato para onde as outras convergem.
| Porta de entrada | Codificação | Quem a usa |
|---|---|---|
CLI delonix |
argv, classes de saída estáveis | operadores, scripts |
Contrato de nó delonix.node.v1 |
gRPC e HTTP/JSON no mesmo socket unix local | qualquer cliente local (desenho; ainda não servido) |
CRI runtime.v1 |
gRPC num socket unix | o kubelet |
| MCP | JSON-RPC sobre stdio | um cliente de IA local, uma sessão por processo |
Pontos de desenho do contrato de nó, todos escritos no ADR-0040 D4 e no ADR-0042:
- Os ficheiros
.protosão a fonte de verdade; o mapeamento REST vem das anotaçõesgoogle.api.httpe o documento OpenAPI é gerado a partir delas. Um gate de CI verifica o formato, o lint, as quebras de compatibilidade face à última release, que cada RPC excepto os streams bidireccionais (Exec,Console) tem um mapeamento HTTP, e que o OpenAPI commitado é o gerado. - Serviços orientados a recursos (
ContainerService,PodService,VirtualMachineService,NetworkService,VolumeService,ImageService,StackService,NodeService,OperationService), uma mensagem de pedido por RPC, identidade explícita comonamespace/name. - O trabalho longo devolve uma
Operationque é persistida antes de ser confirmada, para que um servidor reiniciado possa dizerInterruptedem vez deRUNNINGpara sempre. - Só local.
SO_PEERCRED, o mesmo uid, sem TCP, sem TLS, sem identidade no motor — o ADR-0010 rejeitou uma API remota. Qualquer coisa fora do nó põe o seu próprio proxy à frente.
EntrevistadorPorque não simplesmente um servidor REST?
CandidatoPorque os clientes gRPC e as ferramentas de shell merecem ambos uma codificação de primeira classe, e gerar as duas a partir de um único ficheiro impede-as de divergir. A CLI também não é cidadã de segunda: as suas classes de saída (
delonix-model/src/exitcode.rs) são as classesDX_*que o ADR-0040 D4 exige que os erros do contrato carreguem.
Onde vive no código: proto/delonix/node/v1/{node,compute,infra,operations,common}.proto;
scripts/contract_gate.py; docs/api/openapi.yaml; crates/interfaces/delonix-cri/src/lib.rs
(serve_blocking); bins/delonix-mcp-bin/src/main.rs; crates/foundation/delonix-model/src/exitcode.rs.
Estado honesto: nenhum crate referencia ainda o delonix.node.v1; os programas locais usam a API de
gestão (crates/interfaces/delonix-mgmt), que o ADR-0042 planeia migrar e remover.
4. Desenho de alto nível#
CandidatoVou organizá-lo em camadas para que as regras do domínio nunca importem o kernel, e vou fazer com que cada processo de vida longa seja dono de exactamente uma coisa.
Legenda — caixa branca com borda vermelha: bloco de construção do motor · região delineada: uma camada · cilindro, azul: estado em disco · seta sólida: chamada ou fluxo de dados, com etiqueta.
As regras do domínio nunca montam, arrancam nem configuram a rede, e um adaptador é dono de todos os ficheiros debaixo do state root.
flowchart TB
subgraph IF["interfaces — parse a request, present a result"]
CLI["CLI<br/><small>delonix</small>"]
CRI["CRI server<br/><small>delonix-cri</small>"]
API["local API server<br/><small>delonix-mgmt</small>"]
MCP["MCP server<br/><small>delonix-mcp</small>"]
end
subgraph CX["contexts — use cases, ports and records"]
COMPUTE["compute<br/><small>Container, Vm, RunOpts, resolve_run, launch, ports</small>"]
STACK["stack<br/><small>Kind table, 3-way plan</small>"]
NODE["node<br/><small>event log, pid and host checks, server dispatch</small>"]
end
subgraph AD["adapters and providers — implement ports"]
LINUX["kernel<br/><small>clone, mounts, cgroups, seccomp</small>"]
SDN["SDN<br/><small>pin, control, nftables, slirp</small>"]
OCI["OCI<br/><small>registry, CAS, layers</small>"]
VMS["VM backends<br/><small>Cloud Hypervisor, libvirt, Proxmox</small>"]
STA["state<br/><small>stores, atomic writes, secret vault</small>"]
end
FILES[("files under one state root")]
IF -->|"call use cases"| CX
AD -->|"implement ports"| CX
IF -->|"call directly, today"| AD
STA -->|"flock, temp file + rename"| FILES
class CLI,CRI,API,MCP,COMPUTE,STACK,NODE,LINUX,SDN,OCI,VMS,STA block
class FILES store
classDef person fill:#191513,stroke:#191513,color:#ffffff
classDef engine fill:#cc2823,stroke:#8f1b17,color:#ffffff
classDef block fill:#ffffff,stroke:#cc2823,color:#191513
classDef external fill:#e1ddda,stroke:#8a817c,color:#191513
classDef store fill:#2390c8,stroke:#17618a,color:#ffffff
- Camadas (ADR-0040 D1): fundação → contextos → adaptadores/providers → interfaces → binários,
impostas por
scripts/arch_fitness.py. - O estado são registos JSON e ficheiros endereçados por conteúdo debaixo de um só root, com
escritas atómicas e
flockà volta de cada ler-modificar-escrever — sem base de dados, porque não há nenhum daemon para ser dono dela. Os tipos de registo são definidos à parte deles (Container/Vmno contextodelonix-compute;Statuse os registos de firewall no crate de fundaçãodelonix-model); os ficheiros só são abertos através do adaptadordelonix-state. - Os processos existem por carga (um supervisor que é o pai do container, o init, um shim de
logs) e por nó quando a rede é usada (um pin que só segura namespaces, um processo de controlo
reiniciável, um uplink
slirp4netns). Mais nada fica de pé.
Onde vive no código: scripts/arch_fitness.py (LAYERS, ALLOWED);
crates/adapters/delonix-state/src/store.rs (Store::update, JsonStore::update,
write_atomic); crates/contexts/delonix-compute/src/record.rs (Container, Vm);
crates/foundation/delonix-model/src/records.rs (Status, ContainerFw, FwRule); crates/contexts/delonix-compute/src/{ports,launch}.rs;
crates/adapters/delonix-linux/src/supervise.rs (run_supervised).
5. Aprofundamentos#
5.1 Um container run rootless#
EntrevistadorLeva-me pelo
run -d -p 8080:80 nginxcomo utilizador sem privilégio.
CandidatoResolver tudo o que pode falhar antes de criar um processo; depois criar o processo parado, configurá-lo de fora, e só o libertar quando estiver pronto.
- Resolver. Uma especificação de execução (
RunOpts) vem de cada entrada — flags da CLI, um manifesto de Pod, a API Docker, o CRI. Oresolve_runfaz pull da imagem se estiver ausente, prepara o sistema de ficheiros raiz, resolve o--usercontra ele, resolve volumes e dispositivos, e valida as opções de segurança. Todas as recusas acontecem aqui, e uma guarda remove o directório preparado em qualquer retorno antecipado. - Registar. O
build_recordtransforma a especificação num registoContainer(puro). - Escolher o pai. Para um arranque destacado a CLI faz fork de um supervisor que se torna o
pai do container (
launch::start→should_supervise). Só o pai real pode fazerwaitpid, por isso é isto que torna possíveis o código de saída real e o--restartsem daemon. - Clone. O
spawnchamaclonecom namespaces novos de mount, UTS, PID e IPC, mais user e network namespaces quando o container tem os seus próprios. O filho fica bloqueado num pipe. - Configurar de fora, por uma ordem fixa. O pai escreve os mapas de uid/gid
(
write_userns_maps, através denewuidmapquando existe um intervalo de subuid), prepara o cgroup, corre o hookon_started(aqui:slirp_attachcom as portas de-p, para que a rede exista antes de o entrypoint correr), e só então escreve o byte «go». - Dentro do filho. O
container_initmonta o overlay (mount_overlay_if_marked), faz bind dos volumes, prepara o/dev, fazpivot_root, mascara caminhos de/proc, aplica capabilities, seccomp eno_new_privs, e sinaliza «mounted» num segundo pipe antes doexecvp. - Publicar o registo em último. O pai espera pelo byte «mounted» (
wait_for_mounts), espera brevemente pelo resultado do exec, e só então fazstore.savedeRunning.
Legenda — os participantes são processos; as setas sólidas são chamadas, linhas de socket, forks ou clones (a etiqueta diz qual); as setas tracejadas são respostas ou bytes devolvidos; uma auto-seta é trabalho dentro desse processo; as notas marcam estado ou esperas.
O filho é criado bloqueado, configurado de fora, e o registo só é guardado depois de o filho reportar os seus mounts.
sequenceDiagram
participant CLI as delonix
participant SV as supervisor
participant I as child (init)
CLI->>CLI: resolve_run, build_record
CLI->>SV: fork (launch::start → supervise)
SV->>I: clone(NEWNS, NEWUTS, NEWPID, NEWIPC, NEWNET, NEWUSER)
Note over I: blocked on the sync pipe
SV->>SV: write_userns_maps
SV->>SV: setup_cgroup
SV->>SV: on_started hook — slirp_attach with -p ports
SV->>I: "go" byte
I->>I: overlay mount, volumes, pivot_root, caps, seccomp
I-->>SV: "mounted" byte
SV->>SV: store.save Running
SV-->>CLI: started
I->>I: execvp
EntrevistadorPorquê esperar por «mounted» antes de guardar o registo?
CandidatoPorque o registo é o que outros processos lêem para decidir que podem entrar no container. Antes do
pivot_root, umsetnspara o mount namespace do filho aterra no sistema de ficheiros do host. Medido a 2026-08-28 com uma sonda capaz de distinguir os dois sistemas de ficheiros: antes da correcção, 5 de 54execs lançados logo a seguir arun -dcorreram fora do container; depois dela, 0 de 54. A espera tem três desfechos (MountWait::{Ready, InitExited, Unknown}) e um tecto, por isso um mount pendurado não pode pendurar orun.
Onde vive no código: crates/contexts/delonix-compute/src/run.rs (resolve_run,
build_record); crates/contexts/delonix-compute/src/launch.rs (start, should_supervise,
WorkloadRuntime); crates/adapters/delonix-linux/src/workload.rs (HostWorkload);
crates/adapters/delonix-linux/src/supervise.rs (run_supervised);
crates/adapters/delonix-linux/src/lib.rs (spawn, write_userns_maps, setup_cgroup,
container_init, setup_rootfs, wait_for_mounts, MountWait);
bins/delonix-runtime-bin/src/cmd/container.rs (cmd_run).
5.2 Rede: pin, controlo, slirp, nftables#
EntrevistadorOs containers precisam de falar uns com os outros, de ser isolados por namespace e de publicar portas — sem
CAP_NET_ADMINno host.
CandidatoConstruir um mundo de rede privado dentro de um namespace de que o utilizador é dono, ligá-lo ao host em user space, e fazer toda a filtragem aí.
Separar segurar de servir. Um único processo «holder» que fosse dono dos namespaces e servisse pedidos derrubaria a rede de todas as cargas sempre que reiniciasse. Por isso:
- o pin (
delonix netns pin) cria ele próprio os namespaces de user, de rede e de mount e depois só dorme (pin_main). O seu pid é o alvo de cadansenter -t <pin>, e nunca muda; - o processo de controlo (
delonix netns control, arrancado através densenterpara dentro dos namespaces do pin) serve um socket unix0600restringido porSO_PEERCRED, e corre DNS, DHCP e Router Advertisements. É reiniciável: oensure_upreinicia só ele quando o pin está vivo; - um
slirp4netnsliga otap0à netns do pin e expõe um socket de API paraadd_hostfwd.
O pin cria os seus namespaces no próprio processo (o chamador escreve os mapas de ids através de
dois pipes) em vez de via unshare(1), porque um perfil AppArmor é associado pelo caminho do
executável que cria o user namespace, e o /usr/bin/unshare não é o do motor.
Juntar-se a uma rede custom. Um container em --net web não consegue fazer setns para uma
netns pertencente ao user namespace do pin. Por isso a CLI pede ao processo de controlo que crie a
netns e o veth (attach …), e depois re-executa-se a si própria dentro dos namespaces de user e
de mount do pin (nsenter -t <pin> -U -m -n -- ip netns exec <netns> delonix netns run <spec>); a
segunda passagem herda os namespaces de user e de rede em vez de os criar.
Publicar uma porta são dois passos, ambos estado do dataplane e não estado de processo (é por
isso que se podem acrescentar e remover portas num container a correr): add_hostfwd no slirp único,
e uma regra DNAT dentro da netns do pin (publish … no socket de controlo).
Filtrar com um verdict map. A tabela de ingress (table ip dlxing) é construída de modo a que a
política por container custe o mesmo seja qual for o número de containers:
forward priority -20 fwguard drop 169.254.0.0/16 and 127.0.0.0/8
forward priority -10 fwdeny established → accept; bridge pair in @netpair → verdict; bridge↔bridge → drop
forward priority -5 fwcont ip daddr vmap @fwmap ; ip saddr vmap @fwmap
forward priority 0 forward policy drop; established; tap0; same-bridge; @netpair
O fwcont tem duas regras; as regras de cada container vivem na sua própria chain, alcançada através
do verdict map fwmap chaveado por IP. O tráfego entre redes é descartado par a par, a menos que
uma NetworkRoute ponha o par em @netpair — uma rota diz que o pacote pode atravessar, e a chain
por container continua a decidir se é permitido.
O isolamento por namespace vive na chain de cada container: os membros de @dlxns<hash> (mesmo
namespace) são aceites, e as ligações novas vindas de qualquer outro endereço de container
(@dlxall) são descartadas; as respostas continuam a fluir porque o drop só casa com ct state new.
Uma política de ingress explícita substitui esse default. O IPv6 na SDN é recusado por omissão
(table ip6 com policy drop), porque todas as regras acima são IPv4.
Legenda — os participantes são processos; as setas sólidas são chamadas, linhas de socket, forks ou clones (a etiqueta diz qual); as setas tracejadas são respostas ou bytes devolvidos; uma auto-seta é trabalho dentro desse processo; as notas marcam estado ou esperas.
Juntar-se a uma rede custom é uma linha no socket de controlo mais um re-exec para dentro dos namespaces do pin; publicar uma porta são duas escritas no dataplane.
sequenceDiagram
participant CLI as delonix (1st pass)
participant C as control
participant S as slirp4netns
participant P2 as delonix netns run
CLI->>C: attach netns ip bridge gateway namespace
C->>C: ip netns add, veth to bridge, anti-spoofing, namespace sets
CLI->>P2: nsenter -t pin -U -m -n ip netns exec netns
P2->>S: add_hostfwd host-port (api socket)
P2->>C: publish proto host-port ip container-port (DNAT)
P2->>C: firewall id ip rules
P2->>P2: launch — inherits user and net namespaces
EntrevistadorO processo de controlo serve uma ligação de cada vez. Não é um estrangulamento?
CandidatoÉ deliberadamente o ponto de serialização das mudanças de netns, veth e nftables, que não se podem intercalar. O risco são os clientes desistirem na fila: quando a v0.47.0 foi preparada, 30 attaches concorrentes com um tecto de leitura de 5 segundos perderam 15; com o tecto da resposta subido (
CONTROL_REPLY_TIMEOUT, 30 s) os 30 concluíram. O tecto de I/O por ligação (CONTROL_IO_TIMEOUT) existe para que um cliente preso não congele o plano de controlo do nó.
Onde vive no código: crates/adapters/delonix-sdn/src/infra.rs (ensure_up,
start_pin, pin_main, start_control, control_main, control_loop, start_slirp,
attach_container, do_attach, publish_port, join_argv, ingress_table_ruleset,
fw_chain_body, dlxns_set, DLXALL_SET, ingress_v6_refusal_ruleset, CONTROL_IO_TIMEOUT,
CONTROL_REPLY_TIMEOUT); crates/adapters/delonix-sdn/src/pin_userns.rs;
crates/adapters/delonix-sdn/src/run_network.rs (HostNetwork);
crates/contexts/delonix-compute/src/network.rs (attach_custom_network, wire_network);
bins/delonix-runtime-bin/src/cmd/container.rs (reexec_into_netns, run_from_spec).
5.3 Imagens: CAS, camadas partilhadas e o mount de muitas camadas#
EntrevistadorUm nó corre vinte containers da mesma imagem. O que está em disco?
CandidatoOs blobs uma vez, as camadas descompactadas uma vez, e um pequeno directório gravável por container.
- CAS. Os blobs são nomeados pelo seu sha256 em
blobs/sha256/<hex>; escrever um digest que já existe é um no-op. Um pull verifica cada blob contra o manifesto e o manifesto contra o digest que o utilizador fixou (verify_manifest_digest) — caso contrário um pin seria decorativo. - Downloads retomáveis. O download de um blob volta a tentar com
Range:a partir dos bytes que já tem (BLOB_ATTEMPTS), e distingue um206no offset pedido (retoma), um206noutro sítio e um200(recomeça). A verificação do digest no fim é o que torna a costura segura. - Camadas partilhadas. O
prepare_overlaycriaupper/,work/,merged/para o container e escreve a lista ordenada dos directórios de camadas partilhadas emoverlay-lowers. É o próprio init do container que o monta, dentro do seu mount namespace, onde um utilizador sem privilégio tem permissão para o fazer. O contrato é um ficheiro em disco e não um campo em memória porque o caminho rootless re-executa o binário e uma struct não atravessa essa fronteira.
Contas de guardanapo, tal como registadas para a v0.59.0: a cópia flat anterior custava a cada
container uma árvore de imagem completa — num host de desenvolvimento containers/ tinha 47 GiB,
quase tudo cópias idênticas, e cada run de uma imagem de 2,1 GiB passava cerca de 13 s a copiar.
Partilhar as camadas levou esse directório para 7,2 GiB.
- Muitas camadas. O mount(2) clássico passa lowerdir=a:b:c… como uma única string e o kernel
copia no máximo uma página dela, truncando em silêncio. Medido para o
ADR-0037 (validado a 2026-09-06): 20 camadas (4084 bytes)
montaram, 30 (5994 bytes) falharam, e uma imagem builder de 91 camadas precisava de 9107 bytes. O
mount usa agora fsopen/fsconfig/fsmount/move_mount com uma chamada lowerdir+ por camada,
por isso não há tecto de comprimento.
Onde vive no código: crates/adapters/delonix-oci/src/cas.rs (Cas::write,
Cas::has); crates/adapters/delonix-oci/src/registry.rs (blob_with_progress_capped,
BLOB_ATTEMPTS, parse_content_range, verify_manifest_digest);
crates/adapters/delonix-oci/src/overlay.rs (prepare_overlay, LOWERS_FILE);
crates/adapters/delonix-oci/src/run_images.rs (HostImages);
crates/adapters/delonix-linux/src/lib.rs (mount_overlay_if_marked, fsopen_overlay).
5.4 microVMs: uma porta, um registo e a armadilha do firmware#
EntrevistadorAcrescenta VMs sem construir uma abstracção de hypervisor que vaze por todo o lado.
CandidatoUm trait por provider, um registo que a raiz de composição preenche, e as peculiaridades que pertencem ao provider respondidas pelo provider.
- Porta. O
VmBackendtemid,available,boot,is_running,ip,stop, e operações opcionais (pause,snapshot,restore, …) cuja resposta por omissão é «não suportado». Os factos do provider são métodos, não verificações de strings nos sítios de chamada:ip_is_predicted(o endereço do Cloud Hypervisor é calculado a partir do MAC, não observado),manages_own_storage(um nó remoto é dono do seu disco),destroydistinto destop(localmente o disco é do motor; remotamente só o destroy o liberta). - Registo. O
builtin_backendssemeia o Cloud Hypervisor e o libvirt por ordem de preferência; oregister_backendacrescenta mais (o backend Proxmox, registado pela raiz de composição da CLI só quando configurado). Um registo transporta uma closure factory e uma flagauto_selectable, para que a auto-detecção nunca construa — e portanto nunca autentique — um backend remoto. Registar não faz I/O. - Rede de uma VM. O Cloud Hypervisor corre dentro da netns do pin e recebe um
tapnuma bridge de rede através da portaVmNetwork, que a SDN implementa (HostVmNetwork); odelonix-vmnão depende dodelonix-sdn. Como o servidor DHCP é do próprio motor e determinístico, o lease é conhecido antes de o convidado arrancar, e é isso que permite que o isolamento de namespace se aplique ao endereço de uma VM desde o primeiro pacote — e é também por isso que «tem um IP» não é prova de um convidado arrancado (osdn_reachablepergunta por ARP de dentro da netns). - Firmware. A procura de firmware do Cloud Hypervisor prefere o EDK2
CLOUDHV.fdaohypervisor-fw(DEFAULT_CH_FIRMWARES, com um teste que fixa a ordem). - cloud-init. O
VmConfigtransporta intenção (hostname, utilizador, chaves SSH); os backends locais realizam-na como um ISO NoCloud cujonetwork-configidentifica a NIC primária pelo MAC, e um backend remoto pode realizá-la de forma nativa.
Onde vive no código: crates/adapters/delonix-vm/src/lib.rs (VmBackend,
BackendRegistration, builtin_backends, register_backend, select_backend, auto_detect,
backend_for, CloudHypervisorBackend, LibvirtBackend, launch_vmm, DEFAULT_CH_FIRMWARES,
set_network); crates/adapters/delonix-vm/src/cloudinit.rs (generate_seed_iso);
crates/contexts/delonix-compute/src/ports.rs (VmNetwork);
crates/adapters/delonix-sdn/src/vm_network.rs (HostVmNetwork);
crates/adapters/delonix-sdn/src/infra.rs (sdn_reachable, dhcp_lease_ip);
crates/providers/delonix-proxmox/src/lib.rs (ProxmoxBackend);
bins/delonix-runtime-bin/src/cmd/vmbackends.rs (register_configured).
5.5 O reconciliador declarativo, sem ficheiro de estado#
EntrevistadorO
applytem de convergir, detectar deriva e podar — ao estilo do Terraform — mas disseste sem daemon e sem base de dados.
CandidatoGuardar a última spec aplicada no próprio recurso, derivar a posse de uma label, e fazer do planeamento uma função pura.
- Plano puro. O
reconcile::plan(desired, actual, stack)recebe dois snapshots e devolveVec<Change>; nunca abre um store. Isso torna os casos difíceis testáveis como dados. - Diff de três vias. O último mapa de campos aplicado é guardado no recurso
(
delonix.io/last-applied). Um campo presente na máquina mas ausente do manifesto é revertido só se fomos nós a defini-lo; caso contrário é deixado em paz — a distinção que um diff de duas vias não consegue fazer. - Posse por label (
delonix.io/stack). Um recurso sem dono éAdoptado; um que pertence a outra stack é umConflicte nunca é tocado; o--prunee odestroysó vêem o que tem a label. - As acções são
Create,Adopt,Update(a quente, mesmo PID),Replace(recusado a menos que seja dado--replace <Kind>/<name>, verificado antes de qualquer coisa ser criada),NoOp,Delete,Conflict,NotConverged. Oplan --detailed-exitcoderesponde 0/2/1 para um gate de deriva em CI. - Uma única tabela de factos dos Kinds (domínio, forma, se converge, se tem teardown, se é namespaced, como é observada a presença) governa o planeador, a ordem de apply e a ordem de teardown, em vez de listas mantidas em sincronia à mão.
Legenda — os participantes são o operador, o comando
stack apply, o planeador puro e os stores e o dataplane sobre os quais actua; as setas sólidas são chamadas; as setas tracejadas são respostas; as caixasalteoptsão o ramo de falha e a poda opcional.
Nada é criado antes de o plano ser verificado, e uma falha a meio do apply é carimbada em vez de revertida.
sequenceDiagram
participant U as operator
participant A as stack apply
participant P as reconcile::plan
participant M as stores and dataplane
U->>A: apply -f manifest [--prune] [--replace K/n]
A->>M: read actual state (actual_of)
A->>P: plan(desired, actual, stack)
P-->>A: changes
A->>A: refuse_unallowed — Replace without --replace fails before any creation
A->>M: run_layers in dependency order
alt a layer fails
A->>M: salvage_ownership — stamp what this run created
A-->>U: error (no rollback)
end
A->>M: converge_and_stamp — hot updates, labels, last-applied
opt --prune
A->>M: prune — Delete changes in teardown order
end
Onde vive no código: crates/contexts/delonix-stack/src/reconcile.rs (plan,
Action, Change, STACK_LABEL, LAST_APPLIED, hot_fields_for, encode_last_applied);
crates/contexts/delonix-stack/src/kinds.rs (KindFacts, facts, stack_kinds, converges,
has_teardown); bins/delonix-runtime-bin/src/cmd/stack.rs (apply, apply_docs,
refuse_unallowed, run_layers, salvage_ownership, converge_and_stamp, prune,
destroy_one).
6. Compromissos#
| Decisão | O que compra | O que custa |
|---|---|---|
| Sem daemon; um supervisor por container destacado, systemd para a persistência no arranque | nenhum processo único cuja morte leve todas as cargas; cada processo tem um dono óbvio | nada vê um processo morrer a menos que o seu próprio supervisor o veja; um chamador que não consegue fazer fork arranca sem supervisão e o código de saída real perde-se; os órfãos precisam de ceifadores explícitos |
Ficheiros JSON + flock em vez de uma base de dados |
estado inspeccionável, tolerante a crashes, sem dependência extra, funciona entre os processos da CLI/CRI/supervisor | sem transacções nem consultas; a verdade sobre a vivacidade é reconciliada na leitura (reconcile_status, safe_to_signal) |
Uplink em user space (slirp4netns) em vez de pares veth no host |
funciona com zero privilégio no host | salto extra e CPU em user space; um cliente de loopback aparece como o gateway do slirp (SLIRP_GW) e não como ele próprio |
| Separação pin/controlo | um reinício do controlo não mexe em nenhum fio | dois processos sobre os quais raciocinar, e os upgrades in-place continuam a ter de reconhecer pins mais antigos |
Re-exec em vez de clone no próprio processo nos servidores |
o clone nunca corre num processo com várias threads |
um processo por operação, e texto de erro a atravessar uma fronteira de processo — o ciclo que o ADR-0040 remove com um launcher |
| Overlay montado pelo init do container | uma cópia de cada camada em disco; mount sem privilégio | a vista fundida de um container parado precisa de um processo auxiliar que segure o mount (reexec_mapped_hold) |
| Despacho por verdict map | custo por pacote constante à medida que os containers crescem | as regras são texto gerado; o gerador e o leitor de contadores têm de partilhar a formatação (fw_rule_tail) |
| Diff de 3 vias no recurso | nenhum ficheiro de estado para perder ou divergir | só os campos que um Kind consegue ler de volta podem ser comparados; os valores de Secret não são decifrados para planear |
7. Modos de falha e os limites de um só nó#
EntrevistadorDiz-me como é que se parte.
- O processo de controlo morre. O
ensure_upencontra o pin vivo e reinicia só o plano de controlo dentro dos namespaces sobreviventes; as cargas a correr mantêm os seus PIDs e a rede. - O pin morre. Os namespaces vão com ele e não se consegue voltar a entrar neles, por isso a infra
é reconstruída. O
delonix net netns upencontra os containers e membros de pod que estavam a correr com rede e reinicia-os (reconcile_after_respawn;DELONIX_NO_AUTO_RECOVER=1só reporta). Isto é recuperação por reinício, e lê só o store de containers — as VMs não são recuperadas desta forma. - Um upgrade por cima de um holder mais antigo. Um holder anterior à separação a servir num caminho de socket legado é detectado e reportado com os dois caminhos; deliberadamente não é morto automaticamente, porque isso derrubaria a rede de todas as cargas.
- O
applymorre a meio. O apply é fail-fast sem rollback. Antes de criar o que quer que seja valida o grafo e recusa substituições não autorizadas; se uma camada falhar, o que esta execução criou é carimbado com posse (salvage_ownership) para que umdestroyou--pruneposterior ainda lhe consiga chegar, e a execução falhada é registada como uma revisão. - Fugas sem daemon. Cada lease e referência é libertada por um detach normal, por isso tudo o que
morre de outra forma deixa fuga. Medido a 2026-08-25: o ficheiro de IPAM de uma rede tinha 391
leases, dos quais 47 pertenciam a um container existente. Os ceifadores respeitam uma janela de
graça (
REF_MARKER_GRACE) porque um container a ser criado segura um lease e uma referência antes de ter registo; o ceifador de IPAM é de duas passagens (um lease só é reclamado se ainda estiver órfão numa execução posterior, depois da janela) e falha fechado — um store ilegível é um erro, nunca «nada está vivo». A vivacidade conta todos os registos de container, a netns de pod dos membros de pod e os marcadores de referência ligados, e não só os ids de containers a correr (cmd/prune.rs::lease_owners,live_ref_owners). - A corrida da espera pelos mounts (5.1): fechada publicando o registo só depois de o init
reportar os seus mounts; um arranque destacado cujo init sai antes de montar é um erro, não
0. - Limites de um só nó. Cada rede é um
/16dentro da netns do pin; cada mudança de netns/veth/nftables passa por uma única ligação de controlo serializada; o débito doslirp4netnsé em user space; e mover uma VM para outro host (vm migrate) implica tempo de indisponibilidade real — a migração ao vivo é um NO-GO tal como está construído (ADR-0031). O escalonamento entre nós está fora de âmbito por desenho.
Onde vive no código: crates/adapters/delonix-sdn/src/infra.rs (ensure_up,
stale_holder_message, reap_orphan_refs, REF_MARKER_GRACE);
crates/adapters/delonix-sdn/src/ipam.rs (reap_orphan_leases);
crates/adapters/delonix-sdn/src/lib.rs (reap_orphan_slirp);
bins/delonix-runtime-bin/src/cmd/netns.rs (reconcile_after_respawn, is_reattach_candidate);
bins/delonix-runtime-bin/src/cmd/prune.rs (lease_owners, live_ref_owners);
bins/delonix-runtime-bin/src/cmd/stack.rs (salvage_ownership);
crates/adapters/delonix-linux/src/lib.rs (reconcile_status, MountWait).
8. Perguntas de seguimento#
Porque não acrescentar um pequeno daemon para eventos e reinícios?
Porque cada processo residente é um domínio de falha e uma superfície de ataque. O registo de eventos
é um ficheiro só de acrescento (delonix_runtime_core::events), os reinícios pertencem ao supervisor
por container, e a persistência no arranque é uma unit systemd por carga (delonix system boot). Um
daemon precisa do seu próprio ADR com a evidência do que as alternativas não conseguiram fazer — ver
o ADR-0034 para um caso em que a pergunta surgiu, e o
ADR-0021 (Proposto) para uma reconciliação contínua que
se mantém daemonless.
Como é que o CRI arranca um container se o servidor não pode fazer clone?
O StartContainer constrói um RunOpts tipado, escreve-o num ficheiro 0600 e corre
delonix __apirun <spec>, que chama o mesmo cmd_run que a CLI. O ADR-0040 D5 substitui o salto pela
CLI por um executável launcher. A política de recursos no caminho CRI segue o kubelet
(ADR-0038).
Porque é que a API de gestão é só local? O acesso remoto implica identidade, autorização, certificados e auditoria de chamadores de que o motor não tem noção. O ADR-0010 rejeitou-o; a superfície MCP é local pela mesma razão (ADR-0025).
Como acrescentarias um provider de VM novo?
Um crate novo que implementa VmBackend, registado na raiz de composição — sem editar os sítios de
chamada (ADR-0008). O ADR-0040 D3 move os botões dos providers
para extensões com namespace e as peculiaridades para capacidades; o OpenStack está condicionado a um
spike (ADR-0039).
Como é que os Services balanceiam a carga sem um VIP?
Um Service selecciona containers por label e o DNS interno devolve vários registos A, rodados a
cada consulta — sem dataplane novo (ADR-0032).
Porquê ext4 e não btrfs/zfs debaixo do state root? O overlay sobre uma cache de camadas partilhada já eliminou a duplicação; um sistema de ficheiros diferente só é revisitado perante uma necessidade medida (ADR-0016).
E o macOS e o Windows? Não é um port — nada do que este motor usa existe fora do kernel Linux. O plano é um launcher para uma VM convidada Linux (ADR-0036, Proposto).
Para onde vai a reestruturação? Quatro camadas, uma especificação de execução, portas de provider com capacidades, um contrato de nó servido num servidor por socket activation, e um launcher dono de cada spawn que cria namespaces (ADR-0040, ADR-0042).
Seguinte: Delonixfile e VMfile — as duas gramáticas de build — Delonixfile para imagens OCI e VMfile para discos arrancáveis — tal como os analisadores as aceitam.