Si alguna vez has intentado hacer que diferentes procesos en un sistema Linux se comuniquen entre sí de manera eficiente, sabes que el panorama es… digamos, fragmentado. Tenemos D-Bus, sockets Unix con protocolos personalizados, APIs REST sobre localhost, gRPC y un sinnúmero de otros enfoques. Cada uno viene con su propia complejidad, requisitos de herramientas y curva de aprendizaje.
Recientemente, me encontré con Varlink (y su hermano más nuevo centrado en Rust Zlink), y conectó inmediatamente con lo que estamos intentando lograr en DownToZero. Estamos construyendo infraestructura que necesita comunicación inter-procesos fiable y de bajo overhead: desde orquestación de contenedores hasta gestión de la configuración de máquinas. Cuanto más profundizaba en Varlink, más me daba cuenta de que esto podría ser exactamente lo que necesitamos.
En esta entrada, te guiaré por mis experimentos con Varlink. Construiremos un servicio hello world sencillo juntos, paso a paso, y compartiré mis impresiones sobre por qué esta tecnología me entusiasma para el futuro de nuestra plataforma.
Varlink es un formato de descripción de interfaces y un protocolo diseñado para definir e implementar interfaces de servicio. Piénsalo como una alternativa más simple y moderna a D-Bus, o una alternativa más ligera a gRPC para comunicación local.
Si has trabajado con D-Bus antes, probablemente conozcas el dolor: sistemas de tipos complejos, introspección que requiere herramientas especiales, archivos de configuración XML que parecen diseñados para confundir. D-Bus es potente, pero también proviene de una época en la que “simple” no era una prioridad de diseño. Varlink adopta un enfoque diferente: es lo que D-Bus podría parecer si se diseñara hoy, con sensibilidades modernas sobre la experiencia del desarrollador.
Esto es lo que hace a Varlink interesante:
Interfaces que se autodescriben: Cada servicio Varlink puede describir su propia API. Puedes conectar con cualquier servicio y preguntar “¿qué puedes hacer?” y obtener una respuesta legible por máquina (y por humanos).
Agnóstico al lenguaje: El protocolo es lo suficientemente simple como para que existan implementaciones en Rust, Go, Python, C y más. Pero, lo más importante, las interfaces en sí son neutrales al lenguaje.
Basado en sockets: La comunicación ocurre sobre sockets Unix (o TCP para conexiones remotas), lo que significa que encaja bien con el ecosistema Linux, contenedores y systemd.
Protocolo basado en JSON: El formato en la red es JSON, lo que hace que la depuración sea trivial. Literalmente puedes usar netcat para hablar con un servicio Varlink si quieres.
Integración con systemd: Esto es enorme. systemd ya usa Varlink para algunos de sus servicios internos, lo que significa que el protocolo está probado en producción y tiene soporte de primera mano para activación por socket y gestión de servicios.
En DTZ, estamos constantemente lidiando con configuración y orquestación a nivel de máquina. Nuestra infraestructura abarca hardware físico (quizá recuerdes nuestros nodos alimentados por energía solar), contenedores y varios servicios del sistema que necesitan coordinarse.
Actualmente, tenemos una mezcla de enfoques para la comunicación inter-procesos:
Lo que nos falta es una forma unificada para que nuestros servicios a nivel de sistema se comuniquen. Considera estos escenarios:
Para todo esto, Varlink ofrece una solución atractiva. Es local-first (genial para latencia), autodocumentado (genial para depuración) y tiene soporte nativo en systemd (genial para fiabilidad).
El hecho de que systemd use Varlink para servicios como systemd-resolved y systemd-hostnamed significa que potencialmente podemos integrarnos directamente con servicios del sistema usando el mismo protocolo que usamos para nuestros propios servicios. Eso es poderoso.
Suficiente teoría: manos a la obra. He creado una implementación simple de hello world para probar, y te guiaré por la construcción desde cero.
El código fuente completo está disponible en https://github.com/DownToZero-Cloud/varlink-helloworld.
Primero, crea un nuevo proyecto de Rust:
cargo new varlink-helloworld
cd varlink-helloworld
Ahora necesitamos añadir nuestras dependencias. Abre Cargo.toml y añade:
[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" }
Estamos usando zlink, la implementación moderna en Rust de Varlink. Es async-first y está construida sobre tokio, lo que encaja perfectamente con cómo construimos servicios en DTZ. También necesitamos serde para la serialización JSON (el formato en la red de Varlink) y futures-util para el manejo de streams.
Ahora la parte divertida: implementar nuestro servicio desde cero. La belleza de zlink es que no necesitamos generación de código ni archivos de definición de interfaz separados. Definimos todo directamente en Rust, lo que significa soporte completo del IDE, comprobación de tipos y nada de magia en tiempo de compilación.
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),
}
}
Este es nuestro punto de entrada: simple y limpio. Nos enlazamos a un socket Unix en /tmp/hello.varlink, creamos nuestro servicio y dejamos que el servidor maneje las conexiones entrantes.
Aquí es donde se nota la elegancia de Varlink. Definimos nuestro protocolo completamente usando tipos de Rust con anotaciones de serde. Veamos cada pieza:
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,
}
El enum HelloWorldMethod representa todos los métodos que nuestro servicio puede manejar. El atributo #[serde(tag = "method")] le indica a serde que use el campo JSON method para determinar en qué variante deserializar. Los atributos #[serde(rename = "...")] mapean nuestras variantes de enum en Rust a los nombres reales de los métodos Varlink.
Observa cómo NamedHello tiene un campo anidado parameters: esto coincide con el protocolo Varlink donde los parámetros del método están envueltos en un objeto parameters en el JSON.
Replies (Outgoing Responses)
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum HelloWorldReply {
Hello(HelloResponse),
VarlinkInfo(Info<'static>),
}
#[derive(Debug, Serialize)]
pub struct HelloResponse {
message: String,
}
El enum de respuesta usa #[serde(untagged)] porque las respuestas Varlink no incluyen un discriminador de tipo: el tipo de respuesta es implícito según el método llamado. HelloResponse es nuestra estructura de respuesta simple que contiene solo un campo message.
Error Handling
#[derive(Debug, ReplyError)]
#[zlink(interface = "rocks.dtz.HelloWorld")]
enum HelloWorldError {
Error { message: String },
}
El macro #[derive(ReplyError)] de zlink genera el código necesario para serializar nuestros errores según el formato de error de Varlink. El atributo #[zlink(interface = "...")] especifica a qué interfaz pertenecen estos errores.
Ahora unimos todo implementando el 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",
})))
}
}
}
}
El trait Service es el corazón de zlink. Desglosemos lo que sucede:
Tipos asociados: Declaramos qué tipos usa nuestro servicio para llamadas de método, respuestas, respuestas por streaming y errores. Esto nos da seguridad de tipos completa en todo momento.
El método handle: Aquí es donde se enrutan todas las llamadas entrantes. Hacemos pattern match sobre la llamada de método deserializada y devolvemos la respuesta apropiada.
MethodReply::Single: Para respuestas no-streaming, envolvemos nuestra respuesta en MethodReply::Single. Varlink también soporta respuestas en streaming (útiles para monitorización o suscripciones), pero aquí lo mantenemos simple.
VarlinkGetInfo: Cada servicio Varlink debería implementar el método org.varlink.service.GetInfo. Esto devuelve metadatos sobre nuestro servicio: vendor, nombre del producto, versión, URL y la lista de interfaces que implementamos.
Inicia el servidor:
cargo run
Deberías ver:
starting varlink hello world server
Ahora, en otra terminal, podemos probarlo usando varlinkctl, que forma parte de systemd. Primero, veamos qué expone el servicio:
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
Esta es la naturaleza autodocumentada de Varlink en acción. El cliente puede descubrir exactamente lo que este servicio ofrece.
Ahora llamemos a nuestros métodos:
varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.Hello {}
Output:
{
"message" : "Hello, World!"
}
Y con un parámetro:
varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.NamedHello '{"name":"jens"}'
Output:
{
"message" : "Hello, jens!"
}
¡Funciona! Tenemos un servicio Varlink totalmente funcional.
Algo que me encanta de Varlink es lo fácil que es explorar y depurar. Dado que el protocolo está basado en JSON, incluso puedes usar herramientas básicas como socat o netcat para pruebas manuales:
echo '{"method":"rocks.dtz.HelloWorld.Hello","parameters":{}}' | \
socat - UNIX-CONNECT:/tmp/hello.varlink
Recibirás una respuesta JSON que puedes pasar por jq o simplemente leer directamente. No hacen falta herramientas de depuración especiales, ni protocolos binarios que descifrar. Cuando estás depurando a las 2 AM y algo no funciona, esta simplicidad es invalorable.
También puedes inspeccionar la definición de la interfaz en sí:
varlinkctl introspect /tmp/hello.varlink rocks.dtz.HelloWorld
Esto devuelve la definición exacta de la interfaz que escribimos antes. Combinado con el comando info, tienes visibilidad completa de lo que cualquier servicio Varlink puede hacer, incluso servicios que nunca hayas visto antes.
Una de las características más potentes de Varlink es su integración con systemd. Puedes crear servicios activados por socket que solo se inician cuando alguien se conecta, y systemd gestiona el ciclo de vida.
Crea una unidad socket de systemd (hello-varlink.socket):
[Unit]
Description=Hello World Varlink Socket
[Socket]
ListenStream=/run/hello.varlink
[Install]
WantedBy=sockets.target
Y una unidad de servicio correspondiente (hello-varlink.service):
[Unit]
Description=Hello World Varlink Service
[Service]
ExecStart=/usr/local/bin/varlink-helloworld
Con activación por socket, systemd escucha en el socket y, cuando llega una conexión, inicia tu servicio y le cede el socket. Esto significa consumo de recursos cero hasta que alguien realmente necesite el servicio: perfecto para nuestra filosofía scale-to-zero en DTZ.
Pero hay más en la historia de systemd. Varios componentes de systemd ya exponen interfaces Varlink:
Esto significa que podemos usar los mismos patrones Varlink que desarrollamos para nuestros servicios para interactuar con el sistema host. ¿Quieres consultar la caché DNS? varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name":"example.com"}'. Mismo protocolo, mismas herramientas, mismo modelo mental.
Para DTZ, esto es particularmente emocionante porque significa que nuestra capa de orquestación puede usar un enfoque unificado tanto para IPC a nivel de aplicación como para la gestión a nivel de sistema. No más cambiar de contexto entre diferentes APIs y protocolos.
Este experimento hello world me tiene genuinamente entusiasmado sobre el potencial de Varlink para DTZ. Aquí hay algunas direcciones que estoy considerando:
Servicio de configuración de máquinas: Un servicio Varlink que exponga ajustes de máquina (configuración de red, límites de recursos, etc.) con control de acceso adecuado.
IPC para orquestación de contenedores: Usar Varlink para la comunicación entre nuestro runtime de contenedores y los servicios de gestión.
Agregación de observabilidad: Un servicio Varlink local que agregue métricas de varios componentes del sistema.
Integración con systemd: Consultar directamente las interfaces Varlink de systemd para estado y gestión de servicios.
Agregación de comprobaciones de salud: Un servicio Varlink central que recopile el estado de salud de todos nuestros servicios en ejecución y exponga un endpoint de salud unificado.
El hecho de que podamos usar el mismo protocolo para hablar con nuestros propios servicios Y con servicios del sistema como systemd-resolved es una gran victoria en términos de consistencia y reducción de complejidad.
También tengo curiosidad por las características de rendimiento. Aunque JSON no es el formato más compacto, para IPC local la sobrecarga de parseo suele ser despreciable en comparación con los beneficios de legibilidad humana. Dicho esto, planeo hacer algunos benchmarks en un experimento posterior para obtener números reales sobre latencia y throughput para nuestros casos de uso.
Varlink encuentra un punto intermedio entre simplicidad y capacidad. No intenta resolver todos los problemas de sistemas distribuidos: se centra en hacer bien el IPC local, con las funciones justas para descubrimiento y seguridad de tipos.
Para DownToZero, donde estamos constantemente optimizando por eficiencia y simplicidad, este enfoque resuena fuertemente. No necesitamos la complejidad de gRPC para la comunicación local. No queremos el overhead de HTTP para llamadas internas de máquina. Varlink nos da un protocolo limpio y bien diseñado que encaja con el ecosistema Linux sobre el que construimos.
Si te interesa experimentar por tu cuenta, toma el código de GitHub, abre tu editor y pruébalo. La curva de aprendizaje es suave, y hay algo satisfactorio en ver esa primera llamada varlinkctl call devolviendo tu mensaje.
¡Felices experimentos!