Alcuni link in questa pagina sono link di affiliazione: se ti registri tramite questi link, il sito riceve una commissione senza costi aggiuntivi per te. Prezzi e consigli non cambiano.

La settimana scorsa un articolo intitolato “Docker is weird” è arrivato in prima pagina su Hacker News: 880 punti, 369 commenti. L’autore sta scrivendo una propria container runtime e ha scoperto che i container non sono una cosa che il kernel conosce, che le immagini sono solo cartelle e che runc ha bisogno di un modulo C perché Go non ha un fork utilizzabile. È una buona lettura, e tutto quanto è vero.

Questo articolo è l’altra metà della storia. Non perché Docker sia strano dentro, ma dove quella stranezza viene a galla su un piccolo server di produzione alle due di notte. Sette comportamenti, ognuno riprodotto qui sotto su una macchina viva con Docker 28.3.1, con l’output reale e non a memoria. Per l’hardware che serve a una macchina così, abbiamo confrontato le opzioni in Il miglior VPS per il self-hosting nel 2026; lo stack in sé è descritto in Caddy e Docker in produzione.

1. Una restart policy non si cura del fatto che il container sia unhealthy

È quella che costa più downtime, perché sembra che tu sia coperto due volte: un healthcheck e restart: always.

docker run -d --name dw1 --restart always \
  --health-cmd 'exit 1' --health-interval 2s --health-retries 1 \
  alpine:3 sleep 600
sleep 12
docker inspect -f 'status={{ .State.Status }} health={{ .State.Health.Status }} failing={{ .State.Health.FailingStreak }} restarts={{ .RestartCount }}' dw1
status=running health=unhealthy failing=5 restarts=0

Cinque controlli falliti di fila, il container dichiaratamente marcato unhealthy, e zero riavvii. Una restart policy reagisce all’uscita del processo principale. Un healthcheck scrive soltanto uno stato. In Docker nudo niente collega le due cose.

Quindi l’applicazione che ha bloccato il proprio pool di worker, tiene la porta aperta e non risponde più resta esattamente dov’è, con una riga di docker ps dall’aria quasi verde in cui Up 3 days (unhealthy) sta in una colonna che nessuno legge. Swarm e Kubernetes agiscono sulla salute. Compose su una sola macchina no.

Cosa fare invece: far sì che il processo esca quando è rotto. Se l’applicazione non sa farlo, metti il controllo fuori da Docker. Un timer systemd che interroga l’endpoint ed esegue docker restart sono cinque righe, e funziona davvero:

curl -fsS --max-time 5 http://127.0.0.1:3000/health >/dev/null || docker restart app

Usa l’healthcheck per ciò in cui è veramente bravo: depends_on: condition: service_healthy, così il database è pronto prima che l’app si colleghi.

2. Una porta pubblicata passa diritta davanti a ufw

Hai configurato ufw. ufw status dice che sono permesse solo 22, 80 e 443. Poi pubblichi una porta in Compose per dare un’occhiata a una dashboard, e quella porta è su internet pubblico.

Ecco perché, sulla stessa macchina:

docker run -d --name pub -p 8099:80 nginx:alpine
iptables -t nat -S | grep 8099
iptables -S FORWARD | head -8
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8099 -j DNAT --to-destination 172.17.0.5:80

-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
...

Leggi l’ordine. Il traffico verso una porta pubblicata viene riscritto in DNAT prima di qualsiasi decisione di filtro che hai preso, poi passato a DOCKER-USER e DOCKER-FORWARD prima di raggiungere la prima catena ufw. Docker lo accetta, il pacchetto viene inoltrato, e le catene ufw più in basso non lo vedono mai. Su questa macchina DOCKER-USER è come sulla maggior parte delle macchine:

-N DOCKER-USER

Vuota. Quella catena esiste proprio perché tu possa metterci le regole dove Docker non le sovrascrive, e quasi nessuno la usa.

Tre rimedi, in ordine di valore:

  • Legare a localhost. -p 127.0.0.1:8099:80 invece di -p 8099:80, e poi arrivarci con un tunnel SSH. È il default giusto per qualunque interfaccia di amministrazione.
  • Non pubblicare affatto. Metti il container sulla rete del reverse proxy e lascia che il proxy lo raggiunga per nome sulla porta interna. Niente nello stack dietro il nostro Caddy pubblica una porta.
  • Scrivere le regole in DOCKER-USER se una porta deve davvero essere pubblica e limitata: iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -p tcp --dport 8099 -j DROP.

Se hai appena ereditato una macchina, questo è il punto uno della passata, e il resto di quella passata sta nell’audit di un server ereditato.

3. I log non ruotano finché non lo aggiungi tu

Il driver di logging predefinito è json-file, e la configurazione predefinita è niente:

docker inspect -f '{{ json .HostConfig.LogConfig }}' dw2
{"Type":"json-file","Config":{}}

Nessun max-size, nessun max-file. Il file sotto /var/lib/docker/containers/<id>/<id>-json.log cresce finché il disco non è pieno. Quanto in fretta? Un container che stampa una riga breve in un ciclo, lasciato andare venti secondi:

docker run -d --name chat alpine:3 sh -c \
  'i=0; while [ $i -lt 200000 ]; do echo "line $i some payload here"; i=$((i+1)); done; sleep 60'
# venti secondi dopo
du -sh /var/lib/docker/containers/9ee39f60*/
19.0M	/var/lib/docker/containers/9ee39f60.../

19 MB in venti secondi da un container che scrive una riga breve per iterazione. Un’applicazione che logga ogni richiesta a livello debug, lasciata sola per un lungo weekend, è un filesystem di root pieno, e un filesystem di root pieno su una macchina singola significa che il database non accetta più scritture, il proxy non può scrivere il suo access log e il rinnovo TLS fallisce.

Sistemalo globalmente, una volta, prima di averne bisogno:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

Va in /etc/docker/daemon.json, poi systemctl restart docker. Vale per i container creati dopo il riavvio, non per quelli esistenti: ricreali. Per servizio in Compose:

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Trenta megabyte per container, limitati, e docker logs continua a funzionare.

4. Il PID 1 è una shell, e il tuo SIGTERM non va da nessuna parte

Due container, lo stesso sleep, fermati nello stesso modo:

docker run -d --name dw2 alpine:3 sh -c 'trap "echo caught; exit 0" TERM; sleep 600'
time docker stop dw2

docker run -d --name dw3 --init alpine:3 sleep 600
time docker stop dw3
dw2    10.11s
dw3     0.09s

Dieci secondi contro nove centesimi. E si noti che quello lento ha un trap su TERM: le shell mettono il trap in coda finché il comando in primo piano non termina, quindi l’handler è inutile mentre sleep è in esecuzione. docker stop manda SIGTERM al PID 1, aspetta il periodo di grazia e poi manda SIGKILL.

Novantanove volte su cento è solo latenza fastidiosa. La centesima è perdita di dati, perché SIGKILL è ciò che il tuo database, il consumer di coda o lo scrittore di file ricevono al posto di una sequenza di arresto. Rende anche ogni deploy dieci secondi più lento per container, cosa che si sente in una pipeline rsync e restart.

Tre vie d’uscita, tutte economiche:

  • Forma exec nel Dockerfile: CMD ["nginx", "-g", "daemon off;"], mai CMD nginx -g 'daemon off;'.
  • exec come ultima riga di uno script di entrypoint, così il tuo processo sostituisce la shell invece di esserne figlio.
  • init: true in Compose, che mette un init minuscolo come PID 1 per inoltrare i segnali e raccogliere gli zombie.

Poi verifica, perché è il tipo di cosa che tutti credono di aver già fatto: docker exec <nome> ps -o pid,comm e guarda cosa è davvero il PID 1.

5. Il DNS dei container esiste solo sulle reti che crei tu

Stesso engine, due container, due resolver diversi. Sul bridge predefinito:

nameserver 192.168.178.1
nameserver 8.8.8.8

Su una rete creata con docker network create:

nameserver 127.0.0.11

127.0.0.11 è il server DNS integrato di Docker, ed è quello che fa funzionare postgres://db:5432. Sul bridge predefinito non c’è: il /etc/resolv.conf dell’host viene copiato dentro e i nomi dei container non risolvono niente. È per questo che gli esempi docker run presi da un blog non si trovano tra loro mentre le stesse immagini in Compose sì: Compose ti crea una rete di progetto.

Vuol dire anche che i nomi dei tuoi servizi non sono un’API stabile. Il vecchio --link è sparito, e il bridge predefinito ti dà solo indirizzi IP, il che porta al punto successivo.

6. Compose ti consegna una subnet diversa da quella che hai autorizzato

docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.19.0.0/16

docker compose down
docker network create decoy1 && docker network create decoy2
docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.21.0.0/16

docker compose down cancella la rete del progetto. Il up successivo ne crea una nuova e prende il primo blocco libero, che non è quello che avevi. Due reti senza alcun rapporto create nel frattempo sono bastate a spostare il progetto da 172.19 a 172.21.

Tutto ciò che avevi fissato alla vecchia subnet ora è silenziosamente sbagliato: una riga di pg_hba.conf, una allowlist applicativa, una regola di firewall, una API che confronta X-Forwarded-For con un CIDR. Il guasto arriva come “connection refused from unknown host” dopo un redeploy di routine, cioè nel momento peggiore possibile per mettersi a leggere pg_hba.conf.

Quindi o smetti di fissare le subnet e usi i nomi dei container su una rete tua, o dichiari la subnet perché non possa spostarsi:

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/24

7. Un kill per OOM non assomiglia a un crash

Dai 32 MB a un container e lascialo allocare:

docker run --rm -m 32m alpine:3 sh -c 'a=""; while true; do a="$a$(head -c 1000000 /dev/zero | tr "\0" x)"; done'
echo $?
137

Il codice di uscita 137 è 128 + 9: SIGKILL. Non il tuo codice, nessuna eccezione, nessuno stack trace, niente nel log applicativo. Il cgroup ha raggiunto il limite e il kernel ha ucciso il processo più grande al suo interno.

Ed è qui che l’articolo di Hacker News e questo si incontrano. Il kernel non uccide “un container”, uccide un processo ordinario che vede da sempre:

hp=$(docker inspect -f '{{ .State.Pid }}' dw4)
ps -o pid,cmd -p $hp
grep NSpid /proc/$hp/status
    PID CMD
1085777 sleep 600
NSpid:	1085777	1

Un processo, due numeri: 1085777 sull’host, 1 dentro. “I container non sono reali” non è una provocazione, è esattamente quello che stai guardando quando debug un kill per OOM. Il PID 1 del container è nella tabella dei processi del tuo host, ed è lì che lo trovi quando docker logs non ha nulla perché il processo non è mai arrivato a scrivere una riga.

Quindi quando un container sparisce senza spiegazioni: leggi prima il codice di uscita (docker inspect -f '{{ .State.ExitCode }} {{ .State.OOMKilled }}'), poi dmesg -T | grep -i oom, poi metti un limite di memoria su ogni servizio, così il kernel uccide il container che si è comportato male e non quello che per caso stava girando sul tuo host.

La checklist

Tutto quanto sopra, come diff da applicare a uno stack che già gestisci:

services:
  app:
    image: app:1.4.2          # non :latest
    init: true                # il PID 1 inoltra i segnali
    restart: unless-stopped
    mem_limit: 512m           # il kernel uccide questo, non il tuo database
    ports:
      - "127.0.0.1:3000:3000" # mai una porta host nuda
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
    depends_on:
      db:
        condition: service_healthy

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/24   # così un redeploy non la sposta

Più due cose che vivono fuori dal file: max-size in /etc/docker/daemon.json per tutto quello che avvii a mano, e un watchdog esterno per lo stato di salute su cui Docker non agirà.

Niente di tutto questo è esotico. È tutto comportamento predefinito, documentato, riproducibile in venti secondi a testa, e ogni voce dell’elenco questo mese ha messo giù un piccolo server di produzione a qualcuno. Continua a succedere perché i default sono tarati su un portatile, dove un file di log da 19 MB e uno stop da dieci secondi non costano niente.

Se vuoi il resto dello stack a cui appartengono queste impostazioni, la configurazione completa della macchina che serve questa pagina è in Caddy e Docker in produzione, e il lato misurazione in analytics self-hosted con Umami.