Aktualizacja, która niczego nie zepsuła
Jedna aplikacja z dwudziestu przestała być zdrowa. Błąd wymieniał pole, którego nikt nigdy nie ustawiał — a zanim skończyliśmy, aktualizacja, która to naprawiła, rozświetliła dwie kolejne rzeczy, które od lat były po cichu zepsute.
Zgłoszenie
Projekt to repozytorium GitOps. Steruje dwoma klastrami Kubernetesa — jednym dla developmentu i stagingu, drugim dla produkcji — a ArgoCD uzgadnia wszystko, co w nim jest.
Stos monitoringu przestał się synchronizować. Wszystko inne było zielone. Błąd brzmiał:
error calculating structured merge diff: error building typed value from live resource: .status.terminatingReplicas: field not declared in schema
Nikt nigdzie nie napisał terminatingReplicas. To nie jest pole, które się ustawia. To pole, które Kubernetes raportuje — liczba podów wciąż się zamykających — dodane do Deploymentów w 1.33 i domyślnie włączone w 1.34.
Klasterowi wyrosło więc pole, a narzędzie wdrożeniowe odmówiło jego przeczytania.
Część, która zaskakuje ludzi
Oczywiste założenie jest takie, że ArgoCD pyta klaster, jak wygląda Deployment. Nie pyta.
Własne FAQ ArgoCD mówi to bez ogródek: „relies on a static (hard-coded) set of schemas for built-in Kubernetes resource types”, czyli opiera się na statycznym, zahardkodowanym zestawie schematów dla wbudowanych typów zasobów. Ten schemat jest zamrożony w czasie budowania, wyznaczony przez biblioteki Kubernetesa, z którymi skompilowano dane wydanie.
klaster (Kubernetes 1.36) ArgoCD (zbudowane na 1.32)
───────────────────────── ───────────────────────────
Deployment.status Deployment.status
replicas replicas
readyReplicas readyReplicas
updatedReplicas updatedReplicas
terminatingReplicas ← nowe · (tu nic nie ma)
└─ "field not declared in schema"
Nasze produkcyjne ArgoCD było z końca 2022 roku. Klaster był aktualny. Nic nie było źle skonfigurowane — po prostu oba rozjechały się o cztery lata, a klaster w końcu powiedział coś, na co narzędzie nie miało słowa.
Spędziliśmy chwilę, próbując kazać ArgoCD ignorować to pole. To nie działa i warto wiedzieć dlaczego: porażka następuje podczas budowania obiektu, zanim powstanie jakikolwiek diff, z którego można by coś wykluczyć. Nie ma haka „pomiń to pole” odpowiednio wcześnie. Jedyną prawdziwą poprawką jest aktualizacja.
Dlaczego tylko jedna aplikacja?
To był szczegół, dzięki któremu całość nabrała sensu.
Ze dwadzieścia aplikacji, ten sam klaster, to samo nowe pole na każdym Deploymencie. Jedna zepsuta.
Zepsuta była jedyną aplikacją skonfigurowaną z server-side apply. Stare ArgoCD po cichu wybierało dla nich inną strategię diffu — taką, która parsuje żywy stan klastra w typowane obiekty. Każda inna aplikacja używała domyślnej strategii, która nigdy nie buduje typowanego obiektu, więc nigdy nie potyka się o nieznane pole.
Zasięg rażenia nie wynosił więc „aplikacje dotknięte aktualizacją Kubernetesa”. Wynosił „aplikacje, które potrzebowały server-side apply” — czyli w praktyce aplikacje dostarczające CRD zbyt duże, żeby zastosować je w jakikolwiek inny sposób.
Zapamiętaj tę myśl.
Zły zakręt
Wybór docelowej wersji powinien był być nudną częścią.
Nowsze ArgoCD przynosi nową główną wersję Helma. Każda aplikacja w repozytorium renderuje chart Helma. To wydawało się prawdziwym ryzykiem, więc plan zakładał wzięcie poprzedniej linii wydań — dostać poprawkę, pominąć skok Helma, być rozsądnym.
Potem ktoś zapytał, na czym faktycznie działają klastry.
Były na Kubernetesie 1.36. „Rozsądna” wersja jest testowana do 1.35. Nowsza — ta unikana — to jedyne bieżące wydanie testowane z 1.36.
Zachowawczy wybór był tym niewspieranym.
To moment warty zapamiętania. Asekuracja nie była irracjonalna; była wyceniona wobec faktu, którego nikt nie sprawdził. Ryzyko, które możesz nazwać i przetestować w praktyce, bije naruszenie macierzy wsparcia, z którym nic nie możesz zrobić. Najpierw sprawdź wersję środowiska, potem wybieraj — cała reszta to preferencje.
Instalator uderza we własną ścianę
Instalacja nowej wersji padła.
Nie na naszej konfiguracji. Na własnym manifeście ArgoCD, który teraz zawiera definicję zasobu niestandardowego zbyt dużą na zwykłe kubectl apply — przekracza 262144 bajty adnotacji, którą client-side apply zapisuje, żeby zapamiętać, co właśnie zastosowało.
Poprawką jest instalacja przez server-side apply.
Co jest tym samym limitem, z tego samego powodu, który w ogóle postawił server-side apply na stosie monitoringu — ustawienie, które wybrało strategię diffu, od której to wszystko się zaczęło. Błąd i instalacja jego poprawki to było to samo ograniczenie w różnych kapeluszach.
Zwrot akcji: nic, co „zepsuliśmy”, nie było nowe
Aktualizacja weszła. Stos monitoringu stał się zdrowy. A niezwiązana aplikacja zrobiła się czerwona.
CRD należący do kontrolera load balancera zgłaszał naruszenie schematu:
NonStructuralSchema: True spec.preserveUnknownFields: Invalid value: true: must be false
Oczywiste odczytanie: aktualizacja to zepsuła. Oczywiste odczytanie było błędne i jest tani sposób, żeby to udowodnić: sprawdzić, czy wcześniej istniała kontrola. Health check, który wygenerował ten komunikat, to plik w drzewie źródeł ArgoCD. Poproś o niego przy tagu starej wersji i nowej — 404, potem 200.
Kontrola była nowa. Defekt był z 2021 roku.
Oto jak się tam znalazł. Ten CRD utworzono z manifestu napisanego pod starszą wersję API, w której preserveUnknownFields domyślnie ma true. Nikt tego nie napisał. Było domniemane i zapisane.
Każdy manifest od tamtej pory — przepisany plik bootstrapu, własna kopia chartu, lata synchronizacji GitOps — po prostu pomija to pole. A pominięcie pola to nie to samo co jego usunięcie. Nic nigdy nie wyczyściło tej wartości, bo nic nigdy o niej nie wspomniało.
Co gorsza, zasób przez cały ten czas pokazywał Synced. Diff GitOps porównuje to, co zadeklarowałeś, z tym, co żyje. Pole, które istnieje tylko w żywym stanie, w ogóle nie bierze udziału w tym porównaniu. Status synchronizacji to stwierdzenie o polach, które napisałeś — nigdy o tym, czy obiekt jest poprawny.
Cztery lata na zielono. Jedna jawna łatka ustawiająca pole na false i znikło.
Koda: joby, które nigdy nie ruszyły
Przy sprawdzaniu zdrowia klastra ktoś zauważył partię zaplanowanych jobów, które padały. kubectl get jobs pokazywał porażki sięgające dwa dni wstecz.
Dwie rzeczy w tym były nieprawdziwe.
Nie padały od dwóch dni. Kubernetes zachowuje tylko kilka ostatnich nieudanych jobów. Kiedy coś pada dłużej niż to okno, każdy widoczny rekord to porażka, a najstarszy widoczny wygląda na początek. Prawdę mówiły zachowane udane uruchomienia: ostatni sukces był jakieś dziesięć miesięcy wcześniej. Dla jednego joba — trzy lata.
Nie padały — nigdy nie ruszały. Każdy job umierał z DeadlineExceeded, co czyta się jak „to trwało za długo”. Nie to znaczy. Termin liczy się od chwili utworzenia joba, a nie od startu kontenera. Pody nie mogły zostać rozplanowane, wisiały w pending i spaliły cały budżet, nic nie robiąc. Żaden kontener nigdy nie ruszył, dlatego nie było logów do czytania. Zegar odpalał niemal dokładnie po skonfigurowanym czasie — z punktualnością, jakiej prawdziwa praca rzadko dotrzymuje.
Przyczyną był jeden commit sprzed dziesięciu miesięcy. Wycofano grupę węzłów, a plik konfiguracji zaktualizowano, żeby skierować obciążenie gdzie indziej. Plik zawierał jedenaście reguł planowania: jedną dla długotrwałej usługi, dziesięć dla zaplanowanych jobów. Commit zmienił jedną z nich.
Usługa się przeniosła i pozostała zdrowa. Właśnie dlatego nikt nie zauważył, że pozostałe dziesięć wskazuje na grupę węzłów, która już nie istnieje. Zdrowa rzecz ukrywała te zepsute.
Wskazówki dla czytelnika
Twoje narzędzie wdrożeniowe może nie czytać API Twojego klastra. Wszystko ze schematem wkompilowanym w środek to zależność od wersji, o której nie wiedziałeś, i zawodzi na nowych funkcjach klastra, a nie na twojej konfiguracji.
Sprawdź wersję, zanim wybierzesz wersję. Asekurowanie się przed nazwanym ryzykiem, podczas gdy ograniczenie, które rozstrzyga odpowiedź, wciąż nie jest sprawdzone, to sposób, w jaki „zachowawczy” staje się „niewspieranym”.
Pominięcie pola nigdy go nie usuwa. Zwłaszcza przy migracji wersji API, gdzie zmieniają się wartości domyślne, a manifesty są przepisywane tak, żeby przestały o czymś wspominać. Ustawiaj wartości jawnie, kiedy mają znaczenie.
Synced to nie poprawny, a zielony to nie zdrowy. Oba to stwierdzenia o tym, co sprawdza narzędzie. Żadne nie mówi nic o tym, czego nie sprawdza.
Kiedy aktualizacja coś „psuje”, ustal datę kontroli, zanim obwinisz zmianę. Narzędzia zyskują kontrole. Nowe kontrole po raz pierwszy oceniają starą pracę. Rollback zmarnowałby tu działającą aktualizację i z powrotem ukrył dwa prawdziwe defekty.
Ograniczona historia kłamie o czasie trwania. Okna retencji odpowiadają na pytanie „czy jest zepsute teraz”, nigdy „od kiedy”. Znajdź ostatni sukces, potem potwierdź to czymś nieograniczonym — zwykle historią gita.
Zdrowy komponent może maskować nieudaną migrację. Kiedy wycofujesz grupę węzłów, kolejkę albo endpoint, przeszukaj całą konfigurację pod kątem starej nazwy. To, co nadal działa, nie jest dowodem, że przeprowadzka się zakończyła — to powód, dla którego nikt nie patrzy.