Qualche giorno fa mi sono imbattuto in un comportamento a dir poco bizzarro sulla mia macchina Ubuntu: digitando nel browser un domain name completamente a caso, tipo http://mememem.it/ o http://nxdomain1.it/ (il nome è voluto: NXDOMAIN …
Ovvero come si scambiavano messaggi instantanei due utenti Unix prima dell’avvento di WhatsApp e Telegram. Qualche giorno fa stavo ragionando sui primi sistemi di messaggistica su ARPANET [E] e mi è tornato in mente il …
Da un po’ di tempo avevo il PC disseminato di container Docker installati qua e là, senza un vero criterio. Ho deciso che era il momento di mettere ordine e, allo stesso tempo, di costruire …
Continuo qui una serie di articoli che riguardano il funzionamento di Docker. In questo post ho condiviso la comprensione del perché Docker non possa essere pensato come un virtualizzatore hardware: non viene virtualizzata una macchina …
Qualche giorno fa Ubuntu mi ha mostrato una notifica che annunciava la disponibilità di un aggiornamento del firmware UEFI del mio portatile HP. Ci ho messo troppo a cliccarla, come spesso accade con le notifiche …
Qualche giorno fa mi sono imbattuto in un comportamento a dir poco bizzarro sulla mia macchina Ubuntu: digitando nel browser un domain name completamente a caso, tipo http://mememem.it/ o http://nxdomain1.it/ (il nome è voluto: NXDOMAIN è il codice DNS che indica “Non-eXistent Domain“, cioè un dominio che non esiste), mi ritrovavo puntualmente sulla home page del mio Apache locale.
Controlli
Il primo sospetto è caduto sul file /etc/hosts, che avevo configurato con qualche entry personalizzata:
Ma qui c’è un dettaglio tecnico che va chiarito subito: /etc/hosts non supporta wildcard. Può mappare singoli nomi esatti a un IP, non può intercettare “qualsiasi dominio non trovato” e reindirizzarlo altrove. Quindi il colpevole doveva essere altrove.
Primo controllo: è un proxy?
Un comportamento “catch-all” di questo tipo è tipico anche di un proxy HTTP mal configurato, quindi ho controllato le variabili d’ambiente e le impostazioni di sistema (GNOME/NetworkManager):
A questo punto ho interrogato direttamente il server DNS con dig, per vedere cosa risponde davvero:
$ dig mememem.it
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 17331
;; AUTHORITY SECTION:
it. 3600 IN SOA dns.nic.it. hostmaster.nic.it. ...
Il DNS risponde correttamente NXDOMAIN, con tanto di risposta autoritativa dal registro .it. Quindi il DNS “puro” funziona benissimo. Ma se il DNS dice che il dominio non esiste, perché il browser mi porta comunque su Apache locale?
Terzo controllo: cosa vede davvero il browser (curl -v)
Qui è arrivato l’indizio decisivo. Ho ripetuto la richiesta con curl -v, che a differenza di dig segue lo stesso percorso di risoluzione usato dal browser (tramite le librerie di sistema, non un’interrogazione DNS “grezza”):
$ curl -v http://mememem.it/
* Host mememem.it:80 was resolved.
* IPv4: 127.0.0.1
* Trying 127.0.0.1:80...
* Connected to mememem.it (127.0.0.1) port 80
< HTTP/1.1 200 OK
< Server: Apache/2.4.58 (Ubuntu)
curl risolveva mememem.it a 127.0.0.1, mentre dig diceva NXDOMAIN. Due strumenti, due risultati opposti sullo stesso nome: la discrepanza andava capita lì.
Quarto controllo: getent, per vedere il nome “vero” risolto
Ho quindi usato getent, che interroga il resolver esattamente come fanno le applicazioni (tramite getaddrinfo):
Ed eccola, la chiave di volta: il sistema non stava affatto risolvendo mememem.it, bensì mememem.it.homenet.telecomitalia.it. Qualcosa, a livello di libreria di sistema, appendeva automaticamente un suffisso al nome prima di interrogare il DNS.
homenet.telecomitalia.it è il dominio di ricerca (search domain) che il modem/router TIM annuncia via DHCP a tutti i dispositivi della rete locale. Il meccanismo di risoluzione di glibc, quando un nome non è “pienamente qualificato”, prova ad appendere automaticamente i domini presenti in search prima di arrendersi. Nel mio caso, quel dominio ha una risposta di tipo wildcard gestita dal router stesso, che punta a 127.0.0.1 — dove, guarda caso, gira il mio Apache locale.
Per completezza, ho anche controllato resolvectl query, che invece restituiva “not found”: conferma che il meccanismo di espansione del suffisso avviene a livello di libreria NSS (nsswitch.conf → modulo dns), non nel motore di systemd-resolved interrogato direttamente.
La causa, in sintesi
Il DNS pubblico funziona correttamente e risponde NXDOMAIN per domini inesistenti.
Il router locale, tramite DHCP, imposta homenet.telecomitalia.it come dominio di ricerca.
Le applicazioni (browser, curl, ecc.) tentano automaticamente <nome>.homenet.telecomitalia.it quando il nome “semplice” fallisce.
Il router risponde con un wildcard su quella zona, restituendo 127.0.0.1.
Apache, in ascolto su 127.0.0.1, accetta la richiesta e risponde con la pagina di default — qualunque fosse il dominio digitato.
La soluzione
Ho eliminato il dominio di ricerca automatico a livello di connessione di rete, così i nomi non vengono più espansi con il suffisso della LAN:
Da quel momento, digitare un dominio inesistente mostra correttamente l’errore del browser, e Apache locale risponde solo alle richieste che gli spettano davvero (es. organizer.test, configurato esplicitamente in /etc/hosts).
Morale
Quando DNS e “quello che vede il browser” non coincidono, il sospetto principale non è quasi mai il DNS server in sé, ma come il resolver di sistema costruisce il nome prima di interrogarlo. dig interroga il nome esatto; curl, il browser e qualsiasi altra applicazione passano invece per getaddrinfo(), che applica anche la logica di search domain definita in resolv.conf. Vale sempre la pena confrontare i due, prima di dare per scontato che “il DNS è rotto”.
Tra l’altro: il problema non si presentava se utilizzavo il mio smartphone come router. Questo poteva essere un altro indizio utile perla diagnosi.
Ovvero come si scambiavano messaggi instantanei due utenti Unix prima dell’avvento di WhatsApp e Telegram.
Invio di messaggi tra terminali Unix
Qualche giorno fa stavo ragionando sui primi sistemi di messaggistica su ARPANET [E] e mi è tornato in mente il comando Unix write, quello che consente a un utente di scrivere direttamente sul terminale di un altro utente collegato alla stessa macchina.
In sostanza ricordavo [B] che Ray Tomlison aveva inventato lo standard nome@host per consegnare un messaggio testuale all’utenti Unix nome sul computer remoto host. Lui lo faceva con due programmi seprarati SNDMSG (che consentiva di lasciare dei messaggi ad altri utenti sullo stesso computer) , e CPYNET, che permetteva di copiare file da un computer ARPANET a un altro. Questa cosa mi ha ricordato che all’università utilizzavo un comando (che non rammentavo) per inviare un messaggio immediato ad un utente ad un’altra macchina. Non è una mail, è un messaggio immediato, tipo Telegram, e non è quello che faceva il prototipo di posta elettronica scritto da Tomlinson.
Cercando di ricordare questa cosa che sembrava una curiosità da archeologia informatica, invece, si è aperto un piccolo mondo.
Supponiamo allora di avere due utenti collegati alla stessa macchina Linux. Dal mio terminale eseguo:
sudo write marcob tty3
e scrivo:
ciao Marco come stai?
Passando alla console dell’altro utente, su tty3, compare qualcosa del tipo:
Message from marcob@bombelli (as root) on pts/1 at 19:30
ciao Marco come stai?
La prima sorpresa è questa:
perché sto lavorando dalla sessione grafica (GNOME) su tty2, ma il messaggio dice che proviene da pts/1?
La risposta è che la console grafica e il terminale che vediamo nella finestra non sono la stessa cosa.
Cos’è un terminale e cos’è una sessione grafica GNOME
A questo punto conviene chiarire una distinzione che oggi è facile perdere di vista.
Se non lo avete mai provato è divertnte: premete contemporaneamente sulla tasietera CTRL-ALT-F3. Vi si presenta uno schermo nero con scritto:
marcob@bombelli
E basta. Non c’è mouse, non c’è possibilità di fare screenshot… è un bagno di umiltà che vi catapulta indietro di 40 anni dove c’era solo tastiera e monitor.
Quando con Ctrl+Alt+F3 passo a tty3, non sto aprendo una normale finestra di terminale: sto passando a una console virtuale testuale di Linux.
Lì non c’è GNOME, non c’è un terminal emulator e, soprattutto, non c’è l’ambiente grafico a cui siamo abituati. Il sistema mi mette direttamente a disposizione uno schermo testuale e una tastiera.
Per questo l’esperienza è molto diversa.
Su tty3 posso digitare comandi e vedere caratteri, ma normalmente non ho:
il mouse per selezionare il testo;
il copia e incolla dell’ambiente grafico;
menu e finestre;
strumenti grafici per fare uno screenshot;
tutte le comodità offerte dal desktop.
È, in un certo senso, molto più vicino all’idea storica di terminale: un dispositivo attraverso il quale un utente comunica con il sistema usando essenzialmente input e output testuale.
Quando invece lavoro in GNOME e apro l’applicazione Terminale, la situazione è completamente diversa.
Ho prima di tutto una sessione grafica, associata per esempio a:
/dev/tty2
All’interno di quella sessione gira GNOME. Quando apro GNOME Terminal, il programma crea uno pseudo-terminale, per esempio:
/dev/pts/1
La shell bash comunica con quello pseudo-terminale come se avesse davanti un vero terminale.
Nel secondo caso il terminale che vediamo è quindi una simulazione software di un terminale, inserita dentro un ambiente grafico.
Questo spiega anche perché posso selezionare il testo con il mouse, copiarlo, incollarlo, ridimensionare la finestra o fare uno screenshot: queste funzioni non appartengono al terminale Unix in sé, ma al programma grafico che lo emula e al desktop che lo contiene.
In altre parole, quando sono su tty3 sto usando direttamente una console testuale del sistema; quando sono dentro GNOME Terminal sto usando una finestra grafica che si comporta, agli occhi della shell, come un terminale.
Ed è proprio per rendere possibile questa illusione che esistono gli pseudo-terminali /dev/pts/*.
Quindi: la sessione grafica può essere associata a una console virtuale, ma quando apro GNOME Terminal viene creato uno pseudo-terminale. La shell bash che utilizzo nella finestra può quindi avere come terminale:
/dev/pts/1
Lo si può verificare banalmente con il comando: da sessione grafica GNOME ottengo
$ tty
/dev/pts/0
Dal terminale virtuale (/dev/tty3) invece
$ tty
/dev/tty3
Anche solo scrivere questo articolo amplifica questa differenza: mentre posso fare copia incolla di /dev/pts/0 dal terminale GNOME, non posso farlo dal terminale virtuale! Devo ricopiare a mano /dev/tty3!
La sigla pts significa storicamente pseudo-terminal slave: uno pseudo-terminale è formato da una coppia di estremità, una controllata dal terminal emulator e una che viene presentata alla shell come se fosse un vero terminale.
Dall’altra parte, invece, tty3 è una vera console virtuale Linux:
/dev/tty3
Quindi tty3 e pts/3 non hanno alcun rapporto particolare: il numero uguale sarebbe solo una coincidenza.
Inciso: l’attributo virtuale è perché ho solo un terminale reale nel mio PC: il monitor; però in questo stesso terminale reale posso vedere più terminali virtuali.
La seconda sorpresa arriva osservando cosa fa realmente write.
Concettualmente è molto vicino a:
echo "ciao" > /dev/tty3
Il terminale è infatti rappresentato da un device file e un processo può scriverci dentro.
write aggiunge naturalmente parecchia logica: individua il terminale dell’utente, verifica i permessi, controlla se i messaggi sono consentiti e gestisce l’interazione.
Ma il principio rimane sorprendentemente semplice:
E qui arriva la parte che mi ha interessato di più.
Il programma write deve leggere ciò che digito e scriverlo sull’altro terminale.
In termini Unix:
read()
write()
Esattamente le stesse primitive che avevo già incontrato lavorando con i socket.
Con un terminale:
/dev/pts/1
|
read()
|
programma
|
write()
|
/dev/tty3
Con una connessione di rete:
programma
|
write()
|
socket
|
rete
|
socket
|
read()
|
programma
E improvvisamente la frase Unix
everything is a file
diventa molto meno astratta.
Non significa naturalmente che un socket sia un file memorizzato su disco. Significa piuttosto che Unix cerca di fornire un’interfaccia uniforme a oggetti molto diversi.
File, terminali, pipe e socket possono essere gestiti attraverso file descriptor e primitive come read() e write().
Anche i famosi:
0 = stdin
1 = stdout
2 = stderr
sono semplicemente file descriptor già aperti dal processo.
e scoprire che tutti e tre, normalmente, puntano proprio al mio pseudo-terminale:
/dev/pts/1
Sono partito da un vecchio comando per mandare un messaggio a un altro utente e sono finito dentro uno dei principi fondamentali dell’architettura Unix.
Non è un post sull’AI, probabilmente non farà grandi numeri.
Ma trovo ancora molto affascinante scoprire che sotto strumenti modernissimi continuano a vivere idee progettate decenni fa.
Credo che questo diventerà il primo di una piccola serie su terminali, file descriptor, pipe, socket e processi Unix.
Da un po’ di tempo avevo il PC disseminato di container Docker installati qua e là, senza un vero criterio. Ho deciso che era il momento di mettere ordine e, allo stesso tempo, di costruire qualcosa di nuovo e di concreto: un piccolo ecosistema locale in cui n8n, Python e Ollama (che avevo già installato) potessero parlarsi.
Docker Containerization software
Questo post riassume la mia prima esperienza, con particolare attenzione a un dettaglio che inizialmente ho dovuto assimilare bene: come fanno davvero a comunicare un container Docker e un processo che gira sul PC host.
L’idea è semplice: usare n8n come motore di automazione (workflow low-code), farlo girare in un container Docker, e collegarlo a Ollama, che gira già nativamente sul mio PC (a livello di host, non in un container).
Un file docker-compose.yml per orchestrare i servizi, con n8n esposto sulla porta 5678 e un piccolo servizio Python (FastAPI) pronto per essere richiamato in futuro quando servirà logica più complessa.
n8n workflow automation platform
Il primo ostacolo: i permessi
Al primo avvio, n8n si è rifiutato di partire con questo errore:
Error: EACCES: permission denied, open '/home/node/.n8n/config'
Il problema era la cartella n8n_data, creata automaticamente da Docker con proprietario sbagliato. La soluzione:
sudo chown -R 1000:1000 n8n_data
Sull’utente:gruppo1000:1000 è il caso di fare una piccola digressione. Qui c’è un dettaglio che vale la pena capire bene, perché tocca un concetto centrale di come funzionano i permessi con Docker: i permessi Linux si basano sul numero UID, non sul nome utente.
Il mio utente sul PC host (marcob) ha UID 1000 — è la convenzione standard su Ubuntu: il primo utente creato su un sistema riceve quasi sempre questo numero. Per pura coincidenza numerica, anche l’immagine ufficiale di n8n crea al suo interno un utente chiamato node, anch’esso con UID 1000.
Quando lancio chown -R 1000:1000 n8n_data, sto quindi rendendo proprietario della cartella il mio stesso utente host — non un utente “del container”. Solo che il kernel Linux dell’host non sa nulla del nome node, e il processo dentro il container non sa nulla del nome marcob: entrambi vedono soltanto un numero, 1000, e siccome coincide da entrambe le parti, il container riesce a scrivere nella cartella senza problemi.
Se il mio utente host avesse avuto un UID diverso (es. 1001, come capita se non sei il primo utente creato sul sistema), avrei dovuto usare chown -R 1001:1001 — non perché serva “indovinare” il numero giusto per il container, ma perché l’importante è far coincidere l’UID host con quello che il processo usa dentro al container.
Il secondo ostacolo: Ollama non rispondeva
Una volta partito n8n, ho provato a costruire il primo workflow: un trigger manuale che chiama Ollama per generare del testo con il modello phi3:mini. Ma la chiamata falliva.
Il motivo: avevo avviato Ollama con ollama serve, che di default ascolta solo su 127.0.0.1 — cioè accetta connessioni solo dal PC stesso, non da un container Docker. La soluzione è stata avviarlo così:
OLLAMA_HOST=0.0.0.0 ollama serve
Con questa variabile d’ambiente, Ollama si mette in ascolto su tutte le interfacce di rete del PC, non solo su quella “interna”.
Altro punto da chiarire bene: come si parlano host e container
Qui sta il cuore tecnico di questa esperienza, ed è la parte che vale la pena spiegare bene, perché all’inizio l’ho capita al contrario. È un chiarimento sul funzionamento di Docker più che di n8n.
Un container Docker, di norma, non vede il PC che lo ospita come “localhost”. Dal punto di vista del container, l’host è un sistema esterno, raggiungibile solo attraverso un indirizzo IP dedicato.
Su Linux, Docker crea un’interfaccia di rete virtuale sul PC chiamata docker0, con un proprio indirizzo IP (nel mio caso 172.17.0.1). Questa interfaccia è, a tutti gli effetti, un secondo “connettore di rete” del mio stesso computer, distinta da quello WiFi che uso per navigare su internet (wlo1, con IP 192.168.1.9).
Ecco lo schema che riassume la comunicazione:
Docker network diagram per n8n con Ollama
Come si legge:
Il PC host ha due interfacce di rete distinte: wlo1 verso internet/casa, e docker0 verso i container.
Ollama è un processo dell’host, non è dentro nessun container e non è “parte” di docker0. Semplicemente, avviato con OLLAMA_HOST=0.0.0.0, ascolta su entrambe le interfacce.
Secondo il container n8n, localhost localhost è se stesso, non si riferisce alla macchina ospite; inoltre il container n8n non conosce il WiFi: vede solo l’interfaccia docker0 come sua unica via d’uscita verso l’esterno.
Per questo, nel nodo HTTP Request di n8n, l’URL corretto non è http://localhost:11434, ma:
http://host.docker.internal:11434/api/generate
host.docker.internal è un nome speciale che Docker, su Linux, non risolve automaticamente: bisogna dichiararlo esplicitamente nel docker-compose.yml:
Con questa riga, Docker scrive nel file /etc/hosts del container una riga che fa puntare host.docker.internal proprio all’IP di docker0 — quindi, alla fine, alla mia macchina.
Una distinzione importante: due direzioni, due meccanismi diversi
Un altro punto che mi ha tratto in inganno all’inizio: il fatto che io, dal browser, possa raggiungere n8n con http://localhost:5678non significa che valga anche il contrario.
Direzione
Meccanismo
Esempio
Dall’host verso il container
Port mapping (ports: nel compose)
Il browser chiama localhost:5678 per arrivare a n8n
Dal container verso l’host
docker0 / host.docker.internal
n8n chiama host.docker.internal:11434 per arrivare a Ollama
Sono due strade completamente diverse. Il port mapping ("5678:5678" nel compose) dice a Docker di inoltrare le richieste che arrivano sulla porta 5678 dell’host verso la stessa porta dentro il container. Ma questo meccanismo non funziona al contrario: se dentro n8n avessi provato a chiamare http://localhost:11434, avrei ottenuto un errore, perché “localhost” per un container si riferisce solo a se stesso.
Per approfondire l’argomento del networking Docker ho predisposto un articolo dedicato che trovate qui.
Il primo workflow funzionante
Con questi due problemi risolti, ho costruito il mio primo workflow n8n:
Manual Trigger — per lanciare il workflow a comando
HTTP Request verso Ollama http://host.docker.internal:11434/api/generate, con body JSON:
{
"model": "phi3:mini",
"prompt": "Ciao, chi sei?",
"stream": false
}
Al primo test, Ollama ha risposto correttamente con il testo generato da phi3:mini — segno che tutta la catena di comunicazione, dal container fino al processo sull’host, funzionava come previsto.
Cosa mi porto a casa da questa prima esperienza
In questo primo tentativo è stato fondamenale capire come funziona Docker prima ancora di come funziona n8n.
Una volta compreso è più chiaro come si deve organizzare il docker-compose, soprattutto l’infrastruttura virtuale di rete per consentire ai vari processi (host e docker) di parlarsi fra di loro. Le fasi che ho attrversato per arrivare alla soluzionne funzionante sono state:
organizzare i container per progetto, ognuno nella sua cartella con un proprio docker-compose.yml, rende tutto molto più gestibile rispetto a lanciare container sparsi con docker run.
I permessi dei volumi montati vanno sempre controllati al primo avvio, specialmente su Linux.
Capire la differenza tra “chiamare un container dall’host” e “chiamare l’host da un container” è probabilmente il concetto di rete più importante da avere chiaro quando si comincia a costruire ecosistemi Docker più complessi.
Prossimo passo: collegare anche il servizio Python al workflow, e magari sostituire il trigger manuale con un webhook per rendere il tutto richiamabile dall’esterno.
Docker: un sistema di virtualizzazione a livello di sistema operativo basato su container.
Continuo qui una serie di articoli che riguardano il funzionamento di Docker.
In questo post ho condiviso la comprensione del perché Docker non possa essere pensato come un virtualizzatorehardware: non viene virtualizzata una macchina completa e i processi dei container condividono il kernel del sistema operativo host; i filesystem locali vengono montati e abbiamo anche sfiorato l’argomento del networking. Possiamo al massimo dire che è una virtualizzazione a livello di sistema operativo, che la distingue dalla virtualizzazione hardware propriamente detta.
Qui voglio svilupparlo meglio perché uno degli aspetti inizialmente meno intuitivi è proprio il networking.
Supponiamo di avere sul nostro computer un servizio in ascolto sulla porta 11434. Dal sistema host possiamo raggiungerlo utilizzando TCP/IP:
http://localhost:11434
Avviamo poi un’applicazione all’interno di un container Docker e proviamo a collegarci allo stesso indirizzo. La connessione fallisce.
Nel contesto di docker, ogni container opera all’interno di un proprio namespace di rete.
Quando parliamo di docker, è fondamentale comprendere il suo networking.
Perché?
La risposta breve è che localhost all’interno di un container non identifica l’host, ma identifica il container stesso.
I container in docker possono comunicare tra loro in modo isolato e sicuro.
La risposta più interessante richiede invece di capire come Docker costruisce la rete dei container.
1. Un container non è semplicemente un processo isolato
Un container Docker, come abbiamo già imparato, non è una macchina virtuale: i processi eseguiti nei container utilizzano lo stesso kernel Linux dell’host.
Il kernel, tuttavia, mette a disposizione un meccanismo chiamato namespace, che permette di fornire a gruppi diversi di processi una visione differente delle risorse del sistema. Quindi un processo eseguito dal processore all’interno di un certo namespace, con un abuso di linguaggio diremo che “è eseguito all’interno del container” ma è chiaro da quello che abbiamo detto quello che si intende.
Esistono namespace non solo per i processi, ma anche per diversi tipi di risorse.
Per il networking ci interessa in particolare il network namespace.
Un processo eseguito all’interno di un container può quindi avere una propria visione di:
interfacce di rete;
indirizzi IP;
tabella di routing;
porte;
interfaccia di loopback.
Possiamo schematizzare la situazione così:
HOST
Network namespace dell'host
|
+-- lo
+-- eth0 / wlan0
+-- ...
CONTAINER
Network namespace del container
|
+-- lo
+-- eth0
Questo spiega immediatamente un comportamento che spesso sorprende chi utilizza Docker per la prima volta.
2. localhost è locale al network namespace
Ogni container in docker ha il suo network namespace.
Utilizzando docker, possiamo gestire e orchestrare facilmente applicazioni basate su microservizi.
Normalmente associamo:
localhost
all’indirizzo:
127.0.0.1
ma 127.0.0.1 indica l’interfaccia di loopback del network namespace nel quale si trova il processo che effettua la connessione.
Se eseguiamo:
$ curl http://localhost:11434
sull’host, stiamo chiedendo di contattare la porta 11434dell’host.
Se eseguiamo lo stesso comando dentro un container (supponiamo che si chiami n8n):
$ docker exec -it n8n curl http://localhost:11434
stiamo invece chiedendo di contattare la porta 11434di quel container, localhost è poprio il container stesso.
Non stiamo uscendo dal container per raggiungere l’host.
Quindi essendo 11434 la porta sulla quale ascolta Ollama, il container non la troverà perché nel container non c’è alcuna porta dell’host localhost. L’url funziona però se lo eseguiamo dal browser, e quindi localhost è il PC.
Questo è il principio fondamentale:
localhost non significa “il computer fisico sul quale sto lavorando”.
Significa “questo network namespace”.
Due container differenti hanno quindi due localhost differenti, e anche l’host ne possiede uno proprio.
3. Come fa allora un container a comunicare con l’esterno?
Docker deve costruire un collegamento fra il network namespace del container e quello dell’host.
Nella configurazione più comune questo viene realizzato mediante una coppia di interfacce virtuali chiamata veth pair (virtual ethernet pair).
Possiamo immaginarla come un cavo Ethernet virtuale con due estremità:
Qui c’è una piccola sottigliezza terminologica importante: dentro il container l’estremità della veth pair viene normalmente rinominata eth0. Quindi eth0 del container è, di fatto, una delle due estremità della coppia veth.
Ed è questo che rende molto concreta l’analogia:
veth pair → cavo Ethernet virtuale;
docker0 bridge → switch virtuale;
eth0 del container → scheda di rete virtuale collegata a quel cavo.
Naturalmente tutto avviene nel kernel Linux: non vengono emulati davvero una scheda Ethernet, un cavo e uno switch fisici. Ma dal punto di vista dello stack di rete il modello è sorprendentemente simile a una piccola LAN Ethernet.
Docker assegna inoltre ai container degli indirizzi appartenenti alla subnet della rete Docker.
Docker configura automaticamente gran parte di questa infrastruttura.
È proprio questo uno dei motivi per cui normalmente possiamo eseguire:
docker run ...
e scoprire che il container può immediatamente accedere a Internet.
Con docker, possiamo facilmente configurare le reti per applicazioni scalabili.
6. NAT: come esce un container verso Internet
C’è però ancora un problema.
Un indirizzo come:
172.17.0.2
è un indirizzo privato della rete Docker e non è normalmente instradabile su Internet.
Quando un container apre una connessione verso Internet, l’host effettua quindi una traduzione degli indirizzi, tramite meccanismi di NAT (Network Addreess Translation).
Semplificando:
172.17.0.2
|
| richiesta
v
Docker host
|
| NAT
v
IP dell'host
|
v
Internet
Dal punto di vista del server remoto, la connessione proviene dall’host, non direttamente dall’indirizzo privato del container.
Il principio è molto simile a ciò che avviene normalmente in una rete domestica, dove molti dispositivi con indirizzi privati accedono a Internet attraverso l’indirizzo pubblico del router.
7. Ma allora a cosa serve -p 5678:5678?
Qui nasce un’altra frequente fonte di confusione.
Consideriamo:
docker run -p 5678:5678 ...
La sintassi significa:
HOST:5678 -> CONTAINER:5678
Stiamo quindi pubblicando sull’host un servizio che si trova dentro il container.
Se il container possiede, per esempio:
172.17.0.2:5678
Il networking in docker è un elemento chiave per la sua funzionalità.
Docker configura il sistema affinché una connessione diretta a:
HOST:5678
possa essere inoltrata al servizio del container.
La direzione concettuale è dunque:
ESTERNO
|
v
HOST:5678
|
v
CONTAINER:5678
Il port mapping non serve, in generale, a consentire al container di uscire verso l’esterno.
Serve a rendere raggiungibile dall’esterno un servizio che gira all’interno del container.
Questa distinzione è importante.
Esempio: comunicare con due server MySQL
Un esempio applicativo è il seguente: ho un processo MySQL che gira sull’host ed è in ascolto sulla porta standard 3306. Posso avere contemporaneamente un altro processo MySQL che “gira dentro un container” — metto le virgolette per ricordare che si tratta di un abuso di linguaggio — anch’esso in ascolto sulla porta 3306, ma questa volta nel network namespace del container.
Posso pubblicare la sua porta mediante:
3307:3306
in modo da potermi collegare anche a questo secondo MySQL utilizzando, per esempio, DBeaver che gira sull’host.
Potrò quindi configurare due connessioni in DBeaver:
MySQL host localhost:3306 MySQL container localhost:3307
In entrambi i casi localhost indica esattamente la stessa cosa: l’host, perché il processo che apre la connessione, DBeaver, gira sull’host.
Ciò che cambia è la porta di destinazione:
DBeaver | +---- localhost:3306 ------> MySQL sull'host | +---- localhost:3307 ------> Docker port publishing | v container:3306 | v MySQL
Il significato di localhost dipende da dove gira il client, non da dove gira il server che vogliamo raggiungere.
8. Container diversi non condividono localhost
Supponiamo ora di avere due container:
container A
servizio sulla porta 8000
container B
client
Se dal container B utilizziamo:
http://localhost:8000
non raggiungiamo A.
Stiamo cercando un servizio sulla porta 8000 del container B.
La situazione è infatti:
CONTAINER A CONTAINER B
127.0.0.1 127.0.0.1
| |
v v
A B
Le due interfacce di loopback appartengono a network namespace differenti.
Per comunicare, B deve raggiungere A attraverso la rete Docker.
9. Le reti Docker definite dall’utente
Nelle applicazioni reali è generalmente preferibile creare una rete Docker dedicata invece di utilizzare direttamente il bridge predefinito.
Per esempio:
$ docker network create mynetwork
Possiamo poi collegare diversi container alla stessa rete.
Uno dei vantaggi più importanti è che Docker fornisce un meccanismo DNS interno.
Supponiamo di avere:
n8n
python-service
database
collegati alla stessa rete Docker.
Da n8n possiamo raggiungere il servizio Python utilizzando qualcosa come:
http://python-service:8000
e il database utilizzando:
database:3306
Non è necessario conoscere preventivamente gli indirizzi IP assegnati ai container.
Docker risolve i nomi attraverso il proprio DNS.
Questo è particolarmente importante perché gli indirizzi IP dei container non dovrebbero normalmente essere considerati permanenti.
10. Docker Compose rende questo modello molto naturale
Entrambi sono container appartenenti alla stessa rete Docker.
n8n può quindi utilizzare:
http://python-service:8000
Percorso:
n8n
|
Docker network
|
python-service:8000
In questo caso non è necessario passare attraverso localhost dell’host.
n8n → Ollama
Ollama gira invece sull’host.
n8n dovrà quindi utilizzare un indirizzo che rappresenti l’host dal punto di vista del container, per esempio:
http://host.docker.internal:11434
quando la configurazione Docker lo rende disponibile.
Il percorso è:
n8n
|
Docker network
|
HOST
|
Ollama:11434
Tre comunicazioni apparentemente simili seguono quindi tre percorsi di rete completamente differenti.
14. Un modello mentale utile
Quando una connessione Docker non funziona, prima ancora di controllare firewall, porte o configurazioni, conviene porsi tre domande:
Dove gira il client?
La comprensione del networking in docker è essenziale per sfruttarne tutto il potenziale.
Host o container?
Dove gira il server?
Host, stesso container oppure un altro container?
Che cosa significa l’indirizzo utilizzato dal punto di vista del client?
In particolare:
localhost
va sempre interpretato dal punto di vista del processo che apre la connessione.
Una piccola tabella riassume gran parte del problema:
Client
Server
Indirizzo tipico
Host
Host
localhost:porta
Host
Container
localhost:porta_pubblicata
Container A
stesso container A
localhost:porta
Container A
Container B sulla stessa rete
nome-servizio:porta
Container
Host
host.docker.internal:porta*
* A seconda della piattaforma e della configurazione Docker.
Conclusione
In docker, ogni container può essere visto come un nodo all’interno di una rete virtuale.
Il networking Docker diventa molto meno misterioso quando smettiamo di considerare un container semplicemente come “un programma che gira sul nostro computer”.
Il processo gira effettivamente sullo stesso kernel dell’host, ma Docker lo colloca in un network namespace distinto.
Il networking in docker è essenziale per la corretta comunicazione tra i componenti.
Da questa scelta discende quasi tutto il resto:
Adottare docker per il networking dei container offre grande flessibilità.
network namespace
|
+--> localhost separato
|
+--> interfaccia eth0 virtuale
|
+--> veth pair
|
+--> bridge Docker
|
+--> routing e NAT
|
+--> comunicazione con altre reti
Il bridge collega i container, il routing permette loro di raggiungere altre reti, il NAT consente l’accesso verso l’esterno, il port publishing permette all’esterno di raggiungere i servizi containerizzati e il DNS Docker permette ai container di trovarsi per nome.
Soprattutto, una volta compreso questo modello, una frase apparentemente strana come:
«Da n8n localhost:11434 non raggiunge Ollama che gira sullo stesso computer»
non è più strana.
n8n e Ollama girano sullo stesso computer fisico, ma non stanno guardando la rete dallo stesso namespace.
La tecnologia docker è diventata uno standard nel settore.
Ed è proprio questa distinzione che rende possibile l’isolamento di rete dei container Docker.
Bibliografia
With a little help from my friend ChatGPT (OpenAI).
With a little help from my friend Claude (Anthropic).
Tanenbaum et al., Reti di calcolatori, Pearson 2022
Qualche giorno fa Ubuntu mi ha mostrato una notifica che annunciava la disponibilità di un aggiornamento del firmware UEFI del mio portatile HP. Ci ho messo troppo a cliccarla, come spesso accade con le notifiche di GNOME, l’avviso si è spento non è stato immediato recuperarlo.
È stata l’occasione per approfondire come funziona oggi la gestione degli aggiornamenti firmware su Linux.
Breve ripasso
UEFI è il nuovo sistema di bootloader che ha sostituito BIOS. È il sistema che sovraintende tutte le operazioni di controllo preliminari prima di caricare il sistema operativo.
Io penso con tenerezza alla mia gioventù quando all’accensione avveniva questa cosa qui (l’avvio del BIOS)
Quello che accade all’accensione è spiegato nello schema sottostante:
Accensione │ ▼ Firmware UEFI │ ├── Inizializza CPU, RAM e periferiche ├── Legge le variabili di boot memorizzate nella NVRAM └── Cerca un programma EFI nella partizione EFI (ESP) │ ▼ Bootloader (GRUB, Windows Boot Manager, systemd-boot...) │ ▼ Kernel del sistema operativo │ ▼ Ubuntu / Windows
UEFI quindi inizializza la macchina e avvia il bootloader che quindi permette di scegliere quale partizione far partire (ogni partizione contiene un sistema operativo).
Il termine firmware indica un software che è strettamente legato a uno specifico dispositivo hardware e ne governa il funzionamento di base. Storicamente era memorizzato in memorie permanenti (ROM, PROM, EPROM), difficili o impossibili da modificare, da cui il nome firm (“stabile”, “quasi immutabile”), a metà strada tra hardware e software.
Ho lavorato per anni nell’automazione indutriale e ricordo che i programmi che scrivevamo per i robot venivano scritti su EPROM e quindi rappresentavano un vero e proprio firmware, anche se noi lo chiamavamo software. Di per sé il software non si modificava: veniva cancellata la EPROM (sotto una lampada UV) e veniva riscritta.
Oggi, in realtà, il firmware è quasi sempre memorizzato in memorie flash e può essere aggiornato. Ciò che lo distingue dal normale software non è tanto il supporto su cui risiede, quanto il suo ruolo.
Ad esempio:
GRUB è un bootloader, ma non è un firmware. È un normale programma installato nella partizione EFI del disco e può essere sostituito o riconfigurato come qualsiasi altro software.
Il firmware UEFI, invece, è memorizzato in un chip flash della scheda madre e viene eseguito immediatamente dopo l’accensione del computer, prima ancora che il disco venga letto. È lui che inizializza CPU, memoria, periferiche e individua quale bootloader avviare.
Il percorso di avvio è quindi, semplificando:
Accensione │ ▼ Firmware UEFI │ ▼ Bootloader (GRUB o Windows Boot Manager) │ ▼ Kernel del sistema operativo │ ▼ Ubuntu / Windows
Il primo controllo
Il comando fondamentale è:
fwupdmgr get-updates
Nel mio caso la sorpresa è stata che non era disponibile un nuovo BIOS. La versione del firmware del portatile (F.21) risultava già aggiornata.
L’unico aggiornamento disponibile riguardava invece la UEFI Secure Boot dbx.
Cos’è la dbx?
La dbx (Forbidden Signature Database) è uno dei database utilizzati da UEFI Secure Boot.
Non contiene i certificati autorizzati, bensì l’elenco dei bootloader e delle firme digitali che non devono più essere eseguiti perché ritenuti vulnerabili.
Quando vengono scoperte falle di sicurezza in un bootloader, Microsoft aggiorna questa blacklist. Attraverso il progetto LVFS (Linux Vendor Firmware Service), anche le distribuzioni Linux possono installare tali aggiornamenti utilizzando fwupd.
Nel mio caso il nuovo rilascio aggiungeva alla blacklist alcune versioni vulnerabili di bootloader utilizzati da diversi prodotti commerciali, oltre ad alcune versioni obsolete di shim distribuite in passato da alcune distribuzioni Linux.
Un falso “errore”
Durante l’ispezione dei dispositivi ho notato il messaggio:
Device firmware has been locked
Può sembrare un errore, ma in realtà è il comportamento normale.
Il chip SPI che contiene il firmware UEFI è protetto da scrittura mentre il sistema operativo è in esecuzione. L’aggiornamento non viene scritto direttamente: fwupd lo prepara, lo deposita nella partizione EFI e sarà il firmware stesso ad applicarlo durante il successivo riavvio.
L’aggiornamento
L’installazione si esegue semplicemente con:
sudo fwupdmgr update
Dopo il riavvio il sistema ha aggiornato automaticamente la dbx e Ubuntu è ripartito normalmente.
Anche il dual boot con Windows 11 non ha subito alcuna modifica: la configurazione di avvio, GRUB e le voci presenti nell’UEFI sono rimaste inalterate.
Un piccolo backup per tranquillità
Prima dell’aggiornamento ho salvato la partizione EFI con:
L’immagine risultante occupava circa 300 MB, esattamente quanto la dimensione della partizione. È normale: dd esegue una copia settore per settore, includendo anche lo spazio libero.
Per un semplice backup dei file della partizione EFI sarebbe stato sufficiente anche un archivio tar, decisamente più compatto.
Considerazioni finali
Negli anni passati aggiornare il BIOS significava quasi sempre scaricare un programma Windows o preparare una chiavetta USB avviabile.
Oggi, grazie a LVFS e fwupd, molti produttori distribuiscono direttamente gli aggiornamenti firmware anche agli utenti Linux. Nel mio caso non è stato necessario uscire da Ubuntu: il sistema ha scaricato, verificato e installato l’aggiornamento in modo completamente trasparente.
È uno di quei miglioramenti dell’ecosistema Linux che passano quasi inosservati, ma che rendono l’esperienza quotidiana molto più semplice rispetto a qualche anno fa.
Qualche giorno fa ho deciso di impiegare un po’ di tempo per aggiornare un’applicazione Laravel rimasta ferma per anni.
L’applicazione girava in produzione con:
Laravel 5.7
PHP 7.4
Il provider di hosting continuava a ricordarmi che PHP 7.4 era ormai fuori supporto e suggeriva il passaggio a PHP 8, suggerimento che era abbastanza inistente da dentro l’interfaccia di amministrazione di WordPress.
Avrei aggiornato subito senonché una di queste mie app era rimasta indietro con gli aggiornamenti, quindi non mio era possibile aggiornare semplicemente la versione di PHP: Laravel 5.7 non è compatibile con PHP 8. Occorre quindi aggiornare prima il framework.
L’errore da evitare
La tentazione iniziale è quella di modificare il composer.json e passare direttamente all’ultima versione disponibile.
È probabilmente il modo migliore per ritrovarsi davanti a decine di errori contemporaneamente, senza capire quale sia la causa di ciascuno.
Ho preferito una strategia diversa.
Una versione funzionante ad ogni passo
Per prima cosa ho ricreato l’intero ambiente di produzione desiderato in un container Docker.
Questo mi ha permesso di lavorare in completa sicurezza, con una copia fedele dell’applicazione e del database.
Successivamente ho importato tutto in Git e ho creato un branch dedicato per ogni salto di versione:
Ad ogni passaggio ho seguito sempre lo stesso schema:
aggiornamento del composer.json;
composer update;
correzione degli eventuali problemi;
test dell’applicazione;
commit;
tag Git.
In questo modo ogni versione rimaneva perfettamente funzionante e, in qualsiasi momento, era possibile tornare indietro.
Il passaggio più delicato
Contrariamente a quanto immaginavo, il salto più impegnativo non è stato quello verso PHP 8.
Il punto più critico è stato Laravel 7.
Qui ho incontrato alcune modifiche strutturali del framework:
il passaggio da Exception a Throwable nell’handler delle eccezioni;
l’introduzione del pacchetto laravel/ui per l’autenticazione;
l’aggiornamento di Collision, Tinker e PHPUnit.
Sono tutti cambiamenti documentati, ma affrontandoli uno alla volta risultano sorprendentemente semplici da risolvere.
Laravel diventa più severo
Una delle cose che apprezzo di più negli aggiornamenti è che il framework diventa progressivamente più rigoroso.
Durante la migrazione è emerso un bug che era presente da anni nel codice applicativo.
Avevo una chiamata di questo tipo:
->orderBy('customer', 'data')
L’intenzione era ordinare prima per cliente e poi per data.
Le versioni precedenti lo lasciavano passare; Laravel 8, invece, segnala correttamente che il secondo parametro di orderBy() deve essere necessariamente "asc" oppure "desc".
La correzione è stata banale:
->orderBy('customer')
->orderBy('data')
Questo è uno dei vantaggi meno evidenti degli aggiornamenti: oltre alla sicurezza e alle nuove funzionalità, aiutano a scoprire errori che erano sempre rimasti nascosti.
Un problema… che non era di Laravel
Durante i test una vista MySQL ha smesso improvvisamente di funzionare.
La causa, però, non era il framework.
La vista era stata importata da un dump e manteneva un DEFINER riferito a un utente inesistente nell’ambiente Docker.
In produzione il problema non esisteva perché quell’utente era ancora presente.
È stato un buon promemoria del fatto che, durante una migrazione, non tutto ciò che si rompe dipende necessariamente dal framework.
Il passaggio finale
Una volta raggiunto Laravel 8, il passaggio a PHP 8.1 è stato sorprendentemente semplice.
È bastato aggiornare l’immagine Docker, verificare le dipendenze e ripetere la batteria di test.
Infine ho predisposto una nuova directory sul server, installato le dipendenze con Composer e sostituito la vecchia applicazione semplicemente rinominando le directory.
Il rollback, se fosse stato necessario, sarebbe consistito in due comandi mv.
Considerazioni finali
Ripensandoci, la lezione più importante di questa esperienza non riguarda Laravel.
Riguarda il metodo.
Docker mi ha consentito di lavorare su una copia fedele della produzione.
Git mi ha permesso di fotografare ogni stato stabile della migrazione.
L’approccio incrementale ha fatto sì che ogni errore avesse una sola causa possibile.
Alla fine della giornata l’applicazione era passata da Laravel 5.7 e PHP 7.4 a Laravel 8.83 e PHP 8.1, senza tempi di fermo significativi e con un percorso completamente tracciabile.
Spesso gli upgrade fanno paura perché si immaginano come un salto nel vuoto.
In realtà, affrontati un passo alla volta, sono molto meno traumatici di quanto si possa pensare.
Il ruolo dell’AI
Naturalmente mi sono fatto aiutare dall’AI.
L’intero lavoro, documentazione compresa, ha richiesto circa sei ore: un tempo che, realisticamente, sarebbe stato molto più lungo se avessi dovuto consultare manualmente documentazione, forum e guide per ogni singolo problema.
L’aspetto che mi ha colpito di più, però, è un altro.
La strategia della migrazione è rimasta sempre nelle mie mani: ho deciso di procedere per passi successivi, ricreando l’ambiente con Docker, utilizzando Git per fotografare ogni stato stabile e affrontando una major release alla volta.
L’AI ha svolto il ruolo di un collega competente: suggeriva soluzioni, aiutava a interpretare gli errori, richiamava la documentazione pertinente e proponeva miglioramenti al processo. Ogni scelta importante, però, è stata verificata e compresa prima di essere applicata.
È probabilmente questo il modo di lavorare con l’AI che trovo più produttivo: non sostituisce la comprensione del problema, ma riduce drasticamente il tempo necessario per arrivare alla soluzione.
Bibliografia
With a little help from my friend ChatGPT (OpenAI).
Per capire quello che succede dobbiamo studiare come avviene l’autentiazione di un clinet (sia esso mysql o DBeaver) con il server. Questo schema illustra come viene gestita l’autenticazione
Server | Client | tabella mysql.user | | root -> hash della password H1 | | | digito password = pippo123 | | | |---- chiede la chiave pubblica RSA -->| | | |<----------- chiave pubblica ---------| | | | cifra "pippo123" con la chiave | | pubblica -> "x76f78avhh!6" | | | |------ password cifrata ------------->| | | | | decifra "x76f78avhh!6" con la chiave privata | | | | ottiene "pippo123" | | | | calcola l'hash della password -> H2 | | | | confronta con l'hash memorizzato H1 | | |<-------------------------------------| se H1==H2 OK | Client autenticato
A questo punto sorge spontanea una domanda: se il server mette a disposizione la propria chiave pubblica, perché DBeaver non la richiede automaticamente?
Il motivo è di sicurezza. Se un aggressore riuscisse a frapporsi tra client e server (attacco Man-in-the-Middle), potrebbe sostituire la chiave pubblica del server con una propria. Il client cifrerebbe la password usando la chiave dell’aggressore, che potrebbe quindi decifrarla e inoltrarla successivamente al vero server senza che nessuna delle due parti se ne accorga.
Per questo motivo il driver JDBC, per impostazione predefinita, rifiuta di recuperare automaticamente la chiave pubblica del server. L’opzione allowPublicKeyRetrieval=true comunica invece al driver che, nello specifico contesto, questa operazione è considerata sicura.
La soluzione
Nel mio caso è stato sufficiente modificare la configurazione della connessione in DBeaver.
Utilizzare come host:
127.0.0.1
invece di:
localhost
Nelle proprietà del driver abilitare:
allowPublicKeyRetrieval = true
Trattandosi di una macchina di sviluppo locale, potrei anche mantenere disabilitato SSL:
useSSL = false
oppure, nelle versioni più recenti del driver:
sslMode = DISABLED
Dopo queste modifiche (le ultime due non sono necessarie) la connessione funziona regolarmente fin dal primo tentativo, senza dover eseguire preventivamente il client mysql.
Prova del 9
Ho rimesso l’impostazione allowPublicKeyRetrieval=false da Dbeaver e sono uscito dal programma.
Poi ho riavviato il container con mysql
marcob@bombelli$ docker restart 6d36d6599ad3 6d36d6599ad3 marcob@bombelli$ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 6d36d6599ad3 mysql:8.0 "docker-entrypoint.s…" 15 months ago Up 1 second 0.0.0.0:3306->3306/tcp, [::]:3306->3306/tcp, 33060/tcp mio-mysql
A questo punto, riavviato il servizio, anche la cache viene cancellata.
Ora riavvio DBeaver.
Public Key Retrieval is not allowed
Il comportamento è quello atteso, ho riprodotto l’errore iniziale in modo controllato, infatti la cache è stata cancellata dal riavvio del container e DBeaver non l’ha trovata. La sua politica di non consentire l’acquisizione della chiave pubblica del server gli impedisce di innescare tutto il flusso della figura sopra.
Invece il client mysql (il programma /usr/bin/mysql, per intenderci) va direttamente a chiedere la chiave pubblica: anche questo lo potrei gestire diversamente ma in un contesto locale non ha senso porsi più problemi del necessario, ad un certo punto ben venga il rasoio di Occam: stiamo contattando un servizio sulla stessa macchina quindi non usciamo neanche dal localhost (sì: usiamo TCP/IP perché mysql è in un container Docker e non esiste il socket Unix come spiegato qui).
Dunque ho due modi per uscire da questo problema:
o consento a DBeaver di andare a chiedere la chiave pubblica del server
oppure, con un accesso da terminale, faccio in modo che la password finisca nella cache, così che DBeaver poi possa trovarla.
Per anni avevo sentito ripetere che Docker è una virtualizzazione leggera. È una frase comoda per iniziare, ma in realtà è fuorviante. In particolare non dobbiamo confondere docker con la virtualizzazione hardware.
L’ho capito nel modo migliore: cercando di capire perché MySQL si comportasse in modo apparentemente assurdo.
Il problema
Sul mio PC era in esecuzione un container Docker con MySQL (ma non me lo ricordavo):
$ docker ps
CONTAINER ID IMAGE PORTS
6d36d6599ad3 mysql:8.0 0.0.0.0:3306->3306/tcp
Si faccia attenzione al fatto che ho pubblicato la porta docker su quella che sarebbe la porta standard di MySQL in una installazione host. Provando a collegarmi senza specificare l’host ottenevo un errore:
$ mysql -u root -p
ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock'
Specificando invece l’indirizzo IP locale tutto funzionava:
$ mysql -u root -p -h 127.0.0.1
La stessa password, lo stesso server, due comportamenti completamente diversi.
La prima falsa pista
Ho controllato i processi:
$ ps ax | grep mysql
3265 ? Ssl 3:39 mysqld
«Perfetto» ho pensato. «C’è un processo mysqld locale. Perché allora non trova il socket?»
Ho verificato:
$ ls -l /var/run/mysqld/mysqld.sock
ls: impossibile accedere...
Il socket non esisteva.
Ed è stato proprio questo dettaglio a farmi capire cosa stava realmente succedendo.
Il punto chiave
Quel processo mysqld era sì visibile dall’host, ma apparteneva al container Docker.
Il socket Unix, invece, si trovava nel filesystem del container e non in quello dell’host.
Quando il client MySQL viene eseguito senza specificare -h, tenta automaticamente una connessione tramite socket Unix.
Il file cercato è:
/var/run/mysqld/mysqld.sock
Ma nel filesystem dell’host quel file non esiste.
Specificando invece:
mysql -h 127.0.0.1
la connessione non avviene più tramite socket su filesystema (AF_UNIX), bensì tramite socket TCP/IP (AF_INET) sulla porta 3306, che Docker inoltra correttamente al container.
Ed ecco la rivelazione
Mi sono reso conto che stavo ragionando come se Docker fosse una macchina virtuale.
In una macchina virtuale avrei un sistema operativo completamente separato, con un proprio kernel. I processi della VM non sarebbero visibili dall’host.
Con Docker succede qualcosa di completamente diverso.
I processi del container sono normali processi Linux eseguiti dal kernel dell’host.
Per questo motivo un semplice:
ps ax
mostra tranquillamente anche mysqld.
Ciò che Docker isola non sono i processori o il kernel, ma il punto di vista del processo: filesystem, rete, PID, utenti, hostname e altre risorse vengono separati mediante i namespace del kernel Linux.
Esiste una sola CPU (questo in reatà può essere vero anche per un virtualizzatore hardware).
Esiste una sola RAM.
Esistono invece più ambienti isolati all’interno dei quali i processi vengono eseguiti.
La differenza rispetto a una macchina virtuale
Con una macchina virtuale:
il sistema guest possiede un proprio kernel;
dall’host si vede solo il processo del software di virtualizzazione (QEMU, VirtualBox, VMware…).
Con Docker:
il kernel è condiviso;
i processi del container sono processi reali dell’host;
l’isolamento è ottenuto tramite namespace e cgroup.
Una lezione imparata sul campo
Paradossalmente, non l’ho capito leggendo la documentazione.
L’ho capito perché un semplice socket Unix non veniva trovato.
È uno di quei casi in cui un piccolo problema pratico costringe a costruirsi il modello mentale corretto. Da quel momento molti aspetti di Docker — processi, filesystem, porte, volumi e networking — diventano improvvisamente molto più chiari.
Ed è probabilmente questo il modo migliore per comprendere Docker: non come una “macchina virtuale leggera”, ma come un insieme di processi Linux che condividono lo stesso kernel e vengono isolati dal sistema operativo mediante i namespace e i cgroup.
Due parole su namespace e cgroup
Docker non introduce un nuovo meccanismo di virtualizzazione, ma sfrutta alcune funzionalità già presenti nel kernel Linux.
Namespace
I namespace forniscono l’isolamento. Consentono a un gruppo di processi di avere una propria visione del sistema.
Ad esempio:
PID namespace: il container vede solo i propri processi (all’interno mysqld può essere il processo con PID 1, mentre sull’host ha un PID completamente diverso).
Mount namespace: il container vede un filesystem separato da quello dell’host.
Network namespace: il container possiede una propria interfaccia di rete, un proprio indirizzo IP e una propria tabella di routing.
UTS namespace: il container può avere un hostname differente.
IPC namespace: isola memoria condivisa, semafori e altre primitive di comunicazione tra processi.
Ogni container, quindi, osserva una realtà diversa pur condividendo lo stesso kernel.
cgroup
I control group (cgroup) non isolano, ma controllano le risorse.
Permettono al kernel di limitare e monitorare quante risorse un insieme di processi può utilizzare:
CPU;
memoria RAM;
I/O su disco;
traffico di rete (in alcune configurazioni);
numero massimo di processi.
Ad esempio è possibile avviare un container imponendo che utilizzi al massimo 2 GB di RAM e due core della CPU. Se tenta di superare tali limiti, interviene il kernel senza influire sugli altri processi del sistema.
In altre parole, i namespace determinano l’ambiente in cui il processo vive. Ad esempio:
quali processi può vedere (PID namespace);
quale filesystem considera come radice (mount namespace);
quali interfacce di rete e indirizzi IP possiede (network namespace);
quale hostname vede (UTS namespace).
I cgroup, invece, determinano quante risorse quel processo può consumare:
CPU;
memoria RAM;
I/O del disco;
altri limiti imposti dal kernel.
È proprio la combinazione dei namespace e dei cgroup che consente a Docker di eseguire processi isolati condividendo lo stesso kernel Linux.
A mio avviso, questa è una delle idee più eleganti del kernel Linux. Quando si capisce che un PID non è assoluto ma relativo a un PID namespace, si comprende davvero come i container riescano a isolare i processi senza dover virtualizzare l’intero sistema operativo.
È per questo che parliamo più propriamente di Dcker come piattafroma di virtualizzazione a livello di sistema operativo.
Aprendo Thunderbird mi è comparso un avviso che invitava a intervenire entro il 21 luglio 2026 per continuare a ricevere gli aggiornamenti ESR (Extended Support Release).
Partiamo con le istruzioni operative per chi sa già queste cose e vuole andare subito diritto al punto, poi più avanti ci sofferemeremo brevemente sui canali di distribuzione del software e le politiche Ubuntu.
Istruzioni operative per la modifica del canale
Il motivo dell’avviso è un cambiamento nell’organizzazione dei canali di Thunderbird. Fino a oggi il canale latest/stable distribuiva la versione ESR; dal 21 luglio, invece, diventerà il canale della Monthly Release, con nuove funzionalità rilasciate circa ogni quattro settimane.
Thunderbird offre quattro canali:
Release (Monthly): aggiornamenti ogni circa 4 settimane. È il canale consigliato per la maggior parte degli utenti.
ESR (Extended Support Release): una major release all’anno, con soli aggiornamenti di sicurezza e bugfix: questo è il canale che ho utilizzato nella prima installazione di Thundebird.
Beta: anteprima delle funzionalità future.
Daily: sviluppo continuo, destinato ai tester.
Per chi preferisce la stabilità, come nel mio caso, è stato introdotto un canale dedicato:
Dopo il comando, è possibile verificare il canale attivo con:
snap info thunderbird
che dovrebbe mostrare:
tracking: esr/stable
Il passaggio è trasparente: non vengono persi account, messaggi o impostazioni, ma si continua a ricevere esclusivamente gli aggiornamenti di sicurezza e le correzioni della versione ESR.
Se invece si desidera seguire il ciclo di sviluppo mensile con le nuove funzionalità, non è necessario intervenire: il canale latest/stable verrà automaticamente convertito alla Monthly Release.
Una piccola modifica, quindi, ma importante per chi utilizza Thunderbird come strumento di lavoro quotidiano e preferisce privilegiare affidabilità e stabilità rispetto alle novità più frequenti.
APT e Snap: due modelli di distribuzione diversi
Su Ubuntu le applicazioni possono essere installate con meccanismi differenti. I due più diffusi sono APT e Snap, che seguono filosofie diverse.
Con APT, il software viene distribuito attraverso i repository della distribuzione. Nel caso di Ubuntu, sono i manutentori della distribuzione a preparare e pubblicare i pacchetti, decidendo quando una nuova versione resa disponibile dallo sviluppatore debba essere integrata nei repository ufficiali. L’utente riceve quindi gli aggiornamenti tramite la distribuzione.
Con Snap, invece, il flusso è più diretto. Lo sviluppatore pubblica le nuove versioni della propria applicazione e gli utenti le ricevono senza l’intermediazione dei repository della distribuzione. Questo consente allo sviluppatore di gestire direttamente il ciclo di rilascio e di mettere a disposizione più canali di aggiornamento, come esr/stable, monthly/stable o monthly/beta.
L’utente sceglie il canale da seguire e può cambiarlo con un semplice comando, senza reinstallare l’applicazione né perdere configurazione e dati. È proprio questo meccanismo che ha reso possibile la migrazione descritta in questo articolo: Thunderbird non è stato reinstallato, ma ha semplicemente iniziato a seguire un diverso flusso di aggiornamenti.
Una riflessione su Snap
Questa piccola migrazione mi ha fatto anche rivalutare, almeno in parte, il formato Snap.
In passato ho spesso preferito i pacchetti APT per la loro migliore integrazione con il sistema, le prestazioni generalmente superiori e la filosofia più vicina al tradizionale ecosistema Linux. Tuttavia, in questo caso, il sistema dei canali Snap ha mostrato un vantaggio concreto: è stato sufficiente un semplice comando per passare dal vecchio canale al nuovo ESR, senza reinstallare l’applicazione e senza rischiare di perdere account, messaggi o impostazioni.
Per applicazioni desktop complesse come Thunderbird, distribuite e mantenute direttamente dagli sviluppatori, la possibilità di scegliere e cambiare facilmente il canale di rilascio (ESR, Monthly, Beta) rappresenta un valore aggiunto non trascurabile.
In questo contesto, il canale offre flessibilità e controllo agli utenti.
Rimango dell’idea che APT sia la soluzione migliore per molti strumenti di sistema e di sviluppo, ma questa esperienza dimostra come il sistema dei canali renda particolarmente semplice passare da un ramo di rilascio a un altro (ESR, Monthly, Beta), senza reinstallare l’applicazione né modificare la configurazione dell’utente.
Negli ultimi tempi termini come LLM, embedding e database vettoriali sono usciti rapidamente dall’ambito della ricerca e sono diventati argomenti quasi quotidiani. Sono mattoncini coi quali possiamo costruire sistemi capaci di interrogare grandi collezioni di documenti non più tramite ricerca lessicale tradizionale, ma attraverso la vicinanza semantica tra i testi.
In questi giorni ho realizzato un piccolo prototipo sperimentale di sistema RAG (Retrieval-Augmented Generation) utilizzando esclusivamente strumenti locali: PostgreSQL con l’estensione pgvector, Ollama, Python e una macchina Linux piuttosto modesta, un Intel i5 con 16 GB di RAM e senza alcuna GPU CUDA.
L’obiettivo era capire davvero cosa succede dentro queste architetture che oggi vengono spesso presentate in modo un po’ magico.
Il funzionamento generale è relativamente semplice. I documenti PDF vengono letti, suddivisi in piccoli frammenti e trasformati in embedding numerici tramite un modello locale. Questi vettori vengono memorizzati in PostgreSQL. Quando l’utente pone una domanda, anche la domanda viene convertita in un embedding e il database cerca i frammenti semanticamente più vicini. Solo a quel punto il modello linguistico riceve il contesto recuperato e prova a costruire una risposta.
La parte interessante è che il database non cerca parole identiche come farebbe un classico LIKE, ma effettua una ricerca geometrica nello spazio degli embedding. In altre parole, il sistema tenta di individuare testi “concettualmente vicini” alla domanda.
La sorpresa più grande dell’esperimento è stata probabilmente PostgreSQL. Anche con oltre sedicimila chunk indicizzati, le interrogazioni vettoriali risultavano rapidissime, dell’ordine di pochi decimi di secondo. Il vero collo di bottiglia si è invece manifestato nella generazione finale della risposta da parte del modello linguistico locale.
Qui si entra nel territorio dell’algebra lineare pesante: matrici enormi, meccanismi di attenzione, inferenza autoregressiva token per token. Su CPU tutto questo funziona, ma i tempi crescono rapidamente. In alcuni casi Ollama arrivava addirittura in timeout durante la generazione della risposta.
È stato quindi necessario introdurre varie ottimizzazioni: ridurre il numero di chunk passati al modello, limitare la lunghezza dell’output e rendere più robusta la gestione degli errori. Anche così, le risposte ottenute rimangono talvolta “maccheroniche” o affette da piccole allucinazioni semantiche, ma nel complesso la direzione sembra corretta, soprattutto considerando il setup estremamente limitato.
L’aspetto più istruttivo dell’esperimento è forse proprio questo: osservare concretamente dove finisca il retrieval e dove inizi la generazione linguistica. Un sistema RAG non è semplicemente “un chatbot collegato a un database”, ma una pipeline piuttosto delicata in cui chunking, retrieval, ranking e costruzione del prompt influenzano pesantemente il risultato finale.
Utilizziamo tecnologie come i cookie per memorizzare e/o accedere alle informazioni del dispositivo. Lo facciamo per migliorare l'esperienza di navigazione e per mostrare annunci personalizzati. Il consenso a queste tecnologie ci consentirà di elaborare dati quali il comportamento di navigazione o gli ID univoci su questo sito. Il mancato consenso o la revoca del consenso possono influire negativamente su alcune caratteristiche e funzioni.
Funzionale
Sempre attivo
L'archiviazione tecnica o l'accesso sono strettamente necessari al fine legittimo di consentire l'uso di un servizio specifico esplicitamente richiesto dall'abbonato o dall'utente, o al solo scopo di effettuare la trasmissione di una comunicazione su una rete di comunicazione elettronica.
Preferenze
L'archiviazione tecnica o l'accesso sono necessari per lo scopo legittimo di memorizzare le preferenze che non sono richieste dall'abbonato o dall'utente.
Statistiche
L'archiviazione tecnica o l'accesso che viene utilizzato esclusivamente per scopi statistici.L'archiviazione tecnica o l'accesso che viene utilizzato esclusivamente per scopi statistici anonimi. Senza un mandato di comparizione, una conformità volontaria da parte del vostro Fornitore di Servizi Internet, o ulteriori registrazioni da parte di terzi, le informazioni memorizzate o recuperate per questo scopo da sole non possono di solito essere utilizzate per l'identificazione.
Marketing
L'archiviazione tecnica o l'accesso sono necessari per creare profili di utenti per inviare pubblicità, o per tracciare l'utente su un sito web o su diversi siti web per scopi di marketing simili.
Commenti recenti