HashiCorp Terraform – infrastruktura jako kod

HashiCorp Terraform – infrastruktura jako kod

W kolejnym wpisie skupię się na jednym z najpopularniejszych narzędzi do zarządzania zasobami w chmurze – Terraform od HashiCorp. Terraform to jedno z najbardziej wszechstronnych narzędzi do generycznego tworzenia infrastruktury, którego popularność stale rośnie. W dzisiejszym wpisie skoncentrujemy się na przejściu przez podstawy pracy z Terraform w tym modułów i sposobów korzystania z linii poleceń. W dalszej części wpisu wykorzystamy własnego agenta GitHub do zautomatyzowania wdrażania infrastruktury dla trzypoziomowej aplikacji w chmurze AWS. Jestem przekonany, że artykuł będzie szczególnie pomocny dla tych z Was, którzy dopiero zaczynają swoją przygodę z Terraformem lub poszukują bardziej zaawansowanych wskazówek dotyczących jego stosowania. Zapraszam do lektury!


Inne wpisy z tej serii dotyczące narzędzi HashiCorp:

  1. HashiCorp Packer – automatyzacja budowy obrazów maszyn
  2. HashiCorp Terraform – infrastruktura jako kod ( Jesteś tutaj! )
  3. HashiCorp Vault – centrum zarządzania sekretami
  4. HashiCorp Consul – networking i zarządzanie configuracją
  5. HashiCorp Nomad – prosty orkiestrator aplikacji

Terraform i infrastruktura jako kod

Zacznę od tego czym jest IaC (Infrastructure as Code). Jest to podejście, w którym zamiast ręcznego konfigurowania infrastruktury traktujemy zasoby jak kod programistyczny. Proces tworzenia infrastruktury jest delegowany do narzędzi, które potrafią czytać taki kod i stworzą potrzebną infrastrukturę za nas. Przejście na IaC niesie ze sobą wiele korzyści – od prostszego utrzymywania spójności między różnymi środowiskami przez łatwiejsze skalowanie i powtarzalność, aż po szybsze tworzenie infrastruktury. Infrastruktura opisana za pomocą kodu może być wersjonowana w repozytoriach umożliwiając śledzenie i kontrolowanie historii zmian automatycznie.

Terraform to open-source’owe narzędzie napisane przez HashiCorp, które cieszy się dużą popularnością wśród specjalistów ds. chmury. Za pomocą Terraform możemy deklaratywnie tworzyć infrastrukturę przy użyciu providerów. Chociaż Terraform jest często używany do tworzenia infrastruktury w chmurze to możemy go również wykorzystać do zarządzania innymi zasobami takimi jak konto GitHub, klaster Kubernetes, pliki na lokalnej maszynie czy nawet do tworzenia kluczy prywatnych i publicznych.

Każdy główny dostawca chmury posiada swoje narzędzie do zarządzania infrastrukturą jednak Terraform cieszy się dużą popularnością. Należy jednak pamiętać, że Terraform nie jest złotym środkiem i posiada swoje wady w porównaniu do rozwiązań natywnych. Jednym z głównych problemów jest zarządzanie plikami stanów Terraform, o których więcej opowiem w dalszej części wpisu. Dodatkowo konieczność korzystania z providerów narzuca konkretny cykl pracy z tym narzędziem.

Oto przybliżony cykl pracy z konfiguracją terraform:

Najpierw tworzymy kod opisujący naszą infrastrukturę w języku HCL, a następnie inicjalizujemy go przy użyciu Terraforma. W tym czasie pobierane są wszystkie zależności od providerów, a także sprawdzane jest czy istnieje plik stanu. Jeśli nie wówczas Terraform go tworzy. Następnie, korzystając z planu Terraform możemy zobaczyć jakie zmiany zostaną wprowadzone do infrastruktury, a jeśli zgadzamy się z tymi zmianami wykonujemy komendę terraform apply, która je realizuje. W procesie tym istnieją również inne etapy, które omówię bardziej szczegółowo w odpowiednim miejscu.

Terraform Providers

Tak jak inne narzędzia HashiCorp Terraform również korzysta z pluginów. Obecnie w Terraformie istnieją dwa rodzaje pluginów: providers i provisioners. Jedną z największych zalet Terraform jest to, że potrafi współpracować z większością głównych dostawców chmury, jak również z innymi zasobami. Dzieje się to dzięki „providers”, które stanowią połączenie między rdzeniem Terraform, a konkretnymi grupami zasobów. Obecnie w Terraformie dostępnych jest ponad 500 różnych dostawców.

Aby użyć danego dostawcy w naszym kodzie wystarczy w odpowiednim bloku zadeklarować jakie zależności Terraform ma zaciągnąć przy inicjalizacji. Poniżej przykład dla snowflake.

terraform {
  required_providers {
    snowflake = {
      source = "Snowflake-Labs/snowflake"
      version = "0.57.0"
    }
  }
}

provider "snowflake" {
  # Configuration options
}

Może się zdarzyć, że potrzebujemy zdefiniować dwóch dostawców tego samego typu w jednej konfiguracji, ale o różnych parametrach. W takim przypadku możemy użyć aliasu, aby nadać każdemu dostawcy unikalną nazwę i przypisać odpowiednie zasoby do konkretnego dostawcy.

provider "snowflake" {
  account = "account_1"
  region  = "us-east-1"
  user    = "user_1"
  password = "password1"
  alias   = "east"
}

provider "snowflake" {
  account = "account_2"
  region  = "us-west-2"
  user    = "user_2"
  password = "password2"
  alias   = "west"
}

resource "snowflake_database" "example1" {
  provider = snowflake.east
  name   = "database1"
}

resource "snowflake_database" "example2" {
  provider = snowflake.west
  name   = "database2"
}

W powyższym przykładzie dzięki użyciu aliasów możemy precyzyjnie określić, z którego dostawcy ma korzystać dany zasób. Baza danych „example1” zostanie utworzona w regionie „us-east-1”, a baza „example2” w „us-west-2”.

Plik konfiguracyjny

Konfigurację Terraform piszemy używając języka HCL. Pisałem o nim w poprzednim wpisie gdy tworzyliśmy szablony dla Packer-a. Jednak w przypadku tworzenia konfiguracji Terraform potrzebujemy o wiele bardziej szczegółowej wiedzy. Z tego względu temat ten został rozdzielony na wiele podrozdziałów aby zapoznać się dogłębniej z każdym z nich.

W języku HCL używamy bloków, które mają zazwyczaj następującą strukturę:

W tym przypadku typem bloku jest resource. Pierwsza nazwa bloku identyfikuje jaki to rodzaj zasobu: np. aws_instance, a druga nazwa to unikalna nazwa, np. web nadana przez nas. W przypadku bloków typu variable i output występuje tylko unikalna nazwa bez typu. W ciele zasobu widzimy, że ten obiekt przyjmuje dwa argumenty, ami i instance_type.

Variable

Jest to blok, który definiuje zmienne jakie przyjmuje konfiguracja Terraform. Blok ten może przyjmować następujące argumenty: description, type, default, validation i sensitive. Wszystkie argumenty są opcjonalne.

variable "db_password" {
  description = "Data base password."
  type        = string
  sensitive   = true
  validation {
    condition     = length(var.db_password) > 5
    error_message = "The ddata base password must be at least 6 characters long."
  }
}

Warto zwrócić uwagę na dwa atrybuty, sensitive i validation. Jeśli wartość atrybutu sensitive jest ustawiona na true to wartość tej zmiennej jest ukrywana w logach polecenia terraform plan i apply. Validation natomiast sprawia, że terraform sprawdza, czy podana zmienna spełnia warunek w momencie wykonywania kodu. Jeśli zmienna nie spełnia warunku wówczas program kończy pracę. Rzadko wykorzystuje się wszystkie atrybuty, ale zawsze dobrze jest umieścić typ i opis zmiennej.

By skorzystać ze zmiennej wystarczy użyć słowa kluczowego var i po kropce wskazać nazwę zmiennej np. var.db_password.

Terraform obsługuje następujące typy zmiennych:
string, number (liczby całkowite i zmiennoprzecinkowe) , bool, list, map, object, tuple, any

variable "example_list" {
  type        = list(string)
  default     = ["value1", "value2", "value3"]
}

variable "example_object" {
  type        = object({
                  name = string
                  age  = number
                })
  default     = {
                  name = "John"
                  age  = 30
                }
}

variable "example_tuple" {
  type        = tuple([string, number])
  default     = ["value", 10]
}

variable "example_any" {
  type        = any
  default     = null
}

Istnieje kilka sposobów dostarczenia zmiennych do konfiguracji Terraform. Można to zrobić za pomocą flagi -var lub -var-files. Możliwe jest także wykorzystanie zmiennych środowiskowych. W przypadku powyższego przykładu przekazanie zmiennej example_any za pomocą zmiennej środowiskowej wyglądałoby tak: TF_VAR_EXAMPLE_ANY=test. Jednakże ta metoda ma swoje ograniczenia – można przekazać tylko zmienne typu string lub number. Terraform automatycznie wczytuje zmienne z pliku jeśli znajduje się on w folderze z konfiguracją, a nazwa pliku posiada odpowiednią składnię: <file_name>.auto.tfvars

Data

Blok data umożliwia pobieranie danych z różnych źródeł i wykorzystanie ich w konfiguracji Terraform. Dane mogą pochodzić z bazy danych, plików lub istniejącej infrastruktury, a ich wykorzystanie w konfiguracji pozwala na dostosowanie zachowania Terraform do danego środowiska.

data "aws_instances" "example" {
  filter {
    name   = "tag:environment"
    values = ["production"]
  }

  filter {
    name   = "instance-state"
    values = ["running"]
  }
}

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "my-bucket-name"
    key    = "network.tfstate"
    region = "eu-central-1"
  }
}

Powyżej przedstawione są dwa przykłady bloków data. Pierwszy zwraca dane dotyczące grupy instancji EC2 w chmurze AWS (aws_instances), które spełniają dwa filtry: posiadają tagi o kluczach „environment” o wartości „production” oraz są w stanie „running„.

Drugi przykład jest bardziej interesujący ponieważ wykorzystuje źródło danych terraform_remote_state, które udostępnia dane z pliku stanu innej konfiguracji terraform. W przypadku, gdy tworzymy instancje EC2 w naszej konfiguracji, ale nie chcemy tworzyć sieci, ponieważ już została utworzona w innej konfiguracji terraform możemy pobrać potrzebne dane z tamtego pliku stanu i wykorzystać je w naszej konfiguracji.

Local

Jest to jedyny blok w terraform nie posiadajacy nazwy. W tym bloku umieszczamy zmienne lokalne, stałe i operacje ściśle związane z aktualną konfiguracją.

locals {
  name           = "Red-DevOps"
  ami_id         = "ami-0c55b159cbfafe1f0"
  instance_type  = "t2.micro"
  security_group = "sg-0123456789abcdef"
}

resource "aws_instance" "example" {
  ami                    = local.ami_id
  instance_type          = local.instance_type
  vpc_security_group_ids = [local.security_group]

  tags = {
    Name = "example-instance-${local.name}"
  }
}

W przykładzie powyżej użyliśmy wielu zmiennych lokalnych do stworzenia instancji AWS. Warto zauważyć, że gdy chcemy odwołać się do zmiennej z grupy „locals” wtedy nie zapisujemy jej jako „locals.<zmienna>”, a jedynie jako local.<zmienna>.

Kolejną istotną rzeczą jest sposób interpolacji i wstawienia zmiennej do stringa. W naszym przypadku wstawienie do stringa ${local.name} sprawi, że wynikiem po ewaluacji będzie wartość tagu Name równa „example-instance-Red-DevOps„.

Output

Blok output w Terraform służy do deklarowania danych wyjściowych z pliku konfiguracyjnego. Jest to szczególnie przydatne w przypadku korzystania z modułów, gdyż pozwala na udostępnienie danych z jednej konfiguracji dla innej. Dodatkowo, w przypadku ręcznej pracy z Terraform blok output ułatwia uzyskanie potrzebnych informacji bez konieczności przeszukiwania całych logów.

output "instance_dns" {
  value = aws_instance.example.public_dns
}

output "vpc_id" {
  description = "The ID of the VPC"
  value       = module.vpc.vpc_id
}

Po wykonaniu polecenia terraform apply dla kodu zawierającego powyższe bloki na końcu logów otrzymalibyśmy taką informacje.

Apply complete! Resources: X added, X changed, X destroyed.

Outputs:

instance_dns = ec2-xxx-xxx-xxx-xxx.compute-1.amazonaws.com
vpc_id = vpc-xxxxxxxx

Bloki output pozwalają na udostępnienie danych pomiędzy konfiguracjami – w przypadku omawianego wcześniej bloku data wykorzystaliśmy typ terraform_remote_state o nazwie network aby skorzystać z danych udostępnionych przez inny plik konfiguracyjny. Gdybyśmy w tamtej konfiguracji chcieli wykorzystać dane udostępnionego przez ten output vpc_id wystarczyłoby zapisać data.terraform_remote_state.network.outputs.vpc_id

Resource i praca z dokumentacją

Blok resource jest podstawowym blokiem w terraform, dzięki któremu możemy tworzyć naszą infrastrukturę. W zależności od skomplikowania zasobu musimy zadeklarować różne zmienne w tym bloku. Dla prostych instancji w AWS wystarczy kilka argumentów natomiast do postawienia klastra EMR lub bazy danych potrzebujemy kilkudziesięciu lub nawet większej ilości danych.

Dzięki Terraformowi jesteśmy w stanie tworzyć wiele różnych zasobów, a ilość argumentów oraz ich kombinacje potrafią czasem przyprawić o zawrót głowy. Niejednokrotnie jedno ustawienie wyklucza drugie dlatego bardzo często podczas tworzenia zasobów będziemy sięgać do dokumentacji. Ważne jest aby dobrze ją znać i umieć się po niej poruszać. Dokumentację dla providerów znajdziemy na stronie registry.terraform.io

Przyjrzyjmy się bardziej zasobowi aws_db_instance

Na początku proponuję zapoznać się z pierwszymi akapitami. Zazwyczaj to tam znajdują się wyjątkowo ważne informacje związane z naszym zasobem. Następnie należy przejrzeć Argument Reference aby poznać argumenty jakie zasób przyjmuje oraz Attributes Reference by dowiedzieć się, które dane może udostępnić. W przypadku gdy chcemy skorzystać z gotowego przykładu konfiguracji również możemy go tam znaleźć. Na końcu dokumentacji znajduje się sekcja dotycząca importowania zasobów, którą omówię bardziej szczegółowo w rozdziale poświęconym CLI.

Oto ciekawszy kawałek atrybutów przyjmowanych przez ten zasób. Po pierwsze argument iam_database_authentication_enabled jest opcjonalny. W przypadku atrybutu identifier argument jest opcjonalny o ile nie użyjemy atrybutu restore_to_point_in_time. Atrybut identifier_prefix pozwala na określenie konkretnego przedrostka przed identyfikatorem bazy danych, ale nie możemy go użyć jeśli już użyliśmy atrybutu identifier. Dodatkowo co bardzo ważne oba atrybuty mają „Forces new resource” co oznacza, że jeśli będziemy chcieli zmodyfikować ten argument w trakcie gdy baza już stoi spowoduje to zniszczenie obecnego zasobu i stworzenie go od nowa. Dalej mamy instance_class, który określa typ instancji, na której chcemy stworzyć bazę. Atrybut jest wymagany.

Terraform podczas planowania w łatwy sposób oznacza jak potraktuje dane zasoby. Gdy chce stworzyć zasób oznacza taki blok znakiem +.

Jeśli ma zamiar zmodyfikować zasób oznaczy go tilde ~

W przypadku gdy zmieni się argument, który wymusza odtworzenie całego zasobu zobaczymy kombinację -/+

Podczas niszczenia zobaczymy natomiast tylko znak -

Terraform Provisioners

Terraform posiada drugi plugin provisioners, który umożliwia wykonywanie akcji po stworzeniu danego zasobu takiego jak np. uruchomienie skryptu czy wykonanie określonej komendy na zdalnym hoście lub urządzeniu lokalnym.

Twórcy Terraform odradzają jednak korzystanie z tej funkcjonalności ponieważ Terraform nie zapisuje i nie śledzi zmian wprowadzonych w ten sposób w przeciwieństwie do zmian infrastruktury, które są rejestrowane w pliku stanu. Poniżej przedstawiono kilka przykładów z jego użyciem.

resource "null_resource" "invoke_lambda" {
  provisioner "local-exec" {
    command = "aws lambda invoke --function-name setup-db response.json"
  }
}

resource "aws_instance" "example" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y nginx",
      "sudo service nginx start"
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }
}

W pierwszym przykładzie wykorzystujemy specjalny rodzaj bloku null_resource, który nie jest związany z żadnym providerem. Wewnątrz tego bloku zdefiniowaliśmy provisioner typu local-exec, który wykona komendę AWS CLI wyzwalającą daną lambdę.

W drugim przykładzie dodaliśmy blok provisioners remote-exec i connection do bloku instancji. Użycie słowa kluczowego self wewnątrz zasobu umożliwia pobranie danych o sobie samym. Przykład jest na tyle prosty, że nie wymaga komentarza.

Uwaga!

Twórcy Hasicorp odradzają stosowanie Terraform jako narzędzia do provisioningu, chyba że nie mamy innego wyjścia. Zdecydowanie lepszym rozwiązaniem jest użycie dedykowanych narzędzi takich jak Ansible, Chef, Salt czy CloudInit, które są przeznaczone do tego celu.

Terraform State i Backend

Terraform state to niezaprzeczalnie kluczowy element narzędzia. Jest to miejsce, w którym Terraform przechowuje wszystkie informacje związane z infrastrukturą, którą zarządza i tworzy wraz z zależnościami między poszczególnymi elementami. W momencie gdy Terraform wprowadza zmiany w infrastrukturze wówczas porównuje konfigurację z plikiem stanu aby wprowadzić tylko takie zmiany, które są niezbędne do osiągnięcia stanu zgodnego z konfiguracją. W pliku stanu przechowywane są również informacje wrażliwe dlatego należy koniecznie dbać o jego bezpieczeństwo.

{
      "type": "aws_db_instance",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [
        {
          "index_key": 0,
          "schema_version": 1,
          "attributes": {
            "address": "xxx",
            "allocated_storage": 20,
            "engine": "mysql",
            "engine_version": "8.0",
            "id": "data-base",
            "password": "SuperPassword!",
            "port": 3306,
            "publicly_accessible": false,
            "storage_type": "gp2"
            ...
            }
		]
}

Powyższy fragment przedstawia plik stanu wygenerowany z tworzenia bazy danych MySQL RDS w AWS. Plik miał prawie 400 linijek więc by pokazać tylko istotne informacje pokazałem fragment, na którym widać hasło dla admina zapisane w sposób jawny.

Pliki terraform muszą być przechowwyane w bezpiecznym miejscu. Najlepiej z mechanizmami zapewniającymi na dostęp tylko jednej osobie naraz. Blok backend służy właśnie do tego celu. Możemy w nim skonfigurować miejsce, do którego będziemy się odwoływać pracując z infrastrukturą. Istnieje wiele zdalnych magazynów, w których pliki stanu możemy przechowywać. Przykładowo usługa S3 połączona z DynamoDB zapewnia taką funkcjonalność. Przykład konfiguracji poniżej.

terraform {
  backend "s3" {
    bucket         = "red-devops-terraform-state"
    region         = "eu-central-1"
    encrypt        = true
    dynamodb_table = "red-devops-lock-table"
    key            = "example.tfstate"
  }
}

Jeśli nie zdefiniujemy bloku backend Terraform użyje folderu z konfiguracją i tam zapisze stan infrastruktury. Bardzo ważne jest aby nigdy nie ingerować ręcznie w plik stanu ponieważ może to doprowadzić do zniszczenia infrastruktury. Jeśli musimy coś usunąć z infrastruktury lub zmodyfikować stan powinniśmy posłużyć się poleceniem terraform state

Ze względu na wrażliwy charakter nie przechowujemy plików stanu w zdalnych repozytoriach.

Funkcje wbudowane

W jezyku HCL wystepuje wiele funkcji wbudowanych, które ułatwiają pracę. Funkcje związane z manipulacją znakami, cyframi, kolekcjami, datą i czasem, funkcje kodujące, funkcje ułatwiające pracę z plikami i wiele więcej. Pełna lista znajduje się tutaj.

module "db" {
  source = "git::https://github.com/terraform-aws-modules/terraform-aws-rds.git"

  identifier           = lower("${local.name}")
  engine               = "mysql"
  engine_version       = "8.0"

Funkcja lower pozwala na sprowadzenie wszystkich znaków do małej postaci, przydaje się przy idetyfikatorach baz danych czy bucketów S3, których nazwy zawsze muszą być napisane z małej litery.

resource "local_file" "create_backend_script" {
  content = templatefile(
    "${path.module}/template_backend_script.sh",
    {
      db_admin_name      = "super-admin",
      db_admin_password  = "top-gun-007"
    }
  )
  filename = "${path.module}/backend_script.sh"
}

W tym wypadku funkcja templatefile przyjmuje dwa argumenty plik i zmienne, które podstawi w tym pliku w momencie renderowania. Wystarczy w pliku template_backend_script.sh użyć składni ${db_admin_name} i ${db_admin_password}, a funkcja zajmie się resztą.

resource "aws_security_group" "lambda" {
  name        = "${local.name}-lambda"
  description = "Lambda security group"
  vpc_id      = vpc.example.vpc_id

  egress = [
    merge(
      local.allow_mysql,
      { cidr_blocks = data.terraform_remote_state.network.outputs.database_subnets_cidr_blocks }
    )

  ]

Funkcja merge służy do połączenia kilku map lub obiektów. W tym przypadku, jeśli blok cidr_blocks już występuje w pierwszej mapie zostanie on nadpisany nowym blokiem cidr dostarczonym przez blok data. Funkcja ta jest przydatna, gdy nie chcemy duplikować całego bloku, a jedynie podmienić jeden z parametrów.

Moduły

Moduły to zbiór plików konfiguracyjnych Terraform (.tf) umieszczonych w jednym folderze tworzący funkcjonalną całość. Zazwyczaj składaja się z bloków resources. Jest to najmniejsza konfiguracja, którą możemy przenieść i ponownie użyć w innym miejscu. Podobnie jak Akcje w GitHub Actions czy Role w Ansible moduły są przenośne i mogą być używane wszędzie.

Największą bibliotekę gotowych modułów znajdziecie tutaj. Podobnie jak w przypadku dokumentacji często będziemy tam zaglądać. Moduły znacznie przyspieszają pracę z Terraform. Jednym z najpopularniejszych modułów dla chmury AWS jest moduł tworzący cały VPC. Będziemy z niego korzystać w dalszej części wpisu.


module "vpc" {
  source = "git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git"
...

W celu użycia modułu wystarczy użyć bloku module i wskazać miejsce, z którego moduł ma zostać pobrany. Może być to też ścieżka do folderu na maszynie lokalnej lub repozytorium git. Następnie należy pobrać moduł za pomocą polecenia terraform init, a w ścieżce naszej konfiguracji pojawi się folder .terraform

W tym konkretnym przykładzie moduł VPC stworzy 60 zasobów m.in. podsieci publiczne, prywatne, podsieci baz danych, NAT Gateway, Internet Gateway, Routing table, logflow dla CloudWatch i wiele innych. Oczywiście mamy pełną dowolność konfiguracyjną za pomocą udostępnionych zmiennych. Wszystkie te ustawienia mieszczą się na nieco ponad 70 linijkach. Gdybyśmy chcieli zbudować te zasoby samemu konfiguracja na pewno przekroczyłaby 1000 linijek.

Podstawowe zalety używania modułów to:

  1. Przyspieszenie pracy – ponieważ nie musimy wszystkiego tworzyć od zera
  2. Modularność – możemy łatwo dzielić naszą infrastrukturę co ułatwia zarządzanie i skalowalność
  3. Dostępność gotowych modułów – społeczność dostarcza ogromną liczbę gotowych modułów
  4. Ponowne wykorzystanie – podobnie jak funkcje kawałki konfiguracji możemy wykorzystać w naszej infrastrukturze wszędzie gdzie potrzebujemy

Podczas tworzenia własnych modułów w Terraform warto stosować dobre praktyki czyli dzielenie plików konfiguracyjnych na kilka części, takich jak main.tf, variables.tf, locals.tf, outputs.tf oraz versions.tf. Dzięki temu nasz moduł będzie bardziej czytelny, łatwiejszy do utrzymania i przede wszystkim bardziej elastyczny w użyciu. Dodatkowo, warto dodać przykładowe użycie naszego modułu, które pozwoli innym użytkownikom na szybsze zrozumienie jego funkcjonalności i dostosowanie do swoich potrzeb.

By utworzyć własny moduł wystarczy stworzyć dowolny plik konfiguracyjny Terraform zachowując powyższe nazewnictwo i następnie odwołać się do niego w innym pliku konfiguracyjnym używając bloku module.

Polecenia Terraform – CLI

Nie sposób opisać pracy z Terraform bez poruszenia tematu linii komend. Wbrew pozorom Terraform nie ma zbyt wielu komend, które musimy znać. Poniżej przedstawię najczęściej stosowane polecenia.

terraform init– dzięki niej Terraform sprawdza wersję, pobiera niezbędne zależności i providery, moduły, a także sprawdza, czy możemy użyć backendu, jeśli wcześniej skonfigurowaliśmy odpowiedni blok. Przydatną flagą jest -backend-config=<path_to_file>, która umożliwia przekazanie konfiguracji do bloku backend. Jest to szczególnie przydatne, gdy chcemy dostarczać konfiguracji do tego bloku w sposób dynamiczny

terraform validate – chociaż nie jest to obowiązkowe polecenie w cyklu pracy, ponieważ Terraform musi i tak sprawdzić konfigurację podczas wykonywania plan lub apply, to wykonanie tylko samej validacji jest znacznie szybsze. Komenda ta pozwala na weryfikację poprawności składni naszego kodu Terraform i wykrycie potencjalnych błędów przed wdrożeniem

terraform fmt – bardzo wygodna komenda, która formatuje kod w danym folderze, dodając flagę -recursive możemy zwiększyć jej zasięg do wszystkich podkatalogów

terraform plan – ta komenda analizuje stan istniejącej infrastruktury i porównuje go z kodem Terraform. W wyniku tego procesu Terraform generuje plan zmian, który opisuje jakie operacje muszą być wykonane, aby dostosować stan istniejącej infrastruktury do plików konfiguracyjnych. Zaleca się eksportowanie planu do pliku za pomocą opcji -out <file>, co pozwala na wykonanie dokładnie tego samego planu przy użyciu komendy terraform apply

terraform apply – służy do wprowadzenia zmian w infrastrukturze na podstawie pliku konfiguracyjnego Terraform. Jeśli nie wskażemy pliku z planem wówczas Terraform wygeneruje go automatycznie i wyświetli go w celu potwierdzenia wprowadzanych zmian. Możemy użyć również flagi -auto-approve, aby wyłączyć potwierdzenie. Do tej komendy możemy przekazać zmienne używając flagi -var="var_name=value" lub -var-file=filename.tfvars

terraform destroy – jest odpowiedzialna za zniszczenie infrastruktury, którą wcześniej utworzyliśmy za pomocą plików konfiguracyjnych Terraform. Jest to przydatne narzędzie podczas testowania i manualnego sprawdzania naszej infrastruktury

terraform import – jest stosowana do importowania istniejących zasobów do konfiguracji Terraform i ich zarządzania. Jest to rzadziej używana komenda, ale może być pomocna, gdy chcemy dodać do naszej infrastruktury zasób, który został utworzony poza Terraformem np.

terraform import aws_instance.web i-12345678

Używając tej komendy pobieramy plik stanu dla określonego zasobu, a następnie możemy go uwzględnić w pliku konfiguracyjnym podając jego typ i nazwę zgodną z tą z importu. Dzięki temu będziemy mogli zarządzać tym zasobem za pomocą Terraform. W Terraform Registry znajduje się sekcja „import” dla każdego zasobu. Zawiera ona instrukcje, jak zaimportować dany resource.

terraform show – komenda wyświetli listę wszystkich zasobów znajdujących się w zarządzanym plikiem stanu

terraform state – jest to zestaw komend służących do manipulowania plikiem stanu. Dzięki nim możemy np. usuwać pojedyncze zasoby z pliku stanu (tracąc nad nimi kontrolę), przenosić zasoby do innych plików stanu, pobierać lub wysyłać pliki stanu z lokalnej maszyny

terraform workspace – to polecenie umożliwia stworzenie izolowanego środowiska dla pliku stanu, co jest szczególnie przydatne, gdy chcemy korzystać z jednej konfiguracji w wielu środowiskach. W trakcie części praktycznej wykorzystamy to rozwiązanie do utworzenia dwóch odrębnych środowisk

Workspace

Workspace to mechanizm w Terraformie, który umożliwia nam oddzielenie plików stanu od siebie za pomocą jednej prostej komendy. Domyślnie wszystkie operacje na infrastrukturze wykonywane są w domyślnym workspace, który nazywa się „default” i nie może być usunięty. Aby wyświetlić listę dostępnych workspace’ów należy użyć komendy terraform workspace list

Terraform tworzy osobny folder dla każdego workspace, w którym przechowywane są pliki stanu dla danej konfiguracji. Jeśli nie zdefiniowano backendu pliki stanu są przechowywane lokalnie w folderze terraform.tfstate.d.

Zabrakło

W tekście poradnika celowo pominąłem kilka kluczowych elementów. Przede wszystkim chciałem, aby poradnik skupiał się tylko na czystej wersji Terraform i zawierał tylko darmowe narzędzia.

Istnieje jednak platforma Terraform Cloud udostępniana przez HashiCorp, która zajmuje się częścią odpowiedzialności związaną z zarządzaniem plikami stanu. Istnieje również wersja Enterprise, jeśli chcielibyśmy postawić taki software w naszej własnej chmurze i samodzielnie nim zarządzać. Nie omówiłem również narzędzi takich jak Terragrunt i Terratest, które bardzo dobrze uzupełniają się z czystym Terraform.

W celu uzupełnienia brakującej wiedzy polecam obejrzenie darmowego filmu na youtubie HashiCorp Terraform Associate Certification Course – Pass the Exam!. Ponadto na końcu wpisu znajduje się również kilka innych wartościowych źródeł.

Automatycznie zarządzana infrastruktura z użyciem Terraform

Przechodzimy do konkretnego zadania. Zbudujemy system automatycznego zarządzania infrastrukturą z wykorzystaniem repozytorium GitHub. Aby praca miała większy sens stworzymy infrastrukturę dla aplikacji składającej się z trzech warstw – frontend, backend i bazy danych.

Jakiś czas temu w jednym z procesów rekrutacyjnych miałem za zadanie stworzyć aplikację do zapisywania wyników biegu. Ogólnie mówiąc aplikacja była uruchamiana za pomocą Docker Compose i składała się z trzech kontenerów. Każdy z nich odpowiadał za jedną z warstw – frontend, backend i bazy danych. W linku do repozytorium znajduje się kod aplikacji workoutrecorder.

UWAGA NA ZMIANY W REPOZYTORIACH !!!

Proszę zwrócić uwagę na daty commitów, które znajdziecie we wszystkich repozytoriach dostępnych na https://github.com/red-devops. Często korzystam z wcześniejszego kodu w kolejnych wpisach, dostosowując go do bieżących zadań. W związku z tym może się zdarzyć, że kod prezentowany w tym wpisie będzie różnił się od tego, który znajdziecie w najnowszym commicie repozytorium. Niemniej jednak wszystkie główne commity zostawiam, dlatego proszę porównywać daty publikacji wpisu z datami commitów.

W tym poradniku skupimy się tylko na stworzeniu podstawowej infrastruktury dla naszej aplikacji. W następnych częściach z serii HashiCorp będziemy uzupełniać nasze rozwiązanie o następne elementy tak by na końcu uzyskać w pełni działającą aplikację.

W książce Terraform Up & Running (którą opisałem tutaj) oraz w zbiorze dobrych praktyk dotyczących używania Terraform do tworzenia zasobów zaleca się podzielenie wdrażania infrastruktury chmurowej na kilka warstw. Na początek tworzymy warstwę odnoszącą się do całego konta, w której definiujemy sieć, użytkowników, uprawnienia, sekrety, itd. Następnie na tej warstwie w postaci modułów umieszczamy niezależne od siebie komponenty takie jak bazy danych, grupy autoskalujące, kolejki, itd. Elementy te będą w następnych etapach ze sobą kojarzone za pomocą narzędzi do provisioningu.

Możemy oczywiście umieścić wszystkie warstwy i komponenty w jednej konfiguracji, ale praca z takimi plikami będzie mozolna, trudna i nieefektywna. Jest to przykład złych praktyk. Dużo lepszym rozwiązaniem jest podział ze względu na zakres odpowiedzialności.

Przejdźmy do naszego zadania. Na poniższym rysunku znajdziecie uproszczony schemat architektury, który będziemy wdrażać.

Na schemacie ewidentnie widać podział na trzy poziomy. Dużą zmianą będzie wykorzystanie usługi AWS RDS z silnikiem MySQL zamiast obrazu Dockera z MySQL. Ze względu na specyfikę RDS i ALB musieliśmy wykorzystać dwie Avalibility Zone (AZ), co wyjdzie naszej aplikacji na dobre.

Poza powyższą VPC, która ma za zadanie utrzymywać naszą aplikację, stworzymy jeszcze jedną VPC o nazwie „Platform Engineering” (PE). W tej VPC osadzimy agenta GitHub Actions, z którego będziemy wykonywać polecenia terraform budujące pozostałe elementy infrastruktury.

Można się zastanawiać, czy nie lepiej umieścić taką instancję wewnątrz tej samej VPC, a argumenty znalazłyby się po każdej ze stron. Jednak zdecydowałem się na takie rozwiązanie, aby móc wyczyścić całą infrastrukturę prawie do zera. A sama VPC PE może się przydać w następnych wpisach.

Struktura plików projektu prezentuje się następująco:

W skrócie: moduł 10-„platform-enginerring” zawiera konfigurację do infrastruktury wspomagającej, moduł 11-„terraform-state” tworzy backend Terraform i tabelę DynamoDB do przechowywania plików stanu. Natomiast moduły 12-„network” i 13-„cicd-agent” tworzą sieć i umieszczają w niej agenta, który będzie komunikować się z GitHubem pobierając zadania do wykonania.

Dodatkowo tworzymy odpowiednią rolę dla agenta, co umożliwia nam uniknięcie umieszczania kluczy AWS w serwisie GitHub. Rola ta została nazwana „cicd-role” i została stworzona poza omawianą konfiguracją.

Folder „20-app-infra” zawiera całą konfigurację naszej aplikacji. Rozważałem umieszczenie grup bezpieczeństwa razem z siecią, ponieważ te dwa elementy są podstawą dla wszystkich następnych elementów infrastruktury. Doszedłem jednak do wniosku, że wolę mieć te dwie konfiguracje osobno gdyż jest to bardziej czytelne.

Od teraz możemy dodawać kolejne elementy infrastruktury takie jak 23-„data-base” i 24-„instances” bez zachowania konkretnej kolejności, ponieważ są to moduły niezależne od siebie pod kątem tworzenia.

Podczas tworzenia bazy danych zdecydowałem się na uproszczenie poprzez wcześniejsze utworzenie sekretu, który zawiera hasło dla admina bazy danych. Zrobiłem to, ponieważ sekrety w AWS Systems Manager są rezerwowane na minimalny okres 7 dni po usunięciu. Oznaczałoby to konieczność zmiany nazwy sekretu podczas testów, gdyż jego nazwa byłaby zajęta. Można również użyć generatora losowych nazw i dynamicznie je przypisywać, ale takie rozwiązanie byłoby niepotrzebnym powiększeniem kodu o elementy nie występujące w kodzie produkcyjnym.

Przy tworzeniu wielu środowisk za pomocą terraform najczęściej stosuje się dwa rozwiązania: z użyciem struktury folderów lub używając terraform workspace. Częściej spotykałem się z rozwiązaniem stosującym strukturę folderów bo jest to rozwiązanie łatwiejsze. Powoduje ono jednak, że dla każdego środowiska będziemy musieli duplikować kod. Z kolei używając terraform workspace ilość kodu będzie dużo mniejsza do utrzymania, ale prawdopodobieństwo popełnienia błędu przez człowieka wzrasta. W opisywanym przykładzie wykorzystamy podejście drugie. Dokonamy jego automatyzacji, dzięki czemu wyeliminujemy czynnik ludzki.

Przykładowy podział konfiguracji terraform przy wykorzystaniu struktury folderów bez terraform workspace. Dla każdego ze środowisk musimy w tym podejściu utrzymywać te same foldery.

Wdrażamy infrastrukturę

Pierwszym krokiem jest utworzenie miejsca, w którym będziemy przechowywać pliki stanu. Do tego celu wykorzystamy bucket S3 AWS oraz tabelę DynamoDB do zarządzania Terraform Locks. Jednakże zanim stworzymy te elementy musimy gdzieś zapisać ich stan. Dlatego najpierw zapiszemy plik stanu lokalnie, a następnie przeniesiemy go do utworzonego wcześniej S3. W celu wykonania tej operacji należy tymczasowo zakomentować blok backend "s3" w pliku ./10-platform-engineering/11-terraform-state/dependencies.tf. Po wykonaniu tej czynności będziemy mogli przejść do kolejnych etapów.

terraform {
  required_version = "~> 1.2"
  # backend "s3" {
  #   key = "platform-engineering/terraform.tfstate"
  # }
}

provider "aws" {
  region = var.region
}

Tworzymy infrastrukturę używając terraform init, terraform apply -var="team=Platform-Engineering" -var="region=eu-central-1", sprawdzamy zmiany i zatwierdzamy. Po utworzeniu bucketu i tabeli możemy przenieść nasz stan do chmury. Usuwamy wcześniejszy komentarz i tym razem wykonujemy polecenie terraform init -backend-config=../../backend_config.hcl . Wyciągnąłem wspólne wartości do osobnego pliku backend_config.hcl by nie kopiować argumentów w każdym pliku dependecy. Wpisujemy yes i nasz stan jest już bezpieczny na chmurze.

Zanim przekażemy władzę przy tworzeniu kodu do naszego agenta musimy go najpierw stworzyć. Przechodzimy więc do folderu ./10-platform-engineering/12-network inicjalizując backend i wdrażając sieć dla Platform Enginering, a nastepnie do ./10-platform-engineering/13-cicd-agent powtarzajac operację. Po chwili nasz self-hosted agent powinien poinformować serwis Githuba, że jest gotowy do pracy. Od tego momentu nasza maszyna będzie zajmować się wszystkimi operacjami terraform.

W katalogu ./.github/workflows znajdują się cztery pliki. terraform-dev.yaml i terraform-uat.yaml są praktycznie identyczne. Jedyną zmianą jest parametr określający środowisko wdrożeniowe. Możemy w prosty sposób dodać kolejne środowisko, tworząc dla niego odpowiedni plik. Każdy z tych dwóch plików uruchamia się tylko dla określonego brancha: odpowiednio dev lub uat. Oprócz tych dwóch plików jest jeszcze plik terraform-platform-engineering.yaml, który obsługuje infrastrukturę, którą właśnie stworzyliśmy. Jeśli chcielibyśmy coś zmodyfikować, dodać lub usunąć, możemy to zrobić przez agenta, ale należy uważać, aby nie zmienić czegoś, co może spowodować utratę połączenia z agentem. Instancję, na której znajduje się agent, zabezpieczyliśmy blokiem lifecycle, który nie pozwoli na akcję która doprowadzi do zniszczenia agenta.

lifecycle {
    prevent_destroy = true
  }

Ostatni plik o nazwie terraform-features.yaml sprawdza wszystkie pozostałe branche jakie pojawią się w repozytorium pod kątem poprawności kodu terrafom. Workflow wykonuje szybkie sprawdzenie terraform init, fmt, validate.

Uwaga!

W przypadku self-hosted agentów zalecane jest ich wykorzystywanie wyłącznie w prywatnych repozytoriach. W związku z tym w niniejszym poradniku wszystkie operacje zostały wykonane na prywatnym repozytorium, a po wyłączeniu GitHub Actions przeniesiono je do publicznego. Aby poznać więcej dobrych praktyk związanych z wykorzystaniem self-hosted agentów warto zapoznać się z dokumentacją.

Proces wdrażania i modyfikowania infrastruktury przebiega w następujący sposób. Kod Terraform przesyłamy na dowolną gałąź z wyjątkiem gałęzi środowiskowych, na które nałożyliśmy zabezpieczenia, aby nie można było bezpośrednio wprowadzać na nie zmian. W ramach wstępnej weryfikacji kodu workflow features sprawdza nowy commit. Jeśli wszystko jest w porządku, tworzymy Pull Request (PR) dla pierwszego środowiska, czyli dev. Następnie uruchamia się workflow dev, który generuje plan zmian i zamieszcza wyniki planu w komentarzu do PR. Jeśli zaakceptujemy merge, wszystko jest w porządku, a workflow ponownie uruchomi się, tym razem z poleceniem terraform apply, które wprowadzi zmiany. W przypadku poszczególnych środowisk wykorzystujemy Terraform workspace, który tworzy osobne katalogi dla każdego z nich w naszym bucket S3.

Tworzymy nowy branch o nazwie „feature” i wypychamy go na zdalne repozytorium. Należy pamiętać, że można utworzyć PR tylko wtedy, gdy kod się różni od docelowej gałęzi, dlatego dodajemy do brancha „feature” fałszywy commit o nazwie „trigger PD„. Następnie uruchamiany jest pierwszy workflow, który sprawdza poprawność kodu.

Tworzymy Pull Request i wprowadzamy zmiany z brancha feature na dev.

Po kilku chwilach powiniśmy otrzymać komentarze z planami terraform dla każdego z folderów zawierajacego konfigurację terraform.

Tak prezentuje się komentarz dodawany przez bota w przypadku gdy status planu zakończył się sukcesem. Możemy rozwinąć plan i zobaczyć szczegóły. Jeśli plan się nie powiedzie komentarz będzie wyglądał następująco:

Po sprawdzeniu informacji o błędzie okazało się, że brakuje wymaganych danych w źródle, z którego korzysta konfiguracja terraform. Ponieważ tworzymy całą infrastrukturę od podstaw, sieć nie została jeszcze utworzona błąd ten jest naturalny. Został zatem dodany warunek ignorujący błędy dla polecenia terraform plan. Użytkownik musi zdecydować przy potwierdzaniu PR, czy dany błąd jest prawdziwym błędem, czy fałszywym alarmem.

Podczas wykonywania komendy terraform apply ten workflow tworzy infrastrukturę po kolei, dlatego gdy już dojdziemy do punktu, w którym będziemy tworzyć grupy zabezpieczeń (SG) błędy związane z brakiem wymaganych danych nie powinny wystąpić. Pracując z Terraform zwykle dodajemy elementy do istniejącej już infrastruktury, dlatego takie błędy zdarzają się rzadziej niż na etapie tworzenia wszystkiego od zera.

Po zaakceptowaniu Pull Requesta zostanie wygenerowane zdarzenie „pushed” dla gałęzi „dev„, co spowoduje uruchomienie workflow z komendą terraform apply.

W przypadku zdarzenia pushed dla gałęzi środowiskowych zadania związane z planowaniem są pomijane w workflow.

Po kilkunastu minutach powinniśmy mieć gotowe środowisko dev. Proces ten możemy powtórzyć dla środowiska uat, a także skopiować go do innych środowisk korzystając z zaproponowanej konfiguracji. Dzięki temu wszystko jest łatwe do kontrolowania i skalowania, a przejście między odpowiednimi gałęziami pozwala na monitorowanie zasobów w poszczególnych środowiskach.

Cały kod razem z ścieżkami, które widzieliście wyżej znajdziecie w repozytorium Red-DevOps na GitHubie.

Na koniec dwie uwagi.

Przeglądając plany wysłane do komentarzy w PR widzimy, że są one pokaźne. Jest to całkiem normalne, ponieważ tworzymy wszystko od początku. W następnych iteracjach gdy będziemy dodawać infrastrukturę plany będą dużo skromniejsze ponieważ zmiany będą mniejsze. Podział konfiguracji na grupy network, SG, dazy danych, itd. pomaga w zachowaniu porządku i lepszej kontroli wprowadzonych zmian.

Podczas usuwania infrastruktury, niezależnie od tego, czy jest to duża czy mała część lepiej jest zakomentować konfigurację terraform, zamiast używać terraform destroy. To pozwoli na śledzenie, która infrastruktura powinna zostać usunięta. W przypadku dużych likwidacji, gdzie śledzenie zmian może być szczególnie trudne zakomentowany fragment kodu zawsze można łatwo przywrócić lub całkowicie usunąć. Historia zawsze jest dostępna w repozytoriach, ale w przypadku pracy z dziesiątkami różnych repozytoriów na różnych branchach takie rozwiązanie jest o wiele łatwiejsze.

Podsumowanie

W tym wpisie przedstawiłem wam podstawy Terraform – od składni języka i podstawowych komend, aż po bardziej zaawansowane koncepty takie jak zarządzanie wieloma środowiskami, plikami stanu i możliwościami automatyzacji. Terraform to bez wątpienia jedno z najpopularniejszych narzędzi, ale jedną z jego wad jest stosunkowo krótki okres życia. Wiele projektów rozpoczęło pracę z nim jeszcze przed wydaniem wersji 1.0.0 co miało wpływ na niekompatybilność z nowszymi wersjami i ilość bugów.

Poniżej umieszczam kilka linków do bardzo ciekawych materiałów, które szczególnie polecam. Warto zwrócić uwagę na ostatni link. Jest to repozytorium do automatyzacji budowania projektów w chmurze GCP z użyciem terraform. Architekturę i rozwiązania można bez problemu przenosić miedzy chumurami. Posiłkowałem się tym repo tworząc również moje rozwiązanie.

Polecane materiały:

Comments are closed.