Pięć błędów przy wdrażaniu pierwszej aplikacji na Kubernetes

Pięć błędów przy wdrażaniu pierwszej aplikacji na KubernetesFail by Aris-Dreamer

Wielu uważa, że wystarczy przenieść aplikację na Kubernetes (czy to za pomocą Helm, czy ręcznie) — i wszystko będzie w porządku. Ale to nie jest takie proste.

Zespół Mail.ru Cloud Solutions Przetłumaczyła artykuł inżyniera DevOps, Juliana Gindiego. Opisuje, z jakimi pułapkami jego firma się zetknęła w trakcie migracji, abyście nie popełniali tych samych błędów.

Krok pierwszy: ustawienia żądań podów i limitów

Zacznijmy od skonfigurowania czystego środowiska, w którym będą działać nasze pody. Kubernetes świetnie radzi sobie z planowaniem podów i zarządzaniem stanami awarii. Okazało się jednak, że planista czasami nie może umieścić podu, jeśli ma trudności z oceną, ile zasobów jest potrzebnych do jego prawidłowego działania. Tutaj pojawiają się żądania zasobów i limity. Trwa wiele dyskusji na temat najlepszego podejścia do ustawienia żądań i limitów. Czasami wydaje się, że to naprawdę bardziej sztuka niż nauka. Oto nasze podejście.

Żądania podu (pod requests) — to podstawowa wartość używana przez planistę do optymalnego rozmieszczenia podu.

Z dokumentacji Kubernetes: na etapie filtrowania określany jest zestaw węzłów, na których można zaplanować pod. Na przykład filtr PodFitsResources sprawdza, czy węzeł ma wystarczające zasoby, aby spełnić konkretne żądania zasobów podu.

Żądania aplikacji wykorzystujemy w taki sposób, aby można było ocenić, ile zasobów w rzeczywistości potrzebuje aplikacja do prawidłowego działania. Dzięki temu planista może realistycznie rozmieszczać węzły. Początkowo chcieliśmy ustawić żądania z zapasem, aby zapewnić wystarczającą ilość zasobów dla każdego podu, ale zauważyliśmy, że czas planowania znacznie się wydłużył, a niektóre pody nigdy nie zostały w pełni zaplanowane, jakby nie wpłynęły na nie żadne żądania zasobów.

W takim przypadku planista często "wyciskał" pody i nie mógł ich ponownie zaplanować z powodu tego, że płaszczyzna zarządzania nie miała pojęcia, ile zasobów będzie potrzebować aplikacja, a to kluczowy element algorytmu planowania.

Limity podu (pod limits) — to bardziej wyraźne ograniczenie dla podu. Stanowi maksymalną ilość zasobów, które klaster przydzieli kontenerowi.

Znów, z oficjalnej dokumentacji: jeżeli kontener ma ustawiony limit pamięci 4 GiB, kubelet (i środowisko wykonawcze kontenera) wymusi jego przestrzeganie. Środowisko wykonawcze nie pozwala kontenerowi na użycie więcej niż ustalony limit zasobów. Na przykład, kiedy proces w kontenerze próbuje wykorzystać więcej pamięci niż dozwolone, jądro systemu kończy ten proces z błędem „brak pamięci” (OOM).

Kontener zawsze może wykorzystać więcej zasobów, niż określono w żądaniu, ale nigdy nie może wykorzystać więcej niż wynika z limitu. Ustalenie tej wartości jest trudne, ale bardzo istotne.

W idealnym przypadku chcemy, aby wymagania dotyczące zasobów podu zmieniały się w trakcie cyklu życia procesu, nie wpływając na inne procesy w systemie — to cel ustalania limitów.

Niestety, nie mogę podać konkretnych wskazówek co do ustalanych wartości, ale sami przestrzegamy następujących zasad:

  1. Używając narzędzia do testowania obciążenia, modelujemy podstawowy poziom ruchu i obserwujemy zużycie zasobów podu (pamięci i procesora).
  2. Ustawiamy żądania podu na arbitralnie niską wartość (z ograniczeniem zasobów około 5 razy większym niż wartość żądań) i obserwujemy. Kiedy żądania są zbyt niskie, proces nie może się rozpocząć, co często powoduje tajemnicze błędy czasu wykonania w Go.

Chciałbym zauważyć, że wyższe limity zasobów utrudniają planowanie, ponieważ pod potrzebuje docelowego węzła z wystarczającą ilością dostępnych zasobów.

Wyobraź sobie sytuację, w której masz lekką stronę internetową z bardzo wysokim limitem zasobów, na przykład 4 GB pamięci. Prawdopodobnie ten proces będzie musiał być skalowany horyzontalnie, a każdy nowy moduł będzie musiał być planowany na węźle z dostępną ilością pamięci nie mniejszą niż 4 GB. Jeśli taki węzeł nie istnieje, klaster musi wprowadzić nowy węzeł do obsługi tego podu, co może zająć trochę czasu. Ważne jest, aby osiągnąć minimalną różnicę między żądaniami zasobów a limitami, aby zapewnić szybkie i płynne skalowanie.

Krok drugi: konfigurowanie testów Liveness i Readiness

To jeszcze jeden delikatny temat, który często porusza się w społeczności Kubernetes. Ważne jest, aby dobrze rozumieć testy żywotności (Liveness) i gotowości (Readiness), ponieważ zapewniają one mechanizm stabilnego działania oprogramowania i minimalizują czas przestoju. Mogą jednak poważnie wpłynąć na wydajność aplikacji, jeśli nie są poprawnie skonfigurowane. Poniżej znajduje się krótkie podsumowanie, co obu prób dotyczą.

Liveness pokazuje, czy kontener działa. Jeśli się nie powiedzie, kubelet zabija kontener, a dla niego włącza się polityka ponownego uruchamiania. Jeśli kontener nie jest wyposażony w próbę Liveness, domyślnym stanem będzie sukces — jak podano w dokumentacji Kubernetes.

Testy Liveness powinny być tanie, czyli nie powinny zużywać zbyt dużo zasobów, ponieważ są uruchamiane często i powinny informować Kubernetes, że aplikacja jest uruchomiona.

Jeśli ustawiłeś parametr uruchamiania co sekundę, to doda 1 zapytanie na sekundę, więc pamiętaj, że do obsługi tego ruchu będą potrzebne dodatkowe zasoby.

W naszej firmie testy Liveness sprawdzają kluczowe komponenty aplikacji, nawet jeśli dane (np. z zdalnej bazy danych lub pamięci podręcznej) nie są w pełni dostępne.

Skonfigurowaliśmy w aplikacjach punkt końcowy "zdrowia", który po prostu zwraca kod odpowiedzi 200. To oznacza, że proces jest uruchomiony i zdolny do przetwarzania zapytań (ale jeszcze nie ruchu).

Próba Gotowość informuje, czy kontener jest gotowy do obsługi zapytań. Jeśli próba gotowości nie powiedzie się, kontroler punktów końcowych usuwa adres IP poda z punktów końcowych wszystkich usług odpowiadających podowi. To również jest opisane w dokumentacji Kubernetes.

Testy Readiness zużywają więcej zasobów, ponieważ muszą dotrzeć do backendu w taki sposób, aby pokazać gotowość aplikacji do przyjmowania zapytań.

W społeczności toczy się wiele dyskusji na temat tego, czy należy bezpośrednio odwoływać się do bazy danych. Biorąc pod uwagę koszty (sprawdzanie jest wykonywane często, ale można je regulować), zdecydowaliśmy, że w przypadku niektórych aplikacji gotowość do obsługi ruchu jest uznawana za spełnioną dopiero po sprawdzeniu, czy z bazy danych zwracane są rekordy. Dobrze przemyślane próby gotowości zapewniły wyższy poziom dostępności i wyeliminowały przestoje podczas wdrażania.

Jeśli zdecydujesz się na wykonanie zapytania do bazy danych w celu sprawdzenia gotowości aplikacji, upewnij się, że będzie to jak najtańsze. Weźmy takie zapytanie:

SELECT small_item FROM table LIMIT 1

Oto przykład, jak konfigurujemy te dwie wartości w Kubernetes:

livenessProbe: 
 httpGet:   
   path: /api/liveness    
   port: http 
readinessProbe:  
 httpGet:    
   path: /api/readiness    
   port: http  periodSeconds: 2

Można dodać kilka dodatkowych parametrów konfiguracyjnych:

  • initialDelaySeconds — ile sekund minie między uruchomieniem kontenera a rozpoczęciem próby.
  • periodSeconds — interwał oczekiwania między próbami.
  • timeoutSeconds — liczba sekund, po upływie których pod jest uważany za awaryjny. Zwykły czas oczekiwania.
  • failureThreshold — liczba niepowodzeń testów, zanim sygnał restartu zostanie wysłany do poda.
  • successThreshold — liczba udanych prób, zanim pod przechodzi w stan gotowości (po awarii, gdy pod jest uruchamiany lub przywracany).

Krok trzeci: konfiguracja domyślnych polityk sieciowych poda

W Kubernetes topology sieciowa jest „płaska”, domyślnie wszystkie pody wchodzą ze sobą w bezpośredni kontakt. W niektórych przypadkach może to być niepożądane.

Potencjalny problem z bezpieczeństwem polega na tym, że atakujący może wykorzystać jedno podatne na ataki aplikację, aby wysyłać ruch do wszystkich podów w sieci. Jak w wielu obszarach bezpieczeństwa, zasada najmniejszych uprawnień ma tu zastosowanie. W idealnym przypadku polityki sieciowe powinny jasno określać, które połączenia między podami są dozwolone, a które nie.

Na przykład poniżej znajduje się prosta polityka, która zabrania całego przychodzącego ruchu do konkretnej przestrzeni nazw:

---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:  
 name: default-deny-ingress
spec:  
 podSelector: {}  
 policyTypes:  
   - Ingress

Wizualizacja tej konfiguracji:

Pięć błędów przy wdrażaniu pierwszej aplikacji na Kubernetes
(https://miro.medium.com/max/875/1*-eiVw43azgzYzyN1th7cZg.gif)
Bardziej szczegółowo tutaj.

Krok czwarty: niestandardowe zachowanie za pomocą hooków i init-kontenerów

Jednym z naszych głównych zadań było zapewnienie wdrożeń w Kubernetes bez przestojów dla programistów. Jest to trudne, ponieważ istnieje wiele sposobów na zakończenie pracy aplikacji oraz zwolnienie zajmowanych przez nie zasobów.

Szczególne trudności pojawiły się z Nginx. Zauważyliśmy, że podczas sekwencyjnego wdrażania tych podów aktywne połączenia były przerywane przed ich pomyślnym zakończeniem.

Po szerokich poszukiwaniach w internecie okazało się, że Kubernetes nie czeka, aż połączenia Nginx wyczerpią się, zanim zakończy działanie poda. Dzięki hookowi pre-stop wdrożyliśmy tę funkcjonalność i całkowicie wyeliminowaliśmy przestoje:

lifecycle: 
 preStop:
   exec:
     command: ["/usr/local/bin/nginx-killer.sh"]

Natomiast nginx-killer.sh:

#!/bin/bash
sleep 3
PID=$(cat /run/nginx.pid)
nginx -s quit
while [ -d /proc/$PID ]; do
   echo "Waiting while shutting down nginx..."
   sleep 10
done

Kolejną niezwykle przydatną paradygmatą jest użycie kontenerów init do obsługi uruchamiania konkretnych aplikacji. Jest to szczególnie przydatne, gdy masz proces migracji bazy danych, który wymaga dużych zasobów i należy go uruchomić przed startem aplikacji. Do tego procesu możesz również ustawić wyższy limit zasobów, nie nakładając takiego limitu na główną aplikację.

Inną powszechną schemą jest dostęp do sekretów w kontenerze init, który przekazuje te dane uwierzytelniające głównemu modułowi, co zapobiega nieautoryzowanemu dostępowi do sekretów z samego zasadniczego modułu aplikacji.

Jak zwykle, cytat z dokumentacji: kontenery init uruchamiają bezpiecznie kod lub narzędzia użytkownika, które w przeciwnym razie mogłyby obniżyć bezpieczeństwo obrazu kontenera aplikacji. Przechowując osobno niepotrzebne narzędzia, ograniczasz powierzchnię ataku obrazu kontenera aplikacji.

Krok piąty: konfiguracja jądra

Na koniec opowiemy o bardziej zaawansowanej technice.

Kubernetes jest niezwykle elastyczną platformą, która pozwala na uruchamianie obciążeń w sposób, jaki uważasz za stosowny. Mamy szereg wysoko efektywnych aplikacji, które wymagają ogromnych zasobów. Po przeprowadzeniu szerokich testów obciążeniowych odkryliśmy, że jedna z aplikacji ma trudności z oczekiwaną pulem ruchu, gdy obowiązują ustawienia Kubernetes domyślne.

Jednak Kubernetes pozwala na uruchomienie uprzywilejowanego kontenera, który zmienia parametry jądra tylko dla konkretnego poda. Oto co użyliśmy do zmiany maksymalnej liczby otwartych połączeń:

initContainers:
  - name: sysctl
     image: alpine:3.10
     securityContext:
         privileged: true
      command: ['sh', '-c', "sysctl -w net.core.somaxconn=32768"]

To bardziej zaawansowana technika, która często nie jest potrzebna. Ale jeśli Twoja aplikacja ledwo radzi sobie z dużym obciążeniem, możesz spróbować dostosować niektóre z tych parametrów. Bardziej szczegółowe informacje na ten temat oraz o dostosowywaniu różnych wartości – jak zawsze w oficjalnej dokumentacji.

Na zakończenie

Chociaż Kubernetes może wydawać się gotowym rozwiązaniem "z pudełka", dla niezawodnej pracy aplikacji konieczne jest podjęcie kilku kluczowych kroków.

W trakcie całej migracji do Kubernetes ważne jest, aby przestrzegać "cyklu testowania obciążenia": uruchamiacie aplikację, testujecie ją pod obciążeniem, obserwujecie metryki i zachowanie podczas skalowania, dostosowujecie konfigurację na podstawie tych danych, a następnie powtarzacie ten cykl.

Realistycznie oszacujcie oczekiwany ruch i spróbujcie go przekroczyć, aby zobaczyć, które komponenty zepsują się pierwsze. Przy takim iteracyjnym podejściu do osiągnięcia sukcesu może wystarczyć kilka z wymienionych rekomendacji. Może być jednak konieczne bardziej szczegółowe dostosowanie.

Zawsze zadawajcie sobie takie pytania:

  1. Ile zasobów zużywają aplikacje i jak ta wielkość się zmieni?
  2. Jakie są rzeczywiste wymagania dotyczące skalowania? Ile ruchu średnio będzie obsługiwać aplikacja? A co z szczytowym ruchem?
  3. Jak często usługa będzie wymagała poziomego skalowania? Jak szybko trzeba wprowadzić nowe pody, aby obsłużyć ruch?
  4. Jak poprawnie kończą pracę pody? Czy jest to w ogóle konieczne? Czy można osiągnąć wdrożenie bez przestojów?
  5. Jak minimalizować ryzyko dla bezpieczeństwa i ograniczać szkody spowodowane jakimikolwiek skompromitowanymi podami? Czy jakieś usługi mają uprawnienia lub dostęp, których nie potrzebują?

Kubernetes oferuje niesamowitą platformę, która umożliwia wykorzystanie najlepszych praktyk do wdrażania tysięcy usług w klastrze. Niemniej jednak, wszystkie aplikacje są różne. Czasami wdrożenie wymaga nieco więcej pracy.

Na szczęście Kubernetes udostępnia niezbędne ustawienia do osiągnięcia wszystkich celów technicznych. Korzystając z kombinacji żądań zasobów i limitów, sond Liveness i Readiness, kontenerów inicjujących, polityk sieciowych oraz niestandardowej konfiguracji jądra, możesz osiągnąć wysoką wydajność przy jednoczesnej niezawodności i szybkim skalowaniu.

Co jeszcze przeczytać:

  1. Najlepsze praktyki i zalecenia dla uruchamiania kontenerów oraz Kubernetes w środowiskach produkcyjnych.
  2. 90+ przydatnych narzędzi dla Kubernetes: wdrożenie, zarządzanie, monitorowanie, bezpieczeństwo i nie tylko.
  3. Nasz kanał Wokół Kubernetes na Telegramie.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster