Consigli e trucchi su Kubernetes: caratteristiche dell'esecuzione del graceful shutdown in NGINX e PHP-FPM

Condizioni standard per l'implementazione di CI/CD in Kubernetes: l'applicazione deve essere in grado di non accettare nuove richieste dei clienti prima di un arresto completo e, soprattutto, di completare con successo le richieste già esistenti.

Consigli e trucchi su Kubernetes: caratteristiche dell'esecuzione dello shutdown graduale in NGINX e PHP-FPM

Il rispetto di tale condizione consente di raggiungere un'interruzione zero durante il deployment. Tuttavia, anche utilizzando combinazioni molto popolari (come NGINX e PHP-FPM), si possono incontrare difficoltà che porteranno a un aumento degli errori ad ogni deployment...

Teoria. Come vive un pod

Abbiamo già pubblicato dettagli sul ciclo di vita di un pod questo articolo. Nel contesto del tema trattato, ci interessa il seguente aspetto: nel momento in cui un pod passa allo stato Terminating, non riceve più nuove richieste (il pod eliminato è rimosso dalla lista degli endpoint per il servizio). Pertanto, per evitare downtime durante il deployment, è sufficiente risolvere il problema dell'arresto corretto dell'applicazione.

Inoltre, è importante ricordare che il periodo di grazia è impostato di default a 30 secondi: dopo di che il pod verrà terminato e l'applicazione deve riuscire a elaborare tutte le richieste entro tale periodo. Nota: anche se qualsiasi richiesta che impiega più di 5-10 secondi è già problematica, e un graceful shutdown non sarà d'aiuto...

Per comprendere meglio cosa accade quando un pod termina la sua esecuzione, è sufficiente esaminare il seguente schema:

Consigli e trucchi su Kubernetes: caratteristiche dell'esecuzione dello shutdown graduale in NGINX e PHP-FPM

A1, B1 — Ricezione dei cambiamenti sullo stato del pod
A2 — Invio di SIGTERM
B2 — Rimozione del pod dagli endpoint
B3 — Ricezione delle modifiche (la lista degli endpoint è cambiata)
B4 — Aggiornamento delle regole iptables

Si prega di notare: la rimozione dell'endpoint del pod e l'invio di SIGTERM avvengono non in sequenza, ma in parallelo. E poiché l'Ingress non riceve subito l'elenco aggiornato degli endpoint, nuovi richieste dai clienti arriveranno al pod, causando errori 500 durante la terminazione del pod. (materiale più dettagliato su questo argomento lo abbiamo tradotto). Per risolvere questo problema, è necessario adottare i seguenti approcci:

  • Inviare nell'intestazione della risposta Connection: close (se si tratta di un'applicazione HTTP).
  • Se non è possibile apportare modifiche al codice, l'articolo descrive una soluzione che consentirà di elaborare le richieste fino alla fine del periodo di grazia.

Teoria. Come NGINX e PHP-FPM terminano i loro processi

NGINX

Cominciamo con NGINX, poiché è tutto relativamente chiaro. Immergendoci nella teoria, scopriamo che NGINX ha un processo master e diversi "worker" — questi sono processi figli che gestiscono le richieste dei clienti. È prevista una comoda possibilità: con il comando nginx -s è possibile terminare i processi sia in modalità fast shutdown che in graceful shutdown. È evidente che a noi interessa quest'ultima opzione.

Poi è tutto semplice: è necessario aggiungere nel preStop-hook un comando che manderà il segnale per il graceful shutdown. Questo può essere fatto nel Deployment, nel blocco del contenitore:

       lifecycle:
          preStop:
            exec:
              command:
              - /usr/sbin/nginx
              - -s
              - quit

Ora, al momento della chiusura del pod, nei log del contenitore NGINX vedremo quanto segue:

2018/01/25 13:58:31 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down
2018/01/25 13:58:31 [notice] 11#11: gracefully shutting down

E questo significherà ciò di cui abbiamo bisogno: NGINX attende che le richieste vengano completate, dopodiché uccide il processo. Tuttavia, più avanti verrà esaminato un problema comune che causa la chiusura errata del processo anche in presenza del comando nginx -s quit il processo si chiude in modo non corretto.

A questo punto abbiamo concluso con NGINX: almeno dai log si può capire che tutto funziona come dovrebbe.

E per quanto riguarda PHP-FPM? Come gestisce il graceful shutdown? Vediamo di capirlo.

PHP-FPM

Nel caso di PHP-FPM, ci sono informazioni un po' meno dettagliate. Se facciamo riferimento al manuale ufficiale di PHP-FPM, esso riporta che vengono accettati i seguenti segnali POSIX:

  1. SIGINT, SIGTERM — fast shutdown;
  2. SIGQUIT — graceful shutdown (che è ciò di cui abbiamo bisogno).

Gli altri segnali non sono necessari in questo contesto, quindi ne ometteremo l'analisi. Per chiudere correttamente il processo, è necessario scrivere il seguente preStop-hook:

        lifecycle:
          preStop:
            exec:
              command:
              - /bin/kill
              - -SIGQUIT
              - "1"

A prima vista, è tutto ciò che serve per eseguire un graceful shutdown in entrambi i contenitori. Tuttavia, la questione è più complessa di quanto sembri. Di seguito vengono esaminati due casi in cui il graceful shutdown non ha funzionato e ha causato una breve indisponibilità del progetto durante il deploy.

Pratica. Possibili problemi con il graceful shutdown

NGINX

È utile ricordare prima di tutto: oltre all'esecuzione del comando nginx -s quit c'è un altro passaggio a cui vale la pena prestare attenzione. Abbiamo riscontrato un problema in cui NGINX, anziché inviare il segnale SIGQUIT, inviava comunque SIGTERM, causando l'interruzione non corretta delle richieste. Casi simili possono essere trovati, ad esempio, qui. Purtroppo, non siamo riusciti a stabilire una causa specifica per questo comportamento: si sospettava di alcune versioni di NGINX, ma non è stato confermato. La sintomatologia era rappresentata da messaggi nei log del contenitore NGINX «open socket #10 left in connection 5», dopo di che il pod si fermava.

Possiamo osservare un problema simile, ad esempio, nelle risposte sul nostro Ingress:

Consigli e trucchi su Kubernetes: caratteristiche dell'esecuzione dello shutdown graduale in NGINX e PHP-FPM
Indicatori dei codici di stato al momento del deployment

In questo caso stiamo ricevendo proprio il codice di errore 503 dall'Ingress stesso: non può contattare il contenitore NGINX, poiché quest'ultimo non è più disponibile. Se guardiamo i log del contenitore NGINX, contengono quanto segue:

[alert] 13939#0: *154 open socket #3 left in connection 16
[alert] 13939#0: *168 open socket #6 left in connection 13

Dopo aver modificato il segnale di stop, il contenitore comincia a fermarsi correttamente: ciò è confermato dal fatto che non si osserva più l'errore 503.

Se ti sei imbattuto in un problema simile, ha senso capire quale segnale di stop venga utilizzato nel contenitore e come si presenta esattamente il preStop-hook. È molto probabile che la causa risieda proprio lì.

PHP-FPM… e non solo

Il problema con PHP-FPM si descrive in modo banale: non aspetta il completamento dei processi figli, li termina, causando così errori 502 durante il deployment e altre operazioni. Su bugs.php.net ci sono alcuni segnalazioni di errori dal 2005 (ad esempio, qui e qui), che descrivono questo problema. Tuttavia, probabilmente non vedrai nulla nei log: PHP-FPM dichiarerà la conclusione del suo processo senza errori o avvisi esterni.

Vale la pena sottolineare che il problema stesso può dipendere, in misura maggiore o minore, dall'applicazione stessa e potrebbe non manifestarsi, ad esempio, nel monitoraggio. Se ti imbatte in esso, il primo pensiero è un semplice workaround: aggiungere un preStop-hook con sleep(30). Questo permetterà di completare tutte le richieste che erano in corso (e non ne accettiamo di nuove, poiché il pod ha già è nello stato Terminating), e dopo 30 secondi il pod stesso si fermerà con il segnale SIGTERM.

Quindi, risulta che lifecycle per il contenitore apparirà come segue:

    lifecycle:
      preStop:
        exec:
          command:
          - /bin/sleep
          - "30"

Tuttavia, a causa dell'indicazione di 30 secondi, sleep noi è considerevolmente aumenteremo il tempo di deployment, poiché ogni pod verrà terminato almeno 30 secondi, che non è ideale. Cosa possiamo fare al riguardo?

Ci rivolgiamo al lato responsabile dell'esecuzione reale dell'applicazione. Nel nostro caso, si tratta di PHP-FPM, che non monitora per impostazione predefinita l'esecuzione dei suoi processi figli: il processo master viene terminato immediatamente. È possibile modificare questo comportamento utilizzando la direttiva process_control_timeout, che specifica i limiti temporali per attendere i segnali dal master dai processi figlio. Se si imposta il valore a 20 secondi, si copriranno la maggior parte delle richieste eseguite nel contenitore e, dopo il loro completamento, il processo master verrà arrestato.

Con questa conoscenza, torniamo al nostro ultimo problema. Come già accennato, Kubernetes non è una piattaforma monolitica: ci vuole del tempo per l'interazione tra i suoi diversi componenti. Questo è particolarmente rilevante quando consideriamo il funzionamento di Ingress e altri componenti correlati, poiché a causa di tale ritardo è facile ricevere un picco di errori 500 al momento del deployment. Ad esempio, un errore potrebbe verificarsi durante l'invio della richiesta all'upstream, ma il 'ritardo temporale' nell'interazione tra i componenti è piuttosto breve - meno di un secondo.

Pertanto, nel complesso con la già menzionata direttiva process_control_timeout è possibile utilizzare la seguente costruzione per lifecycle:

lifecycle:
  preStop:
    exec:
      command: ["/bin/bash","-c","/bin/sleep 1; kill -QUIT 1"]

In tal caso, compensiamo il ritardo con il comando sleep e non aumentiamo significativamente il tempo di deployment: c'è una grande differenza tra 30 secondi e uno?.. In sostanza, il 'carico principale' è preso proprio da process_control_timeout, ma lifecycle è utilizzato solo come 'cuscinetto' nel caso di ritardi.

In generale, Il comportamento descritto e il relativo workaround non riguardano solo PHP-FPM. Una situazione simile può verificarsi in un modo o nell'altro utilizzando altri linguaggi di programmazione/framework. Se non si riesce a risolvere il graceful shutdown in altri modi — ad esempio, riscrivendo il codice affinché l'applicazione gestisca correttamente i segnali di terminazione — è possibile utilizzare il metodo descritto. Anche se non è il più elegante, funziona.

Pratica. Test di carico per verificare il funzionamento del pod

Il test di carico è uno dei modi per verificare come funziona un contenitore, poiché questa procedura simula le condizioni reali di utilizzo, quando gli utenti accedono al sito. Per testare le raccomandazioni sopra menzionate, è possibile utilizzare Yandex.Tank: copre perfettamente tutte le nostre esigenze. Di seguito vengono forniti suggerimenti e raccomandazioni per effettuare il test con un esempio pratico, grazie ai grafici di Grafana e a Yandex.Tank stesso, basato sulla nostra esperienza.

La cosa principale qui è verificare i cambiamenti passo dopo passo. Dopo aver aggiunto una nuova correzione, eseguite il test e verificate se i risultati sono cambiati rispetto all'esecuzione precedente. Altrimenti, sarà difficile identificare soluzioni inefficaci e, in prospettiva, si potrebbe anche causare danni (ad esempio, aumentare il tempo di deployment).

Un altro aspetto da considerare è controllare i log del contenitore durante la sua terminazione. Viene registrata lì l'informazione sul graceful shutdown? Ci sono errori nei log quando si accede ad altre risorse (ad esempio, al contenitore PHP-FPM adiacente)? Errori dell'applicazione stessa (come nel caso descritto sopra con NGINX)? Spero che le informazioni introduttive di questo articolo aiutino a capire meglio cosa succede al contenitore durante la sua terminazione.

Quindi, il primo avvio del test è avvenuto senza lifecycle e senza direttive aggiuntive per il server delle applicazioni (process_control_timeout in PHP-FPM). L'obiettivo di questo test era identificare il numero approssimativo di errori (e se ce ne sono in effetti). Inoltre, da informazioni supplementari, si può sapere che il tempo medio di deployment di ciascun pod era di circa 5-10 secondi fino a uno stato di completa prontezza. I risultati sono i seguenti:

Consigli e trucchi su Kubernetes: caratteristiche dell'esecuzione dello shutdown graduale in NGINX e PHP-FPM

Nella dashboard di Yandex.Tank si nota un picco di errori 502, che si è verificato al momento del deployment e che è durato in media fino a 5 secondi. Si presume che queste fossero interruzioni delle richieste esistenti al vecchio pod durante la sua terminazione. Dopo ciò, sono emersi errori 503, risultato del container NGINX fermo, che ha anche interrotto le connessioni a causa del backend (il che ha impedito a Ingress di collegarsi).

Vediamo come process_control_timeout in PHP-FPM ci aiuterà ad aspettare il completamento dei processi figlio, ovvero a correggere tali errori. Un nuovo deployment già utilizzando questa direttiva:

Consigli e trucchi su Kubernetes: caratteristiche dell'esecuzione dello shutdown graduale in NGINX e PHP-FPM

Durante il deploy non ci sono più errori 500! Il deploy avviene con successo, lo shutdown graduale funziona.

Tuttavia, è importante ricordare il momento con i contenitori Ingress, un piccolo percentuale di errori che possiamo ricevere a causa di un lag temporaneo. Per evitarli, è necessario aggiungere una costruzione con sleep e ripetere il deploy. In effetti, nel nostro caso specifico non ci sono stati cambiamenti visibili (non ci sono di nuovo errori).

Conclusione

Per una corretta conclusione del processo, ci aspettiamo dal'applicazione il seguente comportamento:

  1. Aspettare alcuni secondi, dopodiché smettere di accettare nuove connessioni.
  2. Attendere il completamento di tutte le richieste e chiudere tutte le connessioni keepalive che non elaborano richieste.
  3. Terminare il proprio processo.

Tuttavia, non tutte le applicazioni sanno funzionare in questo modo. Una delle soluzioni al problema nell'ambito di Kubernetes è:

  • l'aggiunta di un hook pre-stop, che attenderà alcuni secondi;
  • esaminare il file di configurazione del nostro backend per i parametri appropriati.

L'esempio con NGINX ci aiuta a capire che anche l'applicazione che dovrebbe gestire correttamente i segnali di terminazione potrebbe non farlo, quindi è fondamentale controllare la presenza di errori 500 durante il deploy dell'applicazione. Questo permette di guardare al problema in modo più ampio e non concentrarsi su un singolo pod o contenitore, ma considerare l'intera infrastruttura nel suo complesso.

Come strumento per il test, si può utilizzare Yandex.Tank insieme a qualsiasi sistema di monitoraggio (nel nostro caso per il test sono stati utilizzati dati da Grafana con un backend in Prometheus). I problemi con lo shutdown graduale sono ben visibili sotto carichi elevati, che possono essere generati dai benchmark, e il monitoraggio aiuta ad analizzare la situazione più dettagliatamente durante o dopo il test.

Rispondendo ai commenti sull'articolo: è necessario specificare che i problemi e le soluzioni qui descritte si applicano a NGINX Ingress. Per altri casi ci sono soluzioni diverse, che potremmo considerare nei prossimi materiali del ciclo.

P.S.

Altro dal ciclo K8s tips & tricks:

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster