Experimentieren mit Varlink: Aufbau eines Hello-World-IPC-Dienstes

created: Mittwoch, Dez. 3, 2025

Wenn du schon einmal versucht hast, verschiedene Prozesse auf einem Linux-System effizient miteinander kommunizieren zu lassen, kennst du die Landschaft… sagen wir mal, fragmentiert. Wir haben D-Bus, Unix-Sockets mit eigenen Protokollen, REST-APIs über localhost, gRPC und unzählige andere Ansätze. Jeder bringt seine eigene Komplexität, Tooling-Anforderungen und Lernkurve mit sich.

Kürzlich bin ich über Varlink gestolpert (und dessen neueren, Rust-zentrierten Verwandten Zlink), und es hat sofort mit dem übereingestimmt, was wir bei DownToZero erreichen wollen. Wir bauen Infrastruktur, die zuverlässige, latenzarme Interprozesskommunikation benötigt – von Container-Orchestrierung bis zu Maschinenkonfigurationsverwaltung. Je mehr ich mich mit Varlink beschäftigte, desto mehr wurde mir klar, dass das genau das sein könnte, was wir brauchen.

In diesem Beitrag führe ich dich durch meine Experimente mit Varlink. Wir bauen gemeinsam Schritt für Schritt einen einfachen Hello-World-Dienst und ich teile meine Gedanken dazu, warum mich diese Technologie für die Zukunft unserer Plattform begeistert.

Varlink ist ein Interface-Beschreibungsformat und ein Protokoll, das zum Definieren und Implementieren von Service-Interfaces entwickelt wurde. Denk daran als eine einfachere, modernere Alternative zu D-Bus oder als eine leichtere Alternative zu gRPC für lokale Kommunikation.

Wenn du schon mit D-Bus gearbeitet hast, kennst du wahrscheinlich den Schmerz: komplexe Typsysteme, Introspektion, die spezielle Werkzeuge erfordert, XML-Konfigurationsdateien, die eher verwirren als helfen. D-Bus ist mächtig, aber er stammt aus einer Zeit, in der “einfach” nicht Priorität war. Varlink verfolgt einen anderen Ansatz – so könnte D-Bus aussehen, wenn es heute mit modernen Entwicklererfahrungen entworfen würde.

Das macht Varlink interessant:

  1. Selbstbeschreibende Schnittstellen: Jeder Varlink-Dienst kann seine eigene API beschreiben. Du kannst dich mit einem Dienst verbinden und fragen “was kannst du?” und eine maschinen- (und menschen-) lesbare Antwort erhalten.

  2. Sprachneutral: Das Protokoll ist so einfach, dass Implementierungen in Rust, Go, Python, C und mehr existieren. Wichtiger ist, dass die Schnittstellen selbst sprachneutral sind.

  3. Socket-basiert: Die Kommunikation erfolgt über Unix-Sockets (oder TCP für entfernte Verbindungen), was bedeutet, dass es gut mit dem Linux-Ökosystem, Containern und systemd zusammenarbeitet.

  4. JSON-basiertes Protokoll: Das Wire-Format ist JSON, was das Debuggen trivial macht. Du kannst buchstäblich netcat verwenden, um mit einem Varlink-Dienst zu sprechen, wenn du willst.

  5. systemd-Integration: Das ist riesig. systemd verwendet Varlink bereits für einige seiner internen Dienste, was bedeutet, dass das Protokoll erprobt ist und native Unterstützung für Socket-Aktivierung und Dienstmanagement hat.

Bei DTZ beschäftigen wir uns ständig mit Maschinenkonfiguration und Orchestrierung. Unsere Infrastruktur umfasst physische Hardware (du erinnerst dich vielleicht an unsere solarbetriebenen Knoten), Container und verschiedene Systemdienste, die koordiniert werden müssen.

Derzeit nutzen wir eine Mischung aus Ansätzen für Interprozesskommunikation:

Was uns fehlt, ist ein einheitlicher Weg, wie unsere systemnahen Dienste kommunizieren. Betrachte diese Szenarien:

Für all das bietet Varlink eine überzeugende Lösung. Es ist lokal-zentriert (super für Latenz), selbstdokumentierend (gut für Debugging) und hat native systemd-Unterstützung (gut für Zuverlässigkeit).

Die Tatsache, dass systemd selbst Varlink für Dienste wie systemd-resolved und systemd-hostnamed verwendet, bedeutet, dass wir uns potenziell direkt mit Systemdiensten über dasselbe Protokoll verbinden können, das wir auch für unsere eigenen Dienste nutzen. Das ist mächtig.

Lass uns etwas bauen: Ein Hello-World-Service

Genug Theorie – jetzt wird’s praktisch. Ich habe eine einfache Hello-World-Implementierung erstellt, um die Sache zu testen, und führe dich durch den Aufbau von Grund auf.

Der komplette Quellcode ist verfügbar unter https://github.com/DownToZero-Cloud/varlink-helloworld.

Schritt 1: Projekt einrichten

Zuerst ein neues Rust-Projekt anlegen:

cargo new varlink-helloworld
cd varlink-helloworld

Jetzt müssen wir unsere Abhängigkeiten hinzufügen. Öffne Cargo.toml und füge hinzu:

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

Wir verwenden zlink, die moderne Rust-Implementierung von Varlink. Sie ist async-first und auf tokio aufgebaut, was perfekt zu unserer Art passt, Dienste bei DTZ zu bauen. Wir brauchen außerdem serde für die JSON-Serialisierung (Varlinks Wire-Format) und futures-util für Stream-Verarbeitung.

Schritt 2: Implementierung des Dienstes

Jetzt zum spaßigen Teil – wir implementieren unseren Dienst von Grund auf. Die Schönheit von zlink ist, dass wir keine Codegenerierung oder separaten Schnittstellendefinitionsdateien benötigen. Wir definieren alles direkt in Rust, was volle IDE-Unterstützung, Typprüfung und keine Build-Zeit-Magie bedeutet.

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

Das ist unser Einstiegspunkt – einfach und sauber. Wir binden an einen Unix-Socket unter /tmp/hello.varlink, erstellen unseren Dienst und lassen den Server eingehende Verbindungen bearbeiten.

Schritt 3: Definition der Nachrichtentypen

Hier zeigt sich Varlinks Eleganz. Wir definieren unser Protokoll vollständig mit Rust-Typen und serde-Annotationen. Schauen wir uns die einzelnen Teile an:

Method Calls (Eingehende Anfragen)

#[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,
}

Das HelloWorldMethod-Enum repräsentiert alle Methoden, die unser Dienst verarbeiten kann. Das Attribut #[serde(tag = "method")] sagt serde, dass das JSON-Feld method verwendet wird, um zu bestimmen, in welche Variante des Enums deserialisiert werden soll. Die #[serde(rename = "...")]-Attribute mappen unsere Rust-Enum-Varianten auf die tatsächlichen Varlink-Methodennamen.

Beachte, wie NamedHello ein verschachteltes parameters-Feld hat – das entspricht dem Varlink-Protokoll, bei dem Method Parameter in einem parameters-Objekt im JSON verpackt sind.

Replies (Ausgehende Antworten)

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

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

Das Reply-Enum verwendet #[serde(untagged)], weil Varlink-Antworten keinen Typ-Discriminator enthalten – der Antworttyp ergibt sich implizit aus der aufgerufenen Methode. HelloResponse ist unsere einfache Antwortstruktur mit einem message-Feld.

Fehlerbehandlung

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

Das #[derive(ReplyError)]-Macro von zlink erzeugt den notwendigen Code, um unsere Fehler im Varlink-Fehlerformat zu serialisieren. Das Attribut #[zlink(interface = "...")] gibt an, zu welchem Interface diese Fehler gehören.

Schritt 4: Implementierung des Service-Traits

Jetzt verbinden wir alles, indem wir das Service-Trait implementieren:

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

Das Service-Trait ist das Herzstück von zlink. Lassen wir kurz Revue passieren, was hier passiert:

  1. Assoziierte Typen: Wir deklarieren, welche Typen unser Dienst für Method Calls, Replies, Streaming-Antworten und Fehler verwendet. Das gibt uns vollständige Typsicherheit.

  2. Die handle-Methode: Hier werden alle eingehenden Aufrufe weitergeleitet. Wir matchen auf den deserialisierten Methodenaufruf und geben die passende Antwort zurück.

  3. MethodReply::Single: Für nicht-streamende Antworten verpacken wir unsere Antwort in MethodReply::Single. Varlink unterstützt auch Streaming-Antworten (nützlich für Monitoring oder Subscriptions), aber hier halten wir es einfach.

  4. VarlinkGetInfo: Jeder Varlink-Dienst sollte die Methode org.varlink.service.GetInfo implementieren. Diese gibt Metadaten über unseren Dienst zurück – Vendor, Produktname, Version, URL und die Liste der implementierten Interfaces.

Schritt 5: Ausführen und Testen

Starte den Server:

cargo run

Du solltest sehen:

starting varlink hello world server

Jetzt, in einem anderen Terminal, können wir es mit varlinkctl testen, das Teil von systemd ist. Zuerst schauen wir, was der Dienst anbietet:

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

Das ist die selbstbeschreibende Natur von Varlink in Aktion. Der Client kann genau entdecken, was dieser Dienst anbietet.

Nun rufen wir unsere Methoden auf:

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

Output:

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

Und mit einem Parameter:

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

Output:

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

Es funktioniert! Wir haben einen voll funktionsfähigen Varlink-Dienst.

Debugging und Erkundung

Eine Sache, die ich an Varlink liebe, ist wie einfach es ist, zu erkunden und zu debuggen. Da das Protokoll JSON-basiert ist, kannst du sogar grundlegende Tools wie socat oder netcat für manuelle Tests verwenden:

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

Du bekommst eine JSON-Antwort zurück, die du durch jq pipe’n oder einfach lesen kannst. Keine speziellen Debugging-Tools nötig, keine binären Protokolle zum Dekodieren. Wenn du um 2 Uhr morgens debuggen musst und etwas nicht funktioniert, ist diese Einfachheit unbezahlbar.

Du kannst auch die Schnittstellendefinition selbst introspektieren:

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

Das gibt die exakte Schnittstellendefinition zurück, die wir oben geschrieben haben. Kombiniert mit dem info-Befehl hast du vollständige Sichtbarkeit darüber, was jeder Varlink-Dienst tun kann – sogar Dienste, die du vorher noch nie gesehen hast.

Integration mit systemd

Eine der mächtigsten Eigenschaften von Varlink ist die Integration mit systemd. Du kannst socket-aktivierte Dienste erstellen, die erst starten, wenn sich jemand verbindet, und systemd übernimmt das Lifecycle-Management.

Erstelle eine systemd-Socket-Unit (hello-varlink.socket):

[Unit]
Description=Hello World Varlink Socket

[Socket]
ListenStream=/run/hello.varlink

[Install]
WantedBy=sockets.target

Und eine passende Service-Unit (hello-varlink.service):

[Unit]
Description=Hello World Varlink Service

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

Mit Socket-Aktivierung lauscht systemd auf dem Socket, und wenn eine Verbindung hereinkommt, startet es deinen Dienst und übergibt den Socket. Das bedeutet null Ressourcenverbrauch, bis jemand den Dienst tatsächlich benötigt – perfekt für unsere Scale-to-Zero-Philosophie bei DTZ.

Aber die systemd-Geschichte geht weiter. Mehrere systemd-Komponenten stellen bereits Varlink-Interfaces bereit:

Das bedeutet, wir können genau dieselben Varlink-Patterns, die wir für unsere Dienste entwickeln, verwenden, um mit dem Hostsystem zu interagieren. DNS-Cache abfragen? varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name":"example.com"}'. Gleiches Protokoll, gleiche Tools, gleiches Denkmodell.

Für DTZ ist das besonders spannend, da unsere Orchestrierungsschicht einen einheitlichen Ansatz sowohl für anwendungsnahe IPC als auch für systemnahe Verwaltung verwenden kann. Kein Kontextwechsel mehr zwischen verschiedenen APIs und Protokollen.

Was kommt als Nächstes?

Dieses Hello-World-Experiment hat mich wirklich für Varlinks Potenzial bei DTZ begeistert. Hier sind einige Richtungen, die ich in Betracht ziehe:

  1. Maschinenkonfigurationsdienst: Ein Varlink-Dienst, der Maschinen-Einstellungen (Netzwerk, Ressourcenlimits usw.) mit angemessener Zugriffskontrolle exponiert.

  2. Container-Orchestrierungs-IPC: Varlink für die Kommunikation zwischen unserem Container-Runtime und Management-Diensten nutzen.

  3. Observability-Aggregation: Ein lokaler Varlink-Dienst, der Metriken aus verschiedenen Systemkomponenten aggregiert.

  4. Systemd-Integration: Direkte Abfragen von systemd-Varlink-Interfaces für Dienststatus und Management.

  5. Health-Check-Aggregation: Ein zentraler Varlink-Dienst, der den Gesundheitsstatus aller laufenden Dienste sammelt und einen einheitlichen Health-Endpunkt bereitstellt.

Die Tatsache, dass wir dasselbe Protokoll verwenden können, um mit unseren eigenen Diensten UND Systemdiensten wie systemd-resolved zu sprechen, ist ein großer Gewinn für Konsistenz und reduzierte Komplexität.

Ich bin auch neugierig auf die Leistungscharakteristik. JSON ist zwar nicht das kompakteste Wire-Format, aber für lokale IPC ist der Parsing-Overhead in der Regel gegenüber der Lesbarkeit vernachlässigbar. Dennoch habe ich vor, in einem Folgeexperiment Benchmarks zu Latenz und Durchsatz für unsere Anwendungsfälle zu erstellen.

Fazit

Varlink trifft einen sweet spot zwischen Einfachheit und Leistungsfähigkeit. Es versucht nicht, jedes verteilte Systemproblem zu lösen – es konzentriert sich darauf, lokale IPC wirklich gut zu machen, mit gerade genug Features für Discoverability und Typsicherheit.

Für DownToZero, wo wir ständig auf Effizienz und Einfachheit optimieren, spricht dieser Ansatz sehr an. Wir brauchen nicht die Komplexität von gRPC für lokale Kommunikation. Wir wollen nicht den Overhead von HTTP für maschineninterne Aufrufe. Varlink bietet uns ein sauberes, gut designtes Protokoll, das hervorragend mit dem Linux-Ökosystem zusammenarbeitet, auf dem wir bauen.

Wenn du selbst experimentieren möchtest, schnapp dir den Code von GitHub, öffne deinen Editor und probiere es aus. Die Lernkurve ist sanft, und es macht Freude, den ersten varlinkctl call die eigene Nachricht zurückgeben zu sehen.

Viel Spaß beim Experimentieren!

Ressourcen