Capire il networking di Docker: nozioni indispensabili su host, container, bridge e localhost

Immagine Docker logo white
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 virtualizzatore hardware: 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.

Il concetto di docker è fondamentale per comprendere il networking dei container.

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.

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:

Utilizzando docker, possiamo facilmente gestire i container e il loro networking.

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 11434 dell’host.

Se eseguiamo lo stesso comando dentro un container (es: container0):

$ docker exec -it container0 curl http://localhost:11434

stiamo invece chiedendo di contattare la porta 11434 di quel container.

Non stiamo uscendo dal container per raggiungere l’host.

Questo 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à:

CONTAINER                         HOST

+----------------+               +----------------------+
|                |               |                      |
|             eth0 <===========> veth0                  |
|                |               |                      |
+----------------+               +----------------------+

In docker, due container possono comunicare tra loro attraverso il network namespace.

Un’estremità viene collocata nel network namespace del container e normalmente appare come eth0.

L’altra rimane nel network namespace dell’host.

Ma questo, da solo, non basta: se abbiamo molti container dobbiamo collegare fra loro tutte queste interfacce.

Entra quindi in gioco il bridge.

4. Il bridge Docker

Nella configurazione standard Docker crea sull’host un bridge virtuale, normalmente associato all’interfaccia:

docker0

Possiamo pensarlo, con una certa approssimazione, come uno switch Ethernet virtuale.

Le estremità host delle varie coppie veth vengono collegate al bridge:

                     HOST

              +----------------+
              |    docker0     |
              |     bridge     |
              +---+--------+---+
                  |        |
               veth1      veth2
                  |        |
                  |        |
             +----+--+  +--+----+
             |       |  |       |
             |  C1   |  |  C2   |
             | eth0  |  | eth0  |
             +-------+  +-------+

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.

Per esempio potremmo avere:

docker0        172.17.0.1
container C1   172.17.0.2
container C2   172.17.0.3

Gli indirizzi effettivi dipendono naturalmente dalla configurazione.

A questo punto i container possiedono una vera connettività IP.

5. Il container ha anche una propria tabella di routing

Supponiamo che un container abbia:

eth0 = 172.17.0.2

e che Docker abbia configurato come gateway:

172.17.0.1

La sua tabella di routing sarà concettualmente simile a:

172.17.0.0/16  -> eth0
default        -> 172.17.0.1

Se il container deve raggiungere un indirizzo appartenente alla propria rete Docker, il traffico passa attraverso eth0 e il bridge.

Se deve raggiungere Internet, utilizza invece il gateway.

Il traffico passa quindi attraverso l’host, che provvede a inoltrarlo.

In forma semplificata:

container
   |
 eth0
   |
 veth
   |
docker0
   |
 HOST
   |
eth0 / wlan0
   |
Internet

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

Consideriamo una configurazione semplificata:

services:

  n8n:
    image: docker.n8n.io/n8nio/n8n
    ports:
      - "5678:5678"

  python-service:
    build: ./python-service
    ports:
      - "8000:8000"

Docker Compose crea normalmente una rete per il progetto e collega entrambi i servizi a quella rete.

Dal container n8n, quindi, possiamo raggiungere:

http://python-service:8000

Il nome del servizio diventa un nome DNS utilizzabile nella rete Docker.

Possiamo rappresentare la situazione così:

                    Docker network
            +---------------------------+
            |                           |
        +---+---+                   +---+---+
        |  n8n  |                   |Python |
        |       |                   |service|
        +-------+                   +-------+
            |                           |
            +---------------------------+

          n8n -> python-service:8000

Questo è molto più robusto che utilizzare direttamente gli indirizzi IP dei container.

11. E se il servizio gira sull’host?

Arriviamo ora a un caso particolarmente interessante.

Supponiamo di avere:

HOST
  |
  +-- Ollama :11434
  |
  +-- Docker
        |
        +-- n8n

Ollama non gira in un container.

Gira direttamente sull’host.

Dal sistema host possiamo verificare:

curl http://localhost:11434

Organizzare i container con docker è un approccio vincente.

e ottenere:

Ollama is running

Potremmo allora essere tentati di configurare n8n utilizzando:

http://localhost:11434

Utilizzare docker rende la gestione dei servizi molto più efficiente.

ma ora sappiamo perché non può funzionare.

Dal punto di vista di n8n:

Con docker, possiamo eseguire e gestire facilmente le applicazioni.

localhost

significa:

container n8n

non:

host Docker

Con docker, la gestione del networking diventa un processo molto più semplice.

La situazione reale è:

HOST namespace
+---------------------------------------------+
|                                             |
| localhost:11434                             |
|      |                                      |
|    Ollama                                   |
|                                             |
|      docker0                                |
|         |                                   |
|         | veth                              |
|         |                                   |
|   +-----+--------------------------------+  |
|   | NETWORK NAMESPACE n8n                |  |
|   |                                      |  |
|   | localhost                            |  |
|   |    |                                 |  |
|   |   n8n                                |  |
|   +--------------------------------------+  |
|                                             |
+---------------------------------------------+

Il problema non è quindi che Docker “blocchi localhost”.

È molto più semplice: stiamo indicando l’host sbagliato.

12. Raggiungere l’host da un container

Docker mette a disposizione il nome:

host.docker.internal

Con docker, possiamo avere più service che girano all’interno dei container.

per consentire ai container di riferirsi all’host.

Su Docker Desktop questo meccanismo è disponibile normalmente in modo automatico.

Su Docker Engine in ambiente Linux può invece essere necessario configurare esplicitamente il mapping verso il gateway dell’host, per esempio:

extra_hosts:
  - "host.docker.internal:host-gateway"

A quel punto, dal container, un servizio dell’host può essere indicato come:

http://host.docker.internal:11434

Il percorso concettuale diventa:

n8n
 |
 | eth0
 v
Docker network
 |
 v
host
 |
 v
Ollama:11434

C’è però un’ulteriore condizione importante: il servizio sull’host deve essere in ascolto su un indirizzo raggiungibile dalla rete Docker.

Un processo che ascolta esclusivamente su:

127.0.0.1:11434

accetta connessioni soltanto attraverso il loopback dell’host.

Il container può raggiungere l’host attraverso la rete Docker, ma non entra magicamente nel suo loopback.

È quindi necessario distinguere due problemi differenti:

  1. qual è l’indirizzo dell’host visto dal container?
  2. su quali interfacce il servizio dell’host è effettivamente in ascolto?

Sono due aspetti indipendenti e confonderli rende spesso difficile diagnosticare i problemi di networking Docker.

13. Il nostro esempio completo: n8n, servizio Python e Ollama

Possiamo ora mettere insieme tutti i pezzi.

Supponiamo di avere:

HOST
 |
 +-- Ollama :11434
 |
 +-- Docker
       |
       +-- n8n :5678
       |
       +-- python-service :8000

Esistono tre tipi differenti di comunicazione.

Browser → n8n

Dal browser dell’host utilizziamo:

http://localhost:5678

Il port publishing:

5678:5678

inoltra la connessione verso il container n8n.

Percorso:

Browser
   |
HOST:5678
   |
   v
n8n:5678

n8n → python-service

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:

ClientServerIndirizzo tipico
HostHostlocalhost:porta
HostContainerlocalhost:porta_pubblicata
Container Astesso container Alocalhost:porta
Container AContainer B sulla stessa retenome-servizio:porta
ContainerHosthost.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
  • https://docs.docker.com/get-started/

Aggiornare il firmware UEFI su Ubuntu: la mia esperienza con fwupd

Firmware

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:

sudo dd if=/dev/nvme0n1p1 of=efi-backup.img bs=4M status=progress

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.

Bibliografia

Da Laravel 5.7 / PHP 7.4 a Laravel 8 / PHP 8.1: una migrazione senza traumi

Laravel logo
Laravel logo

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:

Laravel 5.7
        ↓
Laravel 5.8
        ↓
Laravel 6
        ↓
Laravel 7
        ↓
Laravel 8
        ↓
PHP 8.1

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).

DBeaver e MySQL: perché compare l’errore Public Key Retrieval is not allowed?

MySQL 8.0
MySQL 8.0

Mi sono imbattuto in un comportamento piuttosto curioso utilizzando DBeaver con un server MySQL locale.

All’avvio di DBeaver, il tentativo di connessione falliva con il messaggio:

Public Key Retrieval is not allowed

La cosa sorprendente era che bastava aprire un terminale ed eseguire:

mysql -u root -p -h 127.0.0.1

Una volta autenticato con il client a riga di comando, tornando in DBeaver la connessione funzionava perfettamente.

Il contesto

La configurazione del server era la seguente:

  • MySQL locale su Ubuntu;
  • utente root configurato con il plugin di autenticazione caching_sha2_password;
  • DBeaver collegato inizialmente all’host localhost.

Verificando il server:

SELECT user, host, plugin
FROM mysql.user;

ottenevo:

root | % | caching_sha2_password

e:

SHOW VARIABLES LIKE 'default_authentication_plugin';

restituiva:

+-------------------------------+-----------------------+
| Variable_name                 | Value                 |
+-------------------------------+-----------------------+
| default_authentication_plugin | caching_sha2_password |
+-------------------------------+-----------------------+

Flusso delle informazioni

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.

  1. Utilizzare come host:
127.0.0.1

invece di:

localhost
  1. Nelle proprietà del driver abilitare:
allowPublicKeyRetrieval = true
  1. 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.

Perché Docker non è una macchina virtuale

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.

Una rappresentazione mentale

                Kernel Linux
                     │
      ┌──────────────┼──────────────┐
      │              │              │
   Host          Container      Container
   bash            mysqld          nginx

Esiste un solo kernel.

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.

Fonti

Thunderbird su Ubuntu: attenzione al cambio dei canali Snap

Mozilla Thundebidr snap channel update
Mozilla Thundebidr snap channel update

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:

sudo snap refresh --channel=esr/stable thunderbird

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.

Fonti

Un piccolo esperimento RAG locale con Ollama e PostgreSQL

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.

Poyete scaricare la breve relazione tecnica più dettagliata con diagrammi, codice ed esempi reali ottenuti durante questi test.

Un esperimento fatto in casa con un LLM

Da qualche tempo mi chiedo: cosa ho davvero chiesto a ChatGPT in questi anni? Ho scaricato l’archivio completo delle mie conversazioni e ho deciso di analizzarlo — non con un servizio cloud, ma con un LLM in esecuzione sul mio PC. Questo post racconta l’esperimento, le scelte tecniche, i risultati e le riflessioni che ne sono emerse.

LLM per analizzare le mie chat con OpenAI
LLM per analizzare le mie chat con OpenAI

Perché Ollama, e perché in locale

La scelta di usare Ollama come runtime per il modello non è stata casuale. Ollama è un progetto open source scritto in Go che espone un server HTTP locale (sulla porta 11434) attraverso il quale è possibile interagire con modelli LLM esattamente come si farebbe con le API di OpenAI o Anthropic — ma senza inviare dati all’esterno e senza costi per token.

L’installazione su Linux è banale:

curl -fsSL https://ollama.com/install.sh | sh
ollama pull phi3:mini
ollama serve

Da quel momento, il modello è raggiungibile via HTTP esattamente come un’API cloud:

POST http://localhost:11434/api/generate
{
  "model": "phi3:mini",
  "prompt": "...",
  "stream": false
}

Ovviamente il prompt può essere anche scritto da linea di comando, come si farebbe con un chatbot normale, ma in questo caso il prompt viene inoltrato al motore LLM da uno script Python che ripete tante volte lo stesso prompt con parametri diversi.

Questo è il cuore del paradigma: scrivi codice per la struttura, delega al LLM tutto ciò che richiede comprensione del linguaggio naturale.

La scelta del modello: phi3:mini e i vincoli hardware

Il mio PC è un HP Laptop 15s con CPU Intel i5 a 12 core e alimentatore da 45W — nessuno slot PCIe, nessuna possibilità di aggiungere una GPU dedicata. L’intera elaborazione avviene su CPU.

In questo contesto, phi3:mini di Microsoft è stata la scelta obbligata ma non scontata. Si tratta di un modello da 3.8 miliardi di parametri, ottimizzato per ragionamento e instruction following, con un peso di circa 2.2 GB in VRAM (o RAM, nel mio caso). Le alternative come LLaMA 3 8B o Mistral 7B avrebbero richiesto il doppio della memoria e tempi di inferenza ancora più lunghi.

Il log di Ollama durante il caricamento mostra chiaramente il contesto di esecuzione:

llama_kv_cache: CPU KV buffer size = 1536.00 MiB
llama_context: Flash Attention was auto, set to enabled
llama_runner started in 2.30 seconds

Il warning n_ctx_seq (4096) < n_ctx_train (131072) indica che il modello è stato addestrato per gestire contesti fino a 131k token, ma Ollama lo esegue con una finestra di 4096 — più che sufficiente per i nostri prompt.

Il formato dei dati: l’export di ChatGPT

ChatGPT permette di scaricare l’intero archivio delle conversazioni in formato JSON. La struttura non è banale: ogni conversazione è rappresentata come un grafo di nodi collegati da relazioni parent/child, non come una semplice lista di messaggi.

{
  "id": "00b6fac4-...",
  "title": "Invert Square Matrix Python",
  "create_time": 1720644750.545788,
  "current_node": "51aa9598-...",
  "mapping": {
    "51aa9598-...": {
      "message": {
        "author": { "role": "assistant" },
        "content": { "parts": ["Sì, potresti avere ragione..."] }
      },
      "parent": "aaa2f4c3-...",
      "children": []
    }
  }
}

Il campo create_time è un Unix timestamp — i secondi trascorsi dal 1° gennaio 1970 (Unix Epoch). Per ricostruire la conversazione in ordine cronologico è necessario risalire la catena dei nodi a partire da current_node fino alla radice, poi invertire il percorso.

L’architettura dello script

Lo script Python è organizzato in tre livelli funzionali:

  1. Estrazione: legge i file JSON, ricostruisce la catena di messaggi da ogni conversazione
  2. Classificazione: per ogni conversazione costruisce un prompt e lo invia a Ollama via HTTP
  3. Checkpoint: salva i progressi dopo ogni classificazione, permettendo di riprendere in caso di interruzione

Il punto cruciale è la generazione del prompt:

prompt = f"""Analizza questo frammento di conversazione e rispondi SOLO 
con un oggetto JSON con questi campi:
- "argomento": una stringa breve (3-6 parole)
- "parole_chiave": una lista di 3-5 parole chiave

Conversazione:
{estratto}

Rispondi SOLO con il JSON, senza testo aggiuntivo."""

Cambiare questo prompt è tutto ciò che serve per trasformare lo script in un analizzatore di sentiment, un estrattore di entità, un sistema di moderazione. Il codice è la struttura, il prompt è la logica.

La chiamata HTTP a Ollama è altrettanto semplice:

response = requests.post(
    "http://localhost:11434/api/generate",
    json={"model": "phi3:mini", "prompt": prompt, "stream": False},
    timeout=300
)
t_elapsed = round(time.time() - t_start, 2)

Questo è uno dei miei primi esperimenti di interazione tra software e LLM. Mi sono fatto aiutare da Anthropic – Claude nella stesura del codice, che però ho modificato e modulato a mio piacemento, ad esempio abbiamo aggiunto il meccaismo di checkpoint solo successivamente, dopo che ho visto che si doveva ripetere l’analisi dei prompt GPT da capo ogni volta che accadeva un errore o che femavo lo script per altri motivi. Così ho ottimizzato il tempo.

Ovviamente lasciar fare tutto ad un chatbot sarbbe stato più veloce ma così mi sari perso la parte divertente.

I risultati: 50 conversazioni analizzate

Ho analizzato un campione di 50 conversazioni estratte da un archivio di 1812. I numeri sono istruttivi.

Statistiche di elaborazione

MetricaValore
Conversazioni analizzate50
Tempo totale1471s (24,5 minuti)
Tempo medio per conversazione42,0s
Tempo minimo11,1s
Tempo massimo258,7s

Distribuzione dei tempi

FasciaConversazioni
Meno di 20s12 (24%)
20–40s19 (38%)
40–60s15 (30%)
Oltre 60s4 (8%)

La variabilità è significativa: una conversazione breve richiede 11 secondi, una lunga o complessa può arrivare a quasi 4 minuti. Il collo di bottiglia è la generazione dei token su CPU — ogni token prodotto richiede un forward pass completo sulla rete neurale.

Qualità delle classificazioni

  • 74% delle classificazioni sono pulite e corrette (37/50)
  • 26% contengono JSON grezzo non parsato (13/50) — phi3:mini non ha sempre rispettato l’istruzione di rispondere solo con JSON

Questo è un problema noto nel prompt engineering: i modelli piccoli tendono a “ragionare ad alta voce” aggiungendo testo prima o dopo il JSON richiesto, rompendo il parsing. Le soluzioni possibili includono few-shot examples nel prompt, output structuring, o modelli più grandi.

Gli argomenti emersi

La distribuzione tematica delle mie conversazioni con ChatGPT rispecchia abbastanza fedelmente i miei interessi:

  • Fisica teorica: meccanica quantistica, Lagrangiana del Modello Standard, dilatazione del tempo, invarianti adiabatici, polarizzazione ottica
  • Programmazione: Python, JavaScript, PHP, Oracle SQL, Laravel, XML parsing, SQL injection, exploit di servizi remoti
  • Astronomia: coordinate celesti, sonde solari, visibilità dei poli lunari
  • Linux e IT: identificazione hardware, gestione della batteria, VPN
  • Musica: Cubase, Yamaha, importazione partiture MIDI
  • Storia della scienza: Galileo e il Copernicanesimo
  • Varie: tossicità delle piante per i gatti, lavatrice Samsung, il fiume Sile

Riflessioni e sviluppi futuri

Questo esperimento ha confermato qualcosa di importante: usare un LLM come componente di un programma è sorprendentemente accessibile. Non serve GPU, non servono librerie complesse, non serve conoscere i dettagli interni del modello. Basta una chiamata HTTP e un prompt ben formulato.

Il vero cambio di paradigma è concettuale: invece di scrivere codice per ogni singola regola di classificazione, si scrive codice per la struttura del problema e si delega la comprensione del linguaggio naturale al modello. Questo apre possibilità enormi — analisi di email, estrazione di dati da documenti non strutturati, moderazione di contenuti, generazione di dati di test — con una quantità di codice sorprendentemente piccola.

I limiti emersi sono altrettanto istruttivi:

  • Velocità su CPU: 42 secondi per conversazione rendono l’analisi dell’intero archivio (1812 conversazioni) un’operazione da 21 ore. Una GPU entry-level ridurrebbe questo tempo di un fattore 10-20x.
  • Affidabilità del formato di output: phi3:mini non rispetta sempre le istruzioni di formato. Modelli più grandi o tecniche di structured output migliorerebbero il 74% di successo.
  • Qualità semantica: le classificazioni corrette sono generalmente buone, ma alcuni argomenti sono descritti in modo troppo specifico per essere utili in un raggruppamento tematico.

I prossimi passi naturali sono estendere l’analisi all’intero archivio (magari con una GPU), aggiungere una modalità di ricerca semantica, e sperimentare con modelli più capaci come LLaMA 3 8B o Mistral 7B quando l’hardware lo permetterà.

Avere un LLM direttamente sulla macchina mi aprirà inoltre la possibilità di guardare sotto il cofano: capire come funzionano gli embeddings e la tokenizzazione, come viene applicato il softmax, come il modello costruisce la risposta token per token. Cose che si possono leggere sui paper, ma che diventano molto più concrete quando il modello gira davanti al tuo naso. È anche un modo per creare un primo mattoncino per la realizzazione di un vero e proprio Agente.

Il codice completo è disponibile su richiesta.

Problemi con mouse Bluetooth Trust su Ubuntu con chip Realtek RTL8821CE: analisi e soluzione


Negli ultimi giorni ho avuto problemi intermittenti con un mouse Bluetooth Trust (modello 24059-02) connesso al mio HP con Ubuntu. Il mouse ha funzionato regolarmente per un paio di giorni, poi improvvisamente ha smesso di connettersi, pur comparendo tra i dispositivi già accoppiati.

Questo è il resoconto del problema e della soluzione adottata, nella speranza che possa essere utile anche ad altri.

Il contesto hardware e software

  • Sistema operativo: Ubuntu 24.04 LTS
  • Scheda wireless integrata: Realtek RTL8821CE (Wi-Fi + Bluetooth combo)
  • Mouse: Trust Puck Bluetooth Mouse (codice 24059-02)
  • Stack Bluetooth: bluez, bluetoothd, modulo rtw_8821ce

Il dispositivo viene riconosciuto da lsusb come:

Bus 001 Device 003: ID 0bda:b00e Realtek Semiconductor Corp. Bluetooth Radio

e da lspci come:

01:00.0 Network controller: Realtek Semiconductor Co., Ltd. RTL8821CE 802.11ac PCIe Wireless Network Adapter

Il problema

Dopo qualche tempo di funzionamento corretto, il mouse ha smesso di connettersi. L’indagine non è banale. A volte premere il pulsantino rosso sul retro – fino a che si vede una sequenza di led blu che scorre – è sufficiente per riattivare il mouse ma a volte non c’è nulla da fare: neppure spegnedo e riaccendendo il mouse, né staccando e ricollegando l’antennina.

Inizialmente mi sono chiesto se l’antenna fosse solo Wi-Fi o anche Bluetooth. Il chip Realtek RTL8821CE integra entrambi i protocolli e utilizza una sola antenna condivisa. Questo è tipico dei moduli combo wireless presenti nei portatili moderni: lo stesso circuito RF gestisce sia la trasmissione dati Wi-Fi che il canale BLE del mouse Bluetooth.

In questi moduli combo, l’alternanza tra Wi-Fi e Bluetooth avviene tramite una tecnica chiamata Time Division Multiplexing (TDM) per condividere il front-end RF, in modo da evitare interferenze interne.

Qui ho chiesto all’oracolo di San Gpt per ottenere lumi sulla questione. In breve la soluzione è il controller del Blutooth, bluetoothctl

Bluetoothctl

bluetoothctl è un tool da riga di comando per gestire dispositivi Bluetooth in ambiente Linux. Permette di accoppiare, connettere, scollegare e monitorare dispositivi direttamente dal terminale. È un client testuale che interagisce con il demone bluetoothd tramite il sistema D-Bus. Fa parte del pacchetto bluez, lo stack ufficiale per la gestione del Bluetooth su Linux.
In pratica, è il comando testuale che consente di “parlare” direttamente con il sistema Bluetooth. Anche se spartano, è spesso più affidabile delle interfacce grafiche, specialmente quando i dispositivi fanno i capricci.

Un altro problema è stato reperire l’indirizzo MAC del device, ma ci si può arrivare facilemnte utilizzando i comandi sopra (lscpi o lsusb).

Al comando bluetoothctl connect <MAC> ricevevo messaggi come:

Failed to connect: org.bluez.Error.Failed Operation already in progress
Failed to connect: org.bluez.Error.Failed le-connection-abort-by-local

Nei log del kernel:

Bluetooth: hci0: unexpected cc 0x0c7c length: 1 < 3
bluetoothd[1152]: profiles/battery/battery.c:parse_battery_level() Trying to update an unregistered battery

Questi errori sono noti nel contesto dei dispositivi Bluetooth LE con chip Realtek. Il modulo rtw_8821ce presenta instabilità nella gestione della modalità a basso consumo (LPS) e problemi nel completare la connessione GATT con alcuni dispositivi.

La causa

Il mouse si accoppia correttamente ma, per motivi legati al driver o al timing del demone bluetoothd, la connessione BLE può venire interrotta dal sistema locale (errore le-connection-abort-by-local) oppure avviata quando i servizi del dispositivo non sono ancora completamente esposti. Ne consegue il mancato completamento della fase ServicesResolved.

La scheda Realtek è notoriamente sensibile a questi problemi, specialmente dopo resume da sospensione o dopo un pairing precedente lasciato in stato incoerente.

Soluzione adottata

Per ristabilire una connessione pulita al mouse, ho scritto uno script mouse_connect.sh da lanciare ogni volta che il mouse “si pianta”.

Il contenuto dello script

#!/bin/bash

# MAC del mouse Trust
MOUSE_MAC="CB:4C:45:8F:97:4C"

echo "Tentativo di riconnessione al mouse Trust ($MOUSE_MAC)..."

if ! systemctl is-active --quiet bluetooth.service; then
    echo "Il servizio bluetooth non è attivo. Lo riavvio..."
    sudo systemctl restart bluetooth.service
    sleep 2
fi

bluetoothctl <<EOF
power on
agent on
default-agent
trust $MOUSE_MAC
connect $MOUSE_MAC
EOF

echo "Comando inviato. Verifica se il mouse si è riconnesso."

Lo script va salvato nella home directory e reso eseguibile con:

chmod +x ~/mouse_connect.sh

Può poi essere lanciato con:

./mouse_connect.sh

oppure si può creare un alias nel .bashrc:

alias reconn-mouse="~/mouse_connect.sh"

Considerazioni finali

Questo approccio ha permesso di aggirare il problema senza dover reinstallare driver o riavviare il sistema. Tuttavia, se il problema si ripresentasse con regolarità dopo ogni riavvio o sospensione, prenderò in considerazione:

  • L’installazione di un driver migliorato da https://github.com/tomaspinho/rtl8821ce
  • L’aggiunta di uno script automatico al resume (/lib/systemd/system-sleep)
  • La disattivazione del plugin battery nel file main.conf di bluetoothd, per evitare log ripetitivi

Per ora, lo script manuale è una soluzione efficace e veloce.


Having fun with ChatGPT

Ho installato la versione 24.3.1.347 di Oracle SQL Developer però ho una sorpresa: sembra non sia possibile raggruppare le connessioni in cartelle.

Risposta:

Eh sì — è una sorpresa che ha colpito parecchi utenti anche esperti di SQL Developer!
Vediamo bene la situazione

1. In SQL Developer 24.3.1 (e in generale 23.x–24.x)

Purtroppo l’interfaccia “Connections” non permette ancora di creare cartelle o gruppi gerarchici di connessioni, almeno non nel senso di folder drag-and-drop come molti si aspettano.

Puoi ordinare le connessioni alfabeticamente o rinominarle, ma non metterle in sotto-cartelle.


Poi ChatGPT mi elenca alcuni workaround:

  • usare prefissi nel nome
  • esportare e mantenere le connessioni dei vari gruppi in file separati
  • usare workspaces diversi

Conclude con:

3. Nota di roadmap

Oracle ha annunciato che la funzionalità di “Connection Folders” è prevista per una futura major release, ma non è ancora stata inclusa fino alla 24.3.1 (autunno 2025).
Quindi per ora i prefix-based groups restano la soluzione più pratica.

Aggiorno il prompt

In realtà il mio collega mi ha fatto vedere come si fa, è posibile creare cartelle

Risposta (senza vergogna)

Esatto — ottimo colpo d’occhio del tuo collega!
Hai ragione: nelle versioni recenti di SQL Developer (a partire dalla 23.1 in poi) Oracle ha finalmente reintrodotto la possibilità di creare cartelle di connessioni, ma la funzione è un po’ “nascosta”, quindi molti non la notano al primo sguardo.


In effetti la vergogna è un sentimento umano e le reti neurali non sanno cosa sia.

Attenzione: WordPress da problemi se includiamo nel testo i caratteri unicode con gli smileys prodotti da ChatGPT.