Report dal campo
Perché i pod non raggiungono più i servizi Docker Compose dopo la migrazione di K3s da Flannel a Cilium
Che cosa afferma questo documento, e che cosa no
Afferma questo: abbiamo messo in esercizio una combinazione di versioni che la documentazione di compatibilità pubblicata da Cilium non elencava al momento di questo deployment, e al momento della stesura non abbiamo trovato alcun resoconto pubblicato di questa combinazione esatta — con tutti i limiti di una ricerca datata e per forza di cose parziale. Riportiamo che cosa è successo, su un'installazione di produzione. La combinazione è:
| componente | versione | nota |
|---|---|---|
| Kubernetes (K3s) | 1.36 | |
| Cilium | 1.19.6 (GA) | Kubernetes 1.36 non compariva nella documentazione di compatibilità di Cilium consultata al momento del deployment |
| Docker Engine | 29 | introduce la protezione per container in raw/PREROUTING |
| topologia | nodo singolo, Kubernetes e Docker Compose conviventi | i servizi stateful stanno su Compose |
Non afferma questo: che siamo i primi a qualcosa, che la combinazione sia supportata, che qualcuno debba replicarla, o che Cilium funzioni su Kubernetes 1.36 in generale. Cilium 1.19.6 non è testato upstream contro la 1.36. Quello che segue è evidenza locale raccolta su un solo parco macchine: non è una certificazione e non è una raccomandazione. L'abbiamo messa in esercizio perché l'alternativa — una CNI in release candidate in produzione — era peggiore, e il rischio è stato accettato in modo esplicito e per iscritto.
La parte che vale la pena leggere non è la migrazione. Il passaggio da Flannel a Cilium su K3s è documentato a fondo e non ci ha insegnato nulla di nuovo. La parte che vale la pena leggere è una modalità di guasto che esiste solo quando questi tre pezzi si incontrano, il cui sintomo punta molto lontano dalla causa, e che non siamo riusciti a trovare messa in relazione da nessuna parte nella documentazione né dell'una né dell'altra parte.
Il parco macchine
Un nodo singolo ospita due mondi che devono parlarsi:
- Docker Compose possiede i servizi stateful — diverse istanze PostgreSQL,
una cache, un message broker. Nel seguito sono indicati con etichette di
ruolo:
<compose-db-catalog>,<compose-db-communications>,<compose-cache>,<compose-broker>. - Kubernetes (K3s) possiede un insieme crescente di workload stateless.
Raggiungono i servizi Compose attraverso oggetti
Servicedi Kubernetes il cuiEndpointSlicepunta agli indirizzi dei container Compose — un ponte di migrazione voluto, perché i workload parlino nomi Kubernetes su entrambi i lati dello spostamento.
La policy FORWARD di Docker è DROP, quindi il confine era stato reso
esplicito da tempo: un unico script canonico, versionato e idempotente possiede
una allowlist ristretta in DOCKER-USER — dal CIDR dei pod verso un solo
indirizzo di backend, una sola porta, per ogni dipendenza. Quello script era il
confine da mesi, e si riteneva fosse tutto il confine.
Non lo era.
Il sintomo
Dopo lo scambio di CNI, ogni workload si avviava e moriva allo stesso modo: in timeout mentre creava il proprio pool di connessioni al database. Dall'interno di un pod:
| verifica | risultato |
|---|---|
| risoluzione DNS | funziona |
| dal pod all'indirizzo del nodo | funziona |
| dal pod a un Service interno al cluster (CoreDNS) | funziona |
| dal pod a un Service con backend su Compose | va in timeout |
| dall'host allo stesso Service | funziona |
| da un altro container Compose allo stesso Service | funziona |
Quindi: la traduzione dei Service funziona dall'host e da Compose, il DNS funziona, il routing funziona — e fallisce solo il traffico che parte da un pod verso un Service con backend su Compose. Nessun errore a log. La CNI non segnalava alcun drop. Le regole erano tutte presenti e corrette: le catene dei service contenevano la traduzione giusta, la allowlist conteneva le eccezioni giuste, la rotta verso il bridge Compose si risolveva correttamente per un pacchetto inoltrato proveniente dall'indirizzo del pod.
Una cattura di pacchetti mostrava il SYN uscire dall'interfaccia del pod, ritrasmettere e non arrivare mai al bridge Compose. I contatori sulla regola di allowlist e sulla regola di masquerade della CNI restavano fermi. Il pacchetto spariva fra due punti in cui tutto sembrava a posto.
Il meccanismo
Due fatti, ciascuno per sé del tutto ordinario.
Fatto uno — Docker Engine 28/29 protegge nella tabella raw gli indirizzi dei
container non pubblicati. Per ogni porta di container che non viene
pubblicata, Docker installa una regola di questa forma:
-t raw -A PREROUTING -d <container-address>/32 ! -i <compose-bridge> -j DROP
Sul percorso di ingresso IPv4 che ci interessa, l'hook raw/PREROUTING viene
valutato prima del connection tracking, prima di nat e quindi molto prima di
DOCKER-USER, che vive in filter/FORWARD. L'intento è corretto — l'indirizzo
di un container non deve essere raggiungibile aggirando il bridge — ed è un
comportamento documentato.
Fatto due — dove avvenga la traduzione del Service dipende dalla CNI.
Con un kube-proxy in iptables e una CNI che non traduce i service per conto
proprio, un pacchetto che va da un pod a un ClusterIP è ancora indirizzato al
ClusterIP quando raggiunge raw/PREROUTING. La regola di Docker fa match
sull'indirizzo del container. Non corrisponde. Il pacchetto prosegue, riceve
il DNAT in nat/PREROUTING, attraversa filter/FORWARD dove la allowlist lo
accetta, e viene masqueradato in uscita. Il confine è davvero DOCKER-USER.
Con Cilium, anche con kubeProxyReplacement: false, la traduzione del ClusterIP
per il traffico originato dai pod avviene nel datapath eBPF, prima che il
pacchetto entri in netfilter. Quindi il pacchetto arriva a raw/PREROUTING
già indirizzato al container Compose, su un'interfaccia lxc* — che non è il
bridge Compose.
È esattamente la situazione per cui la regola di Docker esiste. E la scarta, nella prima tabella, prima che venga consultata una qualsiasi delle regole scritte per questo parco macchine.
La allowlist non è mai stata sbagliata. Semplicemente non veniva mai raggiunta.
kubeProxyReplacement: false non è una garanzia sul datapath
Viene naturale leggere kubeProxyReplacement: false come «la traduzione dei
service avviene comunque in iptables, quindi netfilter vede il ClusterIP».
L'inferenza è sbagliata, ed è esattamente l'inferenza che ci ha tenuti a cercare
nella tabella sbagliata.
kubeProxyReplacement governa se Cilium assuma per intero il ruolo di
kube-proxy. Non promette che ogni traduzione di service passi da netfilter
quando è disattivato: Cilium documenta che, anche con
kubeProxyReplacement: false, il traffico ClusterIP interno al cluster
proveniente dai pod viene bilanciato pacchetto per pacchetto nel datapath eBPF.
Il load balancing a livello di socket (traduzione al momento della connect())
è una modalità distinta, con una propria configurazione — conviene verificare
quale sia attiva, invece di attribuirle il comportamento per default. Quello che
abbiamo osservato qui è la conseguenza che conta per il confine: con
kubeProxyReplacement: false, il traffico ClusterIP originato dai pod arrivava a
raw/PREROUTING portando già l'indirizzo di backend.
La regola operativa che ne abbiamo tratto: non ragionare dal flag; verificare dove avviene la traduzione. Bastano due controlli:
cilium status --verboseecilium config view | grep -i -e socketlb -e lbmostrano quali percorsi di service sono gestiti in BPF;- il metodo dei contatori descritto più avanti mostra, per singola verifica, se netfilter abbia mai visto il ClusterIP. Se le catene dei service restano ferme mentre si tenta una connessione, la traduzione è avvenuta prima di netfilter — qualunque cosa suggeriscano i flag.
Perché era rimasto invisibile
Questa modalità di guasto non può presentarsi finché è in uso la vecchia CNI,
perché la premessa — un pacchetto che porta già l'indirizzo di backend al momento
di raw — non si verifica mai. E non si può trovare leggendo la configurazione:
ogni regola coinvolta è corretta, nella tabella giusta, nell'ordine giusto, e fa
esattamente quello che dichiara. L'interazione esiste solo sulla giuntura.
Come lo abbiamo dimostrato
La diagnosi che ha chiuso la questione vale più della correzione, perché è generale: confrontare i contatori delle due regole che rappresentano le spiegazioni in competizione, attorno a una singola prova.
# prima
iptables -t raw -L PREROUTING -v -n -x | grep 'DROP.*<compose-db-communications>'
iptables -t nat -L <service-chain> -v -n -x | grep KUBE-SEP
# un tentativo di connessione dall'interno del pod
kubectl exec -n <ns> <pod> -- bash -c 'timeout 6 bash -c "echo > /dev/tcp/<clusterip>/<port>"'
# dopo
Risultato: il contatore del DROP raw di Docker si è mosso di +8; il
contatore dell'endpoint di service si è mosso di +0. Una misura, due numeri,
nessuna interpretazione necessaria. Tutto il resto che stavamo guardando — rotte,
regole di masquerade, tabelle di policy routing, il feed dei drop della CNI
stessa — era compatibile con entrambe le spiegazioni e quindi non discriminava
nulla.
Tre note pratiche, ognuna delle quali ci è costata tempo:
- Sondare con
bash, non consh./dev/tcpè una builtin di bash; sottoshla prova riporta un fallimento per qualunque destinazione, indipendentemente dalla connettività. - Attenzione agli
ACCEPTterminanti quando si leggono i contatori. UnACCEPTin una catena definita dall'utente termina l'attraversamento di quel hook, quindi il contatore fermo di una regola a valle non significa che il pacchetto sia stato scartato lì. - Non leggere un exit code attraverso una pipe.
cmd | sedriporta lo stato dised. Abbiamo prodotto una misura sbagliata in questo modo e l'abbiamo dovuta rifare.
Leggere dai contatori un pacchetto che sparisce
Lo schema che rende questo guasto disorientante è: la connessione va in timeout,
i contatori degli endpoint di service (KUBE-SVC/KUBE-SEP) non si muovono mai,
e il feed dei drop della CNI tace. Ogni strumento sta dicendo il vero; osservano
livelli diversi.
KUBE-SEPfermo non significa «nessun traffico». Significa che l'hooknatdi netfilter non ha mai visto un pacchetto ancora indirizzato al ClusterIP — che è esattamente quello che accade quando la traduzione è già avvenuta nel datapath eBPF.- Un feed dei drop silenzioso non significa «nessun drop». Cilium riporta i
drop decisi dal proprio datapath; un pacchetto scartato da una regola
rawdi netfilter gli è invisibile. - Una entry di conntrack mancante restringe ulteriormente il campo.
rawgira prima del connection tracking, quindi un drop lì non lascia alcuna traccia in conntrack; un drop infilterne lascerebbe una. rawè l'hook che gira prima del connection tracking (priorità degli hook: raw −300, conntrack −200, mangle −150 — quindimanglegira dopo conntrack e non può spiegare una entry di conntrack mancante). Quando ogni stadio successivo non mostra nulla, conta prima inraw.
La procedura resta quella sopra: scegliere le due regole che rappresentano le
due spiegazioni in competizione, leggere entrambi i contatori, eseguire una
prova, rileggere. Un tentativo, due numeri. Nel nostro caso il contatore del DROP
raw è avanzato del numero di ritrasmissioni mentre KUBE-SEP è rimasto a zero
— il pacchetto veniva giudicato una tabella prima del confine che presidiavamo.
La correzione
Estendere il proprietario canonico del firewall fino alla tabella in cui la
decisione viene davvero presa. In concreto: una catena governata in raw, con
salto da raw/PREROUTING dopo il feeder della CNI e prima delle regole DROP di
Docker, contenente esattamente le stesse eccezioni ristrette che la allowlist
in filter già esprimeva — dal CIDR dei pod verso un solo indirizzo di backend,
una sola porta letterale, per ogni dipendenza.
Le proprietà contano più delle regole stesse:
- Gli indirizzi si ricavano a runtime dal container runtime, non si scrivono mai a mano. Un indirizzo letterale in una regola di firewall sopravvive al container da cui è stato copiato e diventa silenziosamente un'eccezione per chi lo eredita.
- La posizione si verifica, non si assume. Un allow messo dopo il DROP non permette nulla — e ogni prova continuerebbe a passare grazie a una entry di connection tracking già esistente, quindi il controllo deve confrontare l'indice del nostro salto con l'indice del primo DROP di Docker per i nostri backend, non limitarsi ad accertare che entrambi esistano. Docker inserisce le nuove regole in testa quando i container si avviano, quindi un salto posizionato correttamente ieri può essere scavalcato oggi.
- Purge idempotente di ciò che è nostro e basta, per tag di proprietà, così che le regole obsolete costruite con vecchi indirizzi non possano accumularsi.
- I test negativi fanno parte del contratto: una porta non dichiarata e una destinazione non dichiarata devono restare negate dopo la correzione, e verifichiamo che lo siano.
Questo non è un aggiramento dell'isolamento di Docker. È la stessa eccezione
esplicita che il parco macchine concedeva già in filter, espressa nella tabella
in cui il pacchetto viene effettivamente giudicato. L'autorizzazione per singolo
workload viene poi ristretta ulteriormente con le NetworkPolicy.
Stato di produzione dopo la migrazione
Questa è stata una migrazione di produzione, non un esercizio isolato di laboratorio. Cilium 1.19.6 e Hubble girano ora su un nodo Kubernetes 1.36.2 che continua a convivere con un parco Docker Compose che ospita le dipendenze stateful.
All'ultima verifica post-migrazione il nodo risultava Ready; Cilium riportava 49 controller in salute e un nodo di cluster raggiungibile; nove pod Kubernetes erano in esecuzione, con sette endpoint Cilium pronti. Una verifica di salute da servizio a servizio attraverso il confine Compose-Kubernetes ha restituito HTTP 200, gli endpoint HTTP pubblici sono rimasti disponibili, e il parco Compose riportava 66 container in esecuzione senza alcun container unhealthy.
La migrazione ha corretto tre difetti reali di transizione: i percorsi CNI dovevano seguire il container runtime invece di un layout K3s dato per scontato; un dispositivo VXLAN residuo di Flannel doveva essere rimosso in sicurezza prima che Cilium potesse possedere il tunnel; e la traduzione eBPF dei service da parte di Cilium esponeva gli indirizzi di backend Compose alla policy della tabella raw di Docker prima che DOCKER-USER potesse vedere il flusso.
Il ponte finale è deliberatamente stretto: una catena raw a livello host consente solo le dipendenze pod-verso-Compose dichiarate, per ruolo di backend e porta TCP. Gli indirizzi si ricavano dai container vivi, le regole di nostra proprietà vengono ricostruite in modo idempotente, l'ordinamento delle regole viene verificato rispetto ai drop di Docker, e prove negative confermano che destinazioni e porte non dichiarate restano irraggiungibili.
Il percorso di rollback è stato provato in dry-run e resta deliberatamente manuale. Supporta sia il recupero della sola policy sia un ripristino completo da Cilium a Flannel, con pulizia dello stato del vendor, rimozione di CNI, eBPF e CRD e controlli post-recupero obbligatori. Non è stato invocato in produzione perché lo stato obiettivo con Cilium si è ristabilito ed è rimasto in salute; questo report non avanza quindi alcuna affermazione su una durata di rollback misurata in produzione.
Deviazioni in produzione
Il primo guasto in produzione è stata una nostra assunzione
Il primo rollout di Cilium non è fallito per un bug di prodotto non documentato. È fallito perché avevamo configurato i percorsi CNI partendo da un'assunzione sul layout di K3s, invece di ricavarli dal container runtime che li avrebbe consumati una volta disattivato Flannel.
Il risultato è stato un cluster senza un datapath dei pod utilizzabile: i workload si avviavano, ma non riuscivano a ottenere la configurazione di rete di cui avevano bisogno. La correzione non è stata un altro percorso scritto a mano. Abbiamo cambiato il contratto di preflight e di deployment in modo che i percorsi attesi vengano ricavati dal runtime che li decide davvero.
La distinzione è importante. I test di mutazione avevano dimostrato che il vecchio gate faceva rispettare la propria asserzione. Non avevano dimostrato che la premessa dell'asserzione fosse vera. Un test può essere vivo, severo e proteggere comunque il modello sbagliato.
Altre deviazioni registrate durante la finestra
- Un'interfaccia VXLAN residua di Flannel occupava ancora la porta UDP del tunnel richiesta da Cilium. L'abbiamo rimossa solo dopo aver verificato che non trasportasse stato di forwarding, invece di cancellare alla cieca un dispositivo di rete vivo.
- La traduzione dei ClusterIP avveniva nel datapath eBPF di Cilium prima di
netfilter. Docker valutava quindi il backend Compose tradotto in
raw/PREROUTING, prima che le concessioni già presenti inDOCKER-USERpotessero vedere il flusso. - Un pod può restare
Runningpur appartenendo ancora a stato di rete obsoleto. Il criterio finale di convergenza è stato perciò l'uguaglianza insiemistica fra tutti i pod non host-network e gli endpoint Cilium, non un elenco di workload ricordato a memoria.
Che cosa qui non è inedito
Detto chiaramente, così nessuno debba scoprirlo in un thread di commenti:
- Migrare K3s da Flannel a Cilium è ben documentato, e non ci ha insegnato nulla di nuovo.
- Il layout delle directory CNI di K3s, e la sopravvivenza del dispositivo di
tunnel di Flannel a
flannel-backend: nonementre continua a occupare la porta UDP del tunnel, sono entrambi noti. Il nostro contributo non è averli scoperti: è averli verificati contro il runtime che decide davvero, e averli incorporati nel contratto operativo. Il racconto di che cosa è andato storto sta in Deviazioni in produzione qui sopra, e non viene ripetuto. - La protezione
rawdi Docker è documentata come comportamento di Docker. Quello che abbiamo trovato non documentato è la sua interazione con una CNI che traduce i service prima di netfilter.
L'affermazione è volutamente stretta: questa specifica interazione, a queste versioni, in un parco ibrido Compose/Kubernetes. Se è documentata altrove e ci è sfuggita, preferiamo essere corretti che citati.
Riproduzione
Forma minima, senza alcun prodotto coinvolto:
- Un host con Docker Engine 29 e una distribuzione Kubernetes, con almeno un servizio Compose che non pubblica porte.
- Un
ServiceKubernetes il cuiEndpointSlicepunta all'indirizzo di quel container. - Con un
kube-proxyin iptables e una CNI che non pre-traduce, più una allowlist inDOCKER-USER: un pod raggiunge il Service. - Installare una CNI che traduce i ClusterIP in eBPF prima di netfilter.
- Lo stesso pod ora va in timeout, mentre l'host e gli altri container Compose
continuano a raggiungere il Service. Il contatore del DROP
rawdi Docker per quell'indirizzo di container avanza; il contatore dell'endpoint di service no.
Stato della validazione al momento della stesura
Completato e verificabile al momento dell'osservazione:
- la migrazione dal vivo da Flannel a Cilium su questo nodo;
- la diagnosi del guasto pod-verso-Compose, con il metodo dei contatori descritto sopra;
- il confine ristretto nella tabella raw, con controlli positivi e negativi eseguiti subito dopo la correzione;
- un gate di convergenza del datapath che confronta pod ed endpoint come insiemi, in entrambe le direzioni, con una suite di test avversariale per il gate stesso.
Esplicitamente non completato al momento dell'osservazione:
- l'autorizzazione per singolo workload tramite NetworkPolicy;
- la suite di connettività del vendor;
- una finestra di burn-in misurata, e qualsiasi affermazione di stabilità operativa prolungata.
Questo documento non le rivendica, e non lo farà finché non saranno vere e misurate.
Avvertenze
- Nodo singolo, un solo parco macchine, una sola finestra di osservazione. Non abbiamo provato configurazioni multi-nodo, altre CNI con traduzione precedente a netfilter, né Docker 28 contro 29 separatamente.
- Cilium 1.19.6 su Kubernetes 1.36 è fuori dalla matrice di supporto upstream. Nulla di quanto scritto qui va letto come prova che l'abbinamento sia sicuro in generale; è la prova che ha funzionato qui, sotto le nostre verifiche, durante una finestra.
- La correzione allarga un'eccezione a livello host. È accettabile in questo parco macchine perché le eccezioni sono ristrette, ricavate a runtime, verificate nella posizione e coperte da test negativi. Da copiare sono le proprietà, non le regole.
Pubblicato il 30/07/2026 come report dal campo su lavori svolti il 29/07/2026; edizione italiana pubblicata l'08/08/2026. Ogni affermazione riportata è sostenuta da una voce di evidenza registrata; il registro di tracciabilità è interno per scelta. Questa pagina riceverà addenda datati man mano che la validazione prosegue (suite di connettività del vendor, burn-in misurato, NetworkPolicy per singolo workload) — nuove evidenze, non correzioni.
Verbano Tech progetta e gestisce parchi container ibridi. Parliamone.