Typowe warunki realizacji CI/CD w Kubernetes: aplikacja powinna być w stanie przed całkowitym zatrzymaniem nie przyjmować nowych żądań od klientów, a co najważniejsze — skutecznie kończyć już istniejące.

Spełnienie tego warunku pozwala na osiągnięcie zerowego przestoju podczas wdrożenia. Jednak nawet przy użyciu bardzo popularnych zestawień (jak NGINX i PHP-FPM) można napotkać trudności, które mogą prowadzić do wzrostu liczby błędów przy każdym wdrożeniu...
Teoria. Jak funkcjonuje pod
Szczegółowe informacje o cyklu życia poda publikowaliśmy już wcześniej . W kontekście omawianego tematu interesuje nas to: w momencie, gdy pod przechodzi w stan Terminating, przestają być do niego wysyłane nowe żądania (pod z listy endpointów dla usługi). W ten sposób, aby uniknąć przestojów podczas wdrożenia, wystarczy z naszej strony rozwiązać problem poprawnego zatrzymania aplikacji.
Należy również pamiętać, że okres łaski domyślnie wynosi : po tym czasie pod zostanie zakończony, a aplikacja powinna zdążyć obsłużyć wszystkie żądania przed tym okresem. Uwaga: choć każde żądanie, które trwa dłużej niż 5-10 sekund, już stanowi problem, a graceful shutdown nie pomoże mu już...
Aby lepiej zrozumieć, co się dzieje, gdy pod kończy swoje działanie, wystarczy zbadać następujący schemat:

A1, B1 — Otrzymywanie zmian stanu poda
A2 — Wysłanie SIGTERM
B2 — Usunięcie poda z endpointów
B3 — Otrzymanie informacji (lista endpointów się zmieniła)
B4 — Aktualizacja reguł iptables
Zauważ: usunięcie endpointu poda i wysłanie SIGTERM odbywa się nie sekwencyjnie, a równolegle. A z powodu faktu, że Ingress nie otrzymuje zaktualizowanej listy endpointów od razu, do poda będą wysyłane nowe żądania od klientów, co spowoduje błędy 500 podczas zakończenia działania poda. (bardziej szczegółowy materiał w tej kwestii już ). Należy rozwiązać ten problem w następujący sposób:
- Wysyłać w nagłówkach odpowiedzi Connection: close (jeśli dotyczy to aplikacji HTTP).
- Jeśli nie ma możliwości wprowadzenia zmian w kodzie, to w dalszej części artykułu opisano rozwiązanie, które pozwoli obsłużyć żądania do końca okresu łaski.
Teoria. Jak NGINX i PHP-FPM kończą swoje procesy
NGINX
Zacznijmy od NGINX, ponieważ w jego przypadku wszystko jest bardziej oczywiste. Zgłębiając teorię, dowiadujemy się, że NGINX ma jeden proces master i kilka 'workerów' — to są procesy potomne, które obsługują żądania klientów. Istnieje wygodna możliwość: za pomocą komendy nginx -s Kończymy procesy w trybie szybkiego zamknięcia lub w trybie łagodnego zamknięcia. Oczywiste jest, że interesuje nas ta ostatnia opcja.
Dalej wszystko jest proste: musimy dodać do komendę, która wysyła sygnał o łagodnym zamknięciu. Można to zrobić w Deployment, w bloku kontenera:
lifecycle:
preStop:
exec:
command:
- /usr/sbin/nginx
- -s
- quitTeraz w momencie zamykania pod'a w logach kontenera NGINX zobaczymy następujące:
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 I to będzie oznaczać to, czego potrzebujemy: NGINX czeka na zakończenie przetwarzania żądań, po czym kończy proces. Zresztą, poniżej zostanie omówiony powszechny problem, z powodu którego nawet przy obecności komendy nginx -s quit proces zamyka się niepoprawnie.
Na tym etapie zakończyliśmy z NGINX: przynajmniej z logów można zrozumieć, że wszystko działa tak, jak należy.
Jak wygląda sprawa z PHP-FPM? Jak obsługuje łagodne zamknięcie? Przyjrzyjmy się temu.
PHP-FPM
W przypadku PHP-FPM informacji jest nieco mniej. Jeśli bazować na PHP-FPM, mówi się w niej, że przyjmowane są następujące sygnały POSIX:
-
SIGINT,SIGTERM— szybkie zamknięcie; -
SIGQUIT— łagodne zamknięcie (to, czego potrzebujemy).
Inne sygnały w tej kwestii nie są potrzebne, dlatego ich analiza zostanie pominięta. Aby poprawnie zakończyć proces, potrzebny będzie następujący hook preStop:
lifecycle:
preStop:
exec:
command:
- /bin/kill
- -SIGQUIT
- "1"Na pierwszy rzut oka, to wszystko, co jest potrzebne do wykonania łagodnego zamknięcia w obu kontenerach. Niemniej jednak, zadanie jest bardziej skomplikowane, niż się wydaje. Poniżej omówiono dwa przypadki, w których łagodne zamknięcie nie działało i powodowało krótkotrwałą niedostępność projektu podczas wdrażania.
Praktyka. Potencjalne problemy z łagodnym zamknięciem
NGINX
Przede wszystkim warto pamiętać: oprócz wykonania komendy, nginx -s quit jest jeszcze jeden etap, na który warto zwrócić uwagę. Zdarzyło się, że NGINX zamiast sygnału SIGQUIT nadal wysyłał SIGTERM, przez co żądania nie były poprawnie zakończone. Podobne przypadki można znaleźć, na przykład, . Niestety, nie udało nam się ustalić konkretnej przyczyny takiego zachowania: mieliśmy podejrzenie dotyczące wersji NGINX, ale nie potwierdziło się. Objawy polegały na tym, że w logach kontenera NGINX zaobserwowano komunikaty "open socket #10 left in connection 5", po czym pod zatrzymywał się.
Możemy obserwować taki problem, na przykład, w odpowiedziach na odpowiednim Ingressie:

Wskaźniki kodów statusu w momencie wdrożenia
W tym przypadku otrzymujemy kod błędu 503 z samego Ingress: nie może się połączyć z kontenerem NGINX, ponieważ ten jest już niedostępny. Jeśli spojrzysz w logi kontenera z NGINX, znajdziesz w nich następujące:
[alert] 13939#0: *154 open socket #3 left in connection 16
[alert] 13939#0: *168 open socket #6 left in connection 13Po zmianie sygnału stop kontener zaczyna zatrzymywać się prawidłowo: potwierdza to fakt, że więcej nie obserwuje się błędu 503.
Jeśli napotkałeś podobny problem, warto zrozumieć, jaki sygnał stop jest używany w kontenerze i jak dokładnie wygląda hook preStop. Możliwe, że przyczyna tkwi właśnie w tym.
PHP-FPM… i nie tylko
Problem z PHP-FPM można opisać prosto: nie czeka na zakończenie procesów potomnych, terminując je, co prowadzi do błędów 502 podczas wdrożenia i innych operacji. Na bugs.php.net od 2005 roku znajdują się różne zgłoszenia błędów (na przykład, i ), w których opisana jest ta kwestia. Prawdopodobnie w logach nic nie zobaczysz: PHP-FPM ogłosi zakończenie swojego procesu bez żadnych błędów czy powiadomień zewnętrznych.
Należy zaznaczyć, że sam problem może w mniejszym lub większym stopniu zależeć od samej aplikacji i nie manifestować się, na przykład w monitoringu. Jeśli jednak się z nim spotkasz, na myśl przychodzi prosty workaround: dodać hook preStop z sleep(30). Pozwoli to zakończyć wszystkie wcześniejsze zapytania (a nowe nie są przyjmowane, ponieważ pod już jest w stanie Terminating), a po upływie 30 sekund sam pod zakończy się sygnałem SIGTERM.
Wynika z tego, że lifecycle dla kontenera będzie wyglądać następująco:
lifecycle:
preStop:
exec:
command:
- /bin/sleep
- "30" Jednakże, z powodu wykazania 30-sekundowego sleep przeprowadziliśmy znacznie zwiększymy czas wdrożenia, ponieważ każdy pod będzie terminowany minimum 30 sekund, co jest niekorzystne. Co można z tym zrobić?
Zwróćmy się do strony odpowiedzialnej za bezpośrednie wykonanie aplikacji. W naszym przypadku to PHP-FPM, który domyślnie nie monitoruje wykonania swoich procesów potomnych: proces główny jest natychmiast terminowany. Można zmienić to zachowanie za pomocą dyrektywy process_control_timeout, która określa limity czasowe oczekiwania na sygnały od głównego procesu przez procesy podrzędne. Ustalenie wartości na 20 sekund pokrywa większość zapytań wykonywanych w kontenerze, a po ich zakończeniu proces główny zostanie zatrzymany.
Z tą wiedzą wracamy do naszego ostatniego problemu. Jak już wspomniano, Kubernetes nie jest monolityczną platformą: interakcja między różnymi jej komponentami wymaga pewnego czasu. Jest to szczególnie istotne, gdy rozważamy działanie Ingressów i innych pokrewnych komponentów, ponieważ z powodu takiego opóźnienia podczas wdrażania można łatwo napotkać skoki błędów 500. Na przykład błąd może wystąpić na etapie wysyłania zapytania do upstreamu, ale sam „czas opóźnienia” interakcji między komponentami jest dość krótki — poniżej sekundy.
Dlatego, łącznie z wcześniej wspomnianą dyrektywą process_control_timeout można użyć następującej konstrukcji dla lifecycle:
lifecycle:
preStop:
exec:
command: ["/bin/bash","-c","/bin/sleep 1; kill -QUIT 1"] W takim przypadku rekompensujemy opóźnienie poleceniem sleep i nie zwiększamy znacznie czasu wdrażania: w końcu różnica między 30 sekundami a jedną jest zauważalna? W zasadzie "główną pracę" wykonuje właśnie process_control_timeout, a lifecycle jest używane jedynie jako "zabezpieczenie" na wypadek opóźnienia.
Ogólnie rzecz biorąc, opisane zachowanie oraz odpowiednie obejście dotyczą nie tylko PHP-FPM. Podobna sytuacja może w taki czy inny sposób wystąpić przy używaniu innych języków programowania/frameworków. Jeśli nie można poprawić graceful shutdown innymi metodami — na przykład przerabiając kod tak, aby aplikacja prawidłowo obsługiwała sygnały zakończenia — można zastosować opisany sposób. Nie jest to najpiękniejsze rozwiązanie, ale działa.
Praktyka. Testowanie obciążeniowe w celu sprawdzenia działania poda
Testowanie obciążeniowe to jeden ze sposobów na sprawdzenie, jak działa kontener, ponieważ procedura ta przybliża rzeczywiste warunki bojowe, kiedy na stronę wchodzą użytkownicy. Do przetestowania powyższych zaleceń można wykorzystać : doskonale spełnia wszystkie nasze potrzeby. Poniżej przedstawiono porady i rekomendacje dotyczące przeprowadzania testów z przystępnym — dzięki wykresom Grafana i samemu Yandex.Tankowi — przykładem z naszego doświadczenia.
Najważniejsze tutaj — sprawdzać zmiany etapamiPo dodaniu nowej poprawki uruchom testy i sprawdź, czy wyniki zmieniły się w porównaniu do poprzedniego uruchomienia. W przeciwnym razie trudno będzie zidentyfikować nieefektywne rozwiązania, a w przyszłości można nawet zaszkodzić (na przykład wydłużając czas wdrożenia).
Inny aspekt — sprawdzaj logi kontenera podczas jego terminacji. Czy pojawia się tam informacja o graceful shutdown? Czy w logach są błędy przy dostępie do innych zasobów (na przykład do sąsiedniego kontenera PHP-FPM)? Błędy samej aplikacji (jak w opisanym wyżej przypadku z NGINX)? Mam nadzieję, że wprowadzenie w tej artykule pomoże lepiej zrozumieć, co dzieje się z kontenerem podczas jego terminacji.
Tak więc pierwsze uruchomienie testów odbyło się bez lifecycle i bez dodatkowych dyrektyw dla serwera aplikacji (process_control_timeout w PHP-FPM). Celem tego testu było zidentyfikowanie przybliżonej liczby błędów (i czy w ogóle występują). Również z dodatkowych informacji warto wiedzieć, że średni czas wdrożenia każdego poda wynosił około 5-10 sekund do stanu pełnej gotowości. Wyniki są następujące:

Na panelu informacyjnym Yandex.Tanka widać wzrost błędów 502, który wystąpił w momencie wdrożenia i utrzymywał się średnio przez 5 sekund. Przypuszczalnie były to przerwane istniejące żądania do starego poda, gdy był on terminowany. Po tym pojawiły się błędy 503, które były wynikiem zatrzymania kontenera NGINX, który również przerwał połączenia z powodu backendu (przez co Ingress nie mógł się z nim połączyć).
Spójrzmy, jak process_control_timeout w PHP-FPM pomoże nam czekać na zakończenie procesów potomnych, tzn. naprawić takie błędy. Ponowne wdrożenie już z wykorzystaniem tej dyrektywy:

Podczas wdrożenia nie ma już błędów 500! Wdrożenie przebiega pomyślnie, graceful shutdown działa.
Jednak warto przypomnieć sobie o kontenerach Ingress, w których niewielki procent błędów możemy otrzymywać z powodu tymczasowego opóźnienia. Aby ich uniknąć, wystarczy dodać konstrukcję ze sleep i powtórzyć wdrożenie. Niemniej jednak w naszym konkretnym przypadku nie było widać zmian (błędów znowu nie ma).
Podsumowanie
Aby prawidłowo zakończyć proces, oczekujemy od aplikacji następującego zachowania:
- Oczekiwać kilka sekund, po czym przestać przyjmować nowe połączenia.
- Czekać na zakończenie wszystkich żądań i zamknąć wszystkie połączenia keepalive, które nie wykonują żądań.
- Zakończ swój proces.
Jednak nie wszystkie aplikacje mogą działać w ten sposób. Jednym z rozwiązań problemu w rzeczywistości Kubernetes jest:
- dodanie hooka pre-stop, który poczeka kilka sekund;
- przyjrzenie się plikowi konfiguracyjnemu naszego backendu pod kątem odpowiednich parametrów.
Przykład z NGINX pokazuje, że nawet aplikacja, która pierwotnie powinna poprawnie reagować na sygnały zakończenia, może tego nie robić, dlatego krytycznie ważne jest sprawdzenie obecności błędów 500 podczas wdrażania aplikacji. Pomaga to również spojrzeć na problem szerzej, nie koncentrując się na pojedynczym podzie lub kontenerze, ale analizując całą infrastrukturę.
Jako narzędzie do testowania można użyć Yandex.Tank w połączeniu z dowolnym systemem monitoringu (w naszym przypadku do testów używano danych z Grafany z backendem opartym na Prometheusie). Problemy z graceful shutdown są dobrze widoczne przy dużych obciążeniach, które może generować benchmark, a monitoring pomaga dokładniej zrozumieć sytuację podczas lub po teście.
Odpowiadając na feedback dotyczący artykułu: warto zaznaczyć, że problemy i sposoby ich rozwiązania są opisywane w kontekście NGINX Ingress. Dla innych przypadków istnieją inne rozwiązania, które być może omówimy w kolejnych materiałach cyklu.
P.S.
Inne z cyklu K8s tips & tricks:
- «»;
- «»;
- «»;
- «».
Źródło: habr.com
