Zielone deploye, które niczego nie wdrożyły

Krótka historia detektywistyczna ze świata wdrożeń w chmurze.

Zgłoszenie

Zaczęło się od jednego zdania: „Wdróż brancha QA na nasze środowisko dev na AWS ECS. Co muszę zmienić?”

Uczciwa odpowiedź brzmiała: „nic”. Projekt — aplikacja webowa czasu rzeczywistego napisana w Elixirze i Phoenixie, działająca na ECS na EC2 — miał ręcznie uruchamiany workflow GitHub Actions, który buduje dowolny wskazany branch, wypycha obraz do ECR i go wdraża. Rola wdrożeniowa ufała każdemu branchowi. Wybierasz branch, wciskasz przycisk.

Do końca dnia ten przycisk trzy razy zaświecił się na zielono, a dev dostał dokładnie jeden z tych deployów.

Trop 1: serwis, którego nie ma

Pierwsze uruchomienie zbudowało i wypchnęło obraz bez problemu, po czym padło na samym ostatnim kroku:

service/<cluster>/<service> is MISSING

Serwis był widoczny w konsoli. Miał po prostu inną nazwę. Kilka miesięcy wcześniej zmiana w infrastrukturze dodała do serwisu ECS nadpisanie name, które dokleiło nowy sufiks. Klucz w mapie Terraforma się nie zmienił, więc IaC wyglądało tak samo. Tylko workflow jednego brancha z feature'em dostał nową nazwę. Wszystkie pozostałe branche dalej wskazywały na starą.

Najbardziej podstępne było to, że poprzedni krok, „pobierz aktualną task definition”, przechodził. Starej rodziny task definition nikt nie usunął. Pipeline bez słowa pobierał nieaktualną definicję sprzed miesięcy i wywracał się dopiero przy próbie aktualizacji serwisu, który pod tą nazwą już nie istniał.

Poprawka w dwóch liniach, merge, ponowne uruchomienie. Zielono.

Trop 2: zielono, zielono, a dalej stara wersja

W ciągu następnej godziny w ten sam sposób poszły dwa kolejne commity. Oba uruchomienia zielone. Potem padło rutynowe pytanie: „Czy dev jest faktycznie aktualny względem ostatniego pipeline'u?”

ECS odpowiedział inaczej:

ostatni run pipeline'u  → zarejestrował rewizję 7  (najnowszy commit)
serwis faktycznie ma    → rewizję 5               (dwa commity wstecz)

Zdarzenia serwisu opowiadały to, czego pipeline nie powiedział:

(deployment …) deployment failed: tasks failed to start.
rolling back to deployment …
… has reached a steady state.

Circuit breaker wdrożeń ECS zrobił, co do niego należało, i wycofał oba deploye. Akcja GitHuba była skonfigurowana tak, by czekać na stabilność serwisu, a serwis po rollbacku jest idealnie stabilny — na starej rewizji. Krok przeszedł. Nikt się nie dowiedział.

  job CI                              serwis ECS
  ──────                              ───────────
  rejestruje rev 7 ─────────────►     startuje taski rev 7
  czeka na „stable” …                 taski padają ✗ padają ✗
                                      circuit breaker działa
                                      rollback do rev 5
  „serwis stabilny” ◄──────────────   steady state (rev 5)
  ✅ sukces

Trop 3: zdrowy serwis, który nie widział swojej bazy

Dobrze, dev siedział więc na starszej rewizji. Ta przynajmniej działała?

Nie działała. Jej strumień logów zawierał tysiące takich linii:

FATAL 28000 (invalid_authorization_specification)
no pg_hba.conf entry for host "…", user "…", database "…", no encryption

Zarządzana instancja PostgreSQL wymuszała SSL. Nowszy commit — ten wycofywany — był dokładnie tym, który włączał SSL. Wersja, która została uruchomiona, nie mogła otworzyć ani jednego połączenia z bazą.

A ECS przez cały czas raportował ją jako HEALTHY. Oba endpointy health checków pochodziły z gotowej biblioteki bez podpiętych żadnych sprawdzeń. Zwracały 200, dopóki działał serwer HTTP. Load balancer dalej kierował użytkowników do aplikacji, w której padało każde zapytanie, a siatka bezpieczeństwa, która wycofałaby zepsutą rewizję, nigdy nie zadziałała, bo nic nigdy nie uznało jej za zepsutą.

Sytuacja była więc odwrócona: dobra rewizja ciągle była wycofywana, a zepsuta ciągle przechodziła health checki.

Trop 4: kod wyjścia 137

Dlaczego nowa rewizja nie mogła wystartować? Zatrzymane taski mówiły to wprost:

Exit code: 137
reason: OutOfMemoryError: Container killed due to memory usage

Pierwszy odruch: nowy kod ma wyciek pamięci. Błąd. Taski starej rewizji też były zabijane, ilekroć restartowały się podczas nieudanych rolloutów. To nie była regresja w kodzie. To był problem z rozmiarem, który leżał niezauważony.

Task definition wyglądała tak:

task      memory = 900   ← rezerwacja na hoście
container memory = 450   ← twardy limit; powyżej OOM-kill

Jeden kontener w tasku. W ECS wartość na poziomie kontenera to twardy limit, niezależnie od tego, ile rezerwuje task. Połowa przydziału była zarezerwowana na hoście i nie dało się jej nigdy użyć.

Co wypchnęło nową rewizję ponad 450 MiB? Gdy tylko połączenie z bazą zadziałało, pierwsze linie logów pochodziły z cogodzinnego joba w tle, który pobierał kanały z newsami: kilka kopii naraz, pobieranie i parsowanie stron, wycofywanie się przed ścianą 429 Too Many Requests. Kilka sekund później task padł. Stara rewizja nigdy nie doszła tak daleko, bo bez bazy danych tego joba nie dało się nawet zaplanować. Jej taski uderzyły w ten sam sufit w jakiś inny sposób. Najbardziej prawdopodobny podejrzany to niekończąca się burza ponownych prób połączenia, ale nikt tego nie udowodnił, a przy takim limicie nie miało to większego znaczenia.

Poprawka i poboczny wątek

Poprawka to dwie liczby w Terraformie: task 1024, kontener 1024. Hosty miały zapas — każda instancja udostępniała taskom ok. 3,8 GB, a nawet rolling deploy na 200% nie stawiał na żadnym hoście więcej niż dwóch tasków.

Uruchomienie terragrunt plan rzuciło jednak fałszywym tropem:

Error: Required plugins are not installed
  there is no package for hashicorp/aws 6.52.0 cached in .terraform/providers
ERROR  There is no variable named "dependency".
ERROR  resolving dependency "<network-discovery>" outputs: Failed to execute "tofu output -json"

Wygląda to na problem z lockfile'em. Nie jest. Cache poszczególnych unitów Terragrunta trzymają symlinki do jednego współdzielonego magazynu providerów, a coś usunęło z niego starsze wersje. Oczywisty ruch — terragrunt init w zależności wskazanej przez błąd — padł tak samo i tym razem obwiniał zależność tego unitu. Każda poprawka przesuwała tylko błąd o jeden poziom niżej w łańcuchu.

Jednolinijkowe przeszukanie wiszących symlinków pokazało, że zepsuty był niemal każdy unit w repozytorium. Cache trzymają tylko pobrane kopie (prawdziwy state leży w S3), więc poprawką było usunięcie ich wszystkich i ponowne uruchomienie:

find . -type d -name .terragrunt-cache -prune -exec rm -rf {} +

Potem plan był czysty. Wyszła też jedna przyjemna właściwość: pipeline kopiuje najnowszą rewizję task definition i podmienia tylko obraz. Więc terraform apply rejestruje rewizję z nową pamięcią, następny run CI ją dziedziczy i nikt nie musi walczyć z Terraformem o kontrolę nad działającym serwisem.

Sprawdzone tam, gdzie trzeba

Czwarty run też był zielony. Tym razem nikt nie uwierzył mu na słowo:

Jeszcze jedna korekta. W połowie śledztwa logi migracji pokazały znaczniki czasu sprzed dwóch miesięcy i pierwsze odczytanie brzmiało: „dev nie był migrowany od tamtej pory”. Znowu błąd. To były po prostu najstarsze migracje istniejące wyłącznie na branchu QA. Nigdy nie miały okazji się wykonać, bo żadna rewizja zdolna połączyć się z bazą nie utrzymała się wystarczająco długo.

Wskazówki dla czytelnika