Si alguna vez has intentado que diferentes procesos en un sistema Linux se comuniquen 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 sinfín de otros enfoques. Cada uno viene con su propia complejidad, requisitos de herramientas y curva de aprendizaje.
Recientemente, me topé con Varlink (y su hermano más nuevo centrado en Rust, Zlink), y de inmediato encajó con lo que intentamos lograr en DownToZero. Estamos construyendo infraestructura que necesita comunicación inter-proceso fiable y de baja sobrecarga, desde la orquestación de contenedores hasta la gestión de configuraciones de máquinas. Mientras más profundizaba en Varlink, más me daba cuenta que podría ser exactamente lo que necesitamos.
En esta publicación, te guiaré a través de mis experimentos con Varlink. Construiremos juntos un servicio simple hello world, 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 protocolo diseñado para definir e implementar interfaces de servicios. Piénsalo como una alternativa más simple y moderna a D-Bus, o una opción más liviana que gRPC para comunicación local.
Si has trabajado con D-Bus antes, probablemente conozcas el problema: 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 es de una época en la que “simple” no era una prioridad en el diseño. Varlink toma un enfoque diferente: es como se vería D-Bus si fuera diseñado hoy, con sensibilidades modernas sobre la experiencia del desarrollador.
Esto es lo que hace a Varlink interesante:
Interfaces autodescriptivas: Cada servicio Varlink puede describir su propia API. Puedes conectarte a cualquier servicio y preguntar “¿qué puedes hacer?” y obtener una respuesta legible tanto por máquinas como por humanos.
Independiente del lenguaje: El protocolo es lo suficientemente simple como para que existan implementaciones en Rust, Go, Python, C y más. Pero lo importante es que las interfaces en sí son neutrales al lenguaje.
Basado en sockets: La comunicación ocurre a través de sockets Unix (o TCP para conexiones remotas), lo que significa que se integra bien con el ecosistema Linux, contenedores y systemd.
Protocolo basado en JSON: El formato en el “wire” es JSON, lo que hace que depurar sea trivial. Literalmente puedes usar netcat para comunicarte 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 batalla y tiene soporte propio para activación por socket y gestión de servicios.
En DTZ, tratamos constantemente con configuración y orquestación a nivel máquina. Nuestra infraestructura abarca hardware físico (quizás recuerdes nuestros nodos solares), contenedores y varios servicios del sistema que necesitan coordinarse.
Actualmente, tenemos una mezcla de enfoques para la comunicación entre procesos:
Lo que nos falta es una manera unificada para que nuestros servicios a nivel sistema se comuniquen. Considera estos escenarios:
Para todo esto, Varlink ofrece una solución convincente. Es local primero (genial para latencia), autodescriptivo (genial para depuración) y tiene soporte nativo para systemd (genial para confiabilidad).
El hecho de que systemd mismo 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.
Basta de teoría - manos a la obra. He creado una implementación simple hello world para probar, y te guiaré para construirla desde cero.
El código fuente completo está disponible en https://github.com/DownToZero-Cloud/varlink-helloworld.
Primero, crea un nuevo proyecto Rust:
cargo new varlink-helloworld
cd varlink-helloworld
Ahora necesitamos agregar 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 de Varlink para Rust. Es async-first y está construida sobre tokio, lo cual encaja perfectamente con cómo construimos servicios en DTZ. También necesitamos serde para la serialización JSON (el formato en el wire 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 separados de definición de interfaz. Definimos todo directamente en Rust, lo que significa soporte completo del IDE, chequeo 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 claro. Nos enlazamos al socket Unix en /tmp/hello.varlink, creamos nuestro servicio y dejamos que el servidor maneje las conexiones entrantes.
Aquí es donde se revela la elegancia de Varlink. Definimos nuestro protocolo completamente usando tipos Rust con anotaciones serde. Veamos cada parte:
Llamadas a Métodos (Solicitudes Entrantes)
#[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")] indica a serde que use el campo JSON method para determinar qué variante deserializar. Los atributos #[serde(rename = "...")] mapean las variantes Rust a los nombres reales de métodos Varlink.
Observa cómo NamedHello tiene un campo anidado parameters: esto coincide con el protocolo Varlink donde los parámetros se envuelven en un objeto parameters en el JSON.
Respuestas (Respuestas Salientes)
#[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 simple con el campo message.
Manejo de Errores
#[derive(Debug, ReplyError)]
#[zlink(interface = "rocks.dtz.HelloWorld")]
enum HelloWorldError {
Error { message: String },
}
La macro #[derive(ReplyError)] de zlink genera el código necesario para serializar nuestros errores según el formato 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 núcleo de zlink. Desglosemos lo que pasa:
Tipos Asociados: Declaramos qué tipos usa nuestro servicio para llamadas a métodos, respuestas, respuestas en streaming y errores. Esto nos da seguridad total de tipos en todo momento.
El método handle: Aquí se enrutan todas las llamadas entrantes. Usamos pattern matching sobre la llamada deserializada y devolvemos la respuesta adecuada.
MethodReply::Single: Para respuestas que no son streaming, envolvemos nuestra respuesta en MethodReply::Single. Varlink también soporta respuestas por streaming (útiles para monitoreo o suscripciones), pero aquí mantenemos la simplicidad.
VarlinkGetInfo: Cada servicio Varlink debe implementar el método org.varlink.service.GetInfo. Esto devuelve metadatos sobre el servicio: proveedor, nombre del producto, versión, URL y las interfaces implementadas.
Arranca el servidor:
cargo run
Deberías ver:
starting varlink hello world server
Ahora, en otra terminal, podemos probarlo usando varlinkctl, que es parte de systemd. Primero veamos qué expone el servicio:
varlinkctl info /tmp/hello.varlink
Salida:
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 autodescriptiva de Varlink en acción. El cliente puede descubrir exactamente qué ofrece este servicio.
Ahora llamemos a nuestros métodos:
varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.Hello {}
Salida:
{
"message" : "Hello, World!"
}
Y con un parámetro:
varlinkctl call /tmp/hello.varlink rocks.dtz.HelloWorld.NamedHello '{"name":"jens"}'
Salida:
{
"message" : "Hello, jens!"
}
¡Funciona! Tenemos un servicio Varlink completamente funcional.
Algo que me encanta de Varlink es lo fácil que es explorar y depurar. Como el protocolo es 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 canalizar con jq o leer directamente. No se necesitan herramientas especiales de depuración, ni decodificar protocolos binarios. Cuando estás depurando a las 2 AM y algo no funciona, esta simplicidad es invaluable.
También puedes introspectar la definición de la interfaz:
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 antes habías visto.
Una de las características más potentes de Varlink es su integración con systemd. Puedes crear servicios activados por socket que solo arrancan cuando alguien se conecta, y systemd gestiona el ciclo de vida.
Crea una unidad de socket 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 la activación por socket, systemd escucha en el socket, y cuando llega una conexión, inicia tu servicio y le entrega el socket. Esto significa uso cero de recursos hasta que alguien realmente necesita 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 aplicación como para gestión a nivel sistema. No más cambiar de contexto entre diferentes APIs y protocolos.
Este experimento hello world me tiene genuinamente emocionado sobre el potencial de Varlink para DTZ. Aquí algunas direcciones que estoy considerando:
Servicio de configuración de máquinas: Un servicio Varlink que exponga configuraciones de máquina (configuración de red, límites de recursos, etc.) con control de acceso apropiado.
IPC de orquestación de contenedores: Usar Varlink para comunicación entre nuestro runtime de contenedores y 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 chequeos de salud: Un servicio Varlink central que recoja el estado de salud de todos nuestros servicios corriendo y exponga un endpoint 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 para la consistencia y la reducción de complejidad.
También tengo curiosidad sobre las características de rendimiento. Aunque JSON no es el formato más compacto, para IPC local la sobrecarga del parseo suele ser insignificante comparada con los beneficios de legibilidad humana. Dicho esto, planeo hacer algunos benchmarks en un experimento sucesor para obtener números reales sobre latencia y throughput para nuestros casos de uso.
Varlink encuentra un punto ideal entre simplicidad y capacidad. No intenta resolver todos los problemas de sistemas distribuidos, se enfoca en hacer IPC local realmente bien, con las características justas para descubribilidad y seguridad de tipos.
Para DownToZero, donde constantemente optimizamos para eficiencia y simplicidad, este enfoque resuena mucho. No necesitamos la complejidad de gRPC para comunicación local. No queremos la sobrecarga de HTTP para llamadas internas de máquina. Varlink nos brinda un protocolo limpio, bien diseñado, que se integra perfectamente con el ecosistema Linux sobre el que construimos.
Si te interesa experimentar por ti mismo, toma el código de GitHub, abre tu editor y pruébalo. La curva de aprendizaje es suave, y hay algo muy satisfactorio en ver la primera llamada varlinkctl call devolver tu mensaje.
¡Feliz experimentación!