Terraform w CI: jak testować plan i trzymać stan pod kontrolą

0
48
2/5 - (1 vote)

Nawigacja:

Cel użycia Terraform w CI: jasna intencja i oczekiwany efekt

Docelowy obraz jest prosty: Terraform uruchamia się wyłącznie w pipeline CI, plany zmian infrastruktury są przewidywalne, testowalne i weryfikowalne przed wdrożeniem, a stan infrastruktury pozostaje bezpieczny, współdzielony i pod kontrolą całego zespołu, a nie pojedynczego laptopa. Każda zmiana przechodzi udokumentowaną ścieżkę: commit → plan → review → kontrola polityk → świadome apply z użyciem dokładnie tego samego planu.

Jeśli obecny proces zakłada jakiekolwiek ręczne uruchomienia terraform apply poza CI lub trzymanie stanu lokalnie, to pierwszym celem powinno być odcięcie tej ścieżki i wymuszenie przechodzenia wszystkich zmian przez pipeline z jawnymi punktami kontrolnymi.

Frazy powiązane z tematem: terraform w ci cd, testowanie terraform plan, terraform remote state, blokady stanu terraform, tfsec terratest checks, terraform w github actions, terraform w gitlab ci, approve terraform plan, zarządzanie backendem terraform, bezpieczeństwo terraform state, strategie workspace terraform, audyt pipeline terraform

Szczegółowy widok stacji elektroenergetycznej z transformatorami i liniami
Źródło: Pexels | Autor: Pixabay

Dlaczego Terraform w CI wymaga innych kryteriów niż lokalne uruchomienia

Różnica między „działa u mnie” a „działa w pipeline”

Lokalne uruchomienia Terraform na laptopach są wygodne na starcie, ale praktycznie zawsze zawodzą, gdy pojawia się zespół, wiele środowisk i wymagania audytowe. Konfiguracja zależy wtedy od konkretnej maszyny: wersji Terraform, wersji providerów, dostępnych zmiennych środowiskowych, a nawet od kolejności wcześniejszych komend. To środowisko jest trudne do odtworzenia, a każda różnica może przełożyć się na inny plan.

Pipeline CI działa odwrotnie: uruchomienia są deterministyczne i odtwarzalne. Wersje binariów można przypiąć, zależności są instalowane każdorazowo, a środowisko jest definiowane w kodzie (konfiguracja build image, kontener, job). Dodatkowo każdy krok zostawia ślad: logi, artefakty, wyniki testów. Dzięki temu da się zidentyfikować, kto i kiedy wygenerował plan, kto go zatwierdził i w jakich warunkach został wykonany.

Przepaść między „działa u mnie” a „działa w pipeline” pogłębia się przy incydentach. Ręczne apply z laptopa rzadko ma dobrą obserwowalność: brak centralnych logów, brak standardowych notatek operacyjnych, brak spójnego timestampu w systemach monitoringu. W CI każdy run jest osobnym bytem: ma ID, logi, status, często też powiązany ticket lub merge request.

Jeśli główna ścieżka zmian zakłada lokalne uruchomienia, a CI używane jest „od święta” lub tylko do terraform validate, to trudno mówić o realnej kontroli nad infrastrukturą. Plan może być inny w CI i na laptopie, a stan – rozjechany z powodu ręcznych korekt.

Typowe źródła rozjazdów między lokalnym a CI

Najczęstsze problemy z Terraform w CI pojawiają się tam, gdzie nie zdefiniowano minimalnych standardów środowiska wykonawczego. Poniżej krytyczne punkty:

  • Wersja Terraform – różnice typu 0.14 vs 1.5 potrafią zmienić sposób działania providerów, interpretację typów danych czy format pliku state; brak required_version w kodzie to sygnał ostrzegawczy.
  • Wersje providerów – automatyczne odświeżanie providerów lokalnie (np. przez IDE) kontra przypięte wersje w CI powodują, że plan generowany na laptopie może znacząco różnić się od tego z pipeline.
  • Zmiennie środowiskowe – lokalne eksperymenty z TF_VAR_* lub niestandardowe profile chmurowe (np. AWS_PROFILE) nie są odwzorowane w CI, co zmienia kontekst dostępu lub docelowy account/project.
  • Brak blokad stanu – uruchomienie terraform apply lokalnie w tym samym czasie, gdy CI robi plan lub apply, prowadzi do konfliktów zapisu w state; bez mechanizmu lockingu istnieje wysokie ryzyko uszkodzenia stanu.
  • Ręczna manipulacja plikiem state – lokalne terraform state rm lub przenoszenie plików terraform.tfstate między katalogami jest niewidoczne dla CI i powoduje trudne do wytropienia rozjazdy.

Jeśli w logach zdarzają się sytuacje typu „u mnie plan jest czysty, a w CI masa zmian” lub odwrotnie, to wskaźnik, że środowiska nie są spójne i konieczne jest wprowadzenie twardych wymagań: przypięte wersje, ujednolicone providerzy, bezwzględny zakaz lokalnego apply.

Minimalne wymagania jakościowe dla Terraform w CI

Aby Terraform w CI był czymś więcej niż tylko „ładnym validate”, proces powinien spełniać określone kryteria. Minimalny zestaw wygląda następująco:

  • Reproducibility (powtarzalność) – pełna definicja wersji Terraform i providerów w kodzie (bloki required_version i required_providers), brak „pływających” wersji typu >= bez górnej granicy w krytycznych miejscach.
  • Izolacja środowisk – osobne state dla każdego środowiska (dev/stage/prod), najlepiej także osobne konta/projekty chmurowe, z wyraźnie oddzielonymi pipeline’ami.
  • Weryfikowalność – plan generowany w CI jest zapisywany jako artefakt i to dokładnie ten artefakt jest później używany do apply; recenzent ma stały punkt odniesienia.
  • Kontrola dostępu – tylko pipeline (techniczne konto) ma prawa do apply na produkcji, a członkowie zespołu mogą co najwyżej uruchamiać plan i przeglądać wynik.
  • Mechanizm blokad stanu – backend zapewniający locking (np. S3 + DynamoDB, GCS, Azure Storage z lockami, Terraform Cloud) jako twardy warunek uruchamiania zmian.

Jeśli którekolwiek z tych kryteriów nie jest spełnione, ryzyko nieprzewidywalnych zmian rośnie gwałtownie. Przy awarii zwykle wychodzi na jaw, że brakowało właśnie któregoś z tych „nudnych” fundamentów.

Krótki przykład awarii spowodowanej ręcznym apply

Częsty scenariusz: zmiana security group w AWS, pilne otwarcie portu dla diagnostyki. Inżynier robi terraform apply z laptopa na branchu, bo „CI chwilowo zajęte”. Otwiera port, problem z aplikacją znika. State zapisuje się z tymczasowym otwarciem.

Następnego dnia pipeline CI uruchamia plan z głównego brancha, który nie zna ręcznej zmiany – w planie widać „zamykanie” portu, ale nikt nie łączy faktów. apply idzie dalej, port się zamyka, aplikacja znów ma problem, tym razem bez jasnego powodu. Śledztwo pokazuje dwie niezależne ścieżki modyfikacji stanu: CI i lokalną.

Jeśli proces dopuszcza takie ręczne zmiany, de facto nie ma jednego źródła prawdy. To prosty test: jeśli terraform apply spoza CI jest nadal dozwolone na produkcji, to CI pełni rolę dekoracji, a nie realnego kontrolera zmian.

Punkt kontrolny: jeśli proces zakłada apply z laptopa, to nie istnieje realny CI dla Terraform ani pełna kontrola nad stanem. Pierwszym priorytetem powinno być zablokowanie ręcznych zmian i przeniesienie wszystkich operacji do pipeline.

Architektura: jak ułożyć repozytoria, moduły i środowiska pod CI

Repo monolityczne vs rozdzielone katalogi vs multi-repo

Układ repozytoriów i katalogów ma bezpośredni wpływ na przejrzystość pipeline’ów Terraform. Kluczowe kryteria to: łatwość znalezienia, który pipeline odpowiada za dany state, zakres wpływu pojedynczej zmiany oraz izolacja środowisk.

Popularne podejścia:

  • Repo monolityczne (single-repo) – jedno repo z katalogami envs/ i modules/. Każde środowisko (np. envs/dev, envs/prod) ma osobny katalog i osobny state. Zaleta: pojedynczy punkt wejścia, spójne moduły. Wada: przy dużej organizacji repo zaczyna puchnąć, a pipeline’y wymagają skomplikowanej logiki path-based.
  • Rozdzielone katalogi per domena – nadal jedno repo, ale np. network/, app/, data/, a w każdym z nich envs/dev, envs/prod. Zaleta: PSR (podział zgodnie z domeną odpowiedzialności), łatwiej przypisać zespoły. Wada: rośnie liczba katalogów i pipeline’y muszą reagować na wiele drzew.
  • Multi-repo – osobne repozytoria dla głównych domen (np. infra-network, infra-app, infra-data). Zaleta: minimalny zakres zmian, jasne właścicielstwo. Wada: zależności między repo wymagają dodatkowych mechanizmów orkiestracji (np. pipeline orchestratora).

Dla większości zespołów dobrym startem jest pojedyncze repo z klarowną strukturą envs/ i modules/. Multi-repo ma sens dopiero, gdy skala i organizacja zespołów powoduje kolizje commitów i trudności w zarządzaniu uprawnieniami do jednego repo.

Jeśli przy zwykłym pytaniu „który pipeline zmienia VPC w produkcji?” pojawia się konsternacja lub konieczność szukania w kilku projektach, to architektura repozytoriów wymaga uporządkowania.

Struktura katalogów: envs, modules, regiony i tenanci

Przykładowa, uporządkowana struktura pod CI może wyglądać tak:

infra/
  modules/
    network/
    app/
    monitoring/
  envs/
    dev/
      network/
      app/
    stage/
      network/
      app/
    prod/
      network/
      app/

Każdy katalog w envs/<env>/<domena> stanowi osobną jednostkę wykonawczą dla Terraform – z własnym backendem, własnym plikiem provider.tf i oddzielnym pipeline’em lub jobem. Krytyczna zasada: jedno środowisko = jeden state = jeden pipeline dla danej domeny. Unikanie współdzielonego state między katalogami redukuje ryzyko, że zmiana w jednym miejscu dotknie nieoczekiwanie innej części infrastruktury.

Dla środowisk wieloregionowych warto rozszerzyć schemat:

envs/
  prod/
    eu-west-1/
      network/
      app/
    us-east-1/
      network/
      app/

Region wchodzi wtedy jako dodatkowy poziom katalogu, co pozwala powiązać pipeline’y z konkretnym regionem i uniknąć przypadkowych cross-region apply. W organizacjach multi-tenant (np. wielu klientów) analogiczny poziom może reprezentować tenant: envs/prod/customer-a/app.

Sygnał ostrzegawczy: zmiana w jednym katalogu (np. envs/dev/app) uruchamia pipeline, który modyfikuje również inny state (np. produkcyjny). Jeśli nie jesteś w stanie na podstawie ścieżki katalogu jednoznacznie powiedzieć, do którego state i środowiska odnosi się Terraform, to struktura katalogów jest za mało jednoznaczna.

Moduły współdzielone i ich wersjonowanie

Użycie modułów współdzielonych jest jednym z głównych motorów skalowania Terraform. To jednocześnie miejsce, w którym błędne decyzje wersjonowania potrafią zniszczyć przewidywalność planów. Moduły powinny być traktowane jak biblioteki aplikacyjne, z jasnym wersjonowaniem i kontrolowaną dystrybucją.

Kluczowe zasady:

  • Brak odniesień do main w produkcji – odwoływanie się do gałęzi main w module (np. z Git) powoduje, że każdy merge może zmienić zachowanie produkcyjnego planu bez zmian w katalogu środowiska; to poważna luka w kontroli zmian.
  • Tagi git lub registry – moduły powinny mieć wersje w postaci tagów git (np. v1.2.3) lub być publikowane w registry (Terraform Registry, prywatne registry), a środowiska powinny używać konkretnych numerów wersji.
  • Strategia aktualizacji – zmiana wersji modułu w środowisku powinna przechodzić pełny cykl: dev → stage → prod, z odrębnymi planami i review na każdym etapie; automatyczne „podciąganie” wersji jest sygnałem ostrzegawczym.

Przykładowa definicja modułu z przypiętą wersją:

module "app" {
  source  = "git::ssh://git@github.com/org/terraform-modules.git//app?ref=v1.4.0"
  env     = "prod"
  region  = "eu-west-1"
}

W takim układzie pipeline CI dla envs/prod/app zawsze użyje tej samej wersji modułu, dopóki ktoś świadomie nie zmieni ref= w kodzie. To gwarantuje, że plan nie zmieni się „sam z siebie” po pushu do modułu, który nie został jeszcze zaktualizowany w środowisku.

Jeśli aktualnie w kodzie produkcyjnym występuje source = "git::...//app?ref=main" lub bez ref, to minimum bezpieczeństwa nie jest spełnione i pierwszym krokiem powinna być migracja na wersje tagowane.

Punkt kontrolny architektury repozytorium

Dobrą praktyczną listą kontrolną przy projektowaniu repo i modułów jest:

  • czy dla każdego katalogu z Terraformem da się jednoznacznie wskazać odpowiadający mu state i pipeline,
  • Izolacja środowisk i domen: kiedy rozdzielać state

    Decyzja o liczbie plików stanu nie powinna wynikać z gustu, tylko z twardych kryteriów: granic odpowiedzialności zespołów, częstotliwości zmian oraz ryzyka „lawinowych” planów.

    Typowe powody rozdzielania state na mniejsze jednostki:

  • Inny cykl życia – zasoby, które mają żyć krócej (np. środowiska testowe, tymczasowe laby) nie powinny dzielić state z infrastrukturą bazową (VPC, peeringi, sieć).
  • Inny właściciel – jeśli za sieć odpowiada zespół A, a za aplikację zespół B, wspólny state jest proszeniem się o konflikty i przypadkowe usunięcia.
  • Różne profile uprawnień – administrator DB niekoniecznie powinien mieć możliwość modyfikowania routingu sieciowego i odwrotnie.
  • Zasięg awarii – pojedynczy błąd w module aplikacyjnym nie powinien unieważniać całego planu zawierającego też krytyczne elementy sieci.

Sygnał ostrzegawczy: plan w katalogu „app” wykazuje zmiany w VPC, subnetach lub globalnych politykach. To znak, że state jest zbyt szeroki i przekracza naturalne granice domeny.

Jeśli plan regularnie ma kilkaset zasobów, a recenzent nie jest w stanie w rozsądnym czasie przejrzeć listy zmian, to state jest zbyt rozległy. Pierwszym krokiem powinna być analiza granic domen i wydzielenie stałych fundamentów (sieć, wspólne usługi) do osobnych katalogów i backendów.

Dłoń trzymająca naklejkę z napisem DevOps na tle pleneru
Źródło: Pexels | Autor: RealToughCandy.com

Backend i stan: wybór, konfiguracja i zasady bezpieczeństwa

Kryteria wyboru backendu dla Terraform w CI

Backend w CI pełni rolę „rejestru” infrastruktury. Minimum to wsparcie dla blokad (locking), wersjonowania oraz kontroli dostępu na poziomie organizacji. Decyzja „gdzie trzymamy state” powinna przejść przez kilka filtrów:

  • Locking rozproszony – czy backend zapewnia twardą blokadę dla jednoczesnych apply (S3 + DynamoDB, GCS + locking, Azure Blob + lease, Terraform Cloud/Enterprise).
  • Wersjonowanie plików – możliwość odtworzenia poprzedniego stanu po awarii (versioning w bucketach, built-in history w Terraform Cloud).
  • Integracja z IAM – spójne przypisywanie uprawnień technicznym kontom CI oraz ludziom (role, polityki, audyt zapytań).
  • Bezpieczeństwo transportu i spoczynku – szyfrowanie at rest (KMS, CMEK) i wymuszony TLS.
  • Łatwość odczytu diagnostycznego – dostęp do pliku state w trybie read-only, gdy trzeba ręcznie przeanalizować problem (bez mieszania tego z możliwością edycji).

Jeśli backend nie wspiera locking lub wersjonowania, to przy intensywnym CI prędzej czy później dojdzie do kolizji stanów. W takim scenariuszu nawet najlepsze praktyki kodowe nie ochronią przed utratą spójności.

Przykładowa konfiguracja backendu dla AWS (S3 + DynamoDB)

W środowiskach AWS klasyczne minimum to bucket S3 z włączonym wersjonowaniem oraz tabela DynamoDB dla blokad. Szkic konfiguracji w katalogu środowiska może wyglądać tak:

terraform {
  backend "s3" {
    bucket         = "org-terraform-state"
    key            = "prod/eu-west-1/app/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "org-terraform-locks"
    encrypt        = true
  }
}

Kilka praktycznych kryteriów dla takiego backendu:

  • bucket ma włączone versioning oraz najlepiej blokadę publicznego dostępu,
  • polityka S3 ogranicza odczyt/zapis tylko do ról/kont używanych przez CI i wymaganych administratorów,
  • tabela DynamoDB ma klucz partycjonujący np. LockID i nie jest współdzielona do innych celów,
  • CI używa osobnego role ARN lub access key, a ludzie – innych uprawnień (rozdzielenie ścieżek audytu).

Jeśli ten sam bucket służy jednocześnie do logów aplikacyjnych, artefaktów CI i stanu Terraform, to przy incydencie analiza śladów staje się dużo trudniejsza. Lepszym standardem jest dedykowany bucket per typ danych.

Terraform Cloud / Enterprise jako backend sterujący

Coraz częściej backendem staje się Terraform Cloud/Enterprise, co zmienia rolę klasycznego CI – zamiast wykonywać bezpośrednio apply, pipeline wyzwala run w TFC. Taki model daje kilka korzyści:

  • centralne zarządzanie workspace’ami i stanem,
  • wbudowany locking, wersjonowanie, history changes i logi,
  • możliwość ręcznego „confirm apply” z GUI przy newralgicznych środowiskach,
  • łatwiejsze rozdzielenie plan i apply pomiędzy role (Dev vs Ops).

Sygnał ostrzegawczy: workspace Terraform Cloud zawiera zasoby z kilku katalogów, a zmiany w jednym repo wyzwalają run, który dotyka obszarów zarządzanych też z innych miejsc. W takim układzie tracisz zasadę „jeden katalog = jeden workspace = jeden state”.

Jeśli Terraform Cloud jest używany tylko jako backend (z CLI-driven runs), a CI i tak wykonuje cały cykl init/plan/apply, to warto przemyśleć, czy nie przełączyć się na pełny model TFC runs i uprościć pipeline’y CI do roli orchestratora.

Polityka dostępu do stanu: kto może czytać, kto może pisać

State zawiera nie tylko metadane, ale często poufne informacje (hasła, tokeny, identyfikatory). Konfigurując dostęp, trzeba traktować go jak inny wrażliwy sekret, a nie jak zwykły plik konfiguracyjny.

  • Zapisy do state – tylko konta techniczne CI (i ewentualnie narzędzia automatyzacji) powinny mieć prawo zapisu. Dostęp dla ludzi do apply to wyjątek, nie norma.
  • Odczyt state – rola read-only dla administratorów oraz osób odpowiedzialnych za debugging. Dobrze, jeśli jest to wyraźnie oddzielna ścieżka dostępu.
  • Brak kopiowania state „na bok” – ściąganie lokalnej kopii i ręczne edytowanie to prosty przepis na desynchronizację. Każda taka operacja powinna być sygnałem, że brakuje narzędzi diagnostycznych w CI.

Punkt kontrolny: jeżeli terraform apply z laptopa jeszcze działa, to dostęp do zapisu state jest zbyt szeroki. Zamknięcie tej furtki jest ważniejsze niż dodanie kolejnej reguły w security group.

Rotacja i odzyskiwanie stanu

Awaria backendu lub uszkodzony plik state w środowisku produkcyjnym to sytuacja krytyczna. Aby nie kończyła się ręcznym „sklejaniem” stanu z exportów API chmury, potrzebny jest minimum plan odzyskiwania:

  • Automatyczne backupy – wykorzystanie versioningu w bucketach lub natywnej historii Terraform Cloud jako głównego źródła przywracania.
  • Procedura rollbacku – opisany krok po kroku sposób przywrócenia poprzedniej wersji pliku state i powiązanych kluczy KMS/sekretów.
  • Regularne testy – choćby kwartalne sprawdzenie, czy z wersji sprzed X dni da się odtworzyć state w osobnym, testowym workspace.

Jeśli jedyny plan na uszkodzony state to „zobaczymy, co się da zrobić, jak się wydarzy”, to realnie planu nie ma. Przy pierwszej większej awarii pipeline Terraform zostanie wyłączony „na wszelki wypadek”, co zwykle kończy się powrotem do ręcznych zmian.

Nowoczesna metalowa maszyna zainstalowana w hali produkcyjnej
Źródło: Pexels | Autor: cang hai

Podstawowy pipeline Terraform w CI: szkielet i minimalne kroki

Minimalna sekwencja kroków w pipeline

Nawet najprostszy pipeline musi spełniać kilka warunków, żeby mógł być traktowany jako kontroler zmian, a nie tylko skrypt uruchamiany na serwerze buildów. Minimalny zestaw kroków to:

  1. Checkout kodu – pobranie konkretnego commitu, bez nadpisywania plików lokalnymi tajemnicami.
  2. Konfiguracja środowiska – ustawienie zmiennych środowiskowych z sekretnymi danymi (credentials, tokeny), zwykle z wbudowanego storage CI.
  3. Terraform init – z użyciem backendu wskazanego w kodzie, bez nadpisywania go parametrami z zewnątrz „na stałe”.
  4. Terraform validate – szybka weryfikacja syntaktyczna i podstawowa kontrola błędów.
  5. Terraform plan – zapisany do pliku artefaktu (np. plan.out) oraz zamieniony na czytelny diff w logach lub w komentarzu do MR/PR.
  6. Manualne zatwierdzenie / gate – krok, który wymaga świadomej decyzji (approve) zanim dojdzie do apply, przynajmniej dla środowisk wyższych niż dev.
  7. Terraform apply – wykonywany wyłącznie z wcześniej wygenerowanego planu, bez ponownego liczenia go „na żywo”.

Sygnał ostrzegawczy: pipeline wywołuje terraform apply bezpośrednio z parametrem -auto-approve po plan, bez etapu ręcznego review. Taki proces zdejmuje z recenzentów realną odpowiedzialność, bo nikt nie ma okazji zweryfikować planu przed wejściem zmian.

Oddzielenie pipeline’ów: plan na MR/PR vs apply na main

Dobrą praktyką jest rozdzielenie pipeline’ów ze względu na zdarzenie w repozytorium:

  • Pipeline „plan” na merge request / pull request – uruchamia się przy każdej propozycji zmiany, liczy plan i publikuje go jako komentarz lub artefakt do przeglądu.
  • Pipeline „apply” na merge do głównej gałęzi – startuje dopiero po zmergowaniu, korzysta z tego samego katalogu, ale używa świeżego kodu z main i generuje nowy plan, który po zatwierdzeniu jest aplikowany.

Między tymi dwoma etapami mogą istnieć różnice (np. kolejne commity), dlatego istotne jest powiązanie ich wspólnymi kryteriami:

  • zmiany w main przechodzą przez obowiązkowy pipeline planujący,
  • development nie może pominąć etapu plan na MR/PR poprzez bezpośredni commit do main,
  • każdy apply jest śledzony do konkretnego commitu i merge requestu, z którym jest powiązany.

Jeśli merge do main jest możliwy bez zielonego pipeline’u planującego, to w praktyce można wprowadzić nieprzewidziane zmiany bez recenzji. Taka luka zazwyczaj wychodzi dopiero przy pierwszym krytycznym incydencie.

Mapowanie katalogów na joby w CI

Im bardziej jednorodna jest struktura katalogów, tym prostsza staje się automatyzacja uruchamiania jobów. Typowy wzór:

  • job Terraform uruchamia się tylko dla katalogów, w których zaszły zmiany,
  • reguły typu paths: (GitLab CI) lub paths-ignore: (GitHub Actions) wskazują na konkretne envs/<env>/<domena>,
  • zmiana modułu (w modules/) może uruchomić pipeline testowy modułu, ale nie powinna automatycznie apply w produkcji.

Przykładowo, w GitHub Actions można opisać job dla katalogu envs/prod/app tak:

on:
  push:
    branches: [ main ]
    paths:
      - 'envs/prod/app/**'

Punkt kontrolny: jeżeli zmiana w module powoduje automatyczny apply na produkcji bez jawnej modyfikacji kodu w katalogu środowiska, to brakuje izolacji między rozwojem modułów a wdrożeniem. Minimum to wymóg zmiany referencji wersji modułu w katalogu produkcyjnym.

Przekazywanie sekretów i poświadczeń do pipeline’u

Najczęściej popełnianym błędem jest mieszanie konfiguracji środowiska (credentials, tokeny) z kodem Terraform. W CI obowiązuje kilka żelaznych zasad:

  • poświadczenia chmurowe są przechowywane w sekretach CI (GitLab CI variables, GitHub Actions secrets, Jenkins Credentials), nie w .tfvars w repo,
  • różne środowiska używają różnych kont/rol (np. osobne role IAM dla dev/stage/prod), nawet jeśli działają w jednym tenantcie,
  • sekrety są przekazywane przez zmienne środowiskowe lub provider-specific mechanisms (np. AWS_PROFILE, GOOGLE_APPLICATION_CREDENTIALS), nie wpisywane wprost w providerach Terraform.

Sygnał ostrzegawczy: ten sam zestaw kluczy/poświadczeń używany jest do zarządzania dev, stage i prod. To jednocześnie powiększa zasięg błędu i utrudnia audyt, kto wykonał którą operację.

Przykładowy szkielet jobu Terraform (GitLab CI)

Szkic bazowego jobu można uznać za punkt odniesienia przy audycie pipeline’ów:

Przykładowy szkielet jobu Terraform (GitLab CI) – kontynuacja

Rozsądny szablon jobu w GitLab CI powinien rozróżniać tryb planujący od trybu aplikującego, a jednocześnie dzielić wspólną logikę inicjalizacji i walidacji.

variables:
  TF_ROOT: "envs/prod/app"
  TF_PLAN_FILE: "plan.out"

.terraform_base:
  image: hashicorp/terraform:1.6.6
  variables:
    TF_IN_AUTOMATION: "true"
  before_script:
    - cd "$TF_ROOT"
    - terraform --version
    - terraform init -input=false
    - terraform validate

terraform_plan:
  extends: .terraform_base
  stage: plan
  script:
    - terraform plan -input=false -no-color -out="$TF_PLAN_FILE"
    - terraform show -no-color "$TF_PLAN_FILE" > plan.txt
  artifacts:
    paths:
      - "$TF_ROOT/$TF_PLAN_FILE"
      - "$TF_ROOT/plan.txt"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "$TF_ROOT/**"

terraform_apply:
  extends: .terraform_base
  stage: apply
  script:
    - test -f "$TF_PLAN_FILE" || terraform plan -input=false -no-color -out="$TF_PLAN_FILE"
    - terraform apply -input=false -auto-approve "$TF_PLAN_FILE"
  dependencies:
    - terraform_plan
  when: manual
  allow_failure: false
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      changes:
        - "$TF_ROOT/**"

W tym układzie większość logiki siedzi w szablonie .terraform_base, a poszczególne joby różnią się tylko celem. Kluczowy element to reguły rules i changes, które pilnują, że plan i apply odpalają się tylko wtedy, kiedy w danym katalogu zaszła zmiana.

Punkt kontrolny: jeżeli w jobach Terraform script: zawiera więcej „magicznych” wywołań sed/awk niż samego Terraform, to pipeline zaczyna żyć własnym życiem. Minimum to wydzielenie tej logiki do osobnego skryptu w repo i trzymanie Terraform w centrum jobu.

Stabilność wersji Terraform i providerów w CI

Jednym z częstszych źródeł niespodzianek w planach jest różnica wersji między lokalnymi środowiskami a CI. Zamiast liczyć na domyślne wersje obrazów, lepiej wymusić konkretny zakres:

  • twardo przypisana wersja Terraform w obrazie CI (np. hashicorp/terraform:1.6.6 zamiast latest),
  • używanie required_version w terraform {} oraz required_providers z jawnymi wersjami lub wąskim zakresem,
  • blokada automatycznego podbijania providerów w CI bez świadomej zmiany w kodzie (np. przez dependabot z odpowiednią polityką).

Przy większej liczbie środowisk rozsądne jest posiadanie jednego miejsca z „matrycą” wersji (np. katalog /tooling z Dockerfile dla obrazu terraformowego), z którego korzystają wszystkie pipeline’y. Zmiana wersji Terraform staje się wtedy osobnym, świadomym wdrożeniem.

Sygnał ostrzegawczy: pliki .terraform.lock.hcl są regularnie kasowane w CI lub ignorowane w repo. Jeśli plan nagle „puchnie” mimo braku zmian w kodzie, to najpierw trzeba sprawdzić, czy provider nie został po cichu zaktualizowany.

Testowanie planu: co faktycznie da się sprawdzić w CI

Granice tego, co mówi plan

Plan Terraform to tylko przewidywanie zmian, oparte na aktualnym state i odczycie API providerów. Nie jest testem działania infrastruktury, ale dobrze zorganizowany pipeline może z niego wyciągnąć więcej niż tylko listę modyfikowanych zasobów:

  • skalę zmian (liczbę create/update/destroy),
  • typy modyfikowanych zasobów (np. zmiany w security groupach vs tagi),
  • zmiany potencjalnie destrukcyjne (np. force replacement baz danych, load balancerów).

Jeżeli pipeline traktuje plan wyłącznie jako tekst do „przeklikania” przez recenzenta, to kluczowe ostrzeżenia giną w natłoku szumu. Minimum to takie sformatowanie i przefiltrowanie planu, żeby najważniejsze zmiany były łatwo widoczne w MR/PR.

Formatowanie i publikowanie planu do recenzji

Plan powinien być czytelny dla ludzi, nawet jeśli Terraform generuje go w formacie binarnym do apply. Typowy schemat:

  1. generacja binarnego plan.out do artefaktu,
  2. konwersja do formatu tekstowego (terraform show -no-color plan.out > plan.txt),
  3. opcjonalnie – parsowanie i filtrowanie planu na potrzeby komentarza do MR/PR.

W GitLab CI lub GitHub Actions można dodać krok, który publikuje skróconą wersję planu jako komentarz, np. z podziałem na kategorie zmian:

terraform show -json plan.out > plan.json
python scripts/summary_from_plan.py plan.json > plan_summary.md

Skrypt może wyciągnąć liczby zasobów do utworzenia/zniszczenia i listę newralgicznych typów (RDS, IAM, VPC). Recenzent nie musi przekopywać się przez setki linii, żeby zobaczyć, że jedna zmiana usuwa całą subnetę.

Punkt kontrolny: jeśli plan jest dostępny wyłącznie jako artefakt do pobrania, a nie jako od razu widoczny fragment MR/PR, to realna weryfikacja będzie rzadkością. Minimum to tekstowa wersja planu w logach lub komentarzu, łatwo dostępna bez dodatkowych kliknięć.

Automatyczne reguły jakości oparte o plan

Część ryzyka można zdjąć z ludzi, dodając proste, automatyczne bramki analizujące plan. Nie muszą być skomplikowane – bardziej liczy się konsekwencja ich egzekwowania niż zaawansowanie logiki.

Przykładowe reguły:

  • blokada planu, jeśli następuje usunięcie zasobów produkcyjnych poza zatwierdzoną listą (np. tag deletable=true),
  • oznaczanie pipeline’u jako wymagającego dodatkowej akceptacji, jeśli liczba destroy przekracza określony próg,
  • fail jobu, jeśli określony typ zasobu jest tworzony bez wymaganych tagów lub konfiguracji (np. zasoby bez cost_center w tagach).

Minimalny, a często skuteczny krok to prosty parser JSON planu, który sprawdza liczbę operacji delete/replace i wprowadza osobny status pipeline’u: „małe zmiany / duże zmiany / destrukcyjne zmiany”.

Sygnał ostrzegawczy: każdy plan, niezależnie od skali, trafia do tego samego, jednolitego procesu zatwierdzania. Jeżeli poprawka etykiety i usunięcie klastra bazodanowego mają identyczny workflow, to prędzej czy później duża zmiana przejdzie „przy okazji” drobnych.

Integracja z narzędziami policy-as-code

Plan w formacie JSON pozwala spiąć Terraform z narzędziami typu policy-as-code. Dzięki temu część polityk bezpieczeństwa i zgodności można wymusić jeszcze przed apply, a nie dopiero w audycie post factum.

Typowe opcje:

  • OPA / Conftest – reguły w Rego sprawdzające struktury danych z planu,
  • Sentinel (Terraform Cloud) – polityki egzekwowane bezpośrednio w platformie backendowej,
  • Checkov / tfsec / Terrascan – skanowanie kodu Terraform i czasem planu pod kątem misconfigów.

Kluczowa jest kolejność: najpierw validate, potem skan kodu, dopiero potem plan i polityki oparte o plan. W przeciwnym razie pipeline zaczyna odpytywać API chmury w sytuacji, gdy błąd można było wykryć już na poziomie samych plików .tf.

Punkt kontrolny: jeżeli polityki bezpieczeństwa istnieją wyłącznie w dokumentach, a nie w postaci automatycznych testów w CI, to przy presji czasu dokument przestaje mieć znaczenie. Minimum to choć jeden policy-check w pipeline, który faktycznie potrafi zablokować merge.

Symulowanie różnych ścieżek w CI: dry-run apply i testowe workspace’y

W niektórych przypadkach sam plan nie wystarcza do oceny skutków zmian. Dobrym kompromisem jest wykorzystanie osobnych workspace’ów lub kont chmurowych, w których infrastruktura może być tworzona „na próbę”, bez ryzyka dla produkcji.

Praktyczne warianty:

  • oddzielny workspace Terraform z tym samym kodem, ale innym backendem i nazwami zasobów (np. prefiks sand-),
  • osobny projekt/tenant w chmurze z minimalnymi kwotami, gdzie można przeprowadzić pełne apply,
  • krótkotrwały test-e2e w ramach pipeline’u, który po apply uruchamia scenariusze testowe i na końcu usuwa zasoby.

Taki „miękki” staging ma sens zwłaszcza dla zmian w low-levelowych komponentach sieciowych, bazach danych czy IAM – wszędzie tam, gdzie plan nie pokaże, czy np. aplikacja naprawdę będzie miała dostęp tam, gdzie powinna.

Sygnał ostrzegawczy: staging istnieje tylko formalnie, ale w praktyce jest zarządzany innym kodem niż produkcja. Jeśli plan na stagingu nie odpowiada planowi na produkcji, to nie jest to środowisko testowe, tylko zupełnie inna infrastruktura.

Testy statyczne kodu Terraform przed planem

Część problemów można wyłapać jeszcze zanim Terraform sięgnie po state i API providerów. Pipeline powinien zawierać warstwę testów statycznych, działających na samym kodzie:

  • formatowanieterraform fmt -check jako twarda bramka formatowania,
  • linting – narzędzia typu tflint z własną konfiguracją reguł,
  • skan bezpieczeństwa – Checkov/tfsec, najlepiej z listą reguł obowiązkowych i opcjonalnych.

Układ etapów:

  1. fmt / lint / security scan,
  2. validate,
  3. plan,
  4. policy-as-code (na planie),
  5. apply.

Punkt kontrolny: jeżeli plan jest generowany mimo tego, że lint lub skaner bezpieczeństwa sygnalizuje poważne problemy, to pipeline traktuje te narzędzia jako dekorację. Minimum to konfiguracja, w której krytyczne reguły skutkują przerwaniem pipeline’u przed planem.

Testy modułów infrastruktury w izolacji

Dużo awarii wynika z błędów w modułach, które są później wielokrotnie używane w różnych środowiskach. Zanim moduł trafi do głównego repo infrastruktury, można go przetestować podobnie jak bibliotekę kodu aplikacyjnego.

Elementy takiego podejścia:

  • repozytorium modułu z osobnym pipeline’em,
  • scenariusze testowe, które tworzą minimalny stack z użyciem modułu (np. VPC + 1 instancja),
  • automatyczny terraform destroy na końcu pipeline’u, aby uniknąć „śmieciowej” infrastruktury.

Moduł po przejściu takiego „egzaminu” jest oznaczany wersją i dopiero ta wersja jest referencją w głównym repo środowisk. Błędy przestają się powielać we wszystkich projektach naraz.

Sygnał ostrzegawczy: moduły są zmieniane bez bumpowania wersji (np. source = "git::...//modules/app?ref=main"). Jeśli plan w produkcji niespodziewanie rośnie mimo braku zmian w katalogu środowiska, zwykle oznacza to, że moduł „pod spodem” został zaktualizowany.

Testy po apply: sanity checks i smoke testy

Plan i apply to połowa historii. Druga połowa to sprawdzenie, czy infrastruktura faktycznie działa. Nawet proste sanity checks w pipeline po apply potrafią szybko wychwycić oczywiste błędy.

Przykładowe kontrole:

  • sprawdzenie, czy główne endpointy HTTP zwracają spodziewany kod (200/3xx),
  • weryfikacja dostępności kluczowych portów (np. za pomocą nc lub curl),
  • prosty test aplikacyjny (np. wywołanie health-checka lub logowanie testowym użytkownikiem).

Istotne jest, aby te testy były deterministyczne i szybkie. Jeśli będą zbyt rozbudowane, zespół zacznie szukać sposobów na ich omijanie, a pipeline przestanie być wiarygodnym odzwierciedleniem stanu infrastruktury.

Punkt kontrolny: jeżeli po udanym apply pierwszą rzeczą, którą robi zespół, jest ręczne „przeklikanie” kilku stron i wywołanie ad-hoc skryptów, to testy po-deployowe nie są zautomatyzowane. Minimum to kilka najważniejszych ścieżek użytkownika odwzorowanych w prostych testach smoke.

Obsługa konfliktów i dryfów stanu wykrytych w planie

Plan często ujawnia dryf (drift) – różnice między stanem zapisanym w backendzie a realną infrastrukturą. Jeżeli takie sytuacje są ignorowane, Terraform w CI stopniowo traci kontrolę nad środowiskiem.

Potrzebne są jasne zasady:

  • co robić, gdy plan pokazuje niespodziewane delete/create zasobów, które ktoś zmienił ręcznie w chmurze,
  • kto podejmuje decyzję o zaakceptowaniu takiego planu,
  • kiedy zamiast apply należy najpierw odtworzyć konfigurację w kodzie (np. dodać brakujący zasób do Terraform).

Najczęściej zadawane pytania (FAQ)

Jak poprawnie uruchamiać Terraform w CI/CD zamiast z lokalnego laptopa?

Minimum to wprowadzenie zasady: wszystkie terraform plan i terraform apply idą przez pipeline, a lokalnie można co najwyżej robić terraform validate lub eksperymenty na osobnym state. Techniczne konto CI powinno być jedynym, które ma uprawnienia do modyfikacji produkcyjnej infrastruktury. Każde uruchomienie musi zostawiać ślad: logi, artefakty z planem i powiązanie z commitem.

Punkty kontrolne do sprawdzenia:

  • czy istnieje choć jeden przypadek terraform apply wykonywany z laptopa na produkcję,
  • czy plan użyty do apply jest tym samym artefaktem, który zatwierdził recenzent,
  • czy da się z audytu pipeline odtworzyć: kto, kiedy i z jakiego brancha wykonał zmianę.
  • Jeśli odpowiedź na którekolwiek z tych pytań brzmi „nie”, to CI nie kontroluje infrastruktury – jest tylko dekoracją wokół lokalnych uruchomień.

Jak testować i zatwierdzać terraform plan w pipeline CI?

Bezpieczny schemat to dwustopniowy proces: najpierw job generujący terraform plan i zapisujący go jako artefakt, potem osobny job terraform apply, który używa wyłącznie tego artefaktu. Między nimi musi być ręczny krok akceptacji (approve) – najczęściej w formie zatwierdzenia merge requesta, komentarza triggerującego job lub przycisku „deploy” w narzędziu CI.

Minimalny zestaw kontroli przed akceptacją planu:

  • statyczne skanowanie (np. tfsec, checkov) – czy zmiana nie łamie podstawowych reguł bezpieczeństwa,
  • testy modułów (np. Terratest) – szczególnie dla współdzielonych komponentów sieci, IAM, baz danych,
  • review planu przez osobę inną niż autor commita, przynajmniej na produkcji.
  • Jeśli apply wykorzystuje plan generowany „na świeżo”, a nie zatwierdzony artefakt, to proces jest podatny na niespodziewane różnice w wyniku działania Terraform.

Jak poprawnie skonfigurować backend i zdalny stan Terraform w CI?

Backend musi zapewniać trzy rzeczy: współdzielony dostęp, blokady (locking) i wersjonowanie. Typowy, sprawdzony zestaw to: S3 + DynamoDB (AWS), GCS + blokady (GCP) lub Azure Storage z mechanizmem locków. Plik state nie może być przechowywany lokalnie w repozytorium ani na dyskach developerów; to sygnał ostrzegawczy, że istnieją „ukryte” ścieżki modyfikacji infrastruktury.

Punkty kontrolne dla backendu:

  • osobny bucket/kontener lub przynajmniej wyraźnie rozdzielone prefixy per środowisko (dev/stage/prod),
  • włączone wersjonowanie i szyfrowanie, tak aby można było odtworzyć poprzedni stan po incydencie,
  • dostęp do odczytu stanu ograniczony (np. tylko inżynierowie infra i konto CI), brak publicznych endpointów.
  • Jeśli stan jest trzymany lokalnie lub w systemie bez blokad, ryzyko uszkodzenia state przy równoległych apply i ręcznych poprawkach rośnie gwałtownie.

Jak uniknąć rozjazdów między terraform plan na laptopie a w pipeline CI?

Kluczem jest ujednolicenie środowiska wykonawczego. W kodzie Terraform należy zdefiniować required_version oraz przypięte wersje providerów w required_providers, a w CI wymusić używanie dokładnie tych samych binariów. Dodatkowo zmienne środowiskowe i profile chmurowe (np. AWS_PROFILE, GOOGLE_APPLICATION_CREDENTIALS) muszą być jawnie ustawiane w jobach, a nie polegać na implicit konfiguracji z maszyny developera.

Lista sygnałów ostrzegawczych:

  • brak required_version w głównym terraform block,
  • provider w wersji >= bez górnego limitu, szczególnie w krytycznych modułach,
  • różne wyniki planu przy takim samym kodzie i tym samym stanie w CI i lokalnie.
  • Jeśli „u mnie plan jest czysty, a w CI zmiany” pojawia się częściej niż sporadycznie, to znak, że środowisko nie jest deterministyczne i wymaga twardych ograniczeń wersji.

Jak działa blokada stanu (state locking) w Terraform i dlaczego jest krytyczna w CI?

Locking uniemożliwia jednoczesne modyfikowanie tego samego pliku state przez różne procesy. Gdy pipeline CI wykonuje terraform plan lub terraform apply, backend zakłada blokadę. Kolejna próba zapisu stanu (np. ręczne apply z laptopa) powinna się wtedy nie powieść lub czekać na zwolnienie locka. Bez tego mechanizmu dwa równoległe apply mogą częściowo nadpisać sobie dane, co prowadzi do uszkodzenia stanu.

Punkty kontrolne:

  • czy backend, którego używasz (S3+DynamoDB, GCS, Azure, Terraform Cloud), ma aktywny mechanizm locków,
  • czy pipeline reaguje poprawnie na błąd „state lock” (powtórka z backoffem, wyraźny komunikat, brak ręcznego „odblokowywania” pliku),
  • czy ktoś w zespole nie „naprawia” problemów locków przez ręczne kasowanie rekordów w bazie pod backendem.
  • Jeśli locking jest obchodzony ręcznie lub w ogóle nie istnieje, to każdy incydent z infrastrukturą będzie trudny do odtworzenia i analizy.

Jak organizować repozytorium i workspaces Terraform pod CI (dev, stage, prod)?

Minimum to osobny state na środowisko i jasne powiązanie: katalog → backend → pipeline. Popularny wzorzec to jedno repo z katalogami envs/dev, envs/stage, envs/prod korzystającymi ze wspólnych modułów. Workspace’y Terraform mogą być użyte jako dodatkowa separacja, ale nie powinny zastępować fizycznego rozdzielenia środowisk w backendzie i w konfiguracji CI.

Przy wyborze struktury repo zwróć uwagę na:

  • czy z commita da się jednoznacznie wskazać, który pipeline i który state zostaną uruchomione,
  • czy zmiana w module może przypadkiem dotknąć wielu niezależnych state’ów bez odpowiedniego planu i review,
  • czy zespoły domenowe (network, app, data) mają jasne granice odpowiedzialności w repo i pipeline’ach.
  • Jeśli nie potrafisz szybko odpowiedzieć, który pipeline odpowiada za konkretny state produkcyjny, to struktura repo i workspace’ów wymaga uporządkowania.

Jak zwiększyć bezpieczeństwo terraform state (dane wrażliwe, audyt, dostępy)?

Plik state często zawiera tajne dane (hasła, endpointy, identyfikatory zasobów), więc musi być traktowany jak sekret. Backend powinien mieć włączone szyfrowanie at-rest, wersjonowanie i ograniczone uprawnienia. Dostęp do odczytu i zapisu warto rozdzielić: CI potrzebuje pełnego dostępu, ale typowy developer często tylko read-only i to wyłącznie dla środowisk niższych niż produkcja.