Experimenting with Varlink: Building a Hello World IPC Service

created: mercredi, déc. 3, 2025

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 exigences en outils et sa courbe d’apprentissage.

Récemment, je suis tombé sur Varlink (et son homologue Rust plus récent Zlink), et cela a immédiatement résonné avec ce que nous essayons d’accomplir chez DownToZero. Nous construisons une infrastructure qui nécessite une communication inter-processus fiable et peu coûteuse en overhead — 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 cet article, je vous guide à travers mes expérimentations avec Varlink. Nous construirons ensemble un simple service hello world, étape par étape, et je partagerai mes réflexions sur les raisons pour lesquelles cette technologie me enthousiasme pour l’avenir de notre plateforme.

Varlink est un format de description d’interfaces et un protocole conçu pour définir et implémenter des interfaces de service. Pensez-y comme une alternative plus simple et moderne à D-Bus, ou comme une option plus légère que 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 qui semblent conçus 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 avait été conçu aujourd’hui, avec des considérations modernes pour l’expérience développeur.

Voici ce qui rend Varlink intéressant :

  1. 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 ? » et obtenir une réponse lisible par machine (et par humain).

  2. Agnostique au langage : Le protocole est suffisamment simple pour que des implémentations existent en Rust, Go, Python, C, et plus encore. Mais important : les interfaces elles-mêmes sont indépendantes du langage.

  3. Basé sur des sockets : La communication se fait via des sockets Unix (ou TCP pour des connexions distantes), ce qui signifie qu’il s’intègre bien à l’écosystème Linux, aux conteneurs et à systemd.

  4. Protocole basé sur JSON : Le format filaire est JSON, ce qui rend le débogage trivial. Vous pouvez littéralement utiliser netcat pour parler à un service Varlink si vous le souhaitez.

  5. Intégration avec systemd : C’est énorme. systemd utilise déjà Varlink pour certains de ses services internes, ce qui signifie que le protocole a été éprouvé et bénéficie d’un support natif pour l’activation par socket et la gestion des services.

Chez DTZ, nous traitons constamment de la configuration et de l’orchestration au niveau machine. 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 avons un mélange d’approches pour la communication inter-processus :

Ce qui nous manque, c’est une manière unifiée pour que nos services au niveau système communiquent. Considérez ces scénarios :

Pour tous ces cas, Varlink offre une solution convaincante. Il est local-first (excellent pour la latence), auto-documenté (excellent pour le débogage) et a un support natif systemd (excellent 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 intégrer directement les services système en utilisant le même protocole que pour nos propres services. C’est puissant.

Let’s Build Something: A Hello World Service

Assez de théorie — mettons-nous au travail. J’ai créé une implémentation simple hello world pour tester le terrain, et je vais vous guider pour la construire depuis zéro.

Le code source complet est disponible sur https://github.com/DownToZero-Cloud/varlink-helloworld.

Step 1: Setting Up the Project

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 orientée async et construite sur tokio, ce qui correspond parfaitement à la façon dont nous construisons des services chez DTZ. Nous avons aussi besoin de serde pour la sérialisation JSON (le format filaire de Varlink) et de futures-util pour la gestion des streams.

Step 2: Implementing the Service

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 ou de fichiers de définition d’interface séparés. Nous définissons tout directement en Rust, ce qui signifie un support IDE complet, du typage statique 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() {
    // 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 propre. Nous nous lions à un socket Unix sur /tmp/hello.varlink, créons notre service et laissons le serveur gérer les connexions entrantes.

Step 3: Defining the Message Types

C’est ici que l’élégance de Varlink se révèle. Nous définissons notre protocole entièrement en utilisant des types Rust avec des annotations serde. Regardons chaque partie :

Method Calls (Incoming Requests)

#[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’énumération 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 dans quelle variante désérialiser. Les attributs #[serde(rename = "...")] mappent nos variantes d’énumération Rust aux noms réels des méthodes Varlink.

Notez que NamedHello a un champ imbriqué parameters — cela correspond au protocole Varlink où les paramètres de méthode sont encapsulés dans un objet parameters dans le JSON.

Replies (Outgoing Responses)

#[derive(Debug, Serialize)]
#[serde(untagged)]
enum HelloWorldReply {
    Hello(HelloResponse),
    VarlinkInfo(Info<'static>),
}

#[derive(Debug, Serialize)]
pub struct HelloResponse {
    message: String,
}

L’énumération 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 juste un champ message.

Error Handling

#[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.

Step 4: Implementing the Service Trait

Maintenant, nous relions 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. Décomposons ce qui se passe :

  1. Types associés : Nous déclarons quels types 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.

  2. La méthode handle : C’est ici que tous les appels entrants sont routés. Nous faisons un pattern match sur l’appel de méthode désérialisé et renvoyons la réponse appropriée.

  3. MethodReply::Single : Pour les réponses non-streaming, nous enveloppons notre réponse dans MethodReply::Single. Varlink supporte aussi les réponses en streaming (utile pour le monitoring ou les abonnements), mais nous restons simples ici.

  4. VarlinkGetInfo : Chaque service Varlink devrait 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.

Step 5: Running and Testing

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

Output:

    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 est 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 {}

Output:

{
    "message" : "Hello, World!"
}

Et avec un paramètre :

varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.NamedHello '{"name":"jens"}'

Output:

{
    "message" : "Hello, jens!"
}

Ça marche ! Nous avons un service Varlink pleinement fonctionnel.

Debugging and Exploration

Une chose que j’adore avec Varlink, c’est la facilité d’exploration et de débogage. Comme 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 obtiendrez en retour une réponse JSON que vous pouvez passer dans jq ou simplement lire directement. Pas besoin d’outils de débogage spéciaux, pas de protocoles binaires à décoder. Quand vous debuggez à 2 h du matin et que quelque chose ne fonctionne pas, cette simplicité est inestimable.

Vous pouvez aussi introspecter la définition d’interface elle-même :

varlinkctl introspect /tmp/hello.varlink rocks.dtz.HelloWorld

Cela renvoie la définition exacte de l’interface que nous avons écrite plus tôt. Combiné avec la commande info, vous avez une visibilité complète sur ce que n’importe quel service Varlink peut faire — même les services que vous n’avez jamais vus auparavant.

Integrating with systemd

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 leur 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 tant que personne n’a 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 patterns Varlink que nous développons pour nos services afin d’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 au niveau application et la gestion au niveau système. Fini le changement de contexte entre différentes API et protocoles.

What’s Next?

Cette expérience hello world me rend vraiment enthousiaste quant au potentiel de Varlink pour DTZ. Voici quelques directions que j’envisage :

  1. 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é.

  2. IPC pour l’orchestration de conteneurs : Utiliser Varlink pour la communication entre notre runtime de conteneurs et les services de gestion.

  3. Agrégation d’observabilité : Un service Varlink local qui agrège des métriques de divers composants système.

  4. Intégration systemd : Interroger directement les interfaces Varlink de systemd pour l’état et la gestion des services.

  5. Agrégation des checks de santé : Un service Varlink central qui collecte le statut de santé de tous nos services en exécution et expose un endpoint de santé unifié.

Le fait que nous puissions utiliser le même protocole pour parler à nos propres services ET à des services système comme systemd-resolved est un énorme avantage pour la cohérence et la réduction de complexité.

Je suis aussi curieux des caractéristiques de performance. Bien que JSON ne soit pas le format filaire le plus compact, pour de l’IPC local le coût de parsing est généralement négligeable comparé 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’usage.

Wrapping Up

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 est concentré sur le fait de bien faire l’IPC local, avec juste ce qu’il faut 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 le surcoût de HTTP pour des appels internes aux machines. Varlink nous donne un protocole propre et bien conçu qui s’intègre bien à l’écosystème Linux sur lequel nous nous appuyons.

Si vous voulez expérimenter vous-même, récupérez le code sur GitHub, lancez votre éditeur et essayez. La courbe d’apprentissage est douce, et il y a quelque chose de satisfaisant à voir ce premier varlinkctl call vous renvoyer votre message.

Bonnes expériences !

Resources