Terraform og AWS

Harald Vinje

Del:

24. september 2023

16 min lesning

Kategorier:CloudAWSDevOps

Har du jobbet med utvikling og deployment av software til en av de større skytjenesteleverandørene merker du fort hvor mange bevegelige deler som er i spill. Med Terraform kan du automatisere opprettelsen av skykomponentene dine gjennom såkalt "Infrastructure as Code". I dette innlegget får du en liten intro til Terraform, AWS og skyarkitektur, samt to eksempler på implementasjon.

Terraform og AWS logo

Problemet

Jobber man med å utvikle og publisere software til skyen (Public Cloud), selv relativt enkle applikasjoner eller tjenester, oppdager man fort alle mulighetene skyleverandører som f. eks AWS, GCP eller Microsoft Azure tilbyr. Med mange muligheter og mye frihet kommer det en haug med valg som må tas og operasjoner som må foretas for å få ting oppe å kjøre på en sikker og robust måte. Typisk følger man gjerne en oppskrift over hvordan man navigerer seg rundt i et GUI for å opprette alle brukere, tilganger, prosesser og ressurser for å oppnå det man ønsker. Dette går relativt greit om man ikke trenger å gjøre det så ofte, men diverse problemer kan oppstå etter hvert som virksomheten vokser. For å nevne noen:

  • Du glemmer eller mister oversikt over alle stegene som måtte til for å få tjenestene dine til å kjøre.
  • Opprettelsen av infrastrukturen har mange blokkerende avhengigheter, med lang tid mellom hvert steg hvor du som bruker må inn å klikke i ubestemte intervaller.
  • Om du må bytte fra én konto til en annen pga f. eks reorganisering må du gjenta hele prosessen.
  • Du har ingen total oversikt over alt som kjøres i skyen. Skyleverandørene har insentiver til å la deg ha ting kjørende som du kanskje har glemt eller oversett ettersom de tjener penger på det.

Løsning: Infrastructure as Code i Terraform

Infrastructure as Code (IaC), altså infrastruktur som kode, handler om å automatisere prosessen for å opprette, endre og eventuelt ta ned infrastruktur gjennom deklarativ kode. Infrastruktur betyr i denne sammenheng ting som eksisterer i skyen, som f. eks webservere, databaser, route tables, fillagringssystemer som S3 og Cloud Storage + en hel del andre ting (teknisk sett kan en ressurs i Terraform være noe så enkelt som tekstfil på din lokale maskin, men i praksis brukes det aller mest til ressurser i skyen). Det er finnes flere verktøy for å sette opp infrastruktur via tekst eller kode (som f. eks AWS CDK), men Terraform er per nå den mest populære, i tillegg til at den har fordelen av å strekke seg over de fleste store Public Clouds, i motsetning til f. eks AWS CDK, som er eksklusivt for AWS.

Anatomien til Terraformkode

Det viktigste elementet i Terraform er det som kalles en resource eller ressurs på norsk. Et enkelt eksempel på kode som oppretter en web server hos AWS kan kan se slik ut:

main.tf
1resource "aws_instance" "web" {
2 ami = "ami-a1b2c3d4"
3 instance_type = "t2.micro"
4
5 tags = {
6 Name = "HelloWorld"
7 }
8}

Det reserverte ordet resource etterfølges alltid av typen ressurs som skal opprettes, og dermed et variabelnavn man selv bestemmer for referanse senere. Ovenfor har vi altså skrevet kode for å opprette en ressurs av typen aws_instance (som er en EC2-instans) som vi har kalt "web". Innenfor klammeparentesene har vi spesifisert argumentene eller parameterne for ressurstypen. De relevante argumentene står i dokumentasjonen for den ressurstypen.

Neste steg

Etter koden er skrevet blir neste steg å initialisere Terraform ved å kjøre terraform init, og deretter anvende eller "applye" koden ved å kjøre terraform apply. Om alt går etter planen skal du få beskjed om at ressursene dine er opprettet. Du vil i tillegg sitte igjen med en fil kalt terraform.tfstate. Dette er en fil som holder styr på tilstanden til ressursene dine, dvs hva som er opprettet og hvordan det er konfigurert per nå. Dette for at Terraform skal holde styr på som må endres neste gang du endrer koden og kjører terraform apply.

To praktiske eksempler

Hvor komplisert Terraformkoden blir avhenger naturligvis av hvor komplisert den underliggende arkitekturen er, og de som er kjent i skyverdenen vet at arkitekturen kan bli svært omfattende om prosjektet er stort nok. Det er mange ting jeg har unnlatt om Terraform til nå, så jeg tenkte å gå gjennom to praktiske eksempler, ett relativt enkelt og et mer komplisert, og å forklare ting underveis.

Et enkelt eksempel: En remote server tilgjengelig via SSH

Noen ganger trenger man bare en billig, trygg og enkel server å jobbe med. For trygg og enkel kommunikasjon trenger vi SSH-tilgang, som vi må åpne opp for på serveren. Siden AWS er en av de mest kjente, i tillegg til å ha en free tier, går vi for AWS EC2 som leverandør av serveren. Det aller første vi må gjøre er å fortelle Terraform at vi ønsker å bruke AWS, ved å spesifisere en såkalt provider. For å holde ting organisert spesifiserer vi dette i filen provider.tf:

provider.tf
1terraform {
2 required_providers {
3 aws = {
4 source = "hashicorp/aws"
5 version = "4.67.0"
6 }
7 }
8 required_version = "~> 1.4.0"
9}
10
11provider "aws" {
12 region = "eu-west-1"
13}

I filen main.tf definerer vi all infrastrukturen vår:

main.tf
1resource "aws_default_vpc" "default" {}
2
3resource "aws_security_group" "allow_ssh" {
4 name = "allow_ssh"
5 description = "Allow ssh inbound traffic"
6 vpc_id = aws_default_vpc.default.id
7
8 ingress {
9 description = "SSH from everywhere"
10 from_port = 22
11 to_port = 22
12 protocol = "tcp"
13 cidr_blocks = ["0.0.0.0/0"]
14 }
15
16 egress {
17 from_port = 0
18 to_port = 0
19 protocol = "-1"
20 cidr_blocks = ["0.0.0.0/0"]
21 }
22
23 tags = {
24 Name = "allow_ssh"
25 }
26}
27
28resource "tls_private_key" "ubuntu_ssh_key" {
29 algorithm = "RSA"
30 rsa_bits = 4096
31}
32
33resource "aws_key_pair" "ubuntu_key_pair" {
34 key_name = var.generated_key_name
35 public_key = tls_private_key.ubuntu_ssh_key.public_key_openssh
36
37 provisioner "local-exec" {
38 command = <<-EOT
39 echo '${tls_private_key.ubuntu_ssh_key.private_key_pem}' > ./'${self.key_name}'.pem
40 chmod 400 ./'${self.key_name}'.pem
41 EOT
42 }
43
44 provisioner "local-exec" {
45 when = destroy
46 command = <<-EOT
47 chmod 600 ./'${self.key_name}'.pem
48 rm ./'${self.key_name}'.pem
49 EOT
50 }
51}
52
53resource "aws_instance" "ubuntu_server" {
54 ami = "ami-096800910c1b781ba"
55 instance_type = "t2.micro"
56
57 vpc_security_group_ids = [aws_security_group.allow_ssh.id]
58 key_name = aws_key_pair.ubuntu_key_pair.key_name
59
60 tags = {
61 Name = "UbuntuServer"
62 }
63}

Som man kan se opprettes opprettes 5 Terraformressurser, som alle er nødvendige for å få serveren til å kjøre som ønsket. De tre viktigste her er:

  • aws_security_group, for å kontrollere hvilke protokoller og IP-adresser som skal ha tilgang til serveren din. Les mer om Security Groups her.
  • aws_key_pair: AWS' key pair. SSH-nøkkel lagret hos AWS for å verifisere tilgang.
  • aws_instance: Selve serveren. Vi spesifiserer et AMI, som inneholder informasjon om operativsystemet, annen software og hardware som trengs for å starte serveren.

I aws_instance-ressursen benyttes referanser til de andre ressursene i koden. Terraform er da smart nok til å skjønne i hvilken rekkefølge ressursene må opprettes for å få det til å fungere. Kjører vi nå terraform init for å initialisere Terraform, og deretter terraform apply -auto-approve for å spinne opp ressursene får vil outptut noe a la dette:

Kommandolinje av terraform

Den siste linja, ec2_public_dns = "ec2..." kommer fordi vi har har laget en output block:

output.tf
1output "ec2_public_dns" {
2 description = "Public DNS of the EC2 instance"
3 value = try(aws_instance.ubuntu_server.public_dns)
4}

DNS-navnet til EC2-instansen er nødvendig for å koble på serveren via SSH. Den vil også nå være tilgjengelig ved å kjøre terraform output ec2_public_dns. Ettersom SSH-nøkkelpar også er opprettet (privat på filsystemet og tilhørende public hos AWS) kan vi koble oss på serveren: ssh -i my-ec2-ubuntu-key-pair.pem ubuntu@$(terraform output ec2_public_dns | tr -d '"'):

Ubuntuserver koblet til

Nå som koden er skrevet kan man enkelt spinne opp så mange servere hos AWS man ønsker, og ta dem ned igjen raskt ved å kjøre terraform destroy -auto-approve. Du slipper å gå gjennom klikkedansen i GUIet til AWS for hver gang ting skal opprettes eller endres.

Et mer komplisert eksempel: Scheduled task

I dette eksempelet er målet å ha en jobb eller "task" som skal kjøres hver dag, hente dagens data fra en nettside, og lagre resultatet i en database. Vi holder oss til AWS som skyleverandør. Jeg har laget en web scraper som sjekker prisjakt.no for dagens tilbud, samler opp produkt-ID, navn, avslagsprosenten og butikk for dagens tilbud (kildekode på GitHub). Dette høres kanskje ikke så komplisert ut, men å få det til å kjøre oppe i skyen kan kreve sitt:

  • Et miljø der koden kan kjøres.
  • En "scheduler" som kan spesifisere når og hvor ofte tasken skal kjøres, i tillegg til å starte tasken på disse tidspunktene.
  • En database for å lagre alle resultatene.
  • Logging av resultater og eventuelle feilmeldinger.

I tillegg til dette burde vi følge gode utviklingsrutiner og forsøke å ha høy sikkerhet. Det vil innebære ting som:

  • CI/CD - Automatiske deployments fra Github til AWS.
  • Least privilege - Miljøet som kjører koden skal kun ha tilgang til det den trenger. Tilgangen på alle ressursene som opprettes skal begrenses så mye som mulig.

AWS-tjenester

Har man vært litt borti AWS vet man også at å velge ut hvilke skytjenester man skal benytte ikke alltid er en dans på roser.

AWS er komplisert-meme

Siden dette hovedsakelig er et innlegg om Terraform skal jeg ikke gå i detaljer på de forskjellige AWS-tjenestene, men en liten begrunnelse på valgene blir det

  • Som miljø for å kjøre koden bruker jeg ECS tasks. Med ECS kan jeg pakke inn koden min et Docker image og shippe til ECR (som er litt som en Docker hub i AWS). Alternativt kunne jeg brukt AWS Lambda, men Lambda har en tidsbegrensning på max 15 minutter, som jeg vil unngå.
  • Som database bruker jeg AWS RDS med PostgreSQL, en industristandard.
  • Som scheduler bruker jeg Amazon EventBridge Scheduler, som har en fleksibel måte å spesifisere tidsintervaller på.

Dette er de viktigste ressursene, men for å tilrettelegge kommunikasjon og gjøre ting sikkert trenger vi en rekke andre ting også:

  • En VPC (Virtual Private Cloud), for å kunne isolere infrastrukturen vår fra det åpne nettet, og kun eksponere det som trengs ved å splitte ressursene våre i public og private subnets.
  • Security groups (nevnt tidligere i innlegget).
  • En internet gateway i subnettet der scrapingtasken skal kjøre, da den må ha tilgang til det åpne nettet/internett.
  • Tilstrekkelige, men begrensede tilganger via IAM roller og policies.
  • AWS Secrets Manager for å lagre brukernavn og passord til databasen, slik at det er på et sikkert sted og ikke direkte i koden.
  • En proxy server, eller bastion host, som skal være en EC2-instans i det åpne subnettet, som må brukes om man vil inspisere databasen fra lokal maskin, siden databasen befinner seg bak et privat subnett.

I tillegg trengs:

En (nesten) komplett oversikt over arkitekturen ser nå slik ut:

AWS arkitekturdiagram

Så var det å få ned dette i Terraform da.

Terraformstrukturen

Det er mange måter å strukturere Terraformkoden sin på. Enkelte organisasjoner har all infrastrukturkoden sin i ett repo, andre splitter det inn på applikasjonsnivå. I dette tilfellet har vi kun én applikasjon i "organisasjonen", så vi plasserer alt i en mappe som heter terraform i repoet til scraperen vår. Hele filtreet i denne mappen er følgende:

Filtre av terraformmappen

Som du kan se splittes filene vanligvis inn i variables.tf, output.tf og main.tf. Mainfilen på det øverste nivået ser slik ut:

main.tf
1terraform {
2 required_providers {
3 aws = {
4 source = "hashicorp/aws"
5 version = "~> 5.17.0"
6 }
7 }
8
9 required_version = ">= 1.2.0"
10 backend "s3" {
11 bucket = "prisjakt-scraper-terraform-remote-state"
12 key = "terraform.tfstate"
13 region = "eu-west-1"
14 }
15}
16
17provider "aws" {
18 region = var.region
19}
20
21module "vpc" {
22 source = "./modules/vpc"
23 vpc_name = var.vpc_name
24}
25
26module "task" {
27 source = "./modules/task"
28
29 task_name = var.task_name
30 schedule_expression = var.schedule_expression
31 vpc_id = module.vpc.vpc_id
32 subnet_id = module.vpc.public_subnet_id
33 database_arn = module.database.database_arn
34 database_credentials_secret_arn = module.database.database_credentials_secret_arn
35}
36
37module "database" {
38 source = "./modules/database"
39
40 database_name = var.database_name
41 vpc_id = module.vpc.vpc_id
42 subnet_ids = module.vpc.private_subnet_ids
43 container_security_group_id = module.task.container_security_group_id
44 bastion_host_security_group_id = module.vpc.bastion_host_security_group_id
45}
46
47module "github_actions_iam" {
48 source = "./modules/github_actions_iam"
49
50 app_name = var.app_name
51 repo_ref = var.repo_ref
52}

Det benyttes moduler for å splitte opp kode og ansvarsområder. Moduler kan ses litt på som funksjoner i vanlige programmeringsspråk, hvor feltene i modulene er argumenter som slenges inn og benyttes av modulene. I f. eks databasemodulen ser vi at var.database_name og var.container_security_group_id leses.

main.tf
1resource "aws_security_group" "db_security_group" {
2
3 name = "${var.database_name}-security-group"
4 vpc_id = var.vpc_id
5 ingress {
6 from_port = 5432
7 to_port = 5432
8 protocol = "tcp"
9 security_groups = [var.container_security_group_id, var.bastion_host_security_group_id]
10 }
11}
12
13resource "aws_db_subnet_group" "db_subnet_group" {
14 name = "${var.database_name}-subnet-group"
15 subnet_ids = var.subnet_ids
16}
17
18resource "aws_db_instance" "prisjakt_scraper_db" {
19 identifier = "${var.database_name}-instance"
20 db_name = var.database_name
21 engine = "postgres"
22 engine_version = "15.3"
23 allow_major_version_upgrade = true
24 instance_class = "db.t3.micro"
25 username = "postgres"
26 backup_retention_period = 7
27 backup_window = "07:00-09:00"
28 maintenance_window = "sun:05:00-sun:06:00"
29 manage_master_user_password = true
30 vpc_security_group_ids = [aws_security_group.db_security_group.id]
31 allocated_storage = 20
32 storage_type = "gp2"
33 publicly_accessible = false
34 skip_final_snapshot = true
35 db_subnet_group_name = aws_db_subnet_group.db_subnet_group.name
36 ca_cert_identifier = "rds-ca-rsa4096-g1"
37}
38
39resource "aws_db_snapshot" "prisjakt_scraper_db_snapshot" {
40 db_instance_identifier = aws_db_instance.prisjakt_scraper_db.identifier
41 db_snapshot_identifier = "${var.database_name}-snapshot"
42}
43

De mer interessante attributtene for en database er ellers ting som engine = "postgres" og allocated_storage = 20. Disse kan lett endres på i koden og anvendes om du f. eks skulle få lyst til å oppgradere lagringsplass eller endre backup-vinduet.

Verdiene på variablene som sendes inn i mainfilen på øverste nivå defineres i filen terraform.tfvars, og ser i dette tilfellet slik ut:

terraform.tfvars
1app_name = "prisjakt-scraper"
2database_name = "prisjaktscraperdb"
3region = "eu-west-1"
4vpc_name = "prisjakt-vpc"
5task_name = "prisjakt-scraper-task"
6schedule_expression = "rate(24 hour)"
7repo_ref = "repo:haraldvinje/prisjakt_scraper:ref:refs/heads/master"

Hvorvidt denne skal inkluderes i versjonskontroll avhenger av om det er sensitive detaljer i den eller ikke. I dette tilfellet er det ingen store hemmeligheter, så den kan være åpen og tilgjengelig i versjonskontroll.

To andre moduler i koden jeg vil nevne som ikke direkte tilhører infrastrukturen som trengs for å kjøre tasken er github_actions_iam og remote_state. I mappen github_actions_iam settes en tilkobling til AWS-kontoen fra Github, i tillegg til roller og policies i IAM slik at Github Actions får tilgang til automatisk deployment når koden pushes til masterbranchen (les mer her om du er nysgjerrig på hvordan dette funker). I mappen remote_state er koden for å opprette en S3 bucket som backend for statefilen. Siden Terraform forutsetter en skyressurs for å bruke en remote backend til lagring av statefilen (i motsetning til å ha statefilen lokalt, som er svært upraktisk) får vi et slags høna eller egget-problem. Måten dette kan løses er enten å bare lage S3 bucketen manuelt, eller å spinne den opp med Terraform, selv om du antageligvis da mister oversikt over statefilen (som blir "the state of the state file" 🧐), da den vil være på lokal maskin. Hadde dette prosjektet vært en del av en større organisasjon ville jeg hatt disse to modulene i et annet repo, da det kun trengs én hver av disse ressursene for for alle prosjekter innen samme konto/organisasjon.

Spinn opp!

Nå som all Terraformkoden er skrevet kan vi "applye" den for å spinne opp infrastrukturen. Den kan ofte først være lurt å validere og se planen ved å kjøre terraform validate og terraform plan, så du får tilbakemelding på om du vil få en gyldig tilstand. At terraform plan går gjennom er forøvrig ingen garanti for at terraform apply lykkes på alle ressurser, men bedre enn ingenting. Har du mange tunge ressurser kan det fort ta 10-15 minutter før alt er oppe å kjøre, men får å få en oversikt over tilstanden eller "staten" etter ting er satt opp kan man kjøre terraform state list:

Terraform state list

Det er også definert en outputfil, output.tf, hvor det er definert noen outputs som kan leses ved å kjøre terraform output:

Terraform outputs

Disse outputene er nyttige om vil koble til databasen via proxy serveren. Shell scriptet db_connection.sh bruker ouptutet fra resurrsene som er opprettet:

db_connection.sh
1#!/bin/bash
2
3echo -e "You will need to have the following software installed on your system for this script to work:\n - terraform \n - aws CLI \n - aws-session-manager-plugin \n - jq\n"
4
5set -e
6set -o pipefail
7
8cd terraform
9[ ! -d ".terraform" ] && terraform init > /dev/null && rm .terraform.lock.hcl
10DB_PORT=$(terraform output database_port | tr -d '"')
11echo "Creating database connection. Make sure no other processes use port $DB_PORT."
12
13EC2_INSTANCE_ID=$(terraform output bastion_host_id | tr -d '"')
14DB_SECRET_ARN=$(terraform output database_credentials_secret_arn | tr -d '"')
15DB_USERNAME=postgres
16DB_PASSWORD=$(aws secretsmanager get-secret-value --secret-id "$DB_SECRET_ARN" --query SecretString --output text | jq '."password"' | tr -d '"')
17DB_ENDPOINT=$(terraform output database_address | tr -d '"')
18
19echo DB_USERNAME=$DB_USERNAME
20echo DB_PASSWORD="$DB_PASSWORD"
21echo DB_HOST=localhost
22echo DB_PORT="$DB_PORT"
23echo -e DB_NAME=prisjaktscraperdb'\n'
24
25echo -e 'Creating tunnel to database!\nUse credentials as described above to connect.'
26aws ssm start-session --target "$EC2_INSTANCE_ID" --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters host=$DB_ENDPOINT,portNumber="$DB_PORT",localPortNumber="$DB_PORT"

Selv om bare overflaten av koden er sett på har vi nå fått en smakebit på mange av Terraforms nyttige funksjoner. Sjekk gjerne ut koden på GitHub for en mer komplett oversikt.

Strategier

Å sette opp Terraform for en stor organisasjon med mange kodebaser, produkter og tjenester krever en del veivalg og strategier, og har man bestemt seg for én er det ikke alltid like lett å refaktorere som med andre kodebaser. Jeg skal på tampen av innlegget komme med noen tanker av hvordan man kan legge opp koden sin på tvers av team. Et viktig forbehold er såklart at strategien avhenger helt av hvordan organisasjonen er strukturert, hvordan ansvarsområder er fordelt og hvordan tilganger og kontostruktur hos skyleverandørene man benytter seg av er lagt opp. Det sagt syns jeg dette er et godt utgangspunkt:

  • Én kodebase for felles infrastruktur, som alle team trenger eller kan trenge.
  • Én kodebase for moduler som kan være nyttige for mange av teamene i en organisasjon. F. eks om mange av teamene trenger en "static_webiste_host" kan de hente dette fra denne kodebasen og tweake på den etter eget behov i egen kode.
  • Applikasjonsspesifikk infrastruktur i den respektive applikasjonens kodebase.

Konklusjon

Terraform kan være vanskelig å få grep på, da det krever god forståelse av mange av aspektene ved Public Cloud, i tillegg til spesifikk kunnskap om leverandørene man bruker. Det er et svært kraftig verktøy når man får dreisen på det, og kan for store virksomheter automatisere og streamline utvikling på en måte som gir dem en stor fordel mtp hastighet, oversikt og mobilitet. Håper du dro nytte av innlegget!

Del: