Architecture
Entretien de conception de système — le Delonix Engine
Avant de lire : Architecture et Les crates — cette page défend pourquoi la structure qu'elles décrivent a cette forme.
RecruteurConcevez un moteur de containers et de microVMs pour un seul nœud Linux. Il doit fonctionner sans root par défaut, sans daemon résident, et il ne doit pas savoir qui l'appelle.
Cette page répond à cet énoncé comme le ferait un bon candidat, puis confronte chaque réponse à ce que fait réellement le Delonix Engine. Chaque analyse approfondie se termine par Où cela se trouve dans le code, qui liste les fichiers et les symboles lus pour cette page. Pour la carte structurelle (couches, graphe des crates, processus, chemins d'état), lisez d'abord Architecture ; cette page traite de pourquoi la conception a cette forme. Après elle, vous pouvez défendre — ou contester avec preuve à l'appui — les principaux choix de conception du moteur : la création de processus rootless, le holder réseau, les couches d'image partagées, le port de VM et le réconciliateur sans fichier d'état.
Les chiffres cités ci-dessous sont des mesures consignées dans le dépôt avec leur date ou leur release, et non des faits intemporels. Mesurez à nouveau avant de vous appuyer sur l'un d'eux.
1. Exigences#
CandidatAvant de dessiner des boîtes, je veux fixer ce que « terminé » veut dire.
Fonctionnelles#
- Exécuter des containers à partir d'images OCI : pull, décompression, création, démarrage, arrêt, exec, logs, suppression.
- Exécuter des microVMs à partir d'images disque, sur plus d'un hyperviseur.
- Des réseaux entre charges de travail : bridges privés, ports publiés, pare-feu, noms DNS, isolation par namespace.
- Du stockage : volumes nommés, bind mounts, partages réseau.
- Un fonctionnement déclaratif : un manifeste de Kinds,
plan,apply, détection de dérive, prune. - Servir le kubelet à travers le CRI, afin que le moteur puisse être le runtime d'un nœud Kubernetes.
- Exposer les mêmes opérations à des programmes locaux (une API, un protocole d'outils d'IA), et pas seulement à un shell.
Non fonctionnelles#
- Rootless-first. Le chemin normal s'exécute sous un utilisateur non privilégié ; le privilège est opt-in et annoncé.
- Daemonless. Aucun processus ne tourne « au cas où ». La persistance appartient à systemd ou à un processus par charge de travail qui a un propriétaire.
- Aucune connaissance du consommateur. Pas de tenant, de compte, de plan ni de facturation ; le moteur valide son propre contrat au lieu de faire confiance à un appelant pour refuser ce qu'il ne sait pas faire.
- Observable par des standards ouverts (OpenTelemetry, Prometheus) et un journal d'événements.
- Échec honnête. Une opération refusée ou à moitié effectuée est signalée comme telle, avec une
raison et une classe de sortie stable — jamais
0sur un échec. - Survit aux redémarrages de ses propres processus de contrôle sans perturber les charges de travail en cours.
2. Contraintes de contexte : ce qu'un utilisateur Linux non privilégié peut faire#
CandidatLe rootless modifie la conception plus que toute autre exigence, alors je vais lister les règles du noyau avec lesquelles je dois vivre.
| Le noyau permet à un utilisateur non privilégié de… | …mais pas de | Conséquence pour la conception |
|---|---|---|
créer un user namespace et y être l'uid 0 (CLONE_NEWUSER) |
mapper des uids arbitraires de l'hôte | un mappage à un seul uid, sauf si newuidmap/newgidmap et /etc/subuid accordent une plage ; une image qui fait un chown vers l'uid 101 a besoin de la plage |
créer des namespaces net, mount, PID, IPC, UTS appartenant à ce user namespace, avec CAP_NET_ADMIN/CAP_SYS_ADMIN à l'intérieur |
toucher au namespace réseau initial de l'hôte | le réseau est construit à l'intérieur d'un namespace que possède le moteur ; atteindre l'hôte nécessite un pont en espace utilisateur (slirp4netns) |
faire un mount d'overlayfs, de tmpfs, de binds dans son propre mount namespace |
monter dans la vue de l'hôte | l'init du container lui-même effectue le montage overlay après le clone |
| écrire des limites dans un sous-arbre cgroup v2 délégué | écrire dans des cgroups qui ne lui ont pas été délégués | les limites ne s'appliquent que là où systemd les a déléguées (systemd-run --user --scope -p Delegate=yes) |
faire un setns dans un namespace appartenant à son propre user namespace |
faire un setns dans un namespace appartenant au user namespace d'un autre processus |
rejoindre un réseau créé ailleurs implique d'entrer d'abord dans le user namespace de son propriétaire |
exécuter clone en toute sécurité dans un processus mono-thread |
supposer que clone est sûr dans un processus multi-thread (clone n'exécute pas les handlers pthread_atfork) |
les serveurs construits sur tokio doivent confier la création de processus à un nouveau processus |
Deux politiques d'hôte reviennent constamment et ressemblent à des bugs du moteur : Ubuntu 23.10+ restreint les user namespaces non privilégiés via AppArmor (un profil est attaché au chemin de l'exécutable qui crée le namespace), et une simple session SSH n'est pas un scope cgroup délégué. Chaque ligne du tableau est un primitif enseigné pas à pas dans Fondations Linux ; les deux politiques d'hôte se trouvent dans Préparer votre environnement — Pièges d'hôte connus.
3. API#
CandidatUn seul ensemble d'opérations, plusieurs portes — et l'une d'elles est le contrat vers lequel les autres convergent.
| Porte | Encodage | Qui l'utilise |
|---|---|---|
CLI delonix |
argv, classes de sortie stables | opérateurs, scripts |
Contrat de nœud delonix.node.v1 |
gRPC et HTTP/JSON sur la même socket unix locale | tout client local (conception ; pas encore servi) |
CRI runtime.v1 |
gRPC sur une socket unix | le kubelet |
| MCP | JSON-RPC sur stdio | un client d'IA local, une session par processus |
Points de conception du contrat de nœud, tous écrits dans ADR-0040 D4 et ADR-0042 :
- Les fichiers
.protosont la source de vérité ; le mappage REST provient des annotationsgoogle.api.httpet le document OpenAPI en est généré. Un gate (contrôle CI) vérifie le format, le lint, les ruptures de compatibilité par rapport à la dernière release, que chaque RPC sauf les flux bidirectionnels (Exec,Console) a un mappage HTTP, et que l'OpenAPI commité est bien celui qui est généré. - Des services orientés ressources (
ContainerService,PodService,VirtualMachineService,NetworkService,VolumeService,ImageService,StackService,NodeService,OperationService), un message de requête par RPC, l'identité exprimée explicitement parnamespace/name. - Un travail long renvoie une
Operationqui est persistée avant d'être acquittée, de sorte qu'un serveur redémarré puisse direInterruptedau lieu deRUNNINGpour toujours. - Local uniquement.
SO_PEERCRED, même uid, pas de TCP, pas de TLS, pas d'identité dans le moteur — ADR-0010 a rejeté une API distante. Tout ce qui est hors du nœud place son propre proxy devant.
RecruteurPourquoi pas simplement un serveur REST ?
CandidatParce que les clients gRPC et l'outillage shell méritent tous deux un encodage de première classe, et que les générer à partir d'un seul fichier les empêche de diverger. La CLI n'est pas non plus une citoyenne de seconde zone : ses classes de sortie (
delonix-model/src/exitcode.rs) sont les classesDX_*que l'ADR-0040 D4 exige que les erreurs du contrat portent.
Où cela se trouve dans le code : 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.
État honnête : aucun crate ne référence encore delonix.node.v1 ; les programmes locaux utilisent
l'API de gestion (crates/interfaces/delonix-mgmt), que l'ADR-0042 prévoit de migrer puis de
supprimer.
4. Conception de haut niveau#
CandidatJe vais l'organiser en couches pour que les règles du domaine n'importent jamais le noyau, et je vais faire en sorte que chaque processus de longue durée possède exactement une chose.
Légende — boîte blanche à bordure rouge : bloc de construction du moteur · région délimitée : une couche · cylindre, bleu : état sur disque · flèche pleine : appel ou flux de données, étiqueté.
Les règles du domaine ne montent, ne lancent ni ne configurent jamais le réseau, et un seul adaptateur possède tous les fichiers sous la racine d'état.
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
- Couches (ADR-0040 D1) : fondation → contextes → adaptateurs/providers → interfaces → binaires,
imposées par
scripts/arch_fitness.py. - L'état consiste en enregistrements JSON et en fichiers adressés par contenu sous une seule
racine, avec des écritures atomiques et un
flockautour des lectures-modifications-écritures — pas de base de données, car il n'y a pas de daemon pour en posséder une. Les types d'enregistrement sont définis à part d'eux (Container/Vmdans le contextedelonix-compute;Statuset les enregistrements de pare-feu dans le crate de fondationdelonix-model) ; les fichiers ne sont ouverts que par l'adaptateurdelonix-state. - Les processus existent par charge de travail (un superviseur qui est le parent du container,
l'init, un shim de logs) et par nœud lorsque le réseau est utilisé (un pin qui ne fait que tenir
les namespaces, un processus de contrôle redémarrable, un uplink
slirp4netns). Rien d'autre ne reste actif.
Où cela se trouve dans le code : 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. Analyses approfondies#
5.1 Un container run rootless#
RecruteurDécrivez-moi
run -d -p 8080:80 nginxexécuté par un utilisateur non privilégié.
CandidatRésoudre tout ce qui peut échouer avant de créer un processus ; puis créer le processus à l'arrêt, le configurer de l'extérieur, et ne le libérer que lorsqu'il est prêt.
- Résoudre. Une seule spécification d'exécution (
RunOpts) provient de chaque point d'entrée — options de la CLI, manifeste de Pod, API Docker, CRI.resolve_runrécupère l'image si elle est absente, prépare le système de fichiers racine, résout--userpar rapport à celui-ci, résout les volumes et les périphériques, et valide les options de sécurité. Chaque refus a lieu ici, et une garde supprime le répertoire préparé en cas de retour anticipé. - Enregistrer.
build_recordtransforme la spécification en un enregistrementContainer(pur). - Choisir le parent. Pour un démarrage détaché, la CLI fork un superviseur qui devient le
parent du container (
launch::start→should_supervise). Seul le vrai parent peut faire unwaitpid; c'est donc ce qui rend possibles le vrai code de sortie et--restartsans daemon. - Cloner.
spawnappellecloneavec de nouveaux namespaces mount, UTS, PID et IPC, plus les namespaces user et réseau lorsque le container reçoit les siens. L'enfant se bloque sur un pipe. - Configurer de l'extérieur, dans un ordre fixe. Le parent écrit les mappages uid/gid
(
write_userns_maps, vianewuidmaplorsqu'une plage de subuid existe), met en place le cgroup, exécute le hookon_started(ici :slirp_attachavec les ports de-p, afin que le réseau existe avant l'exécution de l'entrypoint), et n'écrit qu'ensuite l'octet « go ». - Dans l'enfant.
container_initmonte l'overlay (mount_overlay_if_marked), attache les volumes en bind, met en place/dev, fait unpivot_root, masque des chemins de/proc, applique les capabilities, seccomp etno_new_privs, et signale « mounted » sur un second pipe avant leexecvp. - Publier l'enregistrement en dernier. Le parent attend l'octet « mounted » (
wait_for_mounts), brièvement le résultat de l'exec, et ne fait qu'ensuite lestore.savedeRunning.
Légende — les participants sont des processus ; les flèches pleines sont des appels, des lignes de socket, des forks ou des clones (l'étiquette précise lequel) ; les flèches en pointillés sont des réponses ou des octets renvoyés ; une auto-flèche est un travail interne à ce processus ; les notes marquent un état ou une attente.
L'enfant est créé bloqué, configuré de l'extérieur, et l'enregistrement n'est sauvegardé qu'après que l'enfant a signalé ses montages.
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
RecruteurPourquoi attendre « mounted » avant de sauvegarder l'enregistrement ?
CandidatParce que l'enregistrement est ce que lisent les autres processus pour décider qu'ils peuvent entrer dans le container. Avant le
pivot_root, unsetnsdans le mount namespace de l'enfant aboutit dans le système de fichiers de l'hôte. Mesuré le 2026-08-28 avec une sonde capable de distinguer les deux systèmes de fichiers : avant le correctif, 5 des 54execlancés juste aprèsrun -dse sont exécutés hors du container ; après, 0 sur 54. L'attente a trois issues (MountWait::{Ready, InitExited, Unknown}) et un plafond, de sorte qu'un montage bloqué ne peut pas bloquerrun.
Où cela se trouve dans le code : 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 Réseau : pin, contrôle, slirp, nftables#
RecruteurLes containers doivent se parler, être isolés par namespace et publier des ports — sans
CAP_NET_ADMINsur l'hôte.
CandidatConstruire un monde réseau privé à l'intérieur d'un namespace que possède l'utilisateur, le relier à l'hôte en espace utilisateur, et faire tout le filtrage là-dedans.
Séparer la détention du service. Un unique processus « holder » qui posséderait les namespaces et servirait les requêtes ferait tomber le réseau de toutes les charges de travail à chacun de ses redémarrages. Donc :
- le pin (
delonix netns pin) crée lui-même les namespaces user, réseau et mount, puis ne fait plus que dormir (pin_main). Son pid est la cible de chaquensenter -t <pin>, et il ne change jamais ; - le processus de contrôle (
delonix netns control, lancé viansenterdans les namespaces du pin) sert une socket unix0600restreinte parSO_PEERCRED, et exécute DNS, DHCP et les Router Advertisements. Il est redémarrable :ensure_upne redémarre que lui lorsque le pin est vivant ; - un seul
slirp4netnsattachetap0au netns du pin et expose une socket d'API pouradd_hostfwd.
Le pin crée ses namespaces dans le processus (l'appelant écrit les mappages d'identifiants via
deux pipes) au lieu de passer par unshare(1), car un profil AppArmor est attaché au chemin de
l'exécutable qui crée le user namespace, et /usr/bin/unshare n'est pas celui du moteur.
Rejoindre un réseau personnalisé. Un container sur --net web ne peut pas faire de setns dans
un netns appartenant au user namespace du pin. La CLI demande donc au processus de contrôle de créer
le netns et la veth (attach …), puis se ré-exécute elle-même à l'intérieur des namespaces user
et mount du pin (nsenter -t <pin> -U -m -n -- ip netns exec <netns> delonix netns run <spec>) ; la
seconde passe hérite des namespaces user et réseau au lieu de les créer.
Publier un port se fait en deux étapes, qui relèvent toutes deux de l'état du dataplane plutôt
que de l'état d'un processus (c'est pourquoi des ports peuvent être ajoutés et retirés sur un
container en cours d'exécution) : add_hostfwd sur l'unique slirp, et une règle DNAT dans le netns
du pin (publish … sur la socket de contrôle).
Filtrer avec une verdict map. La table d'ingress (table ip dlxing) est construite de sorte que
la politique par container coûte la même chose quel que soit le nombre 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
fwcont a deux règles ; les règles de chaque container vivent dans sa propre chaîne, atteinte via la
verdict map fwmap indexée par IP. Le trafic entre réseaux est rejeté par paire, sauf si une
NetworkRoute place la paire dans @netpair — une route dit que le paquet peut traverser, et la
chaîne par container décide encore s'il est autorisé.
L'isolation par namespace vit dans la chaîne de chaque container : les membres de
@dlxns<hash> (même namespace) sont acceptés, et les nouvelles connexions depuis toute autre
adresse de container (@dlxall) sont rejetées ; les réponses passent toujours, car le rejet ne
correspond qu'à ct state new. Une politique d'ingress explicite remplace ce comportement par
défaut. L'IPv6 dans le SDN est refusé par défaut (table ip6 avec policy drop), car toutes les
règles ci-dessus sont IPv4.
Légende — les participants sont des processus ; les flèches pleines sont des appels, des lignes de socket, des forks ou des clones (l'étiquette précise lequel) ; les flèches en pointillés sont des réponses ou des octets renvoyés ; une auto-flèche est un travail interne à ce processus ; les notes marquent un état ou une attente.
Rejoindre un réseau personnalisé est une ligne sur la socket de contrôle plus une ré-exécution dans les namespaces du pin ; publier un port, ce sont deux écritures dans le 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
RecruteurLe processus de contrôle sert une connexion à la fois. N'est-ce pas un goulot d'étranglement ?
CandidatC'est délibérément le point de sérialisation des modifications de netns, de veth et de nftables, qui ne doivent pas s'entrelacer. Le risque est que des clients abandonnent dans la file : lors de la préparation de la v0.47.0, 30 attaches concurrents avec un plafond de lecture de 5 secondes en ont perdu 15 ; avec le plafond de réponse relevé (
CONTROL_REPLY_TIMEOUT, 30 s), les 30 ont abouti. Le plafond d'I/O par connexion (CONTROL_IO_TIMEOUT) existe pour qu'un client bloqué ne puisse pas figer le plan de contrôle du nœud.
Où cela se trouve dans le code : 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 Images : CAS, couches partagées et montage à nombreuses couches#
RecruteurUn nœud exécute vingt containers de la même image. Qu'y a-t-il sur le disque ?
CandidatLes blobs une seule fois, les couches décompressées une seule fois, et un petit répertoire inscriptible par container.
- CAS. Les blobs sont nommés d'après leur sha256 sous
blobs/sha256/<hex>; écrire un digest existant est un no-op. Un pull vérifie chaque blob par rapport au manifeste et le manifeste par rapport au digest épinglé par l'utilisateur (verify_manifest_digest) — sinon un épinglage serait décoratif. - Téléchargements reprenables. Le téléchargement d'un blob réessaie avec
Range:à partir des octets déjà obtenus (BLOB_ATTEMPTS), et distingue un206à l'offset demandé (reprise), un206ailleurs et un200(redémarrage). La vérification du digest à la fin rend l'assemblage sûr. - Couches partagées.
prepare_overlaycréeupper/,work/,merged/pour le container et écrit la liste ordonnée des répertoires de couches partagées dansoverlay-lowers. C'est l'init du container lui-même qui le monte, dans son mount namespace, là où un utilisateur non privilégié en a le droit. Le contrat est un fichier sur disque plutôt qu'un champ en mémoire, car le chemin rootless ré-exécute le binaire et une struct ne franchit pas cette frontière.
Estimation rapide, telle que consignée pour la v0.59.0 : l'ancienne copie à plat coûtait à chaque
container une arborescence d'image complète — sur un hôte de développement, containers/ occupait
47 GiB, en grande partie des copies identiques, et chaque run d'une image de 2,1 GiB passait
environ 13 s à copier. Le partage des couches a ramené ce répertoire à 7,2 GiB.
- Nombreuses couches. Le mount(2) classique passe lowerdir=a:b:c… comme une seule chaîne et le
noyau en copie au plus une page, en tronquant silencieusement. Mesuré pour
ADR-0037 (validé le 2026-09-06) : 20 couches
(4084 octets) montées, 30 (5994 octets) en échec, et une image de builder à 91 couches nécessitait
9107 octets. Le montage utilise désormais fsopen/fsconfig/fsmount/move_mount avec un appel
lowerdir+ par couche ; il n'y a donc plus de plafond de longueur.
Où cela se trouve dans le code : 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 : un port, un registre et le piège du firmware#
RecruteurAjoutez des VMs sans construire une abstraction d'hyperviseur qui fuit partout.
CandidatUn trait par provider, un registre que remplit la racine de composition, et les particularités propres au provider traitées par le provider.
- Port.
VmBackendpossèdeid,available,boot,is_running,ip,stop, et des opérations optionnelles (pause,snapshot,restore, …) dont la réponse par défaut est « non pris en charge ». Les faits propres au provider sont des méthodes, et non des comparaisons de chaînes aux points d'appel :ip_is_predicted(l'adresse de Cloud Hypervisor est calculée à partir de la MAC, et non observée),manages_own_storage(un nœud distant possède son disque),destroydistinct destop(en local, le disque appartient au moteur ; à distance, seul destroy le libère). - Registre.
builtin_backendsinitialise Cloud Hypervisor et libvirt par ordre de préférence ;register_backenden ajoute d'autres (le backend Proxmox, enregistré par la racine de composition de la CLI uniquement lorsqu'il est configuré). Un enregistrement porte une closure de factory et un drapeauauto_selectable, de sorte que l'auto-détection ne construit jamais — et donc n'authentifie jamais — un backend distant. L'enregistrement ne fait aucune I/O. - Le réseau d'une VM. Cloud Hypervisor s'exécute dans le netns du pin et obtient un
tapsur un bridge réseau à travers le portVmNetwork, que le SDN implémente (HostVmNetwork) ;delonix-vmne dépend pas dedelonix-sdn. Comme le serveur DHCP est celui du moteur et qu'il est déterministe, le bail est connu avant le démarrage de l'invité, ce qui permet à l'isolation par namespace de s'appliquer à l'adresse d'une VM dès le premier paquet — et c'est pourquoi « a une IP » ne prouve pas qu'un invité a démarré (sdn_reachableinterroge par ARP depuis l'intérieur du netns). - Firmware. La recherche de firmware de Cloud Hypervisor préfère EDK2
CLOUDHV.fdàhypervisor-fw(DEFAULT_CH_FIRMWARES, avec un test qui fixe l'ordre). - cloud-init.
VmConfigporte l'intention (hostname, utilisateur, clés SSH) ; les backends locaux la réalisent sous forme d'une ISO NoCloud dont lenetwork-configassocie la NIC principale par MAC, et un backend distant peut la réaliser nativement.
Où cela se trouve dans le code : 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 Le réconciliateur déclaratif, sans fichier d'état#
Recruteur
applydoit converger, détecter la dérive et élaguer — à la manière de Terraform — mais vous avez dit pas de daemon et pas de base de données.
CandidatConserver la dernière spec appliquée sur la ressource elle-même, déduire la propriété d'un label, et faire de la planification une fonction pure.
- Plan pur.
reconcile::plan(desired, actual, stack)prend deux instantanés et renvoieVec<Change>; il n'ouvre jamais de store. Cela rend les cas difficiles testables sous forme de données. - Diff à trois voies. La table des derniers champs appliqués est stockée sur la ressource
(
delonix.io/last-applied). Un champ présent sur la machine mais absent du manifeste n'est rétabli que si nous l'avions défini ; sinon il est laissé tel quel — la distinction qu'un diff à deux voies ne peut pas faire. - Propriété par label (
delonix.io/stack). Une ressource sans propriétaire est adoptée (Adopt) ; une ressource appartenant à une autre stack est unConflictet n'est jamais touchée ;--pruneetdestroyne voient que ce qui porte le label. - Les actions sont
Create,Adopt,Update(à chaud, même PID),Replace(refusée sauf si--replace <Kind>/<name>est fourni, vérifié avant toute création),NoOp,Delete,Conflict,NotConverged.plan --detailed-exitcoderépond 0/2/1 pour un gate de dérive en CI. - Une seule table de faits par Kind (domaine, forme, s'il converge, s'il a un teardown, s'il est namespacé, comment sa présence est observée) gouverne le planificateur, l'ordre d'application et l'ordre de teardown, au lieu de listes tenues synchronisées à la main.
Légende — les participants sont l'opérateur, la commande
stack apply, le planificateur pur et les stores et le dataplane sur lesquels il agit ; les flèches pleines sont des appels ; les flèches en pointillés sont des réponses ; les encadrésaltetoptsont la branche d'échec et la purge optionnelle.
Rien n'est créé avant que le plan ne soit vérifié, et un échec en cours d'application est marqué plutôt qu'annulé.
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
Où cela se trouve dans le code : 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. Compromis#
| Décision | Ce qu'elle apporte | Ce qu'elle coûte |
|---|---|---|
| Pas de daemon ; un superviseur par container détaché, systemd pour la persistance au démarrage | aucun processus unique dont la mort emporte toutes les charges de travail ; chaque processus a un propriétaire évident | personne ne voit un processus mourir à moins que son propre superviseur ne le voie ; un appelant qui ne peut pas faire de fork démarre sans supervision et le vrai code de sortie est perdu ; les orphelins nécessitent des collecteurs explicites |
Fichiers JSON + flock au lieu d'une base de données |
état inspectable, tolérant aux plantages, sans dépendance supplémentaire, fonctionne entre les processus CLI/CRI/superviseur | ni transactions ni requêtes ; la vérité sur l'état vivant est réconciliée à la lecture (reconcile_status, safe_to_signal) |
Uplink en espace utilisateur (slirp4netns) au lieu de paires veth dans l'hôte |
fonctionne avec zéro privilège sur l'hôte | saut supplémentaire et CPU en espace utilisateur ; un client loopback apparaît comme la passerelle slirp (SLIRP_GW) plutôt que comme lui-même |
| Séparation pin/contrôle | un redémarrage du contrôle ne déplace aucun câble | deux processus à comprendre, et les mises à niveau sur place doivent encore reconnaître les anciens pins |
Ré-exécution au lieu d'un clone dans le processus pour les serveurs |
clone ne s'exécute jamais dans un processus multi-thread |
un processus par opération, et un texte d'erreur qui franchit une frontière de processus — le cycle que l'ADR-0040 supprime avec un lanceur |
| Overlay monté par l'init du container | une seule copie de chaque couche sur disque ; montage non privilégié | la vue fusionnée d'un container arrêté nécessite un processus auxiliaire pour tenir le montage (reexec_mapped_hold) |
| Dispatch par verdict map | coût par paquet constant à mesure que les containers augmentent | les règles sont du texte généré ; le générateur et le lecteur des compteurs doivent partager le formatage (fw_rule_tail) |
| Diff à trois voies sur la ressource | aucun fichier d'état à perdre ou à désynchroniser | seuls les champs qu'un Kind peut relire peuvent être comparés ; les valeurs de Secret ne sont pas déchiffrées pour la planification |
7. Modes de défaillance et limites d'un seul nœud#
RecruteurDites-moi comment il casse.
- Le processus de contrôle meurt.
ensure_uptrouve le pin vivant et ne redémarre que le plan de contrôle à l'intérieur des namespaces survivants ; les charges de travail en cours conservent leurs PID et leur réseau. - Le pin meurt. Les namespaces disparaissent avec lui et ne peuvent pas être réintégrés ;
l'infrastructure est donc reconstruite.
delonix net netns uptrouve les containers et les membres de pod qui s'exécutaient avec un réseau et les redémarre (reconcile_after_respawn;DELONIX_NO_AUTO_RECOVER=1se contente de signaler). Il s'agit d'une récupération par redémarrage, et elle ne lit que le store des containers — les VMs ne sont pas récupérées de cette manière. - Une mise à niveau par-dessus un holder plus ancien. Un holder antérieur à la séparation qui sert un ancien chemin de socket est détecté et signalé avec les deux chemins ; il n'est délibérément pas tué automatiquement, car cela ferait tomber le réseau de toutes les charges de travail.
applymeurt à mi-chemin. Apply échoue vite, sans rollback. Avant de créer quoi que ce soit, il valide le graphe et refuse les remplacements non autorisés ; si une couche échoue, ce que cette exécution a créé est marqué comme sa propriété (salvage_ownership), de sorte qu'undestroyou un--pruneultérieur puisse encore l'atteindre, et l'exécution échouée est enregistrée comme une révision.- Des fuites sans daemon. Chaque bail et chaque référence sont libérés par un détachement normal ;
tout ce qui meurt autrement fuit donc. Mesuré le 2026-08-25 : le fichier IPAM d'un réseau contenait
391 baux, dont 47 appartenaient à un container existant. Les collecteurs respectent une fenêtre de
grâce (
REF_MARKER_GRACE), car un container en cours de création détient un bail et une référence avant d'avoir un enregistrement ; le collecteur IPAM fonctionne en deux passes (un bail n'est récupéré que s'il est encore orphelin lors d'une exécution ultérieure, au-delà de la fenêtre) et échoue de manière fermée — un store illisible est une erreur, jamais « rien n'est vivant ». La vivacité compte chaque enregistrement de container, le netns de pod des membres de pod et les marqueurs de référence attachés, et pas seulement les ids des containers en cours d'exécution (cmd/prune.rs::lease_owners,live_ref_owners). - La course de l'attente de montage (5.1) : fermée en ne publiant l'enregistrement qu'après que
l'init a signalé ses montages ; un démarrage détaché dont l'init se termine avant le montage est une
erreur, et non
0. - Limites d'un seul nœud. Chaque réseau est un
/16à l'intérieur du netns du pin ; chaque modification de netns/veth/nftables passe par une seule connexion de contrôle sérialisée ; le débit deslirp4netnsest en espace utilisateur ; et déplacer une VM vers un autre hôte (vm migrate) implique une réelle interruption — la migration à chaud est un NO-GO en l'état (ADR-0031). L'ordonnancement entre nœuds est hors périmètre par conception.
Où cela se trouve dans le code : 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. Questions de suivi#
Pourquoi ne pas ajouter un petit daemon pour les événements et les redémarrages ?
Parce que chaque processus résident est un domaine de défaillance et une surface d'attaque. Le
journal d'événements est un fichier en ajout seul (delonix_runtime_core::events), les redémarrages
appartiennent au superviseur de chaque container, et la persistance au démarrage est une unit systemd
par charge de travail (delonix system boot). Un daemon exige son propre ADR démontrant ce que les
alternatives n'ont pas pu faire — voir ADR-0034 pour un cas
où la question s'est posée, et ADR-0021 (Proposé) pour
une réconciliation continue qui reste daemonless.
Comment le CRI démarre-t-il un container si le serveur ne doit pas faire de clone ?
StartContainer construit un RunOpts typé, l'écrit dans un fichier 0600 et exécute
delonix __apirun <spec>, qui appelle le même cmd_run que la CLI. L'ADR-0040 D5 remplace ce
passage par la CLI par un exécutable lanceur. La politique de ressources sur le chemin CRI suit le
kubelet (ADR-0038).
Pourquoi l'API de gestion est-elle uniquement locale ? L'accès distant implique l'identité, l'autorisation, les certificats et l'audit d'appelants dont le moteur n'a aucune notion. ADR-0010 l'a rejeté ; la surface MCP est locale pour la même raison (ADR-0025).
Comment ajouteriez-vous un nouveau provider de VM ?
Un nouveau crate qui implémente VmBackend, enregistré à la racine de composition — aucune
modification des points d'appel (ADR-0008). L'ADR-0040 D3
déplace les réglages des providers dans des extensions namespacées et leurs particularités dans des
capacités ; OpenStack est conditionné à un spike
(ADR-0039).
Comment les Services répartissent-ils la charge sans VIP ?
Un Service sélectionne des containers par label et le DNS interne renvoie plusieurs enregistrements
A, avec rotation à chaque requête — aucun nouveau dataplane
(ADR-0032).
Pourquoi ext4 et non btrfs/zfs sous la racine d'état ? L'overlay au-dessus d'un cache de couches partagé a déjà supprimé la duplication ; un autre système de fichiers ne sera réexaminé que pour un besoin mesuré (ADR-0016).
Et macOS et Windows ? Pas un portage — rien de ce qu'utilise ce moteur n'existe en dehors du noyau Linux. Le plan est un lanceur pour une VM invitée Linux (ADR-0036, Proposé).
Où va la restructuration ? Quatre couches, une seule spécification d'exécution, des ports de provider avec des capacités, un seul contrat de nœud servi sur un serveur à socket activation, et un lanceur qui possède chaque lancement créant des namespaces (ADR-0040, ADR-0042).
Suivant : Delonixfile et VMfile — les deux grammaires de build — Delonixfile pour les images OCI et VMfile pour les disques amorçables — telles que les analyseurs les acceptent.