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:

  1. utwórz sekret w AWS,
  2. skopiuj do niego aktualną wartość,
  3. zsynchronizuj go do równoległego Secretu w Kubernetesie z sufiksem -sm,
  4. udowodnij, że kopia jest identyczna co do bajtu (po hashu, bez wypisywania wartości),
  5. 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:

  1. oznacza SealedSecret do usunięcia i dokłada finalizer orphan,
  2. zdejmuje ownerReference z Secretu,
  3. 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