Ultimamente mi sono ritrovato con alcuni pod riavviati per colpa di query molto pesanti sul database. La causa l’abbiamo sistemata a parte e non è di quella che voglio parlare: voglio parlare di chi quei pod li ha riavviati, cioè dei probe.

Li configuro da anni, ma fino a oggi non li avevo mai capiti davvero. Li copiavo da un chart all’altro, cambiavo il path e via.

Infografica con tre card. Startup, protegge l'avvio: ha finito di avviarsi? Se fallisce riavvia il container, niente check sul DB. Readiness, interrompe il traffico: pronta a servire? Se fallisce esce dal Service, DB solo con cautela. Liveness, riavvia l'app: l'app è ancora viva? Se fallisce riavvia il container, mai check sul DB

Chi fa le domande

I probe li esegue il kubelet, l’agente di Kubernetes che gira su ogni nodo. A intervalli regolari interroga ogni container (una chiamata HTTP, una connessione TCP, un comando dentro il container o una chiamata gRPC) e in base alla risposta decide cosa fare. Per HTTP conta come successo qualsiasi codice tra 200 e 399.

I probe sono tre, e la cosa che mi era sfuggita è che non sono tre varianti dello stesso controllo: ognuno fa una domanda diversa, e quando la risposta è no succede una cosa diversa.

Probe Domanda Se fallisce
startup Ho finito di avviarmi? il container viene riavviato
readiness Posso ricevere traffico adesso? il pod esce dal Service, nessun riavvio
liveness Sono bloccato al punto che solo un riavvio mi salva? il container viene riavviato

Startup: ho finito di avviarmi?

Lo startup probe serve alle applicazioni lente a partire: JVM, cache da scaldare, file da caricare. Finché non ha successo gli altri due probe non girano proprio, quindi il liveness non può uccidere un container che sta ancora caricando. Prima c’era solo initialDelaySeconds, che è una stima a occhio: troppo bassa e il pod viene ucciso a metà avvio, troppo alta e si aspetta per niente a ogni deploy.

Il tempo concesso è failureThreshold × periodSeconds. Superato quello, il kubelet uccide il container e lo fa ripartire. Non è un “aspetto finché non sei pronto”, è un “hai N secondi, poi ricominci da capo”.

Ed è qui che sbagliavo: confondevo startup e readiness. Per me lo startup era il controllo che tiene il pod fuori finché non è pronto davvero, database compreso. Quello però è il lavoro del readiness. Lo startup decide solo quando gli altri due probe possono cominciare, e se scade riavvia.

La differenza si vede quando dentro ci si mette il database. Se il database è giù durante un deploy e il controllo sta nello startup, i pod nuovi falliscono, ripartono, falliscono di nuovo e finiscono in CrashLoopBackOff, con un’attesa tra un tentativo e l’altro che raddoppia fino a cinque minuti. Quando il database torna, i pod possono metterci minuti ad accorgersene.

Con lo stesso controllo nel readiness non riparte niente: il pod resta fuori dal Service finché il database non risponde, poi entra. Quindi lo startup controlla solo l’applicazione, e di solito può usare lo stesso endpoint del liveness con una tolleranza più larga.

Avevo anche in testa che lo startup dovesse verificare che il nodo fosse sano. Non è compito suo: lo stato dei nodi lo seguono il kubelet e il control plane, e un nodo che smette di rispondere viene marcato NotReady e svuotato indipendentemente dai probe.

E il rollout?

Stessa confusione sul rollout: pensavo che fosse lo startup a tenere su i pod vecchi finché i nuovi non sono pronti. Invece il Deployment guarda se i pod nuovi sono Ready, cioè se passano il readiness. Lo startup c’entra solo di riflesso: finché non passa, il readiness non parte e il pod non può essere Ready. Un pod che non diventa Ready non conta come disponibile, e il rollout non va avanti.

Con una precisazione: “i vecchi restano tutti” vale solo con maxUnavailable: 0. Il default è 25%, quindi con 8 repliche, appena parte il rollout, due pod vecchi vengono spenti senza aspettare nessuno. Il 25% si arrotonda per difetto, per cui sotto le 4 repliche il default vale zero e non ce ne si accorge.

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxUnavailable: 0
    maxSurge: 25%

Se i pod nuovi non diventano mai pronti, il Deployment resta a metà. Dopo progressDeadlineSeconds (dieci minuti di default) viene segnato come fallito, ma nessuno torna indietro da solo: il rollback lo deve fare qualcun altro, a mano, con helm upgrade --atomic o con strumenti come Argo Rollouts.

Readiness: posso ricevere traffico adesso?

Il readiness gira per tutta la vita del pod, non solo all’avvio. Quando fallisce, il pod viene segnato come non pronto e tolto dagli endpoint del Service: niente più richieste nuove. Il container però non viene toccato: continua a lavorare, finisce quello che ha in corso, e quando il probe torna a rispondere rientra nel giro.

Nel nostro caso era proprio quello che serviva. Le query pesanti avevano occupato tutte le connessioni del pool verso il database, quindi ogni richiesta nuova si sarebbe messa in coda. Fuori dal Service, il pod smaltisce le richieste che ha già e quelle nuove vanno alle altre repliche. Appena si libera, il readiness torna verde e il pod riceve di nuovo traffico.

Uscire dagli endpoint ferma le connessioni nuove, non quelle già aperte: un client che tiene la connessione in keep-alive verso il ClusterIP può continuare a mandare richieste allo stesso pod. Gli ingress controller che bilanciano direttamente sugli endpoint, come ingress-nginx, invece smettono quasi subito.

Il rovescio della medaglia

Il readiness funziona bene quando il problema riguarda quel pod: il suo pool è pieno, quello delle altre repliche no, e il traffico si sposta.

Ma se è il database a rallentare per tutti, i pool si riempiono su tutte le repliche nello stesso momento. I readiness falliscono insieme, il Service resta senza endpoint e l’ingress risponde 503 a chiunque. Un servizio lento diventa un servizio spento. E già prima di arrivare a zero, ogni pod tolto dal giro scarica il suo traffico sugli altri, che si riempiono prima: effetto domino.

Per questo distinguo due tipi di controllo:

Il secondo va maneggiato con cura. Togliere tutti i pod dal Service quando il database è giù non salva niente: il servizio sarebbe rotto comunque, solo che al posto di un errore applicativo chiaro gli utenti vedono un 503 generico. A volte va bene così, ma deve essere una scelta, non un effetto collaterale.

Un dettaglio pratico: se il controllo fa una SELECT 1 passando dal pool, con il pool pieno anche il probe si mette in coda e va in timeout, che di default è un secondo. Il risultato è lo stesso, ma per il motivo sbagliato e con una query in più su un database già sotto pressione. Meglio leggere lo stato del pool, se la libreria lo espone.

Liveness: un riavvio mi salverebbe?

Il liveness è quello che fa più danni, perché quando fallisce il kubelet uccide il container e lo fa ripartire. Non nasce un pod nuovo: stesso pod, stesso nodo, stesso IP, e il contatore dei restart che sale.

La domanda giusta non è “sto funzionando bene?” ma “sono bloccato in un modo che solo un riavvio può sistemare?”. Un deadlock, un event loop piantato, un processo vivo che non risponde più a niente: i casi in cui spegnere e riaccendere serve davvero.

Il database qui non c’entra. Se il pool è esaurito perché il servizio sta lavorando, riavviarlo è il contrario di quello che serve:

E se il controllo sul database è uguale per tutti, le repliche si riavviano tutte, magari insieme, e ognuna riapre il suo pool da capo su un database già in difficoltà.

Anche senza database

Togliere il database dal liveness non basta. Se l’endpoint del liveness viene servito dagli stessi worker che gestiscono le richieste, e tutti i worker sono fermi ad aspettare una connessione, anche lui resta in coda. Il probe va in timeout, tre volte di fila, e il container viene riavviato lo stesso. Con i default bastano una trentina di secondi di saturazione.

I rimedi, meglio se insieme:

Come li configuro adesso

Due endpoint:

startupProbe:
  httpGet:
    path: /healthz/live
    port: 8080
  periodSeconds: 5
  failureThreshold: 24   # 2 minuti per partire, poi riavvio
readinessProbe:
  httpGet:
    path: /healthz/ready
    port: 8080
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 2    # fuori dal Service dopo circa 10 secondi
livenessProbe:
  httpGet:
    path: /healthz/live
    port: 8080
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 6    # riavvio solo dopo un minuto senza risposta

Per riferimento, i default sono periodSeconds: 10, timeoutSeconds: 1, failureThreshold: 3 e successThreshold: 1. Il più insidioso è il timeout di un secondo: sotto carico basta poco per superarlo.

Non è obbligatorio averli tutti e tre. Senza readiness il pod è pronto appena il container parte; senza liveness il container viene riavviato solo se il processo esce. Un liveness fatto male è peggio di nessun liveness.

In breve