Se avete mai provato a far comunicare tra loro processi diversi su un sistema Linux in modo efficiente, sapete che il panorama è… diciamo, frammentato. Abbiamo D-Bus, socket Unix con protocolli personalizzati, API REST su localhost, gRPC e innumerevoli altri approcci. Ognuno porta con sé la propria complessità, requisiti di tooling e curva di apprendimento.
Recentemente mi sono imbattuto in Varlink (e nel suo più recente fratello Rust-centrico Zlink), e mi è subito sembrato allinearsi con ciò che stiamo cercando di ottenere a DownToZero. Stiamo costruendo infrastrutture che richiedono 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 vi guiderò attraverso 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 pensato per definire e implementare interfacce di servizio. Pensatelo come un’alternativa più semplice e moderna a D-Bus, o come un’alternativa più leggera a gRPC per la comunicazione locale.
Se avete lavorato con D-Bus in passato, probabilmente conoscete il problema: sistemi di tipi complessi, introspezione che richiede strumenti speciali, file di configurazione XML che sembrano progettati per confondere. D-Bus è potente, ma proviene anche da un’epoca in cui la semplicità non era una priorità di design. Varlink adotta un approccio diverso - è quello che D-Bus potrebbe sembrare se fosse progettato oggi, con sensibilità moderne per l’esperienza dello sviluppatore.
Ecco cosa rende Varlink interessante:
Interfacce auto-descrittive: Ogni servizio Varlink può descrivere la propria API. Puoi connetterti a qualsiasi servizio e chiedere “cosa sai fare?” e ottenere una risposta leggibile da macchina (e anche da umano).
Indipendente dal linguaggio: Il protocollo è abbastanza semplice da avere implementazioni in Rust, Go, Python, C e altro. Ma soprattutto, le interfacce stesse sono neutrali rispetto al linguaggio.
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.
Protocollo basato su JSON: Il formato sul wire è JSON, il che rende il debugging banale. Letteralmente potete usare netcat per parlare con un servizio Varlink se volete.
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.
A DTZ, ci occupiamo costantemente di configurazione a livello macchina e orchestrazione. La nostra infrastruttura copre hardware fisico (potreste ricordare i nostri nodi alimentati a pannelli solari), 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. Considerate questi scenari:
Per tutti questi casi, Varlink offre una soluzione convincente. È local-first (ottimo per la latenza), auto-documentante (ottimo per il debugging) e ha supporto nativo per systemd (ottimo per l’affidabilità).
Il fatto che lo stesso systemd usi Varlink per servizi come systemd-resolved e systemd-hostnamed significa che potremmo integrare direttamente i servizi di sistema usando lo stesso protocollo che usiamo per i nostri servizi. È potente.
Basta teoria - sporchiamoci le mani. Ho creato una semplice implementazione hello world per testare il terreno e vi guiderò nella sua costruzione da zero.
Il codice sorgente completo è disponibile su https://github.com/DownToZero-Cloud/varlink-helloworld.
Per prima cosa, create un nuovo progetto Rust:
cargo new varlink-helloworld
cd varlink-helloworld
Ora dobbiamo aggiungere le nostre dipendenze. Aprite Cargo.toml e aggiungete:
[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 Rust moderna di Varlink. È async-first e costruita su tokio, il che si adatta perfettamente al modo in cui costruiamo servizi a DTZ. Abbiamo anche bisogno di serde per la serializzazione JSON (il formato sul wire di Varlink) e di futures-util per la gestione degli stream.
Ora la parte divertente - implementare il nostro servizio da zero. La bellezza di zlink è che non abbiamo bisogno di generazione di codice o file di definizione dell’interfaccia separati. Definiamo tutto direttamente in Rust, il che significa pieno supporto IDE, controllo dei tipi e niente magia a tempo di compilazione.
Create 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 di ingresso - semplice e pulito. Ci leghiamo a un socket Unix in /tmp/hello.varlink, creiamo il nostro servizio e lasciamo che il server gestisca le connessioni in arrivo.
Qui si vede l’eleganza di Varlink. Definiamo il nostro protocollo interamente usando tipi Rust con annotazioni serde. Guardiamo ogni pezzo:
Chiamate di metodo (richieste in arrivo)
#[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.
Notate come NamedHello abbia un campo annidato parameters - questo corrisponde al protocollo Varlink dove i parametri del metodo sono racchiusi 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 delle risposte 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 struttura di risposta che contiene 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 di errore Varlink. L’attributo #[zlink(interface = "...")] specifica a quale interfaccia appartengono questi errori.
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. Spezzettiamo cosa succede:
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 in tutto il flusso.
Il metodo handle: Qui vengono instradate tutte le chiamate in arrivo. Facciamo pattern matching sulla chiamata di metodo deserializzata e ritorniamo la risposta appropriata.
MethodReply::Single: Per risposte non in streaming, avvolgiamo la nostra risposta in MethodReply::Single. Varlink supporta anche risposte in streaming (utile per monitoraggio o sottoscrizioni), ma qui rimaniamo semplici.
VarlinkGetInfo: Ogni servizio Varlink dovrebbe implementare il metodo org.varlink.service.GetInfo. Questo ritorna i metadati del servizio - vendor, nome del prodotto, versione, URL e la lista delle interfacce implementate.
Avviate il server:
cargo run
Dovreste vedere:
starting varlink hello world server
Ora, in un altro terminale, possiamo testarlo usando varlinkctl, che fa parte di systemd. Prima, 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 auto-descrittiva 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.
Una cosa che adoro di Varlink è quanto sia facile esplorare e fare debugging. Poiché il protocollo è basato su JSON, potete anche usare strumenti di base come socat o netcat per test manuali:
echo '{"method":"rocks.dtz.HelloWorld.Hello","parameters":{}}' | \
socat - UNIX-CONNECT:/tmp/hello.varlink
Riceverete una risposta JSON che potete passare a jq o leggere direttamente. Nessun tool di debug speciale necessario, nessun protocollo binario da decodificare. Quando state facendo debugging alle 2 di notte e qualcosa non funziona, questa semplicità è inestimabile.
Potete anche ispezionare 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, avete visibilità completa su ciò che qualsiasi servizio Varlink può fare - anche servizi che non avete mai visto prima.
Una delle funzionalità più potenti di Varlink è la sua integrazione con systemd. Potete creare servizi attivati da socket che si avviano solo quando qualcuno si connette, e systemd gestisce il ciclo di vita.
Create un’unità socket systemd (hello-varlink.socket):
[Unit]
Description=Hello World Varlink Socket
[Socket]
ListenStream=/run/hello.varlink
[Install]
WantedBy=sockets.target
E l’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 vostro servizio e gli passa il socket. Questo significa zero utilizzo di risorse fino a quando qualcuno non ha effettivamente bisogno del servizio - perfetto per la nostra filosofia di scale-to-zero a DTZ.
Ma la storia systemd non finisce qui. Diversi componenti di systemd espongono già interfacce Varlink:
Questo significa che possiamo usare gli stessi pattern Varlink che stiamo sviluppando per i nostri servizi per interagire con il sistema host. Volete interrogare la cache DNS? varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name":"example.com"}'. Stesso protocollo, stesso tooling, 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 sia per la gestione a livello di sistema. Niente più cambio di contesto tra API e protocolli diversi.
Questo esperimento hello world mi ha davvero entusiasmato sulle potenzialità di Varlink per DTZ. Ecco alcune direzioni che sto considerando:
Servizio di configurazione macchina: Un servizio Varlink che espone impostazioni macchina (configurazione di rete, limiti di risorse, ecc.) con adeguato controllo degli accessi.
IPC per orchestrazione dei container: Usare Varlink per la comunicazione tra il nostro runtime container e i servizi di gestione.
Aggregazione per l’osservabilità: Un servizio Varlink locale che aggrega metriche da vari componenti di sistema.
Integrazione con systemd: Interrogare direttamente le interfacce Varlink di systemd per stato dei servizi e gestione.
Aggregazione dei controlli di integrità: Un servizio Varlink centrale che raccoglie lo stato di salute da tutti i servizi in esecuzione ed espone un endpoint unificato di health.
Il fatto che possiamo usare lo stesso protocollo per parlare sia con i nostri servizi che con servizi di sistema come systemd-resolved è un enorme vantaggio in termini di coerenza e riduzione della complessità.
Sono anche curioso delle caratteristiche di prestazione. Anche se JSON non è il formato più compatto, per l’IPC locale l’overhead di parsing è tipicamente trascurabile rispetto ai benefici della leggibilità umana. Detto questo, ho intenzione di fare qualche benchmark in un esperimento successivo per ottenere numeri reali su latenza e throughput per i nostri casi d’uso.
Varlink trova un punto d’equilibrio tra semplicità e capacità. Non cerca di risolvere ogni problema dei sistemi distribuiti - si concentra nel fare molto bene l’IPC locale, con giusto grado 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 l’overhead di HTTP per chiamate interne alla macchina. Varlink ci offre un protocollo pulito e ben progettato che si integra perfettamente con l’ecosistema Linux su cui costruire.
Se vi interessa sperimentare, prendete il codice da GitHub, aprite il vostro editor e provatelo. La curva di apprendimento è dolce, e c’è qualcosa di soddisfacente nel vedere quella prima varlinkctl call restituire il vostro messaggio.
Buona sperimentazione!