Construire des services de nœud avec Varlink, Zlink et FUSE

created: mercredi, mai 6, 2026

Dans un billet précédent, nous avons examiné Varlink comme une petite expérience. À l’époque, c’était surtout un exercice “hello world” : définir une interface, ouvrir une socket Unix, appeler une méthode, retourner du JSON. C’était utile, mais restait un montage de laboratoire.

Depuis, nous avons commencé à déplacer l’idée au niveau des nœuds de DownToZero. Deux projets sont particulièrement intéressants ici : dtz-node-stats et dtz-node-volume. Le premier expose des informations sur un nœud worker, y compris l’état d’alimentation et les notions matérielles de base. Le second contrôle un système de fichiers FUSE qui monte un volume DTZ dans la machine locale.

Ce billet n’est pas une visite détaillée du code source. Une partie du code des dépôts environnants est une infrastructure interne et n’est pas quelque chose que nous souhaitons publier comme guide d’implémentation. Mais la forme du système, les interfaces et le schéma valent la peine d’être partagés, car Varlink et Zlink conviennent très bien à cette classe de problèmes.

La version courte est la suivante : nous voulons que de petits services locaux sur un nœud soient découvrables, scriptables et ennuyeux à exploiter. Varlink nous fournit le protocole. Zlink nous donne une implémentation Rust qui correspond à nos services asynchrones. FUSE nous donne un moyen de présenter un stockage distant ou basé sur des objets comme un système de fichiers normal. Ensemble, cela permet au code d’orchestration de haut niveau de dire “monter ce volume pour ce contexte” sans savoir comment le pilote de système de fichiers parle au stockage.

Pourquoi l’IPC locale importe sur nos nœuds

Les nœuds DownToZero ne sont pas de simples machines Linux anonymes. Ils font partie d’une plateforme consciente de l’énergie. Nous nous soucions de la provenance de l’énergie, de la consommation d’un nœud, si une machine fonctionne sur batterie ou solaire, et des charges de travail qui doivent y être placées.

En même temps, les fonctionnalités au niveau du nœud ne devraient pas toutes finir dans un seul démon géant. La collecte de métriques, la gestion de l’alimentation, le montage de volumes, l’ordonnancement de conteneurs, la logique de mise à jour et les tâches de maintenance locale ont toutes des cycles de vie différents. Certaines devraient redémarrer indépendamment. Certaines exigent des permissions particulières. Certaines doivent rester suffisamment petites pour être comprises et testées isolément.

Le problème est la coordination. Une fois ces morceaux séparés en processus distincts, ils ont besoin d’un moyen propre de communiquer entre eux. Un serveur REST sur localhost fonctionnerait, mais il apporte le routage HTTP, les ports, les questions TLS et en général plus de surface que nécessaire. Un protocole personnalisé sur socket Unix fonctionnerait aussi, mais alors chaque service invente son propre encadrement, modèle d’erreur et histoire de débogage. D-Bus est établi, mais nous voulions quelque chose de plus simple pour des API machines spécifiques aux services.

Varlink se place bien au milieu. Il utilise des sockets, a un protocole simple basé sur JSON, supporte l’introspection et est facile à appeler depuis des outils en ligne de commande. Pour les services Rust, Zlink nous offre une gestion typée des méthodes tout en gardant le format sur le fil facile à inspecter.

Le résultat est une surface d’API locale qui ressemble plus à :

varlinkctl call /tmp/dtz.varlink rocks.dtz.Node.GetInformation {}

et moins à la conception d’un nouveau plan de contrôle à partir de zéro.

Le petit service : dtz-node-stats

dtz-node-stats est le plus simple des deux projets. Sa tâche est de collecter et d’exposer des faits au niveau du nœud : cœurs CPU, mémoire, métriques liées au temps de fonctionnement, source d’alimentation et consommation électrique. Il peut s’intégrer à un compteur d’énergie, par exemple un appareil Tasmota, ou revenir à des estimations de consommation configurées.

La partie intéressante pour ce billet est l’interface Varlink. Le service écoute sur une socket Unix et expose quelques méthodes ciblées :

rocks.dtz.Node.GetInformation() -> node information
rocks.dtz.Node.GetPowerState() -> current energy state
rocks.dtz.Node.Shutdown() -> graceful shutdown
org.varlink.service.GetInfo() -> standard Varlink introspection

Appeler GetInformation renvoie des faits stables sur le nœud tels que l’ID du nœud, la source d’alimentation configurée, le nombre de cœurs CPU et la taille de la mémoire. Appeler GetPowerState renvoie l’état énergétique actuel et la consommation cumulative en watt-heures.

Ceci est déjà utilisé par deux rôles worker sur nos nœuds.

Le sync-worker appelle GetInformation au démarrage et utilise l’ID de nœud retourné comme son identité locale. Il utilise aussi les valeurs CPU et mémoire rapportées lorsqu’il s’annonce au reste de la plateforme. Cela signifie que le worker n’a pas besoin d’un second fichier de configuration pour les informations de capacité de base, et qu’il n’a pas besoin d’inventer sa propre idée de la machine sur laquelle il tourne. Le service de nœud est la source locale de vérité.

L’async-worker utilise le même schéma, mais pour un objectif légèrement différent. Il lit l’information du nœud une fois au démarrage, la conserve avec l’état d’exécution du worker, puis utilise l’ID du nœud lors du traitement des requêtes, du rapport d’état et de l’étiquetage des métriques. Si vous regardez plus tard la latence ou les compteurs de requêtes, l’étiquette de nœud vient de la même identité supportée par Varlink que les autres services locaux voient.

C’est le but de garder cette interface petite. dtz-node-stats n’a pas à savoir quel type de travail chaque processus effectue. Il doit seulement répondre à quelques questions de niveau nœud de manière cohérente. Les workers peuvent ensuite utiliser ces réponses pour l’enregistrement, des indices de routage, les métriques et la visibilité opérationnelle sans copier la logique de détection de l’hôte dans chaque service.

Le choix de conception important est que ceci n’est pas un point de terminaison de métriques qui prétend être une API de contrôle. Prometheus peut toujours recevoir des métriques. Les journaux peuvent toujours aller vers journald. Mais si un autre processus local a besoin de poser une question au service de nœud, il obtient une petite méthode typée au lieu de scrapper une sortie texte ou d’importer du code d’implémentation.

La méthode Shutdown est aussi un bon exemple de pourquoi l’IPC locale est utile. Lors des mises à jour, un nouveau processus peut trouver une socket existante. Au lieu d’échouer bêtement ou de supprimer le fichier socket, le chemin de démarrage peut vérifier si un service est actif et lui demander de s’arrêter proprement. Si la socket est obsolète, il peut la supprimer. Si le service est vivant, il peut demander un arrêt gracieux. Cela semble petit, mais cela supprime beaucoup de gênes opérationnelles sur les machines périphériques.

Le service plus important : dtz-node-volume

dtz-node-volume est là où le schéma devient plus intéressant. Le service monte un volume appuyé par DTZ en tant que système de fichiers local. Du point de vue des logiciels s’exécutant sur le nœud, le résultat n’est qu’un répertoire sous ./dtz/.... Les fichiers peuvent être créés, lus, écrits, listés et supprimés en utilisant les opérations POSIX normales.

Sous le capot, le système de fichiers est implémenté avec FUSE. FUSE est une frontière puissante : le noyau transfère les requêtes de système de fichiers vers un processus en espace utilisateur, et ce processus décide comment les satisfaire. Cela signifie que le backend de stockage peut être un magasin d’objets, une API, un cache, un service distant ou une combinaison de ceux-ci. Les applications n’ont pas besoin de le savoir. Elles appellent simplement open, read, write, readdir, getattr, et ainsi de suite.

La pièce manquante est le contrôle. Un système de fichiers FUSE est utile une fois monté, mais quelque chose doit encore décider quand le monter, pour quel contexte, à quel chemin et quand le démonter. Nous ne voulons pas que chaque appelant connaisse les détails de configuration de FUSE. Nous ne voulons pas non plus exposer l’implémentation du stockage directement à chaque partie du nœud.

Ainsi dtz-node-volume expose une interface Varlink :

rocks.dtz.Volume.GetInfo() -> active mounts
rocks.dtz.Volume.Mount(context, volume) -> mountpoint and status
rocks.dtz.Volume.Umount(context, volume) -> acknowledgement
org.varlink.service.GetInfo() -> standard Varlink introspection

L’interface est délibérément petite. Monter un volume est une action de contrôle locale. L’appelant fournit le contexte et l’ID du volume. Le service transforme cela en un point de montage comme :

/dtz/{context_id}/{volume_id}

La réponse est tout aussi concise :

{
  "mountpoint": "/dtz/context-.../volume-...",
  "status": "mounted"
}

À partir de là, l’appelant ne parle pas Varlink pour chaque lecture de fichier. Ce serait la mauvaise abstraction. Varlink contrôle le cycle de vie du montage. FUSE gère les opérations du système de fichiers. Cette séparation est le point principal.

Il est tentant de tout construire comme une API RPC. Pour les volumes, cela signifierait des méthodes comme ReadFile, WriteFile, ListDirectory, DeletePath, et des dizaines d’autres. Cette approche fonctionne, mais alors chaque consommateur a besoin d’un client personnalisé. Les outils existants comme cp, rsync, les éditeurs, les shells et les systèmes de build ne pourraient pas l’utiliser directement.

FUSE nous permet d’éviter cela. Le plan de données reste l’interface de système de fichiers que les programmes Linux connaissent déjà. Le plan de contrôle devient un petit service Varlink.

Pour le cycle de vie des volumes, le sync-worker possède la préparation des charges de travail locales. Lorsqu’une charge de travail déclare un volume, le sync-worker demande au service de volume s’il est déjà monté et appelle Mount si nécessaire. Il effectue ensuite un bind-mount du chemin hôte résultant dans la charge de travail. Après cela, la charge de travail effectue des E/S de fichiers normales ; elle ne connaît ni Varlink ni le magasin d’objets.

Une vue séquentielle convient mieux qu’un diagramme topologique ici, car ce qui est intéressant est l’ordre des opérations :

sequenceDiagram
  participant W as sync-worker
  participant V as dtz-node-volume
  participant F as FUSE session
  participant C as container workload
  participant K as Linux VFS
  participant S as DTZ storage backend

  W->>V: GetInfo(context, volume)
  alt volume is not mounted
    W->>V: Mount(context, volume)
    V->>F: create FUSE mount at /dtz/context/volume
    F-->>V: mount ready
    V-->>W: mountpoint + status
  else volume is already mounted
    V-->>W: existing mountpoint + status
  end
  W->>C: start workload with bind mount
  C->>K: normal file IO
  K->>F: FUSE requests
  F->>S: object operations
  S-->>F: object data and metadata
  F-->>K: filesystem reply
  K-->>C: read/write result
  W->>V: Umount when lifecycle policy says so

Cela garde l’API petite et l’expérience utilisateur normale. Le sync-worker peut monter le volume juste avant le démarrage d’une charge de travail. Après cela, la charge de travail utilise un répertoire. Lorsque la charge de travail est terminée, le même rôle worker peut le démonter ou le laisser disponible pour réutilisation, selon la politique de cycle de vie.

Pour nous, c’est exactement le type d’interface que nous voulons sur un nœud. Elle est assez explicite pour être automatisée, mais n’oblige pas chaque charge de travail à apprendre une API de stockage spécifique à DTZ.

Comment le système de fichiers appuyé par des objets est structuré

Il existe plusieurs façons de mapper un système de fichiers sur un magasin d’objets. Nous utilisons une disposition qui sépare les métadonnées de l’espace de noms des données de fichiers.

En termes simplifiés :

fs/      filesystem namespace and metadata
data/    larger file chunks

Chaque fichier ou répertoire possède un objet de métadonnées sous le préfixe fs/. Ces métadonnées contiennent les attributs de type POSIX dont nous avons besoin : mode, IDs des propriétaires, taille, temps de modification, indicateur de répertoire et attributs étendus optionnels. Les petits contenus de fichier peuvent être intégrés directement dans l’objet de métadonnées. Les fichiers plus volumineux utilisent une référence de données, et leurs octets sont stockés en chunks sous data/.

Cela a quelques propriétés utiles.

Les listings de répertoire n’ont besoin d’inspecter que l’espace de noms. Ils n’ont pas besoin de filtrer les chunks de fichiers. Les renommages peuvent être peu coûteux car l’objet de métadonnées peut être déplacé tandis que les gros chunks de données restent à leur place. Les fichiers creux sont possibles parce que les chunks non écrits n’ont pas besoin d’exister. Les petits fichiers évitent des allers-retours supplémentaires parce que leur contenu vit dans les métadonnées.

Encore une fois, les détails d’implémentation exacts ne sont pas la partie intéressante à publier. Le point architectural important est que le stockage d’objets et les systèmes de fichiers POSIX ont des forces différentes. La couche FUSE traduit entre eux. Varlink n’essaie pas de faire partie de cette traduction ; il se contente de démarrer, d’arrêter et de rapporter l’état du montage.

Les attributs étendus comptent plus qu’on ne le croit

Un détail que nous avons ajouté à la conception du volume est le support des attributs étendus. Cela peut sembler une fonctionnalité de système de fichiers de niche, mais cela compte une fois que vous voulez que le répertoire monté se comporte comme un vrai système de fichiers plutôt que comme une démo.

Les attributs étendus sont utilisés pour les métadonnées utilisateur, les capacités, les labels de sécurité, les métadonnées de bureau et divers indices spécifiques aux applications. Si nous les ignorons complètement, beaucoup de choses fonctionnent encore, mais l’abstraction fuit. Si nous les préservons dans les métadonnées, le montage FUSE devient beaucoup plus compatible avec les attentes normales de Linux.

La conception pratique est simple : les métadonnées peuvent contenir une carte optionnelle de noms xattr vers des valeurs encodées en base64. Les opérations FUSE comme getxattr, setxattr, listxattr et removexattr deviennent des opérations de lecture-modification-écriture sur l’objet de métadonnées.

Il y a des compromis. La mise à jour des xattr réécrit les métadonnées. Les mises à jour concurrentes des métadonnées nécessitent une réflexion attentive. Des xattr très volumineux seraient une mauvaise idée. Mais le schéma reste une bonne correspondance pour le type de métadonnées que nous attendons autour des volumes montés.

Rapport des montages actifs

La méthode GetInfo de dtz-node-volume renvoie les montages actifs actuellement. Cela inclut le contexte, l’ID du volume, le point de montage, le statut et l’horodatage d’accès observé le plus récent.

Ce dernier champ est petit mais utile. Un orchestrateur au niveau du nœud peut demander :

varlinkctl call /tmp/dtz-volume.varlink rocks.dtz.Volume.GetInfo {}

et obtenir une vue actuelle de ce que le service de volume croit être monté. Cela est utile pour le débogage, le nettoyage, et plus tard pour un comportement plus conscient de l’énergie. Si un montage n’a pas été accédé depuis un moment, peut-être peut-il être démonté. Si une charge de travail démarre, peut-être que le montage devrait être créé juste à temps. L’interface Varlink nous donne un endroit pour exposer cet état sans coupler les appelants à l’implémentation FUSE.

Nous construisons la plupart de ce code côté nœud en Rust, donc l’ergonomie Rust compte. Zlink nous permet de garder l’implémentation du service typée tout en parlant le protocole Varlink. Les appels de méthode se désérialisent en enums et structs Rust. Les réponses se sérialisent en retour dans la forme JSON attendue. L’introspection Varlink standard n’est qu’une autre méthode exposée par le service.

Cela nous donne un compromis agréable. L’interface reste inspectable depuis l’extérieur :

varlinkctl call /tmp/dtz-volume.varlink org.varlink.service.GetInfo {}

mais le code du service ne devient pas un tas de gestion de requêtes faiblement typées.

Cela facilite aussi les outils locaux. Pendant le développement, une commande shell peut monter un volume, lister les montages ou demander l’état d’alimentation du nœud. En production, systemd peut gérer le processus du service et le chemin de la socket Unix peut rester local au nœud. Nous n’avons pas besoin d’un écouteur réseau public pour ces actions.

Les outils standard comptent

L’une des raisons pour lesquelles nous avons choisi Varlink n’est pas seulement que le protocole lui-même est standardisé. Les outils autour de celui-ci sont standardisés aussi. En pratique, cela compte autant que la définition de l’interface.

Avec varlinkctl, chaque service devient immédiatement utilisable depuis un shell :

varlinkctl call /tmp/dtz.varlink rocks.dtz.Node.GetInformation {}
varlinkctl call /tmp/dtz-volume.varlink rocks.dtz.Volume.GetInfo {}

Cela signifie que le travail de maintenance ne nécessite pas un binaire de débogage spécial ou un petit client ad hoc. Si un nœud se comporte étrangement, nous pouvons nous y connecter en SSH et interroger ce que les services locaux savent. Si un script de mise à jour doit vérifier si un volume est monté, il peut appeler le service Varlink directement. Si une unité systemd ou une tâche de maintenance de type cron a besoin d’un arrêt gracieux, elle peut utiliser la même méthode publique que les clients Rust.

C’est une grande différence par rapport à une API de bibliothèque interne. Une bibliothèque n’est pratique que pour des programmes écrits dans le même langage et publiés selon le même calendrier. Une socket Varlink avec varlinkctl est pratique pour le code Rust, les scripts shell, les playbooks de maintenance et le débogage manuel. Pour l’infrastructure de nœud, cette flexibilité est précieuse.

Ce que cela permet

Le cas d’utilisation immédiat est pratique : monter des volumes DTZ sur des nœuds worker et exposer l’état du nœud aux autres services locaux. Mais le schéma est plus large que ces deux projets.

Un service local peut exposer une petite API de contrôle Varlink tout en faisant quelque chose de plus complexe en interne. Ce “quelque chose” peut être un système de fichiers FUSE, un collecteur de métriques, un contrôleur matériel, un assistant d’ordonnancement ou un programme de mise à jour. L’appelant obtient une interface de méthode stable. Le service garde son implémentation privée.

Pour DownToZero, cela est particulièrement utile car nous essayons de rendre l’infrastructure plus petite et plus consciente de l’énergie. Un nœud peut exposer ses capacités localement. Les services de niveau supérieur peuvent prendre des décisions basées sur l’état d’alimentation, les ressources disponibles et le stockage monté. Les charges de travail peuvent toujours interagir avec des fichiers et des répertoires normaux.

Ceci maintient aussi des responsabilités claires :

État actuel et prochaines étapes

Ceci est encore en évolution. Les interfaces sont délibérément petites parce qu’il est plus facile d’étendre une API locale que de la rétrécir après que plusieurs services en dépendent. Le service de volume est aussi l’endroit où nous attendons le plus d’apprentissage. Les systèmes de fichiers ont beaucoup de cas limites, et les magasins d’objets ne deviennent pas magiquement des systèmes de fichiers POSIX simplement parce qu’une couche FUSE existe.

Mais la direction semble correcte. Nous pouvons exposer des comportements utiles sans publier la logique interne des services. Nous pouvons déboguer avec des outils standards. Nous pouvons laisser les programmes Linux normaux utiliser le stockage monté. Et nous pouvons garder la coordination au niveau du nœud locale au lieu de transformer chaque action interne en une API de plateforme.

La leçon la plus importante jusqu’à présent est que Varlink et FUSE se complètent bien. Varlink n’essaie pas de déplacer des données de fichier. FUSE n’essaie pas d’être un protocole de découverte de services. Ensemble, ils créent une forme propre : appeler une petite méthode locale pour créer le système de fichiers, puis utiliser le système de fichiers comme n’importe quel autre répertoire.

C’est un schéma que nous continuerons d’utiliser dans la pile des nœuds DownToZero.