In un post precedente abbiamo esaminato Varlink come piccolo esperimento. All’epoca era per lo più un esercizio “hello world”: definire un’interfaccia, aprire un socket Unix, chiamare un metodo, restituire un po’ di JSON. È stato utile, ma era ancora una configurazione da laboratorio.
Da allora abbiamo iniziato a spostare l’idea nello strato node di DownToZero. Due progetti sono particolarmente interessanti qui: dtz-node-stats e dtz-node-volume. Il primo espone informazioni su un nodo worker, inclusi stato dell’alimentazione e dati hardware di base. Il secondo controlla un filesystem FUSE che monta un volume DTZ nella macchina locale.
Questo post non è una passeggiata nel codice sorgente. Parte del codice nei repository circostanti è infrastruttura interna e non è qualcosa che vogliamo pubblicare come guida di implementazione. Ma la forma del sistema, le interfacce e il pattern valgono la pena di essere condivisi, perché Varlink e Zlink si adattano molto bene a questa classe di problemi.
La versione breve è questa: vogliamo che piccoli servizi locali su un nodo siano scopritivi, scriptabili e noiosi da gestire. Varlink ci dà il protocollo. Zlink ci dà un’implementazione Rust che si adatta ai nostri servizi asincroni. FUSE ci offre un modo per presentare storage remoto o basato su oggetti come un filesystem normale. Mess i insieme, questo permette al codice di orchestrazione di livello superiore di dire “monta questo volume per questo contesto” senza sapere come il driver del filesystem parla con lo storage.
I nodi DownToZero non sono solo anonime macchine Linux. Fanno parte di una piattaforma attenta all’energia. Ci interessa da dove proviene l’energia, quanta ne consuma un nodo, se una macchina sta funzionando a batteria o a energia solare, e quali carichi di lavoro dovrebbero essere collocati lì.
Allo stesso tempo, le funzionalità a livello di nodo non dovrebbero tutte finire dentro un unico demone gigante. Raccolta metriche, gestione dell’alimentazione, mount dei volumi, scheduling dei container, logica di aggiornamento e compiti di manutenzione locale hanno tutti cicli di vita diversi. Alcuni dovrebbero riavviarsi indipendentemente. Alcuni necessitano di permessi speciali. Alcuni dovrebbero rimanere abbastanza piccoli da poter essere compresi e testati in isolamento.
Il problema è il coordinamento. Una volta che questi pezzi sono processi separati, hanno bisogno di un modo pulito per parlarsi. Un server REST su localhost funzionerebbe, ma porta con sé routing HTTP, porte, questioni TLS e solitamente più superficie di quel che ci serve. Un protocollo socket Unix personalizzato funzionerebbe anche, ma allora ogni servizio inventa il proprio framing, modello di errori e storia di debug. D-Bus è consolidato, ma volevamo qualcosa di più semplice per API macchina specifiche per servizio.
Varlink si situa bene nel mezzo. Usa socket, ha un protocollo semplice basato su JSON, supporta l’introspezione ed è facile da chiamare da strumenti a riga di comando. Per i servizi Rust, Zlink ci offre gestione tipata dei metodi mantenendo il formato wire facile da ispezionare.
Il risultato è una superficie API locale che sembra più:
varlinkctl call /tmp/dtz.varlink rocks.dtz.Node.GetInformation {}
e meno come progettare un nuovo control plane da zero.
dtz-node-stats è il più semplice dei due progetti. Il suo compito è raccogliere ed esporre fatti a livello di nodo: core CPU, memoria, metriche relative all’uptime, fonte di alimentazione e consumo energetico. Può integrarsi con un misuratore di potenza, per esempio un dispositivo Tasmota, o ricadere su stime di consumo configurate.
La parte interessante per questo post è l’interfaccia Varlink. Il servizio ascolta su un socket Unix ed espone pochi metodi mirati:
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
Chiamare GetInformation restituisce fatti stabili sul nodo come l’ID del nodo, la fonte di alimentazione configurata, i core CPU e la dimensione della memoria. Chiamare GetPowerState restituisce l’attuale stato energetico e il consumo cumulativo in watt-ora.
Questo è già utilizzato da due ruoli worker sui nostri nodi.
Lo sync-worker chiama GetInformation durante l’avvio e usa l’ID nodo restituito come identità locale. Usa anche i valori di CPU e memoria riportati quando si annuncia al resto della piattaforma. Ciò significa che il worker non ha bisogno di un secondo file di configurazione per informazioni di capacità di base e non deve inventare una propria idea della macchina su cui gira. Il servizio node è la fonte locale di verità.
Lo async-worker usa lo stesso pattern, ma per uno scopo leggermente diverso. Legge le informazioni del nodo una volta all’avvio, le conserva con lo stato di runtime del worker e poi usa l’ID del nodo quando gestisce le richieste, segnala lo stato di salute e tagga le metriche. Se poi si guarda la latenza o i contatori delle richieste, l’etichetta del nodo proviene dalla stessa identità supportata da Varlink che vedono gli altri servizi locali.
Questo è il motivo per mantenere l’interfaccia piccola. dtz-node-stats non deve sapere che tipo di lavoro svolge ogni processo. Deve solo rispondere in modo coerente a poche domande a livello di nodo. I worker possono quindi usare quelle risposte per registrazione, suggerimenti di routing, metriche e visibilità operativa senza copiare la logica di rilevamento dell’host in ogni servizio.
La scelta di design importante è che questo non è un endpoint di metriche che finge di essere un’API di controllo. Prometheus può ancora ricevere metriche. I log possono ancora andare su journald. Ma se un altro processo locale ha bisogno di fare una domanda al servizio nodo, ottiene un piccolo metodo tipato invece di fare scraping di output testuali o importare codice di implementazione.
Il metodo Shutdown è anche un buon esempio del perché l’IPC locale sia utile. Durante gli aggiornamenti, un nuovo processo può trovare un socket esistente. Invece di fallire alla cieca o cancellare il file socket, il percorso di avvio può verificare se un servizio è attivo e chiedergli di spegnersi. Se il socket è obsoleto, può rimuoverlo. Se il servizio è vivo, può richiedere una chiusura ordinata. Sembra poca cosa, ma rimuove molta imbarazzante complessità operativa dalle macchine di edge.
dtz-node-volume è dove il pattern diventa più interessante. Il servizio monta un volume supportato da DTZ come filesystem locale. Dalla prospettiva del software che gira sul nodo, il risultato è semplicemente una directory sotto ./dtz/.... I file possono essere creati, letti, scritti, elencati e rimossi usando normali operazioni POSIX.
Sotto il cofano, il filesystem è implementato con FUSE. FUSE è un confine potente: il kernel inoltra le richieste del filesystem a un processo in spazio utente, e quel processo decide come soddisfarle. Ciò significa che il backend di storage può essere un object store, un’API, una cache, un servizio remoto o una combinazione di questi. Le applicazioni non devono saperlo. Chiamano semplicemente open, read, write, readdir, getattr e così via.
Il pezzo mancante è il controllo. Un filesystem FUSE è utile dopo che è stato montato, ma qualcosa deve ancora decidere quando montarlo, per quale contesto, a quale percorso e quando smontarlo di nuovo. Non vogliamo che ogni chiamante conosca i dettagli di configurazione di FUSE. Non vogliamo nemmeno esporre l’implementazione dello storage direttamente a ogni parte del nodo.
Quindi dtz-node-volume espone un’interfaccia 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
L’interfaccia è intenzionalmente piccola. Montare un volume è un’azione di controllo locale. Il chiamante fornisce il contesto e l’ID del volume. Il servizio trasforma questo in un mountpoint come:
/dtz/{context_id}/{volume_id}
La risposta è altrettanto minimale:
{
"mountpoint": "/dtz/context-.../volume-...",
"status": "mounted"
}
Da lì, il chiamante non parla Varlink per ogni lettura di file. Sarebbe l’astrazione sbagliata. Varlink controlla il ciclo di vita del mount. FUSE gestisce le operazioni del filesystem. Questa separazione è il punto principale.
È allettante costruire tutto come un’API RPC. Per i volumi, ciò significherebbe metodi come ReadFile, WriteFile, ListDirectory, DeletePath e dozzine di altri. Questo approccio funziona, ma allora ogni consumatore ha bisogno di un client personalizzato. Strumenti esistenti come cp, rsync, editor, shell e sistemi di build non possono usarlo direttamente.
FUSE ci permette di evitarlo. Il data plane rimane l’interfaccia filesystem che i programmi Linux già comprendono. Il control plane diventa un piccolo servizio Varlink.
Per il ciclo di vita del volume, lo sync-worker possiede la preparazione del carico di lavoro locale. Quando un workload dichiara un volume, lo sync-worker chiede al servizio volume se è già montato e chiama Mount se necessario. Poi effettua un bind-mount del percorso host risultante nel workload. Dopo di che, il workload fa IO sui file normalmente; non sa nulla di Varlink o dell’object store.
Una vista a sequenza si adatta meglio di un diagramma di topologia qui, perché la parte interessante è l’ordine delle operazioni:
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
Questo mantiene l’API piccola e l’esperienza utente normale. Lo sync-worker può montare il volume poco prima che un workload inizi. Dopodiché, il workload usa una directory. Quando il workload termina, lo stesso ruolo worker può smontarlo o lasciarlo disponibile per riutilizzo, a seconda della policy di ciclo di vita.
Per noi, questo è esattamente il tipo di interfaccia che vogliamo su un nodo. È abbastanza esplicita da poter essere automatizzata, ma non costringe ogni workload a imparare un’API di storage specifica DTZ.
Ci sono molti modi per mappare un filesystem su un object store. Stiamo usando un layout che separa i metadati dello spazio dei nomi dai dati dei file.
In termini semplificati:
fs/ filesystem namespace and metadata
data/ larger file chunks
Ogni file o directory ha un oggetto di metadata sotto il prefisso fs/. Quel metadata contiene gli attributi in stile POSIX di cui abbiamo bisogno: mode, ID dei proprietari, dimensione, tempo di modifica, flag di directory e attributi estesi opzionali. I contenuti di file piccoli possono essere inclusi direttamente nell’oggetto metadata. I file più grandi usano un riferimento ai dati e i loro byte sono memorizzati come chunk sotto data/.
Questo ha alcune proprietà utili.
Le liste delle directory devono ispezionare solo lo namespace. Non devono filtrare tra i chunk dei file. I rename possono essere economici perché l’oggetto metadata può spostarsi mentre i chunk dati più grandi restano dov’è. I file sparse sono possibili perché i chunk non scritti non devono esistere. I file piccoli evitano viaggi supplementari perché il loro contenuto vive nei metadata.
Di nuovo, i dettagli esatti di implementazione non sono la parte interessante da pubblicare. Il punto architetturale importante è che lo storage a oggetti e i filesystem POSIX hanno punti di forza differenti. Lo strato FUSE traduce tra di essi. Varlink non cerca di far parte di quella traduzione; si limita ad avviare, fermare e riportare lo stato del mount.
Un dettaglio che abbiamo aggiunto al design del volume è il supporto agli attributi estesi. Questo può sembrare una caratteristica di nicchia del filesystem, ma conta una volta che vuoi che la directory montata si comporti come un filesystem reale invece che come una demo.
Gli attributi estesi sono usati per metadata utente, capability, etichette di sicurezza, metadata desktop e vari suggerimenti specifici delle applicazioni. Se li ignoriamo completamente, molte cose funzionano ancora, ma l’astrazione perde. Se li preserviamo nei metadata, il mount FUSE diventa molto più compatibile con le aspettative normali di Linux.
Il design pratico è semplice: i metadata possono contenere una mappa opzionale di nomi xattr verso valori codificati in base64. Le operazioni FUSE come getxattr, setxattr, listxattr e removexattr diventano operazioni di read-modify-write sull’oggetto metadata.
Ci sono compromessi. Aggiornare gli xattr riscrive i metadata. Aggiornamenti concorrenti dei metadata richiedono attenzione. Xattr molto grandi sarebbero una cattiva idea. Ma il pattern è comunque una buona corrispondenza per il tipo di metadata che ci aspettiamo attorno ai volumi montati.
Il metodo GetInfo su dtz-node-volume restituisce i mount attivi correnti. Questo include il contesto, l’ID del volume, il mountpoint, lo stato e il timestamp dell’ultimo accesso osservato.
Quest’ultimo campo è piccolo ma utile. Un orchestratore a livello di nodo può chiedere:
varlinkctl call /tmp/dtz-volume.varlink rocks.dtz.Volume.GetInfo {}
e ottenere una vista corrente di ciò che il servizio volume ritiene montato. Questo è utile per il debug, la pulizia e in seguito per comportamenti più attenti all’energia. Se un mount non è stato accessato per un po’, forse può essere smontato. Se un workload parte, forse il mount dovrebbe essere creato just-in-time. L’interfaccia Varlink ci dà un posto per esporre quello stato senza accoppiare i chiamanti all’implementazione FUSE.
Stiamo costruendo la maggior parte di questo codice lato nodo in Rust, quindi l’ergonomia Rust è importante. Zlink ci permette di mantenere l’implementazione del servizio tipata pur parlando il protocollo Varlink. Le chiamate ai metodi deserializzano in enum e struct Rust. Le risposte si serializzano indietro nella forma JSON attesa. L’introspezione Varlink standard è solo un altro metodo esposto dal servizio.
Questo ci dà un equilibrio piacevole. L’interfaccia rimane ispezionabile dall’esterno:
varlinkctl call /tmp/dtz-volume.varlink org.varlink.service.GetInfo {}
ma il codice del servizio non diventa un ammasso di gestione di richieste fortemente basate su stringhe.
Rende anche gli strumenti locali facili. Durante lo sviluppo, un comando shell può montare un volume, elencare i mount o chiedere al nodo il suo stato di alimentazione. Durante le operazioni, systemd può gestire il processo del servizio e il percorso del socket Unix può rimanere locale al nodo. Non abbiamo bisogno di un listener di rete pubblico per queste azioni.
Una delle ragioni per cui abbiamo scelto Varlink non è solo che il protocollo è standardizzato. Anche gli strumenti intorno ad esso sono standardizzati. In pratica, questo conta tanto quanto la definizione dell’interfaccia.
Con varlinkctl, ogni servizio diventa immediatamente utilizzabile da una shell:
varlinkctl call /tmp/dtz.varlink rocks.dtz.Node.GetInformation {}
varlinkctl call /tmp/dtz-volume.varlink rocks.dtz.Volume.GetInfo {}
Questo significa che il lavoro di manutenzione non richiede un binary di debug speciale o un piccolo client ad-hoc. Se un nodo si comporta in modo strano, possiamo fare SSH e chiedere ai servizi locali cosa sanno. Se uno script di aggiornamento ha bisogno di verificare se un volume è montato, può chiamare direttamente il servizio Varlink. Se un’unità systemd o un job di manutenzione in stile cron ha bisogno di una chiusura ordinata, può usare lo stesso metodo pubblico dei client Rust.
Questa è una grande differenza rispetto a un’API di libreria interna. Una libreria è comoda solo per programmi scritti nella stessa lingua e rilasciati con la stessa cadenza. Un socket Varlink con varlinkctl è comodo per codice Rust, script shell, playbook di manutenzione e debug manuale. Per l’infrastruttura dei nodi, quella flessibilità è preziosa.
Il caso d’uso immediato è pratico: montare volumi DTZ sui nodi worker ed esporre lo stato del nodo agli altri servizi locali. Ma il pattern è più grande di questi due progetti.
Un servizio locale può esporre una piccola API di controllo Varlink mentre fa qualcosa di più complesso internamente. Quel “qualcosa” potrebbe essere un filesystem FUSE, un raccoglitore di metriche, un controllore hardware, un aiutante per lo scheduler o un updater. Il chiamante ottiene un’interfaccia metodica stabile. Il servizio mantiene privata la sua implementazione.
Per DownToZero questo è particolarmente utile perché stiamo cercando di rendere l’infrastruttura più piccola e più attenta all’energia. Un nodo può esporre le sue capacità localmente. I servizi di livello superiore possono prendere decisioni basate sullo stato di alimentazione, risorse disponibili e storage montato. I workload possono comunque interagire con file e directory normali.
Questo mantiene anche chiare le responsabilità:
dtz-node-stats segnala che cosa è il nodo e come è alimentato.dtz-node-volume controlla quando i volumi DTZ appaiono come filesystem locali.Questo è ancora in evoluzione. Le interfacce sono intenzionalmente piccole perché è più facile far crescere un’API locale che ridurla dopo che più servizi dipendono da essa. Il servizio volume è anche il posto dove ci aspettiamo di imparare di più. I filesystem hanno molti casi limite, e gli object store non diventano magicamente filesystem POSIX solo perché esiste uno strato FUSE.
Ma la direzione sembra corretta. Possiamo esporre comportamenti utili senza pubblicare la logica interna del servizio. Possiamo fare debugging con strumenti standard. Possiamo lasciare che i normali programmi Linux usino lo storage montato. E possiamo mantenere il coordinamento a livello di nodo locale invece di trasformare ogni azione interna in un’API di piattaforma.
La lezione più importante finora è che Varlink e FUSE si completano bene a vicenda. Varlink non cerca di spostare i dati dei file. FUSE non cerca di essere un protocollo di discovery dei servizi. Insieme, creano una forma pulita: chiamare un piccolo metodo locale per creare il filesystem e poi usare il filesystem come qualsiasi altra directory.
Questo è un pattern che continueremo a usare nello stack dei nodi DownToZero.