Sperimentare con Varlink: Costruire un servizio IPC Hello World

created: mercoledì, dic 3, 2025

Se hai mai provato a far comunicare processi diversi su un sistema Linux in modo efficiente, sai che il panorama è… diciamo, frammentato. Abbiamo D-Bus, socket Unix con protocolli personalizzati, API REST su localhost, gRPC e innumerevoli altri approcci. Ognuno comporta la propria complessità, requisiti di tooling e curva di apprendimento.

Recentemente ho incontrato Varlink (e il suo più recente fratello Rust-centrico Zlink), e mi ha immediatamente convinto rispetto a ciò che stiamo cercando di ottenere a DownToZero. Stiamo costruendo infrastrutture che necessitano di comunicazione inter-processo affidabile e a basso overhead — dall’orchestrazione dei container alla gestione delle impostazioni macchina. Più approfondivo Varlink, più ho realizzato che potrebbe essere esattamente ciò di cui abbiamo bisogno.

In questo post ti mostrerò i miei esperimenti con Varlink. Costruiremo insieme un semplice servizio hello world, passo dopo passo, e condividerò le mie riflessioni sul perché questa tecnologia mi entusiasma per il futuro della nostra piattaforma.

Varlink è un formato di descrizione delle interfacce e un protocollo progettato per definire e implementare interfacce di servizio. Pensalo come un’alternativa più semplice e moderna a D-Bus, o come un’alternativa più leggera a gRPC per la comunicazione locale.

Se hai lavorato con D-Bus in passato, conosci probabilmente il problema: sistemi di tipi complessi, introspezione che richiede strumenti speciali, file di configurazione XML che sembrano fatti apposta per confondere. D-Bus è potente, ma è anche di un’epoca in cui “semplice” non era una priorità di design. Varlink prende un approccio diverso — è come potrebbe apparire D-Bus se fosse progettato oggi, con sensibili attenzioni moderne all’esperienza dello sviluppatore.

Ecco cosa rende Varlink interessante:

  1. Interfacce autodescrittive: Ogni servizio Varlink può descrivere la propria API. Puoi connetterti a qualsiasi servizio e chiedere “cosa sai fare?” e ottenere una risposta leggibile sia dalla macchina che dall’umano.

  2. Neutrale rispetto al linguaggio: Il protocollo è abbastanza semplice da avere implementazioni in Rust, Go, Python, C e altri. Ma, cosa importante, le interfacce stesse sono indipendenti dal linguaggio.

  3. Basato su socket: La comunicazione avviene su socket Unix (o TCP per connessioni remote), il che significa che si integra bene con l’ecosistema Linux, i container e systemd.

  4. Protocollo basato su JSON: Il formato wire è JSON, il che rende il debug banale. Puoi letteralmente usare netcat per parlare con un servizio Varlink, se lo desideri.

  5. Integrazione con systemd: Questo è enorme. systemd usa già Varlink per alcuni dei suoi servizi interni, il che significa che il protocollo è collaudato e ha supporto di prima parte per socket activation e gestione dei servizi.

Noi di DTZ ci occupiamo costantemente di configurazione e orchestrazione a livello macchina. La nostra infrastruttura spazia da hardware fisico (potresti ricordare i nostri nodi alimentati a energia solare), container e vari servizi di sistema che devono coordinarsi.

Attualmente abbiamo un mix di approcci per la comunicazione inter-processo:

Quello che ci manca è un modo unificato per far comunicare i nostri servizi a livello di sistema. Considera questi scenari:

Per tutti questi casi, Varlink offre una soluzione convincente. È local-first (ottimo per la latenza), autodescrittivo (ottimo per il debug) e ha supporto nativo per systemd (ottimo per l’affidabilità).

Il fatto che lo stesso systemd utilizzi Varlink per servizi come systemd-resolved e systemd-hostnamed significa che possiamo potenzialmente integrare direttamente i servizi di sistema usando lo stesso protocollo che usiamo per i nostri servizi. Questo è potente.

Costruiamo qualcosa: un servizio Hello World

Basta teoria — mettiamoci al lavoro. Ho creato una semplice implementazione hello world per testare il terreno, e ti guiderò nella costruzione da zero.

Il codice sorgente completo è disponibile su https://github.com/DownToZero-Cloud/varlink-helloworld.

Step 1: Creare il progetto

Per prima cosa, crea un nuovo progetto Rust:

cargo new varlink-helloworld
cd varlink-helloworld

Ora dobbiamo aggiungere le dipendenze. Apri Cargo.toml e aggiungi:

[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" }

Stiamo usando zlink, l’implementazione moderna di Varlink in Rust. È async-first e costruita su tokio, il che si adatta perfettamente al modo in cui costruiamo i servizi in DTZ. Abbiamo anche bisogno di serde per la serializzazione JSON (il formato wire di Varlink) e di futures-util per la gestione degli stream.

Step 2: Implementare il servizio

Ora la parte divertente - implementare il nostro servizio da zero. La bellezza di zlink è che non abbiamo bisogno di generazione di codice o di file di definizione delle interfacce separati. Definiamo tutto direttamente in Rust, il che significa pieno supporto dell’IDE, controllo dei tipi e niente magia a build-time.

Crea 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),
    }
}

Questo è il nostro punto d’ingresso — semplice e pulito. Effettuiamo il bind su un socket Unix in /tmp/hello.varlink, creiamo il nostro servizio e lasciamo che il server gestisca le connessioni in arrivo.

Step 3: Definire i tipi di messaggio

Qui emerge l’eleganza di Varlink. Definiamo il nostro protocollo interamente usando tipi Rust con annotazioni serde. Vediamo ogni pezzo:

Chiamate di metodo (richieste in ingresso)

#[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 rappresenta tutti i metodi che il nostro servizio può gestire. L’attributo #[serde(tag = "method")] dice a serde di usare il campo JSON method per determinare in quale variante deserializzare. Gli attributi #[serde(rename = "...")] mappano le varianti dell’enum Rust ai nomi reali dei metodi Varlink.

Nota come NamedHello abbia un campo annidato parameters — questo corrisponde al protocollo Varlink in cui i parametri del metodo sono avvolti in un oggetto parameters nel JSON.

Risposte (risposte in uscita)

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

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

L’enum di risposta usa #[serde(untagged)] perché le risposte Varlink non includono un discriminatore di tipo — il tipo di risposta è implicito in base al metodo chiamato. HelloResponse è la nostra semplice struct di risposta contenente solo un campo message.

Gestione degli errori

#[derive(Debug, ReplyError)]
#[zlink(interface = "rocks.dtz.HelloWorld")]
enum HelloWorldError {
    Error { message: String },
}

La macro #[derive(ReplyError)] di zlink genera il codice necessario per serializzare i nostri errori secondo il formato Varlink. L’attributo #[zlink(interface = "...")] specifica a quale interfaccia appartengono questi errori.

Step 4: Implementare il trait Service

Ora colleghiamo il tutto implementando il 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",
                })))
            }
        }
    }
}

Il trait Service è il cuore di zlink. Vediamo cosa succede:

  1. Tipi associati: Dichiariamo quali tipi il nostro servizio usa per le chiamate di metodo, le risposte, le risposte in streaming e gli errori. Questo ci dà completa sicurezza di tipo.

  2. Il metodo handle: Qui vengono instradate tutte le chiamate in ingresso. Facciamo pattern matching sulla chiamata deserializzata e restituiamo la risposta appropriata.

  3. MethodReply::Single: Per le risposte non in streaming avvolgiamo la nostra risposta in MethodReply::Single. Varlink supporta anche risposte in streaming (utile per monitoring o subscription), ma qui restiamo semplici.

  4. VarlinkGetInfo: Ogni servizio Varlink dovrebbe implementare il metodo org.varlink.service.GetInfo. Questo restituisce i metadata del nostro servizio — vendor, nome prodotto, versione, URL e la lista di interfacce che implementiamo.

Step 5: Eseguire e testare

Avvia il server:

cargo run

Dovresti vedere:

starting varlink hello world server

Ora, in un altro terminale, possiamo testarlo usando varlinkctl, che fa parte di systemd. Per prima cosa, vediamo cosa espone il servizio:

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

Questa è la natura autodescrittiva di Varlink in azione. Il client può scoprire esattamente cosa offre questo servizio.

Ora chiamiamo i nostri metodi:

varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.Hello {}

Output:

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

E con un parametro:

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

Output:

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

Funziona! Abbiamo un servizio Varlink pienamente funzionante.

Debugging ed esplorazione

Una cosa che adoro di Varlink è quanto sia facile esplorare e fare debugging. Poiché il protocollo è basato su JSON, puoi anche usare strumenti di base come socat o netcat per i test manuali:

echo '{"method":"rocks.dtz.HelloWorld.Hello","parameters":{}}' | \
  socat - UNIX-CONNECT:/tmp/hello.varlink

Riceverai indietro una risposta JSON che puoi passare a jq o leggere direttamente. Nessun tool di debug speciale necessario, nessun protocollo binario da decodificare. Quando stai facendo debug alle 2 di notte e qualcosa non funziona, questa semplicità è inestimabile.

Puoi anche introspezionare la definizione dell’interfaccia stessa:

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

Questo restituisce la definizione esatta dell’interfaccia che abbiamo scritto prima. Combinato con il comando info, hai completa visibilità su cosa può fare qualsiasi servizio Varlink — anche servizi che non hai mai visto prima.

Integrazione con systemd

Una delle caratteristiche più potenti di Varlink è la sua integrazione con systemd. Puoi creare servizi socket-activated che partono solo quando qualcuno si connette, e systemd gestisce il ciclo di vita.

Crea un’unità socket systemd (hello-varlink.socket):

[Unit]
Description=Hello World Varlink Socket

[Socket]
ListenStream=/run/hello.varlink

[Install]
WantedBy=sockets.target

E un’unità service corrispondente (hello-varlink.service):

[Unit]
Description=Hello World Varlink Service

[Service]
ExecStart=/usr/local/bin/varlink-helloworld

Con la socket activation, systemd ascolta sul socket e quando arriva una connessione avvia il tuo servizio e gli passa il socket. Questo significa consumo di risorse pari a zero finché qualcuno non ha realmente bisogno del servizio — perfetto per la nostra filosofia scale-to-zero a DTZ.

Ma la storia con systemd non finisce qui. Diversi componenti di systemd espongono già interfacce Varlink:

Questo significa che possiamo usare gli stessi pattern Varlink che sviluppiamo per i nostri servizi per interagire con il sistema host. Vuoi interrogare la cache DNS? varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name":"example.com"}'. Stesso protocollo, stessi strumenti, stesso modello mentale.

Per DTZ, questo è particolarmente entusiasmante perché significa che il nostro livello di orchestrazione può usare un approccio unificato sia per l’IPC a livello applicativo che per la gestione a livello di sistema. Niente più cambi di contesto tra API e protocolli diversi.

Quali sono i prossimi passi?

Questo esperimento hello world mi ha realmente convinto del potenziale di Varlink per DTZ. Ecco alcune direzioni che sto considerando:

  1. Servizio di configurazione macchina: un servizio Varlink che espone impostazioni macchina (configurazione di rete, limiti di risorse, ecc.) con adeguato controllo degli accessi.

  2. IPC per orchestrazione dei container: usare Varlink per la comunicazione tra il nostro runtime di container e i servizi di gestione.

  3. Aggregazione per l’osservabilità: un servizio Varlink locale che aggrega metriche da vari componenti di sistema.

  4. Integrazione con systemd: interrogare direttamente le interfacce Varlink di systemd per stato e gestione dei servizi.

  5. Aggregazione dei controlli di health: un servizio Varlink centrale che raccoglie lo stato di salute da tutti i servizi in esecuzione ed espone un’unica endpoint di health.

Il fatto che possiamo usare lo stesso protocollo per parlare sia con i nostri servizi sia con servizi di sistema come systemd-resolved è una grande vittoria in termini di coerenza e riduzione della complessità.

Sono anche curioso delle caratteristiche prestazionali. Sebbene JSON non sia il formato wire più compatto, per l’IPC locale l’overhead di parsing è tipicamente trascurabile rispetto ai benefici della leggibilità umana. Detto ciò, pianifico di fare alcuni benchmark in un esperimento successivo per ottenere numeri reali su latenza e throughput per i nostri casi d’uso.

Per concludere

Varlink trova un ottimo equilibrio tra semplicità e capacità. Non cerca di risolvere ogni problema dei sistemi distribuiti — si concentra nel fare molto bene l’IPC locale, con il giusto set di funzionalità per discoverability e sicurezza dei tipi.

Per DownToZero, dove ottimizziamo costantemente per efficienza e semplicità, questo approccio risuona fortemente. Non abbiamo bisogno della complessità di gRPC per la comunicazione locale. Non vogliamo il sovraccarico di HTTP per le chiamate interne alla macchina. Varlink ci offre un protocollo pulito e ben progettato che si integra perfettamente con l’ecosistema Linux su cui stiamo costruendo.

Se ti interessa sperimentare in prima persona, prendi il codice da GitHub, apri il tuo editor e prova. La curva di apprendimento è morbida, e c’è qualcosa di soddisfacente nel vedere quel primo varlinkctl call restituire il tuo messaggio.

Buona sperimentazione!

Risorse