Bezpieczna komenda, która usunęła sekret
Krótka historia detektywistyczna ze świata GitOps i sekretów w Kubernetesie.
Ostatni krok
Migracja była prawie skończona. Przez dwa dni klastry Firmy przenosiły wszystkie sekrety z Sealed Secrets do AWS Secrets Manager, czytanego przez External Secrets Operator. Serwis po serwisie, za każdym razem ta sama ostrożna pętla:
- utwórz sekret w AWS,
- skopiuj do niego aktualną wartość,
- zsynchronizuj go do równoległego Secretu w Kubernetesie z sufiksem
-sm, - udowodnij, że kopia jest identyczna co do bajtu (po hashu, bez wypisywania wartości),
- dopiero wtedy przełącz workload.
Jedenaście sekretów przeszło tę pętlę bez zgrzytu. Został jeden: argocd-secret samego ArgoCD, w którym siedzą hasło admina, klient SSO, klucz podpisujący każdą sesję i każdy token API oraz certyfikat TLS. Nadal należał do SealedSecretu, a ten SealedSecret musiał zniknąć, zanim dało się usunąć kontroler Sealed Secrets.
Plan na ten jeden krok to była jedna, dobrze udokumentowana komenda, uruchomiona raz na każdym klastrze:
kubectl -n argocd delete sealedsecret argocd-secret --cascade=orphan
--cascade=orphan to podręcznikowy sposób, żeby usunąć właściciela i zachować to, co do niego należy. Kubernetes zdejmuje ownerReference z obiektu podrzędnego, a potem usuwa nadrzędny. Secret zostaje nietknięty, tylko bez właściciela. To jest ta „bezpieczna” flaga.
Inżynier platformy uruchomił ją na obu klastrach, w odstępie pięciu sekund, i odpisał: „odpięte na obu, sprawdź argocd-secret”.
Sprawdzenie, które wróciło puste
Pierwszy skrypt weryfikujący nie wypisał nic przydatnego:
error: context "get" does not exist
Ten błąd był na własne życzenie. Mały helper w shellu czytał $1 wewnątrz funkcji, więc brał pierwszy argument samej funkcji zamiast nazwy klastra. Poprawka, ponowne uruchomienie i tym razem wynik był gorszy:
Error from server (NotFound): secrets "argocd-secret" not found
Na obu klastrach. Sekret, który bezpieczna komenda miała zachować, zniknął.
Log serwera ArgoCD to potwierdzał:
error retrieving argocd-secret: secret "argocd-secret" not found
Aplikacje dalej świeciły na zielono: kontrolery działają na tym, co już mają w pamięci. Ale każde wywołanie API kończyło się błędem, a przy następnym restarcie ArgoCD mogło wygenerować nowy klucz podpisujący. To unieważniłoby każdą sesję i każdy token CI.
Najpierw przywróć, potem zrozum
Tu opłaciła się wcześniejsza pętla. Krok wcześniej External Secrets Operator zapisał pełną równoległą kopię, argocd-secret-sm, i była już zweryfikowana jako identyczna z oryginałem co do hasha. Przywrócenie to był jeden pipe: dane z kopii wlane do nowego Secretu z oryginalną nazwą i etykietami, bez wyświetlenia na ekranie ani jednej wartości:
kubectl get secret argocd-secret-sm -o json \
| jq '{apiVersion:"v1", kind:"Secret", type:"Opaque",
metadata:{name:"argocd-secret", namespace:"argocd",
labels:{"app.kubernetes.io/part-of":"argocd"}},
data:.data}' \
| kubectl create -f -
Hashe zgadzały się z kopią sprzed odpięcia. ArgoCD zalogowało Loading TLS configuration from secret i błędy ustały. Przerwa trwała nieco ponad dwie minuty na klaster. Nikt nie musiał logować się ponownie, bo klucz podpisujący ani razu się nie zmienił, a w tym oknie akurat nie ruszył żaden job CI.
Pora na „dlaczego”.
Oczywisty podejrzany
Był idealny podejrzany. Następną zmianą w planie był pull request, który kierował External Secrets Operator na sam argocd-secret i wmergowywał do niego kilka kluczy. Gdy inżynier o niego zapytał, okazało się, że ten PR był już zmergowany, dziewięć sekund po drugiej komendzie odpinającej.
Zmiana, która celuje w Secret, zmergowana dokładnie wtedy, gdy ten Secret zniknął, wygląda na winną. Dlatego oś czasu została zbudowana porządnie, z trzech niezależnych zegarów:
T+0s komenda odpinająca, klaster A (historia shella) T+1s kontroler A: "Updating argocd/argocd-secret" T+5s komenda odpinająca, klaster B T+5s kontroler B: "Updating argocd/argocd-secret" T+14s podejrzany PR zostaje zmergowany (serwer Git) T+17s pierwsze "argocd-secret not found" (log serwera ArgoCD) T+148s Secret przywrócony ręcznie T+245s PR wreszcie trafia na klastry (historia synców GitOps)
PR był niewinny. Dotarł na klastry po przywróceniu, zastał Secret, który znowu istniał, i wmergował się do niego dokładnie tak, jak zaprojektowano. Prawdziwa wskazówka siedziała w T+1s i T+5s. W tej samej sekundzie co każde usunięcie kontroler Sealed Secrets zapisał coś do Secretu.
Co się naprawdę stało
--cascade=orphan to nie jest jedna atomowa operacja. Kubernetes:
- oznacza SealedSecret do usunięcia i dokłada finalizer orphan,
- zdejmuje ownerReference z Secretu,
- wypuszcza SealedSecret.
Krok 1 to aktualizacja SealedSecretu. Kontroler obserwujący SealedSecrety (tu starsze wydanie) nie pomija obiektów w trakcie usuwania. Zobaczył update, zrobił to co zawsze, odszyfrował dane i nadpisał Secret, z przywróconym ownerReference.
ty API server Sealed Secrets ctrl garbage collector
| delete --cascade=orphan | | |
|------------------------->| oznacz SealedSecret | |
| | (usuwanie + finalizer) | |
| |---- zdarzenie update --->| |
| | zdejmij ownerRef Secretu | |
| |<-- zapis Secretu --------| |
| | (ownerRef wraca) | |
| | usuń SealedSecret | |
| |-------------------------- właściciel znikł? --->|
| |<-------------------- usuń Secret ---------------|
Gdy SealedSecretu już nie było, garbage collector znalazł Secret wskazujący na właściciela, który nie istnieje, i zrobił swoje. Następna linia w logu samego kontrolera potwierdza kolejność: jego aktualizacja statusu padła z SealedSecret "argocd-secret" not found.
Komenda była poprawna i flaga była poprawna. Kontroler się z nią ścigał.
Błąd pod spodem
Niewygodne jest to, że migracja miała nawyk, który by to wyłapał. Każde zachowanie External Secrets Operatora, które miało znaczenie, było najpierw udowadniane na obiekcie testowym: czy przejmuje istniejący Secret, czy wyrzuca nadmiarowe klucze, czy jego usunięcie zostawia wmergowane klucze? Każde dostało testowy Secret na klastrze nieprodukcyjnym, sprawdzenie i sprzątanie.
Krok odpinania to pominął. Wyglądał na zwykły Kubernetes, a zwykły Kubernetes jest dobrze znany. Tyle że to nie był zwykły Kubernetes: w pętli był kontroler, a kontrolery reagują na zdarzenia, o których nie myślisz jak o zdarzeniach.
Poprawka na przyszłość to jedna linia, uruchamiana przed dotknięciem czegokolwiek, czym zarządza kontroler:
kubectl -n <ns> scale deploy sealed-secrets-controller --replicas=0
Gdy nikt nie nasłuchuje, usuwanie z orphan zachowuje się dokładnie tak, jak w dokumentacji. Tak później usunięto sam kontroler, bez żadnego incydentu.
Wskazówki dla czytelnika
- „Orphan” chroni cię przed Kubernetesem, nie przed kontrolerami. Każdy kontroler, który rekoncyliuje właściciela, może nadpisać obiekt podrzędny w trakcie usuwania. Najpierw zatrzymaj kontroler.
- Miej zweryfikowaną kopię, zanim dotkniesz czegoś krytycznego. Przywrócenie zajęło jeden pipe, bo identyczna co do hasha kopia już istniała. Bez niej te same dwie minuty oznaczałyby rotację poświadczeń dla każdego użytkownika i każdego joba CI.
- Zbuduj oś czasu z niezależnych zegarów, zanim obwinisz najbliższą zmianę. Historia shella, logi kontrolera, serwer Git i historia synców GitOps razem zamieniły „to PR” w „PR dotarł po naprawie”.
- Stosuj swój nawyk testowania także do „oczywistych” kroków. Kroki, które wyglądały na ryzykowne, dostały test na obiekcie testowym. Krok, który wyglądał na oczywisty, nie dostał, i to on ugryzł.
- Zielone aplikacje to nie zdrowy control plane. Workloady cały czas były Synced, a każde wywołanie API serwera GitOps kończyło się błędem.