En una entrada anterior examinamos Varlink como un pequeño experimento. En ese momento fue mayormente un ejercicio de “hola mundo”: definir una interfaz, abrir un socket Unix, llamar a un método, devolver algo de JSON. Eso fue útil, pero seguía siendo un montaje de laboratorio.
Desde entonces hemos comenzado a mover la idea hacia la capa de nodo de DownToZero. Dos proyectos son especialmente interesantes aquí: dtz-node-stats y dtz-node-volume. El primero expone información sobre un nodo trabajador, incluyendo el estado de energía y datos básicos de hardware. El segundo controla un sistema de archivos FUSE que monta un volumen DTZ en la máquina local.
Esta entrada no es un recorrido por el código fuente. Parte del código de los repositorios circundantes es infraestructura interna y no es algo que queramos publicar como guía de implementación. Pero la forma del sistema, las interfaces y el patrón merecen compartirse, porque Varlink y Zlink encajan muy bien para esta clase de problema.
La versión corta es esta: queremos servicios locales pequeños en un nodo que sean descubribles, scriptables y aburridos de operar. Varlink nos da el protocolo. Zlink nos da una implementación en Rust que encaja con nuestros servicios asíncronos. FUSE nos da una manera de presentar almacenamiento remoto o basado en objetos como un sistema de archivos normal. Juntos, esto permite que el código de orquestación de más alto nivel diga “monta este volumen para este contexto” sin saber cómo el controlador del sistema de archivos habla con el almacenamiento.
Los nodos de DownToZero no son solo cajas Linux anónimas. Forman parte de una plataforma consciente de la energía. Nos importa de dónde viene la energía, cuánto consume un nodo, si una máquina funciona con batería o con solar, y qué cargas de trabajo deberían colocarse allí.
Al mismo tiempo, las funciones a nivel de nodo no deberían terminar todas dentro de un único demonio gigante. Recolección de métricas, gestión de energía, montaje de volúmenes, planificación de contenedores, lógica de actualizaciones y tareas de mantenimiento local tienen distintos ciclos de vida. Algunas deberían reiniciarse de forma independiente. Algunas necesitan permisos especiales. Otras deberían permanecer lo bastante pequeñas como para poder entenderse y probarse aisladamente.
El problema es la coordinación. Una vez que estas piezas son procesos separados, necesitan una forma limpia de comunicarse entre sí. Un servidor REST en localhost funcionaría, pero trae enrutamiento HTTP, puertos, preguntas sobre TLS y por lo general más superficie de la que necesitamos. Un protocolo propio sobre socket Unix también funcionaría, pero entonces cada servicio inventa su propio framing, modelo de errores e historia de depuración. D-Bus está establecido, pero queríamos algo más simple para APIs de máquina específicas de servicio.
Varlink encaja bien en el medio. Usa sockets, tiene un protocolo JSON simple, admite introspección y es fácil de invocar desde herramientas de línea de comandos. Para servicios en Rust, Zlink nos ofrece manejo tipado de métodos mientras mantiene el formato en el cable fácil de inspeccionar.
El resultado es una superficie de API local que se siente más como:
varlinkctl call /tmp/dtz.varlink rocks.dtz.Node.GetInformation {}
y menos como diseñar un nuevo plano de control desde cero.
dtz-node-stats es el más sencillo de los dos proyectos. Su trabajo es recopilar y exponer hechos a nivel de nodo: núcleos de CPU, memoria, métricas relacionadas con el tiempo de actividad, fuente de energía y consumo. Puede integrarse con un medidor de energía, por ejemplo un dispositivo Tasmota, o recurrir a estimaciones de consumo configuradas.
La parte interesante para esta entrada es la interfaz Varlink. El servicio escucha en un socket Unix y expone algunos métodos centrados:
rocks.dtz.Node.GetInformation() -> node information
rocks.dtz.Node.GetPowerState() -> current energy state
rocks.dtz.Node.Shutdown() -> graceful shutdown
org.varlink.service.GetInfo() -> standard Varlink introspection
Llamar a GetInformation devuelve hechos estables del nodo como el ID del nodo, la fuente de energía configurada, los núcleos de CPU y el tamaño de memoria. Llamar a GetPowerState devuelve el estado energético actual y el consumo acumulado en vatios-hora.
Esto ya es usado por dos roles de trabajador en nuestros nodos.
El sync-worker llama a GetInformation durante el arranque y usa el ID de nodo devuelto como su identidad local. También usa los valores de CPU y memoria reportados cuando se anuncia al resto de la plataforma. Eso significa que el trabajador no necesita un segundo archivo de configuración para la información básica de capacidad, y no necesita inventarse su propia idea de en qué máquina se está ejecutando. El servicio de nodo es la fuente local de verdad.
El async-worker usa el mismo patrón, pero con un propósito ligeramente distinto. Lee la información del nodo una vez en el arranque, la mantiene con el estado en tiempo de ejecución del trabajador y luego usa el ID del nodo al manejar solicitudes, reportar salud y etiquetar métricas. Si más tarde se miran latencias o contadores de solicitudes, la etiqueta del nodo proviene de la misma identidad respaldada por Varlink que el resto de servicios locales ve.
Ese es el punto de mantener esta interfaz pequeña. dtz-node-stats no necesita saber qué tipo de trabajo realiza cada proceso. Solo tiene que responder unas pocas preguntas a nivel de nodo de forma consistente. Los trabajadores pueden entonces usar esas respuestas para registro, pistas de enrutamiento, métricas y visibilidad operacional sin copiar la lógica de detección del host en cada servicio.
La elección de diseño importante es que esto no es un endpoint de métricas que pretende ser una API de control. Prometheus aún puede recibir métricas. Los logs aún pueden ir a journald. Pero si otro proceso local necesita hacer una pregunta al servicio de nodo, obtiene un pequeño método tipado en lugar de raspar salida de texto o importar código de implementación.
El método Shutdown también es un buen ejemplo de por qué la IPC local es útil. Durante actualizaciones, un proceso nuevo puede encontrar un socket existente. En lugar de fallar a ciegas o eliminar el archivo de socket, el camino de arranque puede comprobar si hay un servicio activo y solicitarle que se apague ordenadamente. Si el socket está obsoleto, puede eliminarlo. Si el servicio está vivo, puede pedir una parada limpia. Esto suena pequeño, pero elimina mucha torpeza operativa de máquinas periféricas.
dtz-node-volume es donde el patrón se vuelve más interesante. El servicio monta un volumen respaldado por DTZ como un sistema de archivos local. Desde la perspectiva del software que se ejecuta en el nodo, el resultado es simplemente un directorio bajo ./dtz/.... Se pueden crear, leer, escribir, listar y eliminar archivos usando operaciones POSIX normales.
Bajo el capó, el sistema de archivos está implementado con FUSE. FUSE es un límite poderoso: el kernel reenvía las solicitudes del sistema de archivos a un proceso en espacio de usuario, y ese proceso decide cómo satisfacerlas. Eso significa que el backend de almacenamiento puede ser un almacén de objetos, una API, una caché, un servicio remoto o una combinación de estos. Las aplicaciones no necesitan saberlo. Simplemente llaman a open, read, write, readdir, getattr, y así sucesivamente.
La pieza que falta es el control. Un sistema de archivos FUSE es útil después de montarlo, pero algo todavía necesita decidir cuándo montarlo, para qué contexto, en qué ruta y cuándo desmontarlo de nuevo. No queremos que cada llamador conozca los detalles de configuración de FUSE. Tampoco queremos exponer la implementación del almacenamiento directamente a cada parte del nodo.
Así que dtz-node-volume expone una interfaz Varlink:
rocks.dtz.Volume.GetInfo() -> active mounts
rocks.dtz.Volume.Mount(context, volume) -> mountpoint and status
rocks.dtz.Volume.Umount(context, volume) -> acknowledgement
org.varlink.service.GetInfo() -> standard Varlink introspection
La interfaz es intencionalmente pequeña. Montar un volumen es una acción de control local. El llamador proporciona el contexto y el ID del volumen. El servicio lo convierte en un punto de montaje como:
/dtz/{context_id}/{volume_id}
La respuesta es igualmente pequeña:
{
"mountpoint": "/dtz/context-.../volume-...",
"status": "mounted"
}
A partir de ahí, el llamador no habla Varlink por cada lectura de archivo. Esa sería la abstracción equivocada. Varlink controla el ciclo de vida del montaje. FUSE maneja las operaciones del sistema de archivos. Esta separación es el punto principal.
Es tentador construir todo como una API RPC. Para volúmenes, eso significaría métodos como ReadFile, WriteFile, ListDirectory, DeletePath y docenas más. Ese enfoque funciona, pero entonces cada consumidor necesita un cliente personalizado. Herramientas existentes como cp, rsync, editores, shells y sistemas de compilación no podrían usarlo directamente.
FUSE nos permite evitar eso. El plano de datos permanece siendo la interfaz de sistema de archivos que los programas de Linux ya entienden. El plano de control se convierte en un servicio Varlink diminuto.
Para el ciclo de vida del volumen, el sync-worker se encarga de la preparación local de la carga de trabajo. Cuando una carga de trabajo declara un volumen, el sync-worker pregunta al servicio de volúmenes si ya está montado y llama a Mount si es necesario. Luego hace un bind-mount de la ruta de host resultante dentro de la carga de trabajo. Después de eso, la carga de trabajo realiza IO de archivos normal; no sabe nada de Varlink ni del almacén de objetos.
Una vista de secuencia encaja mejor que un diagrama de topología aquí, porque la parte interesante es el orden de las operaciones:
sequenceDiagram
participant W as sync-worker
participant V as dtz-node-volume
participant F as FUSE session
participant C as container workload
participant K as Linux VFS
participant S as DTZ storage backend
W->>V: GetInfo(context, volume)
alt volume is not mounted
W->>V: Mount(context, volume)
V->>F: create FUSE mount at /dtz/context/volume
F-->>V: mount ready
V-->>W: mountpoint + status
else volume is already mounted
V-->>W: existing mountpoint + status
end
W->>C: start workload with bind mount
C->>K: normal file IO
K->>F: FUSE requests
F->>S: object operations
S-->>F: object data and metadata
F-->>K: filesystem reply
K-->>C: read/write result
W->>V: Umount when lifecycle policy says so
Esto mantiene la API pequeña y la experiencia de usuario normal. El sync-worker puede montar el volumen justo antes de que arranque una carga de trabajo. Después de eso, la carga de trabajo usa un directorio. Cuando la carga de trabajo finaliza, el mismo rol de trabajador puede desmontarlo o dejarlo disponible para reutilización, dependiendo de la política de ciclo de vida.
Para nosotros, ese es exactamente el tipo de interfaz que queremos en un nodo. Es lo suficientemente explícita para automatizar, pero no obliga a cada carga de trabajo a aprender una API de almacenamiento específica de DTZ.
Hay muchas maneras de mapear un sistema de archivos sobre un almacén de objetos. Estamos usando un esquema que separa los metadatos del espacio de nombres de los datos de los archivos.
En términos simplificados:
fs/ filesystem namespace and metadata
data/ larger file chunks
Cada archivo o directorio tiene un objeto de metadatos bajo el prefijo fs/. Esos metadatos contienen los atributos tipo POSIX que necesitamos: modo, IDs de propietario, tamaño, tiempo de modificación, bandera de directorio y atributos extendidos opcionales. Los contenidos de archivos pequeños pueden incrustarse directamente en el objeto de metadatos. Los archivos más grandes usan una referencia de datos, y sus bytes se almacenan como fragmentos bajo data/.
Esto tiene algunas propiedades útiles.
Los listados de directorio solo necesitan inspeccionar el espacio de nombres. No necesitan filtrar a través de fragmentos de archivos. Los renombramientos pueden ser baratos porque el objeto de metadatos puede moverse mientras los fragmentos de datos grandes permanecen donde están. Los archivos esparcidos son posibles porque los fragmentos no escritos no necesitan existir. Los archivos pequeños evitan viajes adicionales porque su contenido vive en los metadatos.
De nuevo, los detalles exactos de implementación no son la parte interesante para publicar. El punto arquitectónico importante es que el almacenamiento de objetos y los sistemas de archivos POSIX tienen fortalezas distintas. La capa FUSE traduce entre ellos. Varlink no intenta ser parte de esa traducción; solo inicia, detiene e informa sobre el montaje.
Un detalle que añadimos al diseño de volúmenes es el soporte de atributos extendidos. Esto puede parecer una característica de sistema de archivos de nicho, pero importa una vez que quieres que el directorio montado se comporte como un sistema de archivos real en lugar de una demostración de juguete.
Los atributos extendidos se usan para metadatos de usuario, capacidades, etiquetas de seguridad, metadatos de escritorio y diversas pistas específicas de aplicaciones. Si los ignoramos por completo, muchas cosas aún funcionan, pero la abstracción se filtra. Si los preservamos en los metadatos, el montaje FUSE se vuelve mucho más compatible con las expectativas normales de Linux.
El diseño práctico es sencillo: los metadatos pueden contener un mapa opcional de nombres xattr a valores codificados en base64. Operaciones FUSE como getxattr, setxattr, listxattr y removexattr se convierten en operaciones de leer-modificar-escribir sobre el objeto de metadatos.
Hay compensaciones. Actualizar xattrs reescribe metadatos. Las actualizaciones concurrentes de metadatos requieren pensar con cuidado. Los xattrs muy grandes serían una mala idea. Pero el patrón sigue siendo una buena combinación para el tipo de metadatos que esperamos alrededor de volúmenes montados.
El método GetInfo en dtz-node-volume devuelve los montajes actualmente activos. Esto incluye el contexto, el ID del volumen, el punto de montaje, el estado y la última marca de tiempo de acceso observada.
Ese último campo es pequeño pero útil. Un orquestador a nivel de nodo puede preguntar:
varlinkctl call /tmp/dtz-volume.varlink rocks.dtz.Volume.GetInfo {}
y obtener una vista actual de lo que el servicio de volúmenes cree que está montado. Esto es útil para depuración, limpieza y más tarde para comportamientos más conscientes de la energía. Si un montaje no ha sido accedido por un tiempo, tal vez pueda desmontarse. Si una carga de trabajo arranca, tal vez el montaje deba crearse justo a tiempo. La interfaz Varlink nos da un lugar para exponer ese estado sin acoplar a los llamadores con la implementación de FUSE.
Estamos construyendo la mayor parte de este código del lado del nodo en Rust, así que la ergonomía de Rust importa. Zlink nos permite mantener la implementación del servicio tipada mientras seguimos hablando el protocolo Varlink. Las llamadas a métodos se deserializan en enums y structs de Rust. Las respuestas se serializan de vuelta en la forma JSON esperada. La introspección Varlink estándar es simplemente otro método expuesto por el servicio.
Eso nos da un equilibrio agradable. La interfaz sigue siendo inspeccionable desde el exterior:
varlinkctl call /tmp/dtz-volume.varlink org.varlink.service.GetInfo {}
pero el código del servicio no se convierte en un montón de manejo de solicitudes fuertemente tipadas por cadenas.
También facilita las herramientas locales. Durante el desarrollo, un comando de shell puede montar un volumen, listar montajes o preguntar al nodo por su estado de energía. En operaciones, systemd puede gestionar el proceso del servicio y la ruta del socket Unix puede permanecer local al nodo. No necesitamos un oyente de red público para estas acciones.
Una de las razones por las que elegimos Varlink no es solo que el propio protocolo esté estandarizado. La herramienta alrededor también está estandarizada. En la práctica, esto importa tanto como la definición de la interfaz.
Con varlinkctl, cada servicio se vuelve inmediatamente utilizable desde un shell:
varlinkctl call /tmp/dtz.varlink rocks.dtz.Node.GetInformation {}
varlinkctl call /tmp/dtz-volume.varlink rocks.dtz.Volume.GetInfo {}
Eso significa que el trabajo de mantenimiento no requiere un binario de depuración especial ni un cliente ad hoc. Si un nodo se comporta de forma extraña, podemos hacer SSH y preguntar a los servicios locales qué saben. Si un script de actualización necesita comprobar si un volumen está montado, puede llamar al servicio Varlink directamente. Si una unidad systemd o un trabajo de mantenimiento tipo cron necesita un apagado limpio, puede usar el mismo método público que los clientes en Rust.
Esto es una gran diferencia respecto a una API de biblioteca interna. Una biblioteca solo es conveniente para programas escritos en el mismo lenguaje y publicados con la misma cadencia. Un socket Varlink con varlinkctl es conveniente para código Rust, scripts de shell, playbooks de mantenimiento y depuración manual. Para la infraestructura de nodos, esa flexibilidad es valiosa.
El caso de uso inmediato es práctico: montar volúmenes DTZ en nodos trabajadores y exponer el estado del nodo a otros servicios locales. Pero el patrón es más grande que estos dos proyectos.
Un servicio local puede exponer una pequeña API de control Varlink mientras hace algo más complejo internamente. Ese “algo” podría ser un sistema de archivos FUSE, un colector de métricas, un controlador de hardware, un ayudante de planificador o un actualizador. El llamador obtiene una interfaz de método estable. El servicio mantiene su implementación privada.
Para DownToZero esto es especialmente útil porque intentamos hacer la infraestructura más pequeña y consciente de la energía. Un nodo puede exponer sus capacidades localmente. Servicios de más alto nivel pueden tomar decisiones basadas en el estado de energía, recursos disponibles y almacenamiento montado. Las cargas de trabajo aún pueden interactuar con archivos y directorios normales.
Esto también mantiene las responsabilidades claras:
dtz-node-stats informa qué es el nodo y cómo se alimenta.dtz-node-volume controla cuándo los volúmenes DTZ aparecen como sistemas de archivos locales.Esto todavía está evolucionando. Las interfaces son intencionalmente pequeñas porque es más fácil ampliar una API local que reducirla después de que múltiples servicios dependan de ella. El servicio de volúmenes también es el lugar donde esperamos aprender más. Los sistemas de archivos tienen muchos casos límite, y los almacenes de objetos no se convierten mágicamente en sistemas de archivos POSIX solo porque exista una capa FUSE.
Pero la dirección se siente correcta. Podemos exponer comportamientos útiles sin publicar la lógica interna del servicio. Podemos depurar con herramientas estándar. Podemos permitir que los programas normales de Linux usen almacenamiento montado. Y podemos mantener la coordinación a nivel de nodo local en lugar de convertir cada acción interna en una API de plataforma.
La lección más importante hasta ahora es que Varlink y FUSE se complementan bien. Varlink no intenta mover datos de archivos. FUSE no intenta ser un protocolo de descubrimiento de servicios. Juntos, crean una forma limpia: llama a un pequeño método local para crear el sistema de archivos, luego usa el sistema de archivos como cualquier otro directorio.
Ese es un patrón que seguiremos usando en la pila de nodos de DownToZero.