Kubernetes: aggiornamenti rolling zero-downtime

6 ottobre 2017

Il Rolling Update si limita a garantire che Kubernetes arresti i pod in modo continuo, uno alla volta, assicurando sempre che sia in esecuzione la quantità minima desiderata di pod. Questo potrebbe sembrare sufficiente per le distribuzioni a tempo zero. Ma come al solito non è così semplice.

Durante l'aggiornamento continuo, Kubernetes deve terminare le vecchie versioni dei pod, dopotutto è quello che si vuole. Ma questo è un problema quando il pod viene terminato nel bel mezzo dell'elaborazione di una richiesta HTTP. E questo può accadere quando l'applicazione non collabora con Kubernetes. In questo articolo vedremo perché succede e come risolverlo.

Il problema

Il problema è diventato evidente quando abbiamo recentemente trasferito uno dei nostri servizi più connessi al nostro cluster Kubernetes. Il trasferimento è avvenuto senza problemi, ma dopo un po' abbiamo iniziato a vedere errori di connessione apparentemente casuali a questo servizio in tutta la nostra piattaforma.

Dopo alcune indagini ci siamo resi conto che quando abbiamo distribuito un nuovo aggiornamento al servizio, c'era la possibilità che alcuni degli altri servizi fallissero alcune richieste durante la distribuzione.

Per dimostrare questa ipotesi ho eseguito un test sintetico utilizzando wrk e attivando contemporaneamente il rolling update:

wrk https://www.server.com/api &
sleep 1 && kubectl set image deployment/api api=driftrock/api:2

Il risultato ha confermato il problema:

Esecuzione di un test di 10s @ https://www.server.com/api
2 thread e 10 connessioni
Statistiche dei thread Avg Stdev Max +/- Stdev
Latenza 290.50ms 176.36ms 1.19s 87.78%
Req/Sec 19.39 9.31 49.00 47.06%
368 richieste in 10.10s, 319.48KB letti
Risposte non-2xx o 3xx: 19
Richieste/sec: 36,44
Trasferimenti/sec: 31,63KB

Analizziamo il problema in modo più dettagliato in questo articolo.

Processo di terminazione dei pod Kubernetes

Vediamo innanzitutto come Kubernetes termina i pod. Sarà fondamentale capire come l'applicazione deve gestire la terminazione.

Secondo la documentazione di https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods segue questi passaggi (più o meno):

  1. Se il pod ha servizi di rete, kubernetes interrompe l'instradamento di nuove connessioni al pod che termina. Le connessioni già stabilite vengono lasciate intatte e aperte.
  2. Kubernetes invia il segnale TERM al processo root di ogni contenitore nel pod, presumendo che i contenitori inizieranno a fermarsi. Il segnale inviato non può essere configurato.
  3. Attende il periodo di tempo specificato in terminationGracePeriodSeconds di pod (30 secondi per impostazione predefinita). Se a questo punto i contenitori sono ancora in esecuzione, invia KILL, terminando i contenitori senza dare loro la possibilità di respirare ancora.

Sembra buono. Qual è il problema?

Sembra che Kubernetes faccia tutto il necessario: interrompe l'invio di nuove connessioni e concede all'applicazione 30 secondi per interrompere il lavoro di elaborazione in corso.

Tuttavia, ci sono due problemi importanti. Il primo riguarda le connessioni HTTP keep-alive e il secondo il modo in cui le applicazioni reagiscono al segnale TERM.

Connessioni keep-alive

Come abbiamo visto sopra, Kubernetes mantiene intatte le connessioni di rete stabilite durante la terminazione. Questo ha senso: Kubernetes concede 30 secondi all'applicazione per dire ai suoi client di disconnettersi o di inviare la risposta.

Ma se l'applicazione non fa nulla, continuerà a ricevere nuove richieste tramite la connessione aperta fino allo scadere dei 30 secondi. Quindi, quando il client invia una nuova richiesta appena un milisecondo prima che Kubernetes termini il container utilizzando il segnale KILL, il client subirà la caduta della connessione invece di ricevere la risposta.

Nel mondo HTTP questo non riguarda solo i client del browser, dove poche connessioni interrotte possono non essere disastrose. La maggior parte delle applicazioni viene eseguita dietro un Load Balancer. Nella maggior parte dei casi il LB si connette al backend usando una connessione keep-alive. Questo influisce anche sulle chiamate API, anche se di solito vengono effettuate come connessioni separate dal client al lato pubblico del Load Balancer.

TERMINE Manipolazione errata del segnale

Ora potreste pensare: "Aspettate un attimo, perché l'applicazione non si ferma? Perché continua a ricevere nuove richieste dopo aver ricevuto il segnale TERM fino all'ultima istruzione della CPU?

Il problema è che le diverse applicazioni all'interno di diverse configurazioni di immagini Docker gestiranno il segnale in modi diversi.

In questa indagine ho notato 3 casi diversi di gestione del segnale TERM da parte delle applicazioni:

  • lo ignora - per esempio il framework Phoenix di Elixir non ha una terminazione aggraziata incorporata(vedi qui)
  • fa qualcos'altro, ad esempio nginx termina immediatamente (cioè non in modo grazioso)(vedi qui)
  • oppure viene eseguito all'interno della shell del contenitore Docker (ad esempio /bin/sh -c "rails s"), quindi l'applicazione non riceve alcun segnale.

In tutti questi casi si verifica il problema descritto a proposito delle connessioni keep-alive. Vediamo come evitarlo e come rendere le applicazioni pronte per il rolling update di kubernetes.

Pod™ pronto per lo zero-downtime

Da quanto detto sopra possiamo capire cosa deve essere soddisfatto affinché i pod siano veramente pronti per gli aggiornamenti su Kubernetes. Provo a spiegarlo per punti:

  • Se il pod dispone di un servizio di rete, deve eliminare con garbo le connessioni dopo aver ricevuto il segnale TERM
  • Se l'applicazione ha una gestione incorporata del segnale TERM, assicurarsi che lo riceva quando viene eseguito nel contenitore Docker
  • Impostare terminationGracePeriodSeconds in base al tempo massimo previsto per la conclusione di un lavoro in corso di elaborazione. Questo dipende dai carichi di lavoro dell'applicazione.
  • Avere almeno 2 (ma idealmente 3) repliche in esecuzione e configurare la distribuzione in modo da mantenere almeno 1 pod in esecuzione durante gli aggiornamenti periodici.

Quando tutte le condizioni sono soddisfatte, Kubernetes si occuperà del resto e farà in modo che gli aggiornamenti continui siano effettivamente a tempo zero, sia per le applicazioni di rete che per i carichi di lavoro in background di lunga durata.

Vediamo ora come realizzare queste condizioni.

Drenaggio delle connessioni (utilizzando il reverse proxy di nginx)

Purtroppo non tutte le applicazioni possono essere aggiornate (facilmente) per scaricare le connessioni con grazia. Come già detto, il framework Phoenix non ha un supporto integrato. Per evitare di avere a che fare con queste discrepanze tra le diverse tecnologie, possiamo creare una semplice immagine di nginx, che gestirà questo aspetto in modo universale.

Leggendo la documentazione su come drenare le connessioni mi è stato detto che

nginx può essere controllato con segnali al processo principale:
TERM, INT fast shutdown
QUIT graceful shutdown

Ottimo! Ha il segnale per l'arresto graduale. Ma aspettate... Dobbiamo inviargli il segnale QUIT per farlo, ma Kubernetes invierà solo TERM, che sarà ricevuto anche da Nginx, ma con un risultato diverso: terminare immediatamente.

Per ovviare a questo problema ho scritto un piccolo script di shell che avvolge il processo nginx e traduce il segnale TERM in segnale QUIT:

#!/bin/sh

# Impostare la trappola per il segnale TERM (= 15) e inviare invece QUIT a nginx
trap "echo SIGTERM trapped. Segnalazione di nginx con QUIT.; kill -s QUIT \$(cat /var/run/nginx.pid)" 15

# Avviare il comando in background - in modo che la shell sia in primo piano e riceva segnali (altrimenti ignora i segnali)
nginx "$@" &

CHILD_PID=$!
echo "Nginx avviato con PID $CHILD_PID"

# Attendere il bambino in loop - apparentemente il segnale QUIT a nginx causa l'uscita di `wait` a
# anche se il processo è ancora in esecuzione. Quindi aggiungiamo anche il ciclo finché il processo
# esiste
while kill -s 0 $CHILD_PID; do wait $CHILD_PID; done

echo "Process $CHILD_PID exited. Esce anche..."

Aggiungendo un semplice default.conf con la configurazione del reverse proxy, possiamo creare il file Docker per la nostra immagine proxy:

FROM nginx:mainline-alpine

COPY default.conf /etc/nginx/conf.d/
ADD trap_term_nginx.sh /usr/local/bin/

CMD [ "/usr/local/bin/trap_term_nginx.sh", "-g", "daemon off;" ]

L'invio del segnale TERM al nuovo contenitore comporterà l'uscita di nginx con garbo, ossia attendendo che le richieste attive vengano soddisfatte e non accettando nuove richieste sulle connessioni esistenti. Poi si fermerà da solo.

L'immagine è stata resa disponibile e può essere trovata in Docker Hub: https://hub.docker.com/r/driftrock/https-redirect-proxy/

Assicurarsi che il processo riceva il segnale

Un'altra condizione descritta sopra è quella di assicurarsi che il processo riceva il segnale quando sappiamo che l'applicazione è pronta a gestirlo.

Ad esempio, sidekiq gestisce già TERM come vogliamo. Potrebbe sembrare che non ci sia nulla da fare in più. Purtroppo in un ambiente Docker come Kubernetes, bisogna fare molta attenzione per non commettere errori involontari.

Il problema si presenta quando il contenitore è impostato per eseguire comandi tramite shell. Per esempio:

comando: ["/bin/sh", "-c", "rake db:migrate && sidekiq -t 30"].

In questo caso il processo root del contenitore sarà /bin/sh. Come descritto sopra, Kubernetes gli invia il segnale. Ciò che non è chiaro a prima vista è che la shell UNIX ignora i segnali quando c'è un processo figlio in esecuzione. Non lo inoltra al bambino e non fa nient'altro. Questo fa sì che il segnale non venga inviato alla nostra applicazione - sidekiq nell'esempio precedente.

Ci sono due modi per risolvere il problema. Il modo più semplice è indicare alla shell di sostituirsi all'ultimo comando usando il comando exec:

comando: ["/bin/sh", "-c", "rake db:migrate && exec sidekiq -t 30"].

Ma se è possibile, la cosa migliore è evitare del tutto l'uso di un wrapper di shell. Eseguite il comando direttamente come primo processo e utilizzate i contenitori Kubernetes Init per i comandi che volete eseguire prima dell'avvio dell'applicazione.

Impostazione del pod™ pronto per il tempo di inattività zero

Quando abbiamo un proxy per gestire le connessioni keep-alive nelle applicazioni HTTP e sappiamo come assicurarci che le altre applicazioni ricevano il segnale TERM per fermarsi con grazia, possiamo configurare il nostro pod™ Zero-downtime ready.

La prima cosa da fare è configurare il proxy nginx. Aggiungerlo come un altro contenitore al pod. Il proxy presuppone che la vostra applicazione sia in ascolto sulla porta 8080 e che esso stesso ascolti sulla porta 80. Se i servizi sono già configurati per la porta 80, non occorre fare altro, basta aggiungere il contenitore (nota a margine: è utile dare al contenitore lo stesso nome del contenitore dell'applicazione, con il suffisso -proxy).

...
contenitori:
...
- nome: APP-NOME-proxy
immagine: driftrock/https-redirect-proxy
porte:
- containerPort: 80
...
...

La seconda cosa da fare è assicurarsi che l'applicazione sia pronta a ricevere i segnali TERM. Come descritto sopra, si può fare:

...
contenitori:
- nome: APP-NOME
comando: ['/bin/sh', '-c', 'rake db:migrate && exec puma -p 8080']
...
...
...

E questo è tutto. Naturalmente, dati i vostri dettagli specifici, potreste dover fare qualcosa di leggermente diverso qua e là. Spero comunque che ora abbiate capito l'idea.

Risultato

Per verificarlo, abbiamo aggiornato i pod con la nuova immagine del proxy, quindi abbiamo avviato il benchmark e fatto in modo che Kubernetes eseguisse di nuovo il rolling update:

Esecuzione di un test di 10s @ https://www.server.com/api
2 thread e 10 connessioni
Statistiche dei thread Avg Stdev Max +/- Stdev
Latenza 249.36ms 95.76ms 842.78ms 90.19%
Req/Sec 20.04 9.21 40.00 54.05%
403 richieste in 10.10s, 349.87KB letti
Richieste/sec: 39.91
Trasferimento/sec: 34.65KB

Ora tutte le richieste di benchmark sono state completate con successo anche quando i pod sono stati terminati e riavviati durante il test dell'aggiornamento continuo.

Liberare la scimmia del caos

Dopo di che ho pensato a come effettuare uno stress test. In effetti, quando il deployment Kubernetes sta eseguendo l'aggiornamento continuo, esegue una semplice terminazione del pod sul retro. Lo stesso che si può fare con kubectl delete pod [POD_NAME]. In realtà i passaggi della terminazione descritti sopra sono tratti dall'articolo "Processo di terminazione dei pod", non "Processo di aggiornamento continuo del deployment".

Per questo motivo mi interessava sapere se la nuova configurazione è in grado di gestire l'uccisione dei pod in loop (assicurandosi che ci sia almeno un pod in funzione per tutto il tempo), dando loro solo un tempo molto breve per vivere. In teoria dovrebbe funzionare. Il pod si avvia, riceve forse una richiesta e inizia a elaborarla. Allo stesso tempo riceve il segnale TERM, perché la mia scimmia del caos cercherà di ucciderlo. La richiesta sarà terminata e non ne riceverà altre.

Vediamo cosa succede:

wrk -t 120 https://www.server.com/api &

while true; do
sleep 5
READY_PODS=$(kubectl get pods -l app=api-server -o json | jq -r ".items | map(select(.metadata | has(\"deletionTimestamp\") | not)) | map(select(.status.containerStatuses | map(.ready) | all)) | .[].metadata.name")
EXTRA_READY_PODS=$(echo $READY_PODS | ruby -e 'puts STDIN.readlines.shuffle[1..-1]' | tr '\n' ' ' )
/bin/sh -c "kubectl delete pods $EXTRA_READY_PODS"
kubectl get pods -l app=api-server
done

Fornisce il risultato:

Esecuzione di un test di 120s @ https://www.server.com/api
2 thread e 10 connessioni
Statistiche dei thread Avg Stdev Max +/- Stdev
Latenza 953.28ms 513.90ms 3938.00ms 90.19%
Req/Sec 10.49 9.21 40.00 54.05%
1261 richieste in 120.209s, 21.022MB letti
Richieste/sec: 10.49
Trasferimento/sec: 175.48KB

Questo dimostra che la configurazione non interrompe una singola connessione, anche quando i pod vengono terminati non appena sono pronti e iniziano a ricevere richieste. SUCCESSO!

Conclusione

In sintesi, abbiamo trovato alcuni semplici principi da rispettare per far funzionare Kubernetes e mantenere le distribuzioni a tempo zero. La soluzione che abbiamo trovato funziona anche quando viene messa sotto stress.

Grazie per aver letto e fateci sapere cosa ne pensate. Se avete un altro modo per risolvere questo problema, ci farebbe piacere saperlo!