Sprawa drogi, której nigdy nie zbudowano

Krótka historia detektywistyczna o Terraformie, Kubernetesie i komunikatach błędów, które wskazują wszędzie poza miejscem zbrodni.

 ┌────────────────────────────────────────────────────────────┐
 │                     TABLICA ŚLEDCZA                        │
 ├────────────────────────────────────────────────────────────┤
 │                                                            │
 │   OFIARA: "Instances failed to join the cluster"           │
 │                          |                                 │
 │       .------------------+------------------.              │
 │       |                  |                  |              │
 │  ┌────▼────┐       ┌─────▼─────┐      ┌─────▼─────┐        │
 │  │PODEJRZ. │       │ PODEJRZ.  │      │ PODEJRZ.  │        │
 │  │security │       │ polityki  │      │  tagi     │        │
 │  │ groups  │       │ IAM węzła │      │  subnetów │        │
 │  └────┬────┘       └─────┬─────┘      └─────┬─────┘        │
 │       |                  |                  |              │
 │       '--------- WSZYSCY NIEWINNI ----------'              │
 │                          |                                 │
 │                    ┌─────▼──────┐                          │
 │                    │ PRAWDZIWA  │                          │
 │                    │ PRZYCZYNA: │                          │
 │                    │ flaga CLI  │                          │
 │                    └────────────┘                          │
 └────────────────────────────────────────────────────────────┘

Zlecenie

Firma potrzebowała współdzielonego klastra CI: jednego klastra Kubernetesa, na własnym koncie AWS, uruchamiającego agentów buildów dla wszystkich zespołów. Od zera. Terraform napisano miesiące wcześniej i nigdy nie zastosowano — nikt nie nacisnął przycisku.

Zadanie polegało na naciśnięciu przycisku.

Zajęło to pięć podejść. Każda porażka dawała komunikat błędu wskazujący gdzie indziej niż prawdziwy problem. To historia dwóch najlepszych.

Rozdział 1 — Jajko i kura

Pierwszy terraform plan padł natychmiast:

Error: reading EKS Cluster (...): couldn't find resource
  with data.aws_eks_cluster.<cluster>

Po namyśle rozsądne. Konfiguracja instaluje w klastrze kontroler GitOps przez Helma — a provider Helm w Terraformie musi być skonfigurowany adresem i poświadczeniami klastra, zanim cokolwiek zaplanuje. Te pochodzą z data source, który czyta klaster.

Który jeszcze nie istnieje. Bo to właśnie ta konfiguracja go tworzy.

Terraform rozwiązuje konfigurację providerów przed planowaniem zasobów, które ich używają, więc „będzie istniał, zanim tam dojdziemy” nie jest argumentem, który przyjmuje. To znana zmarszczka i ma znane obejście: najpierw zbuduj klaster w osobnym, celowanym uruchomieniu, potem normalnie zastosuj całą resztę.

terraform apply -target=module.eks_managed_nodes

Jedno polecenie, uruchomione raz, i błędne koło jajka i kury przerwane. Proste.

To polecenie jest czarnym charakterem tej historii. Odkrycie tego zajęło tylko piętnaście minut.

Rozdział 2 — Piętnaście minut donikąd

Celowany apply ruszył. Pojawił się VPC. Wstał control plane klastra. Zaczęła się budować grupa węzłów roboczych.

A potem stała. I stała. I po mniej więcej piętnastu minutach:

Error: waiting for EKS Node Group ... unexpected state 'CREATE_FAILED',
  wanted target 'ACTIVE'. last error: NodeCreationFailure:
  Instances failed to join the kubernetes cluster

Każdy, kto uruchamiał Kubernetesa na AWS, zna ten błąd i wie, że kłamie. „Instances failed to join” to objaw mniej więcej dziewięciu różnych przyczyn: złe security groups, brakujące polityki IAM węzła, brak tagów subnetów, zepsuta konfiguracja uwierzytelniania, zły obraz maszyny, wyłączony DNS w VPC.

Więc śledztwo ruszyło po zwykłej liście. Moduł VPC przeczytano linia po linii. DNS hostnames: włączone. DNS support: włączone. Internet gateway: zdefiniowany. NAT gateway: zdefiniowany, w publicznym subnecie z elastic IP. Prywatna tablica routingu z trasą domyślną wskazującą na niego, powiązana z każdym prywatnym subnetem. Rola IAM węzła z wszystkimi trzema wymaganymi politykami. Tagi subnetów poprawne.

Każdy podejrzany miał alibi. Konfiguracja, tak jak ją napisano, była całkowicie w porządku.

Co nasunęło dużo lepsze pytanie niż „co jest źle skonfigurowane?”

Co faktycznie zostało zbudowane?

Rozdział 3 — Droga, której nigdy nie zbudowano

Log apply wymienia każdy zasób, który tworzy. Przefiltrowanie go tylko pod moduł sieciowy dało podejrzanie krótką listę:

module.vpc.aws_vpc.vpc
module.vpc.aws_subnet.private_subnet

To wszystko. Cała lista.

Żadnego NAT gateway. Żadnego internet gateway. Żadnych tablic routingu. Żadnych publicznych subnetów. Konfiguracja definiowała je wszystkie. Terraform po prostu żadnego z nich nie zbudował.

Prywatne subnety, bez własnej tablicy routingu, po cichu przeszły na domyślną tablicę VPC — która kieruje ruch wewnątrz VPC i nigdzie więcej. Maszyny uruchomiły się idealnie. Po prostu nie mogły dotrzeć do endpointu API Kubernetesa, żeby się zarejestrować, ani do rejestru kontenerów, żeby pobrać oprogramowanie, które tę rejestrację wykonuje.

Nie były źle skonfigurowane. Były zamurowane.

      CO OPISYWAŁA KONFIGURACJA          CO FAKTYCZNIE ISTNIAŁO

      ┌──────────────────────┐           ┌──────────────────────┐
      │   prywatny subnet    │           │   prywatny subnet    │
      │   ┌──────────┐       │           │   ┌──────────┐       │
      │   │  węzeł   │       │           │   │  węzeł   │       │
      │   └────┬─────┘       │           │   └────┬─────┘       │
      │        │             │           │        │             │
      │   ┌────▼─────┐       │           │        X  brak trasy │
      │   │  route   │       │           │                      │
      │   │  table   │       │           │   (nic więcej nigdy  │
      │   └────┬─────┘       │           │    nie powstało)     │
      │        │             │           │                      │
      │   ┌────▼─────┐       │           │                      │
      │   │   NAT    │       │           │                      │
      │   └────┬─────┘       │           │                      │
      └────────┼─────────────┘           └──────────────────────┘
               │
           internet

Dlaczego więc Terraform je pominął?

Przez flagę z Rozdziału 1. -target nie znaczy „ten moduł i infrastruktura wokół niego”. Znaczy: zacznij od celu, idź w górę grafu zależności, zbuduj tylko to, co jest ściśle potrzebne do wytworzenia wejść celu.

Moduł klastra potrzebował ID subnetów. Wytworzenie ID subnetu wymaga VPC i subnetu. Nie wymaga NAT gateway — powiązanie tablicy routingu zależy od subnetu, a nie odwrotnie. Nie ma krawędzi zależności od „subnet istnieje” do „subnet ma dostęp do internetu”.

Ta krawędź istnieje tylko w czasie działania, a graf Terraforma nigdy o czasie działania nie słyszał.

Terraform zrobił więc dokładnie to, o co go poproszono. Zbudował klaster wewnątrz VPC bez wyjścia, zgłosił sukces dla wszystkiego, co utworzył, a konsekwencje zostawił, żeby wypłynęły piętnaście minut później jako timeout rejestracji węzłów.

Poprawka jest o jedną flagę dłuższa:

terraform apply -target=module.vpc -target=module.eks_managed_nodes

Werdykt: grupa węzłów z porażki po piętnastu minutach przeszła do stanu ACTIVE w niecałe trzy. Węzły dołączają szybko, kiedy jest droga.

Rozdział 4 — Apply z cudzą odznaką

Klaster stoi, węzły zdrowe. Została jedna rzecz: instalacja kontrolera GitOps. Potok ruszył i:

Error: installation failed
  * namespaces is forbidden: User ".../<the read-only role>/..."
    cannot create resource "namespaces" at the cluster scope

Wyglądało to na prostą lukę w uprawnieniach — tyle że uprawnienia skonfigurowano starannie i celowo. Potok używał dwóch osobnych tożsamości: roli tylko do odczytu do planowania w merge requestach, żeby każda gałąź mogła bezpiecznie podglądać zmiany, i roli z prawem zapisu do apply, zablokowanej tylko dla chronionych gałęzi. Rola zapisu miała pełny cluster-admin. Była poprawna.

A job apply używał roli zapisu po stronie AWS. Dało się to zweryfikować.

To jak rozmawiał z Kubernetesem jako ta tylko do odczytu?

Oto zwrot akcji. Potok stosuje standardowy bezpieczny wzorzec: plan do zapisanego pliku, przegląd, potem apply dokładnie tego przejrzanego pliku. Nic zaskakującego nie zostaje zastosowane, bo apply nie planuje ponownie.

Ale zapisany plan nie przechowuje tylko zamierzonych zmian. Przechowuje każdą wartość, którą Terraform przeczytał podczas planowania — łącznie z wynikami data source. A poświadczenie klastra pochodzi z data source, który tworzy token Kubernetesa, podpisując żądanie poświadczeniami AWS aktywnymi w chwili jego odczytu.

W czasie planu. Pod rolą tylko do odczytu.

Ten token trafia do pliku planu. Job apply otwiera plik i go odtwarza. Uwierzytelnia się w AWS jako rola zapisu, a potem rozmawia z Kubernetesem tokenem wybitym przez rolę planu i identyfikującym właśnie ją.

   JOB PLAN                                       JOB APPLY
   (tożsamość tylko do odczytu)                   (tożsamość z zapisem)
        │                                              │
        │ czyta poświadczenie klastra                  │
        │ ── podpisuje token jako ODCZYT ┐             │
        │                               ▼              │
        │                    ┌──────────────────┐      │
        └───────────────────►│ zapisany plik    │──────┘
                             │ planu (z tokenem)│      │
                             └──────────────────┘      │
                                                       ▼
                                        rozmawia z Kubernetesem
                                       jako rola TYLKO DO ODCZYTU
                                                  ✗

Reguły dostępu nigdy nie były złe. Apply po prostu nigdy nie używał tożsamości, której wszyscy się spodziewali.

Poprawka: plan zasilający prawdziwy apply ma działać pod tożsamością z zapisem, a plany w merge requestach zachowują tę tylko do odczytu. Ten sam podział, właściwy kierunek.

Jest jeszcze żądło na końcu. Te tokeny klastra wygasają po około piętnastu minutach. Krok apply stoi za przyciskiem ręcznej akceptacji — więc jeśli ktoś zrobi plan, pójdzie na lunch i wróci kliknąć „apply”, ten padnie na wygasłym poświadczeniu, choć nic się nie zmieniło. Trwałą poprawką jest przestać w ogóle wpiekać poświadczenie: skonfigurować providera tak, żeby pobierał świeży token w chwili, gdy jest potrzebny.

Rozdział 5 — Sprawa zamknięta

Kontroler zainstalował się w czterdzieści pięć sekund. Klaster gotowy.

Dwa złe zakręty warto odnotować, bo oba były pewne siebie i oba błędne:

„Kto tworzy klaster, automatycznie jest jego administratorem.” Powszechnie uważane za prawdę, a dla użytego tu modułu fałszywe — ustawienie dające twórcy dostęp administratora domyślnie jest wyłączone. Gdyby tego nie sprawdzono, wszystko zbudowałoby się poprawnie, a potem padło na samym ostatnim kroku, wymagając kolejnej pełnej przebudowy klastra. Wyłapane dzięki przeczytaniu wartości domyślnych modułu zamiast ufania folklorowi. Czyste szczęście w wyczuciu czasu.

Rola tylko do odczytu, która nie może wykonać swojej pracy. Rola planu początkowo dostała oczywiste uprawnienie klastra „podgląd wszystkiego”. Ale wbudowana rola view w Kubernetesie celowo wyklucza sekrety — a Helm przechowuje stan wydań w sekretach. Rola tylko do odczytu z oczywistą polityką po cichu nie może przeglądać wydań Helma. Jest druga, mniej oczywista polityka tylko do odczytu, która je obejmuje; to tej potrzebujesz.

Podsumowanie — Wskazówki dla kolegów śledczych

  1. Kiedy błąd wymienia komponent, sprawdź, czy ten komponent w ogóle został zbudowany. „X nie wystartował” i „X nigdy nie dostał tego, czego potrzebuje” dają identyczne objawy. Czytaj log apply pod kątem tego, co utworzono, a nie tylko tego, co zwróciło błąd.
  2. -target to skalpel i tnie więcej, niż myślisz. Buduje tylko to, co potrzebne do wytworzenia wejść celu — nie otaczającą infrastrukturę, od której ta rzecz zależy w czasie działania. Zależności, które twoja infrastruktura ma w trakcie działania, są niewidoczne dla grafu zależności zbudowanego z odwołań w konfiguracji. Celuj w całe moduły, które muszą istnieć, i traktuj każdy celowany apply jako podejrzany.
  3. Zapisany plan to migawka wszystkiego, co przeczytano, łącznie z poświadczeniami. Jeśli provider jest konfigurowany z data source, wartość tego data source — razem z tokenem — podróżuje w pliku planu. Rozdziel plan i apply na różne tożsamości, a możesz odkryć, że apply nosi odznakę planującego.
  4. Krótkotrwałe tokeny plus bramki ręcznej akceptacji to zła para. Wszystko, co wybija poświadczenie podczas planowania, działa na czas. Przycisk z człowiekiem w pętli łatwo go przeżyje.
  5. Weryfikuj wartości domyślne modułów, których nie napisałeś. „Oczywiście twórca dostaje admina” nic tu nie kosztowało tylko dlatego, że sprawdzono to przed drogim krokiem, a nie po nim.
  6. Piętnastominutowe pętle informacji zwrotnej karzą zgadywanie. Kiedy każda próba kosztuje kwadrans, najtańszym ruchem jest przestać stawiać hipotezy i pójść przeczytać, co faktycznie istnieje.

   PODEJŚCIA 1..4                        PODEJŚCIE 5
  ┌───────────────────────────┐         ┌───────────────────────────┐
  │ x brak poświadczeń        │         │ ✓ VPC + droga na zewnątrz │
  │ x duchy w state           │  ═════► │ ✓ węzły w <3 min          │
  │ x klaster zamurowany      │         │ ✓ kontroler zainstalowany │
  │ x cudza odznaka na apply  │         │   nudno, w 45 sekund      │
  └───────────────────────────┘         └───────────────────────────┘