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 sur localhost, gRPC, et d’innombrables autres approches. Chacune vient avec sa propre complexité, ses outils requis et sa courbe d’apprentissage.
Récemment, je suis tombé sur Varlink (et son jeune frère Rust-centric plus récent Zlink), et ça a immédiatement résonné avec ce que nous essayons d’accomplir chez DownToZero. Nous construisons une infrastructure qui a besoin d’une communication inter-processus fiable et peu coûteuse — de l’orchestration de conteneurs à la gestion des paramètres machine. Plus je creusais Varlink, plus je réalisais que cela pourrait être exactement ce dont nous avons besoin.
Dans cet article, je vous accompagne à travers mes expérimentations avec Varlink. Nous construirons ensemble un simple service hello world, étape par étape, 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 service. Pensez-y comme une alternative plus simple et moderne à D-Bus, ou une alternative plus 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 qui nécessite des outils spéciaux, fichiers de configuration XML qui semblent faits pour embrouiller. D-Bus est puissant, mais il vient aussi d’une époque où la simplicité n’était pas une priorité de conception. Varlink adopte une approche différente — c’est à quoi D-Bus pourrait ressembler s’il était conçu aujourd’hui, avec des sensibilités modernes axées sur l’expérience développeur.
Voici ce qui rend Varlink intéressant :
Interfaces auto-décrites : Chaque service Varlink peut décrire sa propre API. Vous pouvez vous connecter à n’importe quel service et demander « que pouvez-vous faire ? » et obtenir une réponse lisible par machine (et par humain).
Agnostique au langage : Le protocole est suffisamment simple pour que des implémentations existent en Rust, Go, Python, C, et plus encore. Mais surtout, les interfaces elles-mêmes sont neutres vis-à-vis du langage.
Basé sur les sockets : La communication se fait via des sockets Unix (ou TCP pour les connexions distantes), ce qui signifie qu’il s’intègre bien avec l’écosystème Linux, les conteneurs et systemd.
Protocole basé sur JSON : Le format sur le fil 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 avec systemd : C’est énorme. systemd utilise déjà Varlink pour certains de ses services internes, ce qui signifie que le protocole est éprouvé et dispose d’un support natif pour l’activation par socket et la gestion des services.
Chez DTZ, nous gérons constamment la configuration machine et l’orchestration. Notre infrastructure couvre du matériel physique (vous vous souvenez peut-être de nos nœuds alimentés par solaire), des conteneurs, et divers services système qui doivent se coordonner.
Actuellement, nous utilisons un mélange d’approches pour la communication inter-processus :
Ce qui nous manque, c’est une manière unifiée pour nos services de niveau système de communiquer. Considérez ces scénarios :
Pour tout cela, Varlink offre une solution convaincante. Il est local-first (parfait pour la latence), auto-documenté (parfait pour le débogage) et possède un support systemd natif (parfait pour la fiabilité).
Le fait que systemd lui-même utilise Varlink pour des services comme systemd-resolved et systemd-hostnamed signifie que nous pouvons potentiellement nous intégrer directement aux services système en utilisant le même protocole que celui utilisé pour nos propres services. C’est puissant.
Assez de théorie — mettons-nous au travail. J’ai créé une implémentation simple de hello world pour tester le concept, et je vous guide pas à pas pour la construire depuis zéro.
Le code source complet est disponible à l’adresse https://github.com/DownToZero-Cloud/varlink-helloworld.
D’abord, créez un nouveau projet Rust :
cargo new varlink-helloworld
cd varlink-helloworld
Maintenant, nous devons 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-first et construite sur tokio, ce qui s’accorde parfaitement avec la façon dont nous construisons des services chez DTZ. Nous avons aussi besoin de serde pour la sérialisation JSON (le format Varlink) et de futures-util pour la gestion des streams.
Passons à la partie amusante — implémenter notre service depuis zéro. La beauté de zlink est que nous n’avons pas besoin de génération de code ni de fichiers de définition d’interface séparés. Nous définissons tout directement en Rust, ce qui signifie un support IDE complet, le typage, et aucun bricolage à 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() {
// Clean up any existing socket file
let _ = tokio::fs::remove_file(SOCKET_PATH).await;
// Bind to the Unix socket
let listener = unix::bind(SOCKET_PATH).unwrap();
// Create our service and server
let service = HelloWorld {};
let server = Server::new(listener, service);
match server.run().await {
Ok(_) => println!("server done."),
Err(e) => println!("server error: {:?}", e),
}
}
Ceci est notre point d’entrée — simple et clair. Nous nous lions à un socket Unix sur /tmp/hello.varlink, créons notre service, et laissons le serveur gérer les connexions entrantes.
C’est ici que l’élégance de Varlink se manifeste. Nous définissons notre protocole entièrement en utilisant des types Rust avec des annotations serde. Regardons chaque partie :
Méthodes (Appels entrants)
#[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")] indique à serde d’utiliser le champ JSON method pour déterminer la variante dans laquelle désérialiser. Les attributs #[serde(rename = "...")] mappent nos variantes Rust aux noms réels des méthodes Varlink.
Remarquez comment NamedHello a un champ imbriqué parameters — cela correspond au protocole Varlink où les paramètres de méthode sont enveloppés dans un objet parameters dans le 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éponse utilise #[serde(untagged)] parce que les réponses Varlink n’incluent pas de discriminant de type — le type de réponse est implicite selon la méthode appelée. HelloResponse est notre struct de réponse 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 d’erreur Varlink. L’attribut #[zlink(interface = "...")] spécifie à quelle interface ces erreurs appartiennent.
Maintenant, nous relions tout 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. Décomposons ce qui se passe :
Types associés : Nous déclarons les types que notre service utilise pour les appels de méthode, les réponses, les réponses en streaming et les erreurs. Cela nous donne une sécurité de type complète.
La méthode handle : C’est ici que tous les appels entrants sont routés. Nous faisons un pattern match sur la méthode désérialisée et renvoyons la réponse appropriée.
MethodReply::Single : Pour les réponses non-streaming, nous enveloppons notre réponse dans MethodReply::Single. Varlink prend aussi en charge les réponses en streaming (utile pour la supervision ou les abonnements), mais nous gardons ça simple ici.
VarlinkGetInfo : Chaque service Varlink doit implémenter la méthode org.varlink.service.GetInfo. Cela renvoie des métadonnées sur notre service — vendor, nom du produit, version, URL, et la liste des interfaces que nous implémentons.
Démarrez le serveur :
cargo run
Vous devriez voir :
starting varlink hello world server
Maintenant, dans un autre terminal, nous pouvons le tester en utilisant varlinkctl, qui fait partie de systemd. D’abord, voyons 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
Ceci montre la nature auto-descriptive de Varlink en action. Le client peut découvrir exactement ce que ce 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 entièrement fonctionnel.
Une chose que j’adore avec Varlink, c’est la simplicité d’exploration et de débogage. Étant donné que le protocole est basé sur 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 à jq ou lire directement. Pas d’outils de débogage spéciaux nécessaires, pas de protocoles binaires à décoder. Quand vous déboguez à 2h du matin et que quelque chose ne marche pas, cette simplicité est inestimable.
Vous pouvez aussi introspecter la définition de l’interface elle-même :
varlinkctl introspect /tmp/hello.varlink rocks.dtz.HelloWorld
Cela renvoie la définition d’interface exacte que nous avons écrite plus tôt. Combiné avec la commande info, vous avez une visibilité complète sur ce qu’un service Varlink peut faire — même des services que vous n’avez jamais vus auparavant.
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’on se connecte, 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 lorsqu’une connexion arrive, il démarre votre service et lui transmet le socket. Cela signifie zéro utilisation de ressources jusqu’à ce que quelqu’un ait réellement besoin du service — parfait pour notre philosophie scale-to-zero chez DTZ.
Mais il y a plus dans l’histoire systemd. Plusieurs composants systemd exposent déjà des interfaces Varlink :
Cela signifie que nous pouvons utiliser exactement les mêmes modèles Varlink que nous développons pour nos 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 permet à notre couche d’orchestration d’utiliser une approche unifiée pour l’IPC au niveau applicatif et pour la gestion au niveau système. Fini le changement de contexte entre différentes API et protocoles.
Cette expérience hello world me rend vraiment enthousiaste quant au potentiel de Varlink pour DTZ. Voici quelques directions que j’envisage :
Service de configuration machine : Un service Varlink qui expose les paramètres machine (config réseau, limites de ressources, etc.) avec un contrôle d’accès approprié.
IPC pour l’orchestration de conteneurs : Utiliser Varlink pour la communication entre notre runtime de conteneurs et les services de gestion.
Agrégation d’observabilité : Un service Varlink local qui agrège les métriques de divers 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 d’exécution et expose un point d’accès santé unifié.
Le fait que nous puissions utiliser le même protocole pour parler à nos propres services ET aux services système comme systemd-resolved est un énorme avantage en termes de cohérence et de réduction de complexité.
Je suis aussi curieux des caractéristiques de performance. Bien que JSON ne soit pas le format le plus compact, pour l’IPC local le coût d’analyse est généralement négligeable par rapport aux bénéfices de lisibilité humaine. Cela dit, je prévois de faire des benchmarks dans une expérience de suivi pour obtenir des chiffres réels sur la latence et le débit pour nos cas d’utilisation.
Varlink trouve un excellent compromis entre simplicité et capacité. Il n’essaie pas de résoudre tous les problèmes des systèmes distribués — il se concentre sur la communication IPC locale en faisant très bien le travail, avec juste assez de fonctionnalités pour la découvrabilité et la sécurité de type.
Pour DownToZero, où nous optimisons constamment pour l’efficacité et la simplicité, cette approche résonne fortement. Nous n’avons pas besoin de la complexité de gRPC pour la communication locale. Nous ne voulons pas du surcoût de HTTP pour les appels internes aux machines. Varlink nous donne un protocole propre et bien conçu qui s’intègre bien avec l’écosystème Linux sur lequel nous bâtissons.
Si vous voulez expérimenter vous-même, récupérez le code depuis GitHub, ouvrez votre éditeur, et essayez. La courbe d’apprentissage est douce, et il y a quelque chose de satisfaisant à voir ce premier varlinkctl call retourner votre message.
Bonnes expérimentations !