Wskazówki i triki Kubernetes: funkcje płynnego zamykania w NGINX i PHP-FPM

Typowy warunek przy wdrażaniu CI/CD w Kubernetesie: aplikacja musi być w stanie nie akceptować nowych żądań klientów przed całkowitym zatrzymaniem, a co najważniejsze pomyślnie zakończyć istniejące.

Wskazówki i triki Kubernetes: funkcje płynnego zamykania w NGINX i PHP-FPM

Spełnienie tego warunku pozwala na osiągnięcie zerowego przestoju podczas wdrożenia. Jednak nawet przy korzystaniu z bardzo popularnych pakietów (takich jak NGINX i PHP-FPM) możesz napotkać trudności, które doprowadzą do wzrostu liczby błędów przy każdym wdrożeniu...

Teoria. Jak żyje kapsuła

O cyklu życia strąka pisaliśmy już szczegółowo Ten artykuł. W kontekście rozważanego tematu interesuje nas: moment, w którym kapsuła wchodzi w stan Kończenie, nowe żądania przestaną być do niego wysyłane (pod usunięte z listy punktów końcowych usługi). Aby więc uniknąć przestojów w trakcie wdrożenia, wystarczy, że rozwiążemy problem prawidłowego zatrzymania aplikacji.

Należy także pamiętać, że domyślnym okresem karencji jest 30 sekund: po tym czasie pod zostanie zakończony, a aplikacja musi mieć czas na przetworzenie wszystkich żądań przed tym okresem. Operacja: chociaż każde żądanie trwające dłużej niż 5-10 sekund jest już problematyczne i łagodne zamknięcie już mu nie pomoże...

Aby lepiej zrozumieć, co się dzieje po zakończeniu działania poda, spójrz na poniższy diagram:

Wskazówki i triki Kubernetes: funkcje płynnego zamykania w NGINX i PHP-FPM

A1, B1 - Odbiór zmian o stanie paleniska
A2 – Wyjazd SIGTERM
B2 – Usuwanie poda z punktów końcowych
B3 - Odbieranie zmian (zmieniła się lista punktów końcowych)
B4 - Zaktualizuj reguły iptables

Uwaga: usunięcie kapsuły końcowej i wysłanie SIGTERM nie odbywa się sekwencyjnie, ale równolegle. A ponieważ Ingress nie otrzymuje od razu zaktualizowanej listy Endpointów, do poda będą wysyłane nowe żądania od klientów, co spowoduje błąd 500 podczas kończenia poda (więcej szczegółowych materiałów na ten temat można znaleźć w przetłumaczony). Problem ten należy rozwiązać w następujący sposób:

  • Wyślij połączenie: zamknij nagłówki odpowiedzi (jeśli dotyczy to aplikacji HTTP).
  • Jeżeli nie ma możliwości wprowadzenia zmian w kodzie, to w poniższym artykule opisano rozwiązanie, które pozwoli na obsługę zgłoszeń do końca okresu karencji.

Teoria. Jak NGINX i PHP-FPM kończą swoje procesy

nginx

Zacznijmy od NGINX, bo z nim wszystko jest mniej więcej oczywiste. Zagłębiając się w teorię, dowiadujemy się, że NGINX ma jeden proces główny i kilku „pracowników” – są to procesy potomne, które przetwarzają żądania klientów. Dostępna jest wygodna opcja: użycie polecenia nginx -s <SIGNAL> kończ procesy w trybie szybkiego zamykania lub łagodnego zamykania. Oczywiście interesuje nas ta druga opcja.

Wtedy wszystko jest proste: musisz dodać hak preStop polecenie, które wyśle ​​​​wdzięczny sygnał wyłączenia. Można to zrobić we wdrożeniu, w bloku kontenera:

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

Teraz, gdy kapsuła się wyłączy, w dziennikach kontenera NGINX zobaczymy następujące informacje:

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

A to będzie oznaczać to, czego potrzebujemy: NGINX czeka na zakończenie żądań, a następnie zabija proces. Jednak poniżej rozważymy również częsty problem, z powodu którego nawet z poleceniem nginx -s quit proces kończy się niepoprawnie.

I na tym etapie mamy już dość NGINX: przynajmniej z logów widać, że wszystko działa jak należy.

O co chodzi z PHP-FPM? Jak radzi sobie z łagodnym zamykaniem? Rozwiążmy to.

PHP FPM

W przypadku PHP-FPM informacji jest nieco mniej. Jeśli skupisz się na oficjalny podręcznik zgodnie z PHP-FPM powie, że akceptowane są następujące sygnały POSIX:

  1. SIGINT, SIGTERM — szybkie wyłączenie;
  2. SIGQUIT — łagodne zamknięcie (czego potrzebujemy).

Pozostałe sygnały nie są wymagane w tym zadaniu, dlatego pominiemy ich analizę. Aby poprawnie zakończyć proces, będziesz musiał napisać następujący hook preStop:

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

Na pierwszy rzut oka to wszystko, czego potrzeba, aby bezpiecznie zamknąć oba kontenery. Jednak zadanie jest trudniejsze, niż się wydaje. Poniżej znajdują się dwa przypadki, w których łagodne zamknięcie nie zadziałało i spowodowało krótkotrwałą niedostępność projektu podczas wdrażania.

Ćwiczyć. Możliwe problemy z łagodnym zamknięciem

nginx

Przede wszystkim warto pamiętać: oprócz wykonania polecenia nginx -s quit Jest jeszcze jeden etap, na który warto zwrócić uwagę. Napotkaliśmy problem polegający na tym, że NGINX nadal wysyłał sygnał SIGTERM zamiast sygnału SIGQUIT, powodując nieprawidłowe wykonywanie żądań. Podobne przypadki można spotkać np. tutaj. Niestety nie udało nam się ustalić konkretnego powodu tego zachowania: istniały podejrzenia co do wersji NGINX, ale nie zostało to potwierdzone. Objawem było to, że w dziennikach kontenera NGINX zaobserwowano komunikaty: „otwarte gniazdo nr 10 pozostawione w złączu 5”, po czym kapsuła się zatrzymała.

Taki problem możemy zaobserwować chociażby z odpowiedzi na Ingress, którego potrzebujemy:

Wskazówki i triki Kubernetes: funkcje płynnego zamykania w NGINX i PHP-FPM
Wskaźniki kodów stanu w momencie wdrożenia

W tym przypadku otrzymujemy tylko kod błędu 503 od samego Ingress: nie może on uzyskać dostępu do kontenera NGINX, ponieważ nie jest on już dostępny. Jeśli spojrzysz na dzienniki kontenerów za pomocą NGINX, zawierają one następujące informacje:

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

Po zmianie sygnału stopu kontener zaczyna się prawidłowo zatrzymywać: potwierdza to fakt, że błąd 503 nie jest już obserwowany.

Jeśli napotkasz podobny problem, warto dowiedzieć się, jaki sygnał stopu jest używany w kontenerze i jak dokładnie wygląda hak preStop. Jest całkiem możliwe, że przyczyna leży właśnie w tym.

PHP-FPM... i nie tylko

Problem z PHP-FPM opisany jest w banalny sposób: nie czeka on na zakończenie procesów potomnych, tylko je kończy, dlatego podczas wdrażania i innych operacji pojawiają się błędy 502. Od 2005 roku na stronie bugs.php.net znajduje się kilka raportów o błędach (np tutaj и tutaj), który opisuje ten problem. Ale najprawdopodobniej nie zobaczysz niczego w logach: PHP-FPM ogłosi zakończenie swojego procesu bez żadnych błędów i powiadomień stron trzecich.

Warto doprecyzować, że sam problem może w mniejszym lub większym stopniu zależeć od samej aplikacji i może nie objawiać się np. monitoringiem. Jeśli już się z tym spotkasz, na myśl przychodzi Ci proste rozwiązanie: dodaj hak preStop za pomocą sleep(30). Umożliwi to realizację wszystkich wcześniejszych próśb (a nowych nie przyjmujemy, ponieważ pod już zdolny Kończenie), a po 30 sekundach sama kapsuła zakończy się sygnałem SIGTERM.

Okazuje się, że lifecycle dla kontenera będzie wyglądać następująco:

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

Jednak ze względu na 30-sekundę sleep my mocno wydłużymy czas wdrożenia, ponieważ każdy pod zostanie zakończony minimum 30 sekund, czyli źle. Co można zrobić na ten temat?

Przejdźmy do strony odpowiedzialnej za bezpośrednie wykonanie wniosku. W naszym przypadku tak PHP FPMKtóry domyślnie nie monitoruje wykonywania swoich procesów potomnych: Proces główny zostaje natychmiast zakończony. Możesz zmienić to zachowanie za pomocą dyrektywy process_control_timeout, który określa limity czasu oczekiwania procesów potomnych na sygnały od urządzenia głównego. Jeśli ustawisz wartość na 20 sekund, obejmie to większość zapytań uruchomionych w kontenerze i zatrzyma proces główny po ich zakończeniu.

Mając tę ​​wiedzę, wróćmy do naszego ostatniego problemu. Jak wspomniano, Kubernetes nie jest platformą monolityczną: komunikacja pomiędzy jej różnymi komponentami zajmuje trochę czasu. Jest to szczególnie prawdziwe, jeśli weźmiemy pod uwagę działanie Ingressów i innych powiązanych komponentów, ponieważ z powodu takiego opóźnienia w momencie wdrożenia łatwo jest uzyskać wzrost liczby 500 błędów. Na przykład błąd może wystąpić na etapie wysyłania żądania do upstream, ale „opóźnienie” interakcji między komponentami jest dość krótkie - mniej niż sekunda.

Dlatego, W całości ze wspomnianą już dyrektywą process_control_timeout możesz użyć następującej konstrukcji dla lifecycle:

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

W takim przypadku opóźnienie zrekompensujemy poleceniem sleep i nie wydłużają znacząco czasu wdrożenia: w końcu różnica między 30 sekundami a jedną jest zauważalna?.. W rzeczywistości jest to process_control_timeoutI lifecycle wykorzystywane wyłącznie jako „siatka bezpieczeństwa” na wypadek opóźnienia.

Ogólnie rzecz biorąc opisane zachowanie i odpowiednie obejście mają zastosowanie nie tylko do PHP-FPM. Podobna sytuacja może w ten czy inny sposób wystąpić podczas korzystania z innych języków/frameworków. Jeśli nie możesz naprawić płynnego zamknięcia w inny sposób - na przykład przepisując kod tak, aby aplikacja poprawnie przetwarzała sygnały zakończenia - możesz skorzystać z opisanej metody. Może nie jest to najpiękniejsze, ale działa.

Ćwiczyć. Testowanie obciążenia w celu sprawdzenia działania kapsuły

Testy obciążeniowe to jeden ze sposobów sprawdzenia działania kontenera, gdyż ta procedura przybliża go do rzeczywistych warunków bojowych, gdy użytkownicy odwiedzają plac budowy. Aby przetestować powyższe zalecenia, możesz użyć Yandex.Tankom: Doskonale pokrywa wszystkie nasze potrzeby. Poniżej znajdują się wskazówki i zalecenia dotyczące przeprowadzania testów na jasnym przykładzie z naszego doświadczenia dzięki wykresom Grafany i samego Yandex.Tank.

Najważniejszą rzeczą jest tutaj sprawdzaj zmiany krok po kroku. Po dodaniu nowej poprawki uruchom test i sprawdź, czy wyniki zmieniły się w porównaniu do ostatniego uruchomienia. W przeciwnym razie trudno będzie wskazać nieskuteczne rozwiązania, a na dłuższą metę może to tylko zaszkodzić (np. wydłużyć czas wdrożenia).

Kolejnym niuansem jest sprawdzenie dzienników kontenera podczas jego kończenia. Czy jest tam zapisana informacja o łagodnym wyłączeniu? Czy w logach są jakieś błędy podczas uzyskiwania dostępu do innych zasobów (na przykład do sąsiedniego kontenera PHP-FPM)? Błędy w samej aplikacji (jak w przypadku opisanego powyżej NGINX)? Mam nadzieję, że wstępne informacje z tego artykułu pomogą Państwu lepiej zrozumieć, co dzieje się z kontenerem podczas jego kończenia.

Tak więc pierwsza jazda testowa odbyła się bez lifecycle i bez dodatkowych dyrektyw dla serwera aplikacji (process_control_timeout w PHP-FPM). Celem tego testu było określenie przybliżonej liczby błędów (oraz tego, czy w ogóle występują). Z dodatkowych informacji warto także wiedzieć, że średni czas wdrożenia każdego kapsuły do ​​momentu osiągnięcia pełnej gotowości wynosił około 5-10 sekund. Wyniki są następujące:

Wskazówki i triki Kubernetes: funkcje płynnego zamykania w NGINX i PHP-FPM

Panel informacyjny Yandex.Tank pokazuje gwałtowny wzrost liczby 502 błędów, które wystąpiły w momencie wdrożenia i trwały średnio do 5 sekund. Prawdopodobnie było to spowodowane tym, że istniejące żądania kierowane do starego zasobnika były kończone w momencie jego kończenia. Następnie pojawiły się błędy 503, które były skutkiem zatrzymania kontenera NGINX, który również zrywał połączenia ze względu na backend (co uniemożliwiało połączenie się z nim Ingress).

Zobaczmy jak process_control_timeout w PHP-FPM pomoże nam poczekać na zakończenie procesów potomnych, tj. poprawić takie błędy. Wdróż ponownie, korzystając z tej dyrektywy:

Wskazówki i triki Kubernetes: funkcje płynnego zamykania w NGINX i PHP-FPM

Podczas 500. wdrożenia nie ma już błędów! Wdrożenie powiodło się, działa płynne zamknięcie.

Warto jednak pamiętać o problemie z kontenerami Ingress, czyli o niewielkim procencie błędów, jakie możemy otrzymać z powodu opóźnienia. Aby ich uniknąć, pozostaje tylko dodać strukturę sleep i powtórz wdrożenie. Jednak w naszym konkretnym przypadku nie było widać żadnych zmian (znowu żadnych błędów).

wniosek

Aby bezpiecznie zakończyć proces, oczekujemy od aplikacji następującego zachowania:

  1. Poczekaj kilka sekund, a następnie przestań akceptować nowe połączenia.
  2. Poczekaj, aż wszystkie żądania zostaną zakończone i zamknij wszystkie połączenia podtrzymujące, które nie wykonują żądań.
  3. Zakończ proces.

Jednak nie wszystkie aplikacje mogą działać w ten sposób. Jednym z rozwiązań problemu w realiach Kubernetesa jest:

  • dodanie haka przed zatrzymaniem, który odczeka kilka sekund;
  • studiowanie pliku konfiguracyjnego naszego backendu pod kątem odpowiednich parametrów.

Przykład z NGINX jasno pokazuje, że nawet aplikacja, która początkowo powinna poprawnie przetwarzać sygnały zakończenia, może tego nie zrobić, dlatego niezwykle ważne jest sprawdzenie, czy podczas wdrażania aplikacji nie wystąpiło 500 błędów. Pozwala to również spojrzeć na problem szerzej i nie skupiać się na pojedynczym podzescie czy kontenerze, ale spojrzeć na całą infrastrukturę jako całość.

Jako narzędzie testowe możesz używać Yandex.Tank w połączeniu z dowolnym systemem monitorowania (w naszym przypadku dane do testu pobrano z Grafany z backendem Prometheusa). Problemy z płynnym wyłączaniem są wyraźnie widoczne przy dużych obciążeniach, jakie może wygenerować benchmark, a monitorowanie pozwala na bardziej szczegółową analizę sytuacji w trakcie lub po teście.

W odpowiedzi na opinie na temat artykułu: warto wspomnieć, że problemy i rozwiązania są tutaj opisane w odniesieniu do NGINX Ingress. Dla innych przypadków istnieją inne rozwiązania, które możemy rozważyć w kolejnych materiałach z serii.

PS

Inne z serii porad i trików K8s:

Źródło: www.habr.com

Dodaj komentarz