Come promesso alla fine del post su Watchtower Controller, siamo arrivati alla “prossima puntata“: il trasloco di tutti i miei container da Portainer a Komodo.
Facciamo prima un po’ di inventario: in casa avevo un Portainer “master” sulla VM Docker principale, con un portainer_agent sparso su ogni container LXC e sul NAS, più un Portainer a testa sui due Raspberry che tengo a Ravenna.
Accanto a lui, giusto per non farmi mancare nulla, giravano DockMon (che di fatto faceva un pezzo del lavoro di Komodo), Beszel per le metriche (che non guardavo più da mesi), un Watchtower per host e, ciliegina, due Komodo Core: uno di produzione e uno sulla VM di laboratorio, nato per fare due prove e poi rimasto lì a gestire una dozzina di stack veri. Ogni host, insomma, aveva tre o quattro agenti diversi che parlavano con il socket Docker, e io non avevo un solo posto dove guardare per sapere cosa girasse e dove, e pensa pure che non credevo di farmi così tanto del male, beati errori di gioventù (a mia discolpa i Komodo sono arrivati proprio per dare una sorta di ordine).
Perché proprio adesso? Colpa (o merito) della solita chat con i compari di crimini e self-hosting (e di qualsiasi altra stronzata nerd esistente sulla faccia della terra), dove a un certo punto è saltata fuori questa discussione su r/selfhosted: Portainer che taglia il cordone tra la versione gratuita e quella a pagamento.
È stata la scintilla che mi ha fatto guardare attorno, con un obiettivo preciso: giudicare (guardo troppo Bruno Barbieri) una sola grande console centralizzata per tutte le macchine Docker, che mi permettesse anche di spegnere Beszel e DockMon. Komodo è un nome che su Reddit e GitHub gira parecchio, l’ho provato e mi ci sono trovato bene da subito. Da lì al trasloco il passo è stato piuttosto breve.
Il lavoro l’ho fatto in buona parte in una lunga sessione con Claude Code, affiancato a me nel terminale, con accesso SSH al Proxmox e da lì a VM e container. Io decidevo cosa e quando, lui faceva il lavoro sporco (export, confronti, script) e teneva un diario di bordo di tutto, che è poi il motivo per cui oggi riesco a raccontarti la storia con i numeri giusti e non a memoria.
Un solo Komodo per governarli tutti
Il primo passo è stato chiudere il Komodo di laboratorio.
Il Core di produzione resta sulla VM Docker principale, quello del laboratorio andava spento, ma senza perdere gli stack che nel frattempo ci erano finiti dentro (compreso l’alerter Telegram che manda le notifiche di Komodo, un servizio di produzione che girava allegramente sul laboratorio).
Sulla VM del laboratorio è stata installata una Periphery nuova, collegata al Core di produzione, e gli stack sono stati importati via API, con tag e descrizioni. Prima di toccare qualsiasi cosa è stato fatto un export completo del laboratorio (e relativi variabili, secret, ecc.), e dopo l’import le configurazioni sono state confrontate una a una: 12 su 12 identiche. Nessun deploy, nessun container riavviato. Il vecchio Core del laboratorio l’ho tenuto fermo con i volumi ancora al loro posto per qualche giorno, poi l’ho fatto sparire del tutto.
Il trucco che rende possibile tutto questo (e che vale anche per Portainer, ci arrivo tra poco) è banale: Docker Compose riconosce uno stack dal nome del progetto. Se in Komodo crei uno stack con lo stesso nome e lo stesso compose di quello che gira già, per Docker non è cambiato niente: stessi container, stessi volumi, stessa rete. Komodo se ne accorge, lo vede come “running” e da quel momento lo gestisce lui.
Periphery su Alpine (e quel binario che non ne voleva sapere)
Molti dei miei container LXC sono Alpine con Docker dentro. Lì la Periphery girava come container, ma volevo il binario nativo, gestito dal sistema di init, senza un altro container da tenere aggiornato che monta il socket Docker. Il problema è che il binario ufficiale della Periphery è compilato contro glibc, e Alpine usa musl. Il classico gcompat non basta (manca __res_init, e il binario non parte nemmeno).
La soluzione trovata è un pelo creativa ma regge: un piccolo runtime glibc privato in /opt/glibc, preso da un’immagine debian:trixie-slim, e il binario della Periphery “ritoccato” con patchelf perché usi solo quello:
patchelf --set-interpreter "$GLIBC_DIR/$LOADER" --set-rpath "$GLIBC_DIR" "$BIN.new"
Il resto di Alpine resta musl e non se ne accorge nemmeno. Il servizio è un normale servizio OpenRC con supervise-daemon (riavvio automatico testato, uccidendo il processo a mano), e tutto è finito in uno script che installa, aggiorna e, se una versione nuova della Periphery dovesse chiedere una glibc più recente, rinfresca il runtime e riprova. Questo mi ha permesso anche di guadagnare un po’ di spazio disco che con la soluzione del container avrei invece consumato in maniera stupida.
Senza Claude Code, a questa soluzione non ci sarei arrivato, lo ammetto candidamente.
Portainer → Komodo, uno stack alla volta
Arrivati a Portainer, il metodo è stato lo stesso utilizzato per la macchina di laboratorio, ripetuto host per host: export di sicurezza di compose ed env da Portainer, creazione dello stack in Komodo con lo stesso nome, confronto di compose e variabili d’ambiente, nessun redeploy. Un totale di 25 stack passati senza colpo ferire da Portainer a Komodo, e senza che nessun container si accorgesse di nulla, alla faccia dell’hot-swap. A ognuno è rimasta una descrizione “Migrated from Portainer stack #N“, che non serve a niente finché non serve (e allora ti salva).
Il Portainer master l’ho disinstallato alla fine di tutti i passaggi, e la stessa fine è toccata ai residui di portainer-backup e i portainer_agent da tutti gli host. Metodo poi replicato ancora una volta per i Raspberry di Ravenna, per i due Portainer locali e per Watchtower: gli aggiornamenti ora li fa Komodo.
Già che c’eravamo, via anche DockMon (server, agent, volumi, immagini, script di aggiornamento) e Beszel, con tutto il suo storico di metriche che nessuno guardava.
Oggi Portainer non esiste più da nessuna parte nella mia infrastruttura.
Il NAS e i Raspberry lontani
Due casi meritano due righe a parte. Uno dei due NAS casalinghi è uno UGREEN con UGOS, che sotto è una Debian 12. Lì la Periphery gira in container, ma con la cartella di lavoro su /volume1, perché la partizione di sistema di UGOS non è un posto dove lascerei cose che devono sopravvivere a un aggiornamento del firmware. Tutto il resto è andato liscio (7 stack migrati, agent di Portainer e DockMon rimossi), con un’unica avvertenza che mi sono appuntato: l’app Docker di UGOS potrebbe riscrivere daemon.json a ogni aggiornamento, quindi se un giorno Uptime Kuma perde il NAS, so dove andare a guardare per primo.
I due Raspberry di Ravenna, invece, sono fuori casa e arrivano solo via Tailscale. La via facile sarebbe stata fargli accettare le rotta della tailnet, ma così avrebbero visto tutta la LAN di casa mia (e uno dei due sta in una rete che non è nemmeno mia). Ho preferito un tailscale serve sul container LXC che fa da nodo Tailscale in casa: espone sulla tailnet solo la porta del Komodo Core (e poi quella di PatchMon, e quella dei push verso Uptime Kuma), e i Raspberry vedono quelle e basta.
Una piccola nota che mi salvo qui per imperitura memoria (visto che mi ha fatto perdere del tempo): la chiave pubblica del Core va messa in formato PEM, altrimenti la Periphery si lamenta con un poco amichevole “Public key is not base64“.
Il secondo inciampo è arrivato qualche settimana dopo, e onestamente non ci avevo pensato.
Dopo aver aggiornato Tailscale sui due Raspberry (alla 1.104.4), Komodo li segnava entrambi offline. I Raspberry rispondevano, i container giravano, e dalla shell del Raspberry la porta del Core si raggiungeva senza problemi. Dal container della Periphery no: timeout. Il colpevole è lo stateful filtering di Tailscale, che dopo l’aggiornamento risultava attivo: aggiunge una regola che scarta le connessioni nuove inoltrate verso l’interfaccia tailscale0, e il traffico di un container sulla rete bridge di Docker, per il kernel, è proprio traffico inoltrato. Tailscale lo segnala anche nel suo health check (“Stateful filtering is enabled and Docker was detected“), ma solo se vai a guardare.
Due possibili strade: spegnere lo stateful filtering (tailscale set --stateful-filtering=false) oppure mettere la Periphery sulla rete dell’host, con network_mode: host nel compose. Ho scelto la seconda: il firewall di Tailscale resta com’è, e la Periphery esce in rete come qualsiasi altro programma del Raspberry, senza dipendere da come Tailscale deciderà di gestire il forwarding al prossimo aggiornamento. In termini di sicurezza cambia poco: la Periphery monta già il socket di Docker (quindi è di fatto root sull’host), e lavorando in uscita verso il Core non apre nessuna porta in ascolto (verificato, non dato per scontato). Unica avvertenza: è una modifica al compose standard, quindi se un giorno reinstallo la Periphery dallo script ufficiale dovrò ricordarmi di rimetterla.
Per tutti i server nuovi la procedura è stata sempre la stessa: onboarding key creata al momento in Komodo, Periphery installata, key tolta dalla configurazione e cancellata da Komodo subito dopo. Oggi non ce n’è nessuna attiva.
Un po’ di ordine: tag, Kuma e mTLS
Con 59 stack su 10 server, un minimo di ordine serviva. Ogni stack ha almeno un tag, e ogni tag ha un colore diverso: system per l’infrastruttura, tools, casa, reading, media, dev, test, on-demand (grigio, per quello che è spento o che accendo solo quando serve) e ravenna per i due Raspberry.
Gli stessi nomi sono diventati i gruppi di Uptime Kuma, che nel frattempo era cresciuto a caso per anni: 50 monitor in gruppi misti, status page che non usavo, manutenzioni programmate di luglio mai tolte e un database SQLite da 648 MB (al 98% pagine vuote). Kuma non ha un’API per le modifiche in blocco, quindi il riordino è stato fatto direttamente sul database, con il container fermo e un VACUUM INTO di backup prima di ogni modifica. Risultato: 66 monitor in 6 gruppi, tutti con il nome nella forma Servizio (host), tutti con notifica Telegram, e un database di 7 MB. Ancora una volta il compito è stato delegato a Claude Code, mi ci vedi a fare l’operazione a cuore aperto? Grazie, ma no grazie (mi ero fatto un backup e non è servito, l’ho buttato).
Kuma, per sapere se i container degli altri host sono vivi, parla con il loro Docker Engine. Fino a quel momento lo faceva sulla porta 2375 in chiaro, che è il modo in cui non si dovrebbe fare (ne parlavo anche nel post su Watchtower Controller). Ora c’è una piccola CA privata sul Proxmox, ogni host ha il suo certificato server e la Docker API ascolta in mTLS sulla 2376, accettando solo client con un certificato firmato da quella CA (cioè Kuma). La 2375 è chiusa ovunque, e per ogni host è stato verificato che con il certificato si entra e senza no. I certificati scadono nel 2031, e l’ho già scritto nel calendario delle cose da fare (altrimenti nel 2031 mi ritrovo con Kuma tutto rosso e nessuna idea del perché).
Una lezione imparata cambiando la configurazione di dockerd: con live-restore i container sopravvivono al riavvio del demone, ma quelli che montano /var/run/docker.sock (la Periphery in container, un agent qualsiasi, la homepage) restano agganciati al socket vecchio e vanno riavviati a mano. Me ne sono accorto quando Komodo ha cominciato a dirmi che uno stack era “down” mentre girava benissimo.
Gli aggiornamenti, adesso
Qui si chiude il cerchio con Watchtower.
In Komodo c’è una procedura Global Auto Update che ogni notte alle 03:00 controlla le immagini degli stack che hanno l’aggiornamento automatico attivo, e se c’è qualcosa di nuovo ricrea il servizio e mi avvisa su Telegram. È attiva su 50 stack su 59. Restano fuori, di proposito, quelli che compilano da un repository Git, quelli con immagini costruite in locale e quelli spenti o on-demand.
Per aggiornare Komodo stesso (Core e tutte le Periphery, che devono restare allineati, e nel post su Watchtower Controller ti ho raccontato cosa succede quando non lo sono) c’è invece una Action impostata per girare alle 6 del mattino, che fa il giro solo quando su GitHub è uscita una release nuova da almeno 12 ore. Come funziona nel dettaglio te lo racconto in un post a parte (la prossima puntata, ancora una volta!).
Una curiosità per chi volesse giocare con l’API di Komodo: ListStacks restituisce al massimo 50 elementi.
Con 55 stack (all’epoca) qualcuno spariva dai conteggi, finché non è saltato fuori che per un audit completo conviene interrogare un server alla volta.
In conclusione
Ricapitolando: un Komodo Core, 10 server, 59 stack, nessun Portainer, nessun DockMon, nessun Beszel, nessun Watchtower.
Tutto quello che gira in casa (e a Ravenna) lo vedo in un posto solo, gli aggiornamenti hanno un orario e una notifica, i secret stanno nelle Variabili di Komodo e non nei compose, e Uptime Kuma usa gli stessi nomi e gli stessi gruppi. Il bello è che nessuno stack è stato ricreato da zero: stessi nomi di progetto, stessi volumi, e i container nemmeno se ne sono accorti. Ho volutamente deciso di tenere in piedi Uptime Kuma perché monitora anche altro, so benissimo che Komodo ha già un suo sistema di notifica (e lo uso, sì, pure quello).
Se anche tu hai fatto (o stai pensando di fare) questo passaggio, l’area commenti è a tua disposizione per esprimere dubbi, paure, perplessità e chiedere consiglio a un buon medico, ti spiegherà perché gente come noi adora farsi così del male, gratuitamente.
#KeepItSimple