Terraform wprowadzenie

Terraform wprowadzenie

W większości przypadków kiedy zaczynamy przygodę np. z AWS infrastrukturę tworzymy poprzez konsolę webową. Jest to naturalny sposób poznania jak działa dany dostawca usług chmurowych. Nie sprawdza się to, jeżeli próbujemy stworzyć środowisko produkcyjne. Z chwilą gdy musimy postawić drugie środowisko (czy to stage, czy produkcyjne na innym regionie), klikanie po konsoli zaczyna być uciążliwe. Z czasem może to powodować błędy i różnice między dwoma infrastrukturami. Inną sprawą jest sytuacja kiedy ktoś przypadkiem zmienni jakieś ustawienie i okazuje się że część naszej infrastruktury przestaje działać, wtedy musimy po omacku szukać co się zmieniło.

Jednym z narzędzi które pozwalają na rozwiązywanie tego typu problemów jest właśnie terraform.

Czym jest terraform

Terraform to narzędzie służące do zarządzania infrastrukturą, pliki terraforma tworzone są w języku HCL (HashiCorp configuration language). Dzięki temu że zmiany w infrastrukturze są przechowywane jako kod, można je wersjonować, przeprowadzać code review lub też łatwo dzielić się z zespołem informacjami co dokładnie zostało zmienione. Podobny efekt można uzyskać za pomocą:

Siła terraforma wynika z tego że może on być rozszerzany przez dodatkowe pluginy nazywane providerami, ich pełną listę można zobaczyć tutaj lub napisać własny. Przykładowi providerzy to AWS, Azure, Google Cloud, GitHub, Heroku, Docker, Kubernetes.

W tym tutorialu opiszę podstawy języka HCL oraz stworzymy testowo wirtualną maszynę ec2 na AWS. Działające przykłady można znaleźć na GitHubie

Terraform można pobrać z oficjalnej strony. Musimy pamiętać by plik wykonywalny terraforma znajdował się w zmiennej PATH. Można też użyć oficjalnego obrazu dockera wtedy przykładowa komenda będzie wyglądać następująco:

docker run -i -t hashicorp/terraform:light plan

Pliki tf

Rozszerzeniem używanym przez HCL jest tf. Zaczniemy od stworzenia pliku main.tf:

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] 
}

resource "aws_instance" "web" {
  ami           = "${data.aws_ami.ubuntu.id}"
  instance_type = "t2.micro"

  tags {
    Name = "HelloWorld"
  }
}

oraz osobny plik z definicja providera

provider "aws" {
  region = "eu-central-1"
}

Nazwy plików mogą być właściwe dowolne, a sam plik main.tf możemy też rozbić na kilka innych, wszystko jest kwestią tego jak sami chcemy pogrupować nasz kod.

Powyższy przykład to lekko zmieniony kod z oficjalnej dokumentacji terraforma. Przy okazji na powyższej stronie, po lewej możemy zobaczyć wszystkie wspierane zasoby/serwisy AWS, warto się z nimi zapoznać.

Stwórzmy coś!

Mając świeżo przygotowany projekt z terraform musimy go zainicjalizować poprzez komendę:

terraform init

Powyższa komenda ściąganie do folderu .terraform potrzebne providery oraz moduły.

Mając powyższy skrypt, możemy za pomocą terraforma sprawdzić co zostanie utworzone za pomocą komendy:

terraform plan

 + aws_instance.web
      id:                           
      ami:                          "ami-086418121308b30ff"
      arn:                          
      associate_public_ip_address:  
      availability_zone:            
      cpu_core_count:               
      cpu_threads_per_core:         
      ebs_block_device.#:           
      ephemeral_block_device.#:     
      get_password_data:            "false"
      host_id:                      
      instance_state:               
      instance_type:                "t2.micro"
      ipv6_address_count:           
      ipv6_addresses.#:             
      key_name:                     
      network_interface.#:          
      network_interface_id:         
      password_data:                
      placement_group:              
      primary_network_interface_id: 
      private_dns:                  
      private_ip:                   
      public_dns:                   
      public_ip:                    
      root_block_device.#:          
      security_groups.#:            
      source_dest_check:            "true"
      subnet_id:                    
      tags.%:                       "1"
      tags.Name:                    "HelloWorld"
      tenancy:                      
      volume_tags.%:                
      vpc_security_group_ids.#:     


Plan: 1 to add, 0 to change, 0 to destroy.

W tym wypadku zobaczymy, że efektem będzie utworzenie instancji EC2 z podanymi przez nas tagami. Większość wartości które nie zostały przez nas podane zostaną wyliczone w trakcie tworzenia planu.

Jeśli odpowiada nam wynik planu to możemy zdecydować się na to by zmiany zostały zastosowane poprzez:

terraform apply

Jeszcze w trakcie wykonywania komendy zobaczymy po raz kolejny listę rzeczy które pojawiły się przy terraform plan i będziemy musieli potwierdzić że chcemy je stworzyć wpisując yes.

Domyślnie provider AWS do stworzenia zasobów korzysta z tej samej konfiguracji co aws-cli (tego jak to zrobić nie będę tutaj opisywać – tutaj można znaleźć link do dokumentacji). Oznacza to mniej więcej tyle że jeśli nie mamy uprawnień do zrobienia czegoś przez aws-cli, to nie zrobimy też tego poprzez terraforma.

Po tym jak terraform skończy swoją pracę możemy zalogować się w konsoli aws i zobaczyć że nasza instancja ec2 też tam jest.

Żeby po sobie posprzątać i ją usunąć wystarczy wpisać:

terraform destroy

Dla pewności terraform wyświetli nam po raz kolejny co zamierza usunąć i zapyta się czy jesteśmy pewni, wpisujemy yes.

Teraz zajmiemy się dokładniejszym opisaniem zawartości naszego pliku main.tf w którym mamy zdefiniowane dwa elementy:

Element data

Pierwszy element który widzimy to data o nazwie ubuntu który pozwala odczytać listę dostępnych ami (aws_ami) w danym regionie. W tym przypadku szukamy najnowszej wersji ami dla obrazu ubuntu. Ogólnie elementy data nie tworzą niczego, ale pozwalają na odczytanie stanu który już istnieje. W innym przypadku można by użyć elementu data aws_ecs_task by odczytać wersję obrazu dockera z ECS’a.

Element resource

Drugi element to resource informuje on że chcemy coś stworzyć, w tym wypadku jest to aws_instance czyli wirtualna maszyna – ec2. Wewnątrz definicji zasobu podajemy ami_id jaki ma być użyty do stworzenia obrazu, typ instancji oraz tagi jakie chcemy by miała stworzona ec2. Widzimy też że id obrazu podajemy poprzez referencje do zasobu data.

I tu należy dodać że jeśli w jednym zasobie w HCL odwołujemy się do innego zasobu data to musimy referencję prefixować z słowem data, ale jeśli np. chcielibyśmy się odwołać do innego resource to wtedy już prefix data pomijamy (w dalszej części zobaczymy przykład referencji dla resource).

Wracając do różnicy między resource a data to ogólna zasada jest taka że jeśli dany element HCL zaczyna się ze słowem data to znaczy że będziemy odczytywać jakieś dane, a jeśli zaczyna się od resource to znaczy że chcemy coś stworzyć. Z reguły dla różnych typów zasobów mamy dostępne zarówno data jak i resource, np. możemy stworzyć nowy klaster ECS przy użyciu resource aws_ecs_cluster, lub też odczytać jego stan za pomocą data aws_ecs_cluster.

Kolejność tworzenia zasobów

Poniższy fragment stworzy security groupę i później połączy ją z ec2:

resource "aws_security_group" "allow_all" {
  name        = "allow_all"
  description = "Allow all inbound traffic"

  ingress {
    from_port = 0
    to_port = 65535
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "web" {
  ami = "${data.aws_ami.ubuntu.id}"
  instance_type = "t2.micro"
  vpc_security_group_ids = ["${aws_security_group.allow_all.id}"]
}
// data.aws_ami.ubuntu.id zostało pominięte w tym przykładzie ale jest dalej wymagane

Kolejność występowania różnych zasobów w plikach tf nie ma znaczenia, ważne natomiast są zależności między nimi. W tym wypadku nasza instancja ec2 odwołuje się do resourca aws_security_group i odczytuje jego id, terraform widząc taki zapis wie że najpierw musi stworzyć security groupę, a dopiero później ec2. Warto też zauważyć brak prefixu data w referencji ponieważ tym razem odwołujemy się do resource (przeciwieństwie do pierwszego snippetu kodu)

Variables, output, count i inne

No dobrze, a co jeśli chciałbym stworzyć więcej ec2? Można to zrobić na dwa sposoby albo skopiować naszą definicję resourca aws_instance i zmienić jej nazwę czyli ostatni człon z web na np. web2 (nazwy resource/data muszą być unikalne dla wszystkich plików tf w danym folderze). Można też użyć parametru count który działa z każdym resource w HCL:

resource "aws_instance" "web" {
  count = 2
  ami = "${data.aws_ami.ubuntu.id}"
  instance_type = "t2.micro"
}

Z drugiej strony fajnie by było móc kontrolować liczbę instancji bardziej dynamicznie, co też da się zrobić, poprzez użycie zmiennych:

variable "ec2_count" {
  default = 2
  description = "defines how many ec2 instances should be created" 
}

resource "aws_instance" "web" {
  count = "${var.ec2_count}"
  ami = "${data.aws_ami.ubuntu.id}"
  instance_type = "t2.micro"
}

output "ec2_ids" {
  value = "${aws_instance.web.*.id}
}

Zmienne deklarujemy poprzez użycie słowa variable, każda musi posiadać nazwę, opcjonalnie można podać wartość domyślną(default), możemy także podać opis(description) do czego dana zmienna służy. Zmienne mają też różne typy np. string, list, boolean, map, ale na ich temat nie będę się raczej tu rozpisywać. Żeby odwołać się do wartości zmiennej trzeba poprzedzić jej nazwę prefixem var.

Wartość zmiennej można poddać na kilka sposobów:

  • poprzez zmienne środowiskowe z prefixem TF_VAR_{nazwa_zmiennej} czyli w naszym przypadku np: export TF_VAR_count=2.
  • jeśli nie podaliśmy wartości domyślnej to wywołując apply terraform sam zapyta nas jaką wartość ma przypisać zmiennej.
  • przy komendzie apply np. terraform apply -var count=2
  • poprzez podanie wartości z pliku terraform apply -var-file=test.tfvars. Przykładowy plik test.tfvars: count=2

Na samym końcu naszego przykładu możemy zobaczyć definicję output, dzięki temu po wykonanym terraform apply zobaczymy id stworzonych maszyn ec2. W referencji występuje “*” ponieważ gdy dodaliśmy count nasz resource stał się tablicą, gwiazdka informuje że chcemy wszystkie elementy tej tablicy(można też odwołać się do pojedyńczego elementu).

Provider

Co do samego providera AWS to w provider.tf podaliśmy nazwę regionu w którym chcieliśmy stworzyć nasze ec2. Jeśli musimy stworzyć coś w dwóch różnych regionach musimy wtedy zdefiniować kilka providerów i używać aliasów przy wywołaniu resource/data, tak jak w poniższym przykładzie:

provider "aws" {
  region = "eu-central-1"
  alias = "euc1”
}

provider "aws" {
  region = "us-west-2"
  alias = "usw2”
}

resource "aws_instance" "web_usw2" {
  provider = "aws.usw2"
  instance_type = "t2.micro"
}

resource "aws_instance" "web_euc1" {
  provider = "aws.euc1"
  instance_type = "t2.micro"
}

Oczywiście providerów można mieszać, dlatego obok providera AWS może być np. provider Githuba.

Jeśli nasza konfiguracja aws-cli zawiera kilka różnych profili to dodatkowo można zdefiniować pole profile którego wartość zostanie użyta jako profil do wykonania akcji na AWS.

Plik stanu

Mogłeś też zauważyć że w folderze z którego wykonała się komenda terraform apply pojawił się plik terraform.tfstate (pod żadnym pozorem nie należy go edytować ręcznie). To w nim terraform przechowuje wszystkie informacje na temat stworzonych zasobów i dzięki niemu jeśli jeszcze raz wykonamy skrypt, to nie zostaną utworzone nowe zasoby (ponieważ już istnieją). W przypadku gdyby ktoś ręcznie dodał np. nowy tag do ec2 to terraform plan by to pokazał jako różnicę którą zamierza usunąć.

Przy naszej aktualnej konfiguracji taki plik stanu trzeba by wrzucić do repozytorium by inni widzieli nasze zmiany. Nie jest to zalecanym wyjściem, głównym problemem jest to, że dwie osoby modyfikujące ten sam plik stanu terraforma nie widzą swoich zmian, przez co pojawia się wyścig, który wygrywa osoba uruchamiająca terraforma jako ostatnia. W gorszym przypadku operacja może się zakończyć błędem, którego przyczynę będzie nam ciężko określić.

Dlatego lepszą opcją jest użycie remote backendu np. bucketa s3, wtedy nasz stan automatycznie trafia do s3 gdzie każdy może widzieć jakie zmiany zostało wykonane, do tego dodajmy blokowanie stanu żeby dwie osoby nie mogły jednocześnie wrzucać swoich zmian oraz jego szyfrowanie i jesteśmy już całkiem gotowi do produkcyjnych zastosowań.

Poniżej jest przykład jak można zdefiniować stan oparty na istniejącym bucketcie s3:

terraform {
  backend "s3" {
    bucket = "terraform-state" # Nazwa istniejącego bucketa
    key    = "ec2-remote-state-test" # ścieżka do pliku gdzie zapisać stan w bucketcie
    region = "eu-central-1" # region w którym jest bucket
    # profile = "xxxxx" # opcjonalnie profil z aws-cli który ma dostęp do bucketa
  }
}

Podsumowanie, przydatne linki

To by było na tyle jeśli chodzi o najbardziej podstawową funkcjonalność. Oczywiście terraform ma swoje wady, a na jego GitHubie znajdziemy mnóstwo issues na temat odkrytych ograniczeń czy workaroundów. Pozostaje jeszcze problem z kompatybilnością wsteczną, z reguły jeśli jedna osoba nadpisze stan nowszą wersją minor terraforma, to inni użytkownicy też bedą musieli się zmigrować na nią bo inaczej terraform nie pozwoli wykonać apply. Jednak mimo swoich wad, terraform jest niezastąpionym narzędziem które niesamowicie ułatwia pracę i pozwala kontrolować naszą architekturę.

Jako dodatkowa lekturę polecam dokumentację terraforma:

Programista z domieszką DevOps, lubię wszystko co związane z technologiami ułatwiającymi nam pracę. Głównie zajmuję się pisaniem microserviców oraz ich procesem deploymentu do clouda. A pozatym to motocykl, planszówki, seriale i dobra książka.
PODZIEL SIĘ