Si vous avez déjà essayé de faire communiquer efficacement différents processus sur un système Linux, vous savez que le paysage est… disons, fragmenté. Nous avons D-Bus, des sockets Unix avec des protocoles personnalisés, des API REST en localhost, gRPC, et d’innombrables autres approches. Chacune apporte sa propre complexité, ses outils requis, et sa courbe d’apprentissage.
Récemment, je suis tombé sur Varlink (et son homologue Rust plus récent centré sur Rust, Zlink), et cela a tout de suite résonné avec ce que nous essayons d’accomplir chez DownToZero. Nous construisons une infrastructure nécessitant une communication inter-processus fiable avec peu de surcharge – de l’orchestration de conteneurs à la gestion des paramètres machines. Plus je creusais Varlink, plus je réalisais que cela pourrait être exactement ce dont nous avons besoin.
Dans ce billet, je vous guiderai à travers mes expérimentations avec Varlink. Nous construirons ensemble pas à pas un simple service hello world, et je partagerai mes réflexions sur pourquoi cette technologie m’enthousiasme pour l’avenir de notre plateforme.
Varlink est un format de description d’interface et un protocole conçus pour définir et implémenter des interfaces de services. Considérez-le comme une alternative plus simple et moderne à D-Bus, ou une alternative légère à gRPC pour la communication locale.
Si vous avez déjà travaillé avec D-Bus, vous connaissez probablement la douleur : systèmes de types complexes, introspection nécessitant des outils spéciaux, fichiers de configuration XML conçus pour embrouiller. D-Bus est puissant, mais c’est aussi une technologie d’une époque où « simple » n’était pas une priorité de conception. Varlink adopte une approche différente – voilà à quoi D-Bus pourrait ressembler s’il avait été conçu aujourd’hui, avec une sensibilité moderne à l’expérience développeur.
Voici ce qui rend Varlink intéressant :
Interfaces auto-descriptives : Chaque service Varlink peut décrire sa propre API. Vous pouvez vous connecter à n’importe quel service et demander « que peux-tu faire ? » pour obtenir une réponse lisible par machine (et par l’humain).
Indépendant du langage : Le protocole est assez simple pour que des implémentations existent en Rust, Go, Python, C, et plus. Mais surtout, les interfaces elles-mêmes sont neutres vis-à-vis du langage.
Basé sur des sockets : La communication se fait via des sockets Unix (ou TCP pour les connexions distantes), ce qui signifie qu’il s’intègre bien à l’écosystème Linux, aux conteneurs, et à systemd.
Protocole basé sur JSON : Le format transport est JSON, ce qui rend le débogage trivial. Vous pouvez littéralement utiliser netcat pour parler à un service Varlink si vous le souhaitez.
Intégration systemd : C’est énorme. systemd utilise déjà Varlink pour certains de ses services internes, ce qui signifie que le protocole est éprouvé en production et bénéficie d’une prise en charge native de l’activation par socket et de la gestion des services.
Chez DTZ, nous traitons constamment la configuration et l’orchestration au niveau machine. Notre infrastructure couvre le matériel physique (vous vous souvenez peut-être de nos nœuds alimentés par énergie solaire), des conteneurs, et divers services système devant se coordonner.
Actuellement, nous utilisons un mélange d’approches pour la communication inter-processus :
Ce qui nous manque, c’est une façon unifiée pour que nos services au niveau système communiquent. Considérez ces scénarios :
Pour tout cela, Varlink offre une solution convaincante. Il est local-first (idéal pour la latence), auto-documenté (idéal pour le débogage), et dispose d’un support système systemd natif (idéal pour la fiabilité).
Le fait que systemd lui-même utilise Varlink pour des services comme systemd-resolved et systemd-hostnamed veut dire que nous pouvons potentiellement intégrer directement les services système en utilisant le même protocole que pour nos propres services. C’est puissant.
Assez de théorie - mettons les mains dans le cambouis. J’ai créé une implémentation hello world simple pour tester, et je vais vous guider pour la construire à partir de zéro.
Le code source complet est disponible sur https://github.com/DownToZero-Cloud/varlink-helloworld.
D’abord, créez un nouveau projet Rust :
cargo new varlink-helloworld
cd varlink-helloworld
Nous devons maintenant ajouter nos dépendances. Ouvrez Cargo.toml et ajoutez :
[package]
name = "varlink-helloworld"
version = "0.1.0"
edition = "2024"
[dependencies]
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
zlink = { version = "0.2" }
Nous utilisons zlink, l’implémentation Rust moderne de Varlink. Elle est asynchrone par défaut et construite sur tokio, ce qui s’accorde parfaitement avec notre façon de construire des services chez DTZ. Nous avons aussi besoin de serde pour la sérialisation JSON (le format transport de Varlink) et de futures-util pour gérer les streams.
Maintenant la partie amusante - l’implémentation du service à partir de zéro. La beauté de zlink est qu’il n’y a pas besoin de génération de code ou de fichiers de définition d’interface séparés. Tout est défini directement en Rust, ce qui offre une prise en charge complète par l’IDE, vérifications de types et pas de magie au moment de la compilation.
Créez src/main.rs :
use serde::{Deserialize, Serialize};
use zlink::{
self, Call, Connection, ReplyError, Server, Service,
connection::Socket, service::MethodReply,
unix, varlink_service::Info,
};
const SOCKET_PATH: &str = "/tmp/hello.varlink";
#[tokio::main]
async fn main() {
println!("starting varlink hello world server");
run_server().await;
}
pub async fn run_server() {
// Nettoyer un éventuel fichier socket existant
let _ = tokio::fs::remove_file(SOCKET_PATH).await;
// Lier au socket Unix
let listener = unix::bind(SOCKET_PATH).unwrap();
// Créer notre service et notre serveur
let service = HelloWorld {};
let server = Server::new(listener, service);
match server.run().await {
Ok(_) => println!("server done."),
Err(e) => println!("server error: {:?}", e),
}
}
C’est notre point d’entrée – simple et clair. On se lie à un socket Unix à /tmp/hello.varlink, crée notre service, et laisse le serveur gérer les connexions entrantes.
Voici où l’élégance de Varlink se révèle. Nous définissons notre protocole entièrement avec des types Rust annotés par serde. Regardons chaque partie :
Appels de méthode (requêtes entrantes)
#[derive(Debug, Deserialize)]
#[serde(tag = "method")]
enum HelloWorldMethod {
#[serde(rename = "rocks.dtz.HelloWorld.Hello")]
Hello,
#[serde(rename = "rocks.dtz.HelloWorld.NamedHello")]
NamedHello {
#[serde(default)]
parameters: NamedHelloParameters,
},
#[serde(rename = "org.varlink.service.GetInfo")]
VarlinkGetInfo,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct NamedHelloParameters {
name: String,
}
L’enum HelloWorldMethod représente toutes les méthodes que notre service peut gérer. L’attribut #[serde(tag = "method")] ordonne à serde d’utiliser le champ JSON method pour déterminer la variante à désérialiser. Les attributs #[serde(rename = "...")] font correspondre nos variants Rust aux noms réels des méthodes Varlink.
Notez que NamedHello contient un champ imbriqué parameters – cela correspond au protocole Varlink où les paramètres d’une méthode sont dans un objet parameters en JSON.
Réponses (réponses sortantes)
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum HelloWorldReply {
Hello(HelloResponse),
VarlinkInfo(Info<'static>),
}
#[derive(Debug, Serialize)]
pub struct HelloResponse {
message: String,
}
L’enum de réponses utilise #[serde(untagged)] car les réponses Varlink n’incluent pas de discriminateur de type – le type de réponse est implicite selon la méthode appelée. HelloResponse est notre struct simple contenant uniquement un champ message.
Gestion des erreurs
#[derive(Debug, ReplyError)]
#[zlink(interface = "rocks.dtz.HelloWorld")]
enum HelloWorldError {
Error { message: String },
}
Le macro #[derive(ReplyError)] de zlink génère le code nécessaire pour sérialiser nos erreurs selon le format Varlink. L’attribut #[zlink(interface = "...")] spécifie l’interface à laquelle ces erreurs appartiennent.
On relie tout cela en implémentant le trait Service :
struct HelloWorld {}
impl Service for HelloWorld {
type MethodCall<'de> = HelloWorldMethod;
type ReplyParams<'ser> = HelloWorldReply;
type ReplyStreamParams = ();
type ReplyStream = futures_util::stream::Empty<zlink::Reply<()>>;
type ReplyError<'ser> = HelloWorldError;
async fn handle<'ser, 'de: 'ser, Sock: Socket>(
&'ser mut self,
call: Call<Self::MethodCall<'de>>,
_conn: &mut Connection<Sock>,
) -> MethodReply<Self::ReplyParams<'ser>, Self::ReplyStream, Self::ReplyError<'ser>> {
println!("handling call: {:?}", call.method());
match call.method() {
HelloWorldMethod::Hello => {
MethodReply::Single(Some(HelloWorldReply::Hello(HelloResponse {
message: "Hello, World!".to_string(),
})))
}
HelloWorldMethod::NamedHello { parameters } => {
MethodReply::Single(Some(HelloWorldReply::Hello(HelloResponse {
message: format!("Hello, {}!", parameters.name),
})))
}
HelloWorldMethod::VarlinkGetInfo => {
MethodReply::Single(Some(HelloWorldReply::VarlinkInfo(Info::<'static> {
vendor: "DownToZero",
product: "hello-world",
url: "https://github.com/DownToZero-Cloud/varlink-helloworld",
interfaces: vec!["rocks.dtz.HelloWorld", "org.varlink.service"],
version: "1.0.0",
})))
}
}
}
}
Le trait Service est le cœur de zlink. Voici ce qui se passe :
Types associés : On déclare les types utilisés pour les appels, les réponses, les streams de réponse, et les erreurs. Cela garantit une sécurité de type complète.
La méthode handle : C’est ici que tous les appels entrants sont dispatchés. On fait un pattern match sur l’appel désérialisé et on renvoie la réponse adéquate.
MethodReply::Single : Pour les réponses non-stream, on enveloppe la réponse dans MethodReply::Single. Varlink supporte aussi les réponses streamées (utile pour le monitoring ou les abonnements), mais on garde ça simple ici.
VarlinkGetInfo : Chaque service Varlink doit implémenter la méthode org.varlink.service.GetInfo. Elle renvoie des métadonnées sur notre service – vendeur, nom du produit, version, URL, et la liste d’interfaces implémentées.
Démarrez le serveur :
cargo run
Vous devriez voir :
starting varlink hello world server
Maintenant, dans un autre terminal, vous pouvez le tester avec varlinkctl, inclus dans systemd. D’abord, regardons ce que le service expose :
varlinkctl info /tmp/hello.varlink
Sortie :
Vendor: DownToZero
Product: hello-world
Version: 1.0.0
URL: https://github.com/DownToZero-Cloud/varlink-helloworld
Interfaces: org.varlink.service
rocks.dtz.HelloWorld
C’est la nature auto-descriptive de Varlink en action. Le client peut découvrir exactement ce que le service offre.
Appelons maintenant nos méthodes :
varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.Hello {}
Sortie :
{
"message" : "Hello, World!"
}
Et avec un paramètre :
varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.NamedHello '{"name":"jens"}'
Sortie :
{
"message" : "Hello, jens!"
}
Ça marche ! Nous avons un service Varlink pleinement fonctionnel.
Une chose que j’adore avec Varlink, c’est la facilité d’exploration et de débogage. Le protocole utilisant JSON, vous pouvez même utiliser des outils basiques comme socat ou netcat pour des tests manuels :
echo '{"method":"rocks.dtz.HelloWorld.Hello","parameters":{}}' | \
socat - UNIX-CONNECT:/tmp/hello.varlink
Vous recevrez une réponse JSON que vous pouvez passer dans jq ou lire directement. Pas besoin d’outils spéciaux, pas de protocoles binaires à décoder. Quand vous déboguez à 2h du matin et que ça coince, cette simplicité est précieuse.
Vous pouvez aussi introspecter la définition de l’interface elle-même :
varlinkctl introspect /tmp/hello.varlink rocks.dtz.HelloWorld
Cela retourne la définition exacte écrite précédemment. Combiné à la commande info, vous avez une visibilité complète sur ce que tout service Varlink peut faire – même ceux que vous n’avez jamais vus.
L’une des fonctionnalités les plus puissantes de Varlink est son intégration avec systemd. Vous pouvez créer des services activés par socket qui ne démarrent que lorsqu’une connexion est établie, et systemd gère le cycle de vie.
Créez une unité socket systemd (hello-varlink.socket) :
[Unit]
Description=Hello World Varlink Socket
[Socket]
ListenStream=/run/hello.varlink
[Install]
WantedBy=sockets.target
Et une unité service correspondante (hello-varlink.service) :
[Unit]
Description=Hello World Varlink Service
[Service]
ExecStart=/usr/local/bin/varlink-helloworld
Avec l’activation par socket, systemd écoute sur le socket, et quand une connexion arrive, il démarre votre service et lui transmet le socket. Cela signifie aucune consommation de ressources tant que personne n’utilise le service – parfait pour notre philosophie scale-to-zero chez DTZ.
Mais il y a plus avec systemd. Plusieurs composants systemd exposent déjà des interfaces Varlink :
Cela signifie que nous pouvons utiliser les mêmes patterns Varlink que nous développons pour nos propres services pour interagir avec le système hôte. Vous voulez interroger le cache DNS ? varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name":"example.com"}'. Même protocole, mêmes outils, même modèle mental.
Pour DTZ, c’est particulièrement enthousiasmant car cela signifie que notre couche d’orchestration peut utiliser une approche unifiée pour la communication IPC applicative ET la gestion système. Fini les changements de contexte entre différentes APIs et protocoles.
Cette expérience hello world m’enthousiasme réellement sur le potentiel de Varlink pour DTZ. Voici quelques pistes que j’envisage :
Service de configuration machine : Un service Varlink exposant les réglages machine (config réseau, limites ressources, etc.) avec contrôle d’accès.
IPC orchestration conteneurs : Utiliser Varlink pour la communication entre notre runtime conteneur et les services de gestion.
Agrégation observabilité : Un service Varlink local qui agrège les métriques des différents composants système.
Intégration systemd : Interroger directement les interfaces Varlink de systemd pour le statut et la gestion des services.
Agrégation des contrôles de santé : Un service Varlink central qui collecte l’état de santé de tous nos services en cours et expose un endpoint unifié.
Le fait que nous puissions utiliser le même protocole pour parler à nos propres services ET aux services systèmes comme systemd-resolved est une grosse victoire pour la cohérence et la réduction de complexité.
Je suis aussi curieux des performances. Bien que JSON ne soit pas le format le plus compact, pour de l’IPC local le surcoût de parsing est habituellement négligeable comparé aux avantages de lisibilité humaine. Cela dit, je prévois un benchmarking dans une expérience future pour avoir des chiffres précis sur latence et débit dans nos cas d’usage.
Varlink atteint un équilibre parfait entre simplicité et capacité. Il ne cherche pas à résoudre tous les problèmes des systèmes distribués – il se concentre sur faire de l’IPC local très bien, avec juste assez de fonctionnalités pour la découverte et la sécurité de type.
Pour DownToZero, où nous optimisons constamment efficacité et simplicité, cette approche résonne fortement. Nous n’avons pas besoin de la complexité de gRPC pour la communication locale. Nous ne voulons pas la surcharge HTTP pour les appels internes machine. Varlink nous offre un protocole propre, bien conçu, qui s’intègre bien à l’écosystème Linux sur lequel nous construisons.
Si vous êtes tentés d’expérimenter vous-même, prenez le code sur GitHub, ouvrez votre éditeur, et essayez. La courbe d’apprentissage est douce, et c’est très satisfaisant de voir votre premier varlinkctl call renvoyer votre message.
Bonnes expérimentations !