Experimenting with Varlink: Building a Hello World IPC Service

created: Mittwoch, Dez. 3, 2025

Wenn Sie schon einmal versucht haben, verschiedene Prozesse auf einem Linux-System effizient miteinander kommunizieren zu lassen, wissen Sie, dass die Landschaft… sagen wir mal, fragmentiert ist. 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, Werkzeuge Anforderungen und Lernkurve mit sich.

Vor Kurzem bin ich auf Varlink (und dessen neueren, Rust-zentrierten Verwandten Zlink) gestoßen, und es passte sofort zu dem, was wir bei DownToZero erreichen wollen. Wir bauen Infrastruktur, die zuverlässige, latenzarme Interprozesskommunikation braucht – von Container-Orchestrierung bis zur Verwaltung von Maschineneinstellungen. Je mehr ich mich mit Varlink beschäftigt habe, desto mehr wurde mir klar, dass genau das unser Bedarf sein könnte.

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

Varlink ist ein Schnittstellen-Beschreibungsformat und Protokoll, das zur Definition und Implementierung von Service-Schnittstellen gedacht ist. Man kann es als eine einfachere, modernere Alternative zu D-Bus betrachten oder als eine leichtere Alternative zu gRPC für lokale Kommunikation.

Wenn Sie schon mit D-Bus gearbeitet haben, kennen Sie vermutlich den Schmerz: komplexe Typensysteme, Introspektion, die spezielle Werkzeuge erfordert, XML-Konfigurationsdateien, die scheinbar darauf ausgelegt sind, zu verwirren. D-Bus ist mächtig, stammt aber aus einer Zeit, in der „einfach“ keine Designpriorität war. Varlink schlägt einen anderen Weg ein – es sieht so aus, wie D-Bus heute mit modernen Entwicklerfreundlichkeiten aussehen würde.

Das macht Varlink interessant:

  1. Selbstbeschreibende Schnittstellen: Jeder Varlink-Service kann seine eigene API beschreiben. Man kann sich mit jedem Service verbinden und fragen: „Was kannst du?“ und bekommt eine maschinenlesbare (und menschenlesbare) Antwort.

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

  3. Socket-basiert: Die Kommunikation läuft über Unix-Sockets (oder TCP für Remote-Verbindungen), was gute Integration in das Linux-Ökosystem, Container und systemd ermöglicht.

  4. JSON-basiertes Protokoll: Das Wire-Format ist JSON, was Debugging trivial macht. Man kann im Prinzip netcat nutzen, um mit einem Varlink-Service zu kommunizieren.

  5. Systemd-Integration: Ein großes Plus. systemd nutzt Varlink bereits für einige seiner internen Dienste, was bedeutet, dass das Protokoll erprobt ist und erste Unterstützung für Socket-Aktivierung und Service-Management bietet.

Bei DTZ befassen wir uns ständig mit maschinennaher Konfiguration und Orchestrierung. Unsere Infrastruktur umfasst physische Hardware (vielleicht erinnern Sie sich an unsere solarbetriebenen Nodes), Container und verschiedene Systemdienste, die koordiniert werden müssen.

Derzeit nutzen wir verschiedene Ansätze für Interprozesskommunikation:

Was uns fehlt, ist ein einheitlicher Weg, damit unsere systemnahen Dienste miteinander sprechen können. Die folgenden Szenarien verdeutlichen das:

Für all das bietet Varlink eine überzeugende Lösung. Es ist lokal zuerst (ideal für niedrige Latenz), selbstbeschreibend (ideal zum Debuggen) und hat native systemd-Unterstützung (ideal für Zuverlässigkeit).

Dass systemd selbst Varlink für Dienste wie systemd-resolved und systemd-hostnamed nutzt, bedeutet, dass wir potenziell direkt mit Systemdiensten über dasselbe Protokoll interagieren können, das wir auch für unsere eigenen Dienste verwenden. Das ist mächtig.

Let’s Build Something: A Hello World Service

Genug Theorie – legen wir los. Ich habe eine einfache Hello-World-Implementierung erstellt, um erste Erfahrungen zu sammeln, und führe Sie durch den Aufbau von Grund auf.

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

Step 1: Setting Up the Project

Erstellen Sie zunächst ein neues Rust-Projekt:

cargo new varlink-helloworld
cd varlink-helloworld

Jetzt fügen wir die Abhängigkeiten hinzu. Öffnen Sie Cargo.toml und ergänzen Sie:

[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 nutzen zlink, die moderne Rust-Implementierung von Varlink. Sie ist async-first und basiert auf tokio, passt also perfekt zu unserer Arbeitsweise bei DTZ. Außerdem benötigen wir serde für JSON-Serialisierung (Varlinks Wire-Format) und futures-util für Stream-Verarbeitung.

Step 2: Implementing the Service

Jetzt zum spannenden Teil – die Service-Implementierung von Grund auf. Der Vorteil von zlink ist, dass wir keine Code-Generierung oder separate Schnittstellendateien brauchen. Alles definieren wir direkt in Rust, was volle IDE-Unterstützung, Typprüfung und keine Buildzeit-Magie bedeutet.

Erstellen Sie 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() {
    // Bestehende Socket-Datei löschen
    let _ = tokio::fs::remove_file(SOCKET_PATH).await;
    
    // Bind an den Unix-Socket
    let listener = unix::bind(SOCKET_PATH).unwrap();
    
    // Service und Server erstellen
    let service = HelloWorld {};
    let server = Server::new(listener, service);

    match server.run().await {
        Ok(_) => println!("server done."),
        Err(e) => println!("server error: {:?}", e),
    }
}

Dies ist unser Einstiegspunkt – einfach und klar. Wir binden an einen Unix-Socket unter /tmp/hello.varlink, erstellen unseren Service und überlassen dem Server die Bearbeitung eingehender Verbindungen.

Step 3: Defining the Message Types

Hier zeigt sich die Eleganz von Varlink. Wir definieren unser Protokoll vollständig mittels Rust-Typen mit serde-Annotationen. Sehen wir uns die einzelnen Teile an:

Methoden-Aufrufe (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,
}

Die HelloWorldMethod-Enum repräsentiert alle Methoden, die unser Service behandeln kann. Das Attribut #[serde(tag = "method")] weist serde an, das JSON-Feld method zum Deserialisieren der richtigen Variante zu nutzen. Die #[serde(rename = "...")]-Attribute ordnen die Rust-Enum-Varianten den tatsächlichen Varlink-Methodennamen zu.

Beachten Sie, dass NamedHello ein verschachteltes Feld parameters enthält – das entspricht dem Varlink-Protokoll, bei dem Methodenparameter im JSON in einem parameters-Objekt verpackt sind.

Antworten (ausgehende Responses)

#[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 unser simpler Antworttyp mit einem einzigen message-Feld.

Fehlerbehandlung

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

Das #[derive(ReplyError)]-Makro von zlink generiert den notwendigen Code, um unsere Fehler gemäß Varlink-Fehlerformat zu serialisieren. Das Attribut #[zlink(interface = "...")] gibt an, zu welcher Schnittstelle diese Fehler gehören.

Step 4: Implementing the Service Trait

Jetzt binden wir alles zusammen, 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. Hier die wichtigsten Punkte:

  1. Associated Types: Wir definieren, welche Typen unser Service für Methodenaufrufe, Antworten, Streaming-Responses und Fehler verwendet. Das bringt volle Typsicherheit.

  2. Die handle-Methode: Hier werden alle eingehenden Aufrufe verarbeitet. Wir machen ein Pattern-Matching auf die deserialisierten Methodenaufrufe und liefern 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 (z.B. für Monitoring oder Subscriptions), hier halten wir es einfach.

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

Step 5: Running and Testing

Starten Sie den Server:

cargo run

Sie sollten sehen:

starting varlink hello world server

Öffnen Sie nun ein weiteres Terminal und testen Sie mit varlinkctl, das Teil von systemd ist. Zuerst prüfen wir, was der Service anbietet:

varlinkctl info /tmp/hello.varlink

Ausgabe:

    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 Service bietet.

Rufen wir nun unsere Methoden auf:

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

Ausgabe:

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

Und mit Parameter:

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

Ausgabe:

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

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

Debugging and Exploration

Was ich an Varlink liebe, ist wie einfach es zu erkunden und zu debuggen ist. Weil das Protokoll JSON-basiert ist, können Sie sogar einfache Tools wie socat oder netcat für manuelle Tests verwenden:

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

Sie bekommen eine JSON-Antwort zurück, die Sie mit jq durchleiten oder einfach direkt lesen können. Keine speziellen Debugger nötig, keine binären Protokolle zum Entschlüsseln. Wenn man um 2 Uhr nachts debuggt und etwas nicht funktioniert, ist diese Einfachheit Gold wert.

Sie können auch die Schnittstellendefinition introspektieren:

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

Das liefert genau die Schnittstellendefinition zurück, die wir geschrieben haben. Zusammen mit info haben Sie vollständige Transparenz darüber, was jeder Varlink-Service kann – sogar Services, die Sie noch nie gesehen haben.

Integrating with systemd

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

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

[Unit]
Description=Hello World Varlink Socket

[Socket]
ListenStream=/run/hello.varlink

[Install]
WantedBy=sockets.target

Und die korrespondierende Service-Unit (hello-varlink.service):

[Unit]
Description=Hello World Varlink Service

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

Mit Socket-Aktivierung hört systemd auf dem Socket und startet Ihren Service erst bei eingehender Verbindung und übergibt den Socket. Das bedeutet null Ressourcenverbrauch, bis der Dienst tatsächlich benötigt wird – perfekt für unsere Scale-to-Zero-Philosophie bei DTZ.

Aber die systemd-Geschichte geht weiter. Verschiedene systemd-Komponenten bieten bereits Varlink-Schnittstellen:

Das bedeutet, dass wir genau dieselben Varlink-Pattern, die wir für unsere Dienste entwickeln, nutzen können, um mit dem Host-System zu interagieren. DNS-Cache abfragen? varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name":"example.com"}'. Dasselbe Protokoll, dieselben Werkzeuge, dasselbe mentale Modell.

Für DTZ ist das besonders spannend, weil es unserer Orchestrierungsschicht erlaubt, einen einheitlichen Ansatz für Anwendungs-IPC und Systemmanagement zu nutzen. Kein ständiger Kontextwechsel zwischen APIs und Protokollen mehr.

What’s Next?

Dieses Hello-World-Experiment hat mich wirklich begeistert, was Varlink für DTZ leisten kann. Hier einige Ideen für den weiteren Weg:

  1. Maschinen-Konfigurationsdienst: Ein Varlink-Service, der Maschineneinstellungen (Netzwerkeinstellungen, Ressourcenlimits etc.) mit Zugriffskontrolle bereitstellt.

  2. Container-Orchestrierungs-IPC: Varlink für Kommunikation zwischen Container-Runtime und Management-Services verwenden.

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

  4. Systemd-Integration: Direkte Abfrage von systemd-Varlink-Schnittstellen zum Dienststatus und Management.

  5. Healthcheck-Aggregation: Ein zentraler Varlink-Service, der Health-Status aller laufenden Services sammelt und einen einheitlichen Gesundheitsendpunkt bereitstellt.

Dass wir mit demselben Protokoll sowohl eigene Services als auch Systemdienste wie systemd-resolved ansprechen können, ist ein großer Gewinn bei Konsistenz und reduzierter Komplexität.

Ich bin auch neugierig auf die Performance. JSON ist zwar nicht das kompakteste Wire-Format, aber für lokale IPC ist der Parsing-Overhead meist vernachlässigbar im Vergleich zum Vorteil der Lesbarkeit. Ich plane, in einem Folgeexperiment Benchmarks zu Latenz und Durchsatz für unsere Use Cases zu machen.

Wrapping Up

Varlink trifft einen guten Mittelweg zwischen Einfachheit und Funktionalität. Es versucht nicht, alle Probleme verteilter Systeme zu lösen – der Fokus liegt darauf, lokale IPC wirklich gut zu machen, mit gerade genug Features für Entdeckbarkeit und Typsicherheit.

Für DownToZero, wo wir ständig Effizienz und Einfachheit optimieren, spricht uns 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 gestaltetes Protokoll, das gut mit dem Linux-Ökosystem harmoniert, auf dem wir aufbauen.

Wenn Sie selbst experimentieren wollen, holen Sie sich den Code von GitHub, starten Sie Ihren Editor und legen Sie los. Die Lernkurve ist sanft, und es gibt etwas Befriedigendes daran, den ersten varlinkctl call mit eigener Nachricht zurückkommen zu sehen.

Viel Erfolg beim Experimentieren!

Resources