Dwa duchy w potoku wydań
Krótka historia detektywistyczna ze świata Infrastructure as Code.
Punkt wyjścia
Potok wydań Projektu opierał się na wzorcu, który w ogóle warto mieć: zbuduj obraz Dockera dokładnie raz, otaguj go niezmiennym commitem gita, z którego powstał, i nigdy nie buduj go ponownie dla kolejnych środowisk. Staging i produkcja nie dostają świeżych buildów — dostają ten sam obraz, wdrożony ponownie przez przetagowanie po stronie rejestru. To gwarantuje, że produkcja jest bajt w bajt tym, co faktycznie wcześniej sprawdzono, a nie buildem, który „powinien być identyczny”, a po cichu się rozjechał, bo tag obrazu bazowego przesunął się pod spodem.
Dojście do tego wymagało kilku falstartów. Pierwsza naiwna wersja: buduj tylko wtedy, gdy zmieniły się ścieżki istotne dla aplikacji, żeby nie palić minut CI na commitach zmieniających tylko dokumentację. To się posypało przy pierwszym zetknięciu z automatycznym narzędziem do wydań — jego własne commity scalające (podbijające changelog i plik wersji) nigdy nie dotykają kodu aplikacji, więc dla dokładnie tego commita, na który wskazywało każde wydanie, obraz w ogóle nie powstawał, a każda kontrola promocji kończyła się odmową. Kolejna naiwna poprawka — buduj po prostu przy każdym pushu — rozwiązała to, ale przywróciła pierwotny problem: teraz te same commity zmieniające tylko changelog były budowane ponownie, a jeśli cokolwiek w buildzie nie jest idealnie deterministyczne, „identyczny z tym, co sprawdzono” przestaje być prawdą.
Prawdziwa poprawka: porównaj każdy nowy commit z jego rodzicem dla ścieżek, które mają znaczenie. Bez zmian → przenieś dokładnie obraz rodzica dalej zwykłym wywołaniem API rejestru, bez przebudowy i bez możliwości dryfu. Zmiany → prawdziwy build. Proste z perspektywy czasu. I właśnie tam mieszkają oba poniższe duchy.
┌────────────────────────────────┐
│ dev — automatycznie │
│ build albo przetagowanie │
└────────────────┬───────────────┘
│ ten sam sha
▼
┌────────────────────────────────┐
│ testnet — automatycznie │
│ przy wydaniu, retag (bez build)│
└────────────────┬───────────────┘
│ ten sam sha
▼
┌────────────────────────────────┐
│ mainnet — ręcznie │
│ za akceptacją, jawne wywołanie │
└────────────────────────────────┘
Duch nr 1: krok, który padł bez słowa
Krok przetagowania miał jedną kosmetyczną skazę. API rejestru rzuca błąd „already exists”, jeśli przetagowujesz tag na digest, na który już wskazuje — nieszkodliwe, ale przed własnym komunikatem potoku „wszystko w porządku” drukowało groźnie wyglądającą linię [ERROR], więc nawet zielony krok w logach wyglądał strasznie. Poprawka wydawała się prosta: najpierw sprawdź aktualny digest docelowego tagu i jeśli już się zgadza, całkiem pomiń wywołanie przetagowania.
existing_digest=$(aws ecr describe-images --image-ids imageTag="$tag" \ --query 'imageDetails[0].imageDigest' 2>/dev/null)
Wysłane, przejrzane, scalone. A potem, przy zupełnie niezwiązanym pushu chwilę później, krok przetagowania umarł. Nie z błędem — z niczym. Żadnej linii w logu, żadnego komunikatu, tylko czerwony krok i cisza.
Śledztwo zaczęło się tam, gdzie powinno: niczego nie zakładaj, sprawdź fakty.
Pierwszy ruch: odtworzyć dokładnie te wywołania API lokalnie na prawdziwym rejestrze, z tymi samymi krótkotrwałymi poświadczeniami, których użyłby sam potok, żeby oddzielić „AWS CLI zachowuje się dziwnie” od „logika skryptu jest błędna”. To potwierdziło prawdziwy niezerowy kod wyjścia przy zerowym stdout i stderr, kiedy wyjście było przekierowane — zgodne z objawem z potoku, ale jeszcze nie wyjaśnienie.
Drugi ruch: pobrać faktyczną politykę IAM roli CI i sprawdzić ją linia po linii. Miała już każde uprawnienie potrzebne w tym kroku. To wykluczyło kuszące, nudne wyjaśnienie — „brakuje nam uprawnienia” — i zmusiło do uważniejszego spojrzenia na sam skrypt.
Prawdziwy błąd ukrywał się w „poprawce” z poprzedniego incydentu: 2>/dev/null przy podstawieniu polecenia wyrzuca tylko komunikat — nic nie robi z kodem wyjścia. Przypisanie nadal niesie niezerowy kod zwrotny polecenia, które zawiodło. W trybie powłoki, którego GitHub Actions domyślnie używa dla kroków run (natychmiastowe przerwanie przy dowolnym niezerowym wyjściu), ten kod nadal zabijał cały skrypt — tylko po cichu, bo jedyna rzecz, która wyjaśniłaby dlaczego, chwilę wcześniej została wyrzucona w próżnię.
I uderzało to najmocniej dokładnie w przypadku, który wstępna kontrola miała obsłużyć łagodnie: przy pierwszym sprawdzeniu zupełnie nowego tagu, zanim on istnieje. describe-images poprawnie zgłasza „not found” — a to niezerowe wyjście przebrane za oczekiwany wynik. To obsługa błędów samej wstępnej kontroli czyniła ten oczekiwany przypadek śmiertelnym.
Poprawka, kiedy już się znalazła: jawnie połknąć kod wyjścia, a nie tylko komunikat —
existing_digest=$(aws ecr describe-images ... 2>/dev/null) || true
Jedno dodatkowe || true. Lekcja pod spodem jest większa niż jedna linia: ukrycie komunikatu polecenia to nie to samo co obsłużenie jego porażki. Jeśli niezerowe wyjście jest w jakimś przypadku naprawdę oczekiwane, powiedz to jawnie — nie rób go tylko cichym.
Duch nr 2: rola, której nie dało się przyjąć
Promocja na produkcję w tym potoku celowo nie jest automatyczna. Staging jest promowany w chwili, gdy powstaje wydanie — scalenie PR samego narzędzia do wydań jest akcją promocji. Produkcja wymaga, żeby człowiek jawnie ją uruchomił, wskazując dokładny sprawdzony artefakt.
To nie przypadek: wyzwalacz produkcji oparty na zdarzeniach, połączony z bramką wymaganej akceptacji, ustawia w kolejce jedno oczekujące uruchomienie na każde wydanie. Jeśli dwa wydania wyjdą szybko po sobie, a ktoś zaakceptuje starsze, już nieaktualne oczekujące uruchomienie, produkcja po cichu cofa się do starszego wydania. Ręczny, jawnie wycelowany wyzwalacz całkowicie to omija — zawsze jest tylko jedno uruchomienie i wskazuje na to, co człowiek właśnie wybrał.
Tym bardziej zaskoczyło, kiedy ręczne uruchomienie produkcyjne padło na kroku poświadczeń: „not authorized to assume role via web identity” — odmowa po stronie chmury, którą dostajesz, gdy federacyjna relacja zaufania nie akceptuje przedstawionego tokena.
Pierwszy podejrzany: znana pułapka w polityce zaufania, już naprawiona gdzie indziej w tym samym potoku, gdzie dwa różne rodzaje warunku dopasowania subject (dokładne dopasowanie i wildcard) trafiły do tej samej instrukcji polityki. Systemy tożsamości łączą operatorem AND każdy warunek w jednej instrukcji, nawet dwa testujące to samo pole — więc token musiałby jednocześnie spełnić dokładny ciąg i wzorzec wildcard, co jest niemożliwe. Ten błąd był prawdziwy, ale żył w innym jobie. Jego poprawka tego nie dotykała.
Druga próba: dodać kształt subject, którego naturalnie spodziewałbyś się dla ręcznie uruchomionego joba na gałęzi main. Ponowne wdrożenie. Nadal pada. W tym momencie kusi, żeby podejrzewać nieaktualne wdrożenie — czy zmiana polityki zaufania naprawdę się rozpropagowała? Następnym ruchem było więc pobranie żywej polityki zaufania prosto po stronie chmury, a nie z repozytorium konfiguracji, żeby to bezpośrednio wykluczyć. Nowy wpis był tam, aktywny, potwierdzony. A i tak padało. To moment, żeby przestać podejrzewać opóźnienie propagacji i zacząć podejrzewać, że sam kształt subject jest po prostu zły.
Był. Ten job przypina nazwane środowisko wdrożeniowe — dokładnie ten mechanizm, który w ogóle stawia go za ludzką akceptacją. Okazuje się, że samo to ustawienie całkowicie zmienia wydawany token tożsamości: kiedy job określa środowisko, subject tokena staje się „to repozytorium, to środowisko” i koniec — niezależnie od tego, co uruchomiło job, zastępując subject oparty na gałęzi lub tagu, którego normalnie byś się spodziewał. Polityce zaufania kazano oczekiwać gałęzi. GitHub nigdy nie miał jej wysłać.
OCZEKIWANY subject FAKTYCZNY subject od GitHuba
┌─────────────────────────┐ ┌──────────────────────────────┐
│ repo:org/app:ref: │ vs. │ repo:org/app:environment: │
│ refs/heads/main │ │ production │
└─────────────────────────┘ └──────────────────────────────┘
(czemu ufała (co faktycznie
poprawka) przedstawiono — zawsze,
gdy job ma ustawione
`environment:`)
Poprawka: ufać subject w kształcie środowiska zamiast w kształcie gałęzi. Gdy to weszło, ręczna promocja na produkcję przyjęła rolę za pierwszym razem.
Wskazówki na następne śledztwo
- Wyciszenie komunikatu to nie to samo co obsłużenie porażki. Jeśli przekierowujesz stderr, żeby ukryć oczekiwany błąd, kod wyjścia w powłoce z szybkim przerywaniem to nadal ostra amunicja — połknij go celowo, nie rób go tylko cichym.
- Wykluczaj przyczyny dowodami, nie założeniami. Odtworzenie dokładnie tych wywołań lokalnie i pobranie żywej konfiguracji z samej platformy zamiast ufania repozytorium źródłowemu to dwa tanie sposoby na wyeliminowanie złej teorii, zanim utopisz w niej godzinę.
- Poprawka dla jednego joba nie oznacza tej samej poprawki dla joba obok. Dwa joby z identycznym komunikatem błędu mogą mieć zupełnie różne przyczyny — potwierdź, że każda poprawka działa, nie zakładaj, że pierwsza obejmuje cały wzorzec.
- Pozornie niezwiązana konfiguracja joba potrafi po cichu zmienić token tożsamości. Ustawienie, którego jedynym celem jest „postaw to za akceptacją”, po cichu nadpisuje wydawany claim subject, niezależnie od tego, co uruchomiło job. Kiedy poprawka polityki zaufania nie naprawia błędu uwierzytelnienia, sprawdź, co jeszcze w konfiguracji joba może kształtować token, zanim dodasz kolejne wpisy do polityki.
- W najbardziej krytycznym środowisku wybieraj człowieka wskazującego dokładny artefakt zamiast zdarzenia, które automatycznie coś kolejkuje. To odrobina ręcznego tarcia w zamian za gwarancję, że w kolejce nigdy nie czeka nieaktualne wdrożenie tuż przed akceptacją.