Translate

неділю, 18 червня 2023 р.

Terraform. Managing AWS Infrastructure

Terraform - це програма для побудови та безпечного обслуговування інфраструктури. Його основний розробник, компанія HashiCorp, представила перший реліз Terraform в 2012 році і наразі будь-хто може приєднатись до його розробки.

Ресурси в Terraform описуються як код на власній декларативній мові HCL (Hashicorp Common Language), тому він з легкістю може бути доданий до системи контролю версій на зразок Git (Infrastructure as Code). Ця особливість забезпечує зручне відслідковування змін коду, його рецензування, можливість налаштування якісного CI/CD та інше.

Terraform складається із двох основних частин: Core та Plugins. Terraform Core відповідальний за побудову графів залежності ресурсів, плану їх створення чи зміни та, за допомогою протоколу RPC, комунікує із плагінами. У свою чергу Terraform Plugins - це різноманітні реалізації API специфічних сервісів, на кшталт SDN (AWS, Azure, Google Cloud, OpenStack і інші), PaaS-платформ (Heroku), SaaS сервісів (наприклад, DNSimple, CloudFlare, Gitlab), self-hosted програмного забезпечення (Docker, MySQL, RabbitMQ) і неймовірної кількість іншого софту.

Далі буде розглянуто роботу Terraform з cloud-провайдером Amazon Web Services. Забігаючи наперед скажу, що Terraform не cloud-agnostic, тобто для побудови схожої інфраструктури, наприклад, у Google Cloud необхідно буде переписувати всі темплейти.


1. PREREQUIREMENTS


Terraform і його плагіни написані на мові Go зі статичним лінкуванням бібліотек і розповсюджується як готовий бінарний файли для всіх популярних і не дуже ОС. У якості клієнта я буду використовувати Ubuntu та архітектуру amd64, тож завантажу відповідний архів:

$ wget https://releases.hashicorp.com/terraform/1.5.0/terraform_1.5.0_linux_amd64.zip
$ unzip terraform_1.5.0_linux_amd64.zip
$ chmod +x terraform
$ sudo mv terraform /usr/local/bin/

$ terraform -v
Terraform v1.5.0
on linux_amd64

Надалі нам також знадобиться aws-cli, хоч він не обов'язковий і Terraform не потребує його присутності:

$ python3 -m venv venv
$ source venv/bin/activate

$ pip install awscli
$ aws --version
aws-cli/1.27.143 Python/3.11.2 Linux/6.2.0-20-generic botocore/1.29.143 

Використовуючи root доступ до консолі управління AWS, необхідно створити нового користувача з повними правами (або з обмеженими, якщо є плани лімітувати різноманіття ресурсів, котрими зможе оперувати Terraform). Після чого із отриманими AWS_ACCESS_KEY_ID та AWS_SECRET_ACCESS_KEY налаштуємо awscli:

$ aws configure
AWS Access Key ID [None]: MYSECRETKEYID
AWS Secret Access Key [None]: MYSERETKEYVALUE
Default region name [None]: us-east-1
Default output format [None]:


2. CREATING INFRASTRUCTURE WITH TERRAFORM


У цьому розділі ми розглянемо створення простого ресурсу, для розуміння базових можливостей Terraform, та інфраструктури, що схожа на другий сценарій документації AWS.

Код, описаний далі, можна знайти у GitHub репозиторії.

2.1. CREATING SIMPLE EC2 INSTANCE


Створимо просту віртуальну машину EC2 в default VPC та підмережі. Цього разу спробуємо не заморочуватись нічим зайвим.

$ mkdir simple-web

$ vim terraform.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}


Отже було вказано використання aws плагіну, стабільної версії від 4.0 до 5.0 та регіону us-east-1. Параметри доступу до аккаунту (AWS_ACCESS_KEY_ID та AWS_SECRET_ACCESS_KEY) будуть взяті із ~/.aws/credentials, в тому числі для цього ми і налаштовували awscli.

Опишемо дозволи на вхідні і вихідні підключення (Security Group) та змінні для них:

$ vim sgs.tf
resource "aws_security_group" "web-sg" {
  name = "web-sg"

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = "web-sg"
  }
}

resource "aws_security_group_rule" "allow_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web-sg.id

  from_port   = var.http_port
  to_port     = var.http_port
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_ssh_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web-sg.id

  from_port   = var.ssh_port
  to_port     = var.ssh_port
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.web-sg.id

  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

$ vim vars.tf
variable "http_port" {
  default     = 8080
  description = "for HTTP requests"
  type        = number
}

variable "ssh_port" {
  default     = 22
  description = "for SSH requests"
  type        = number
}

# replace ssh key on your own
variable "public-key" {
  default     = "ssh-rsa AAAAB3N...onJqBB my-email@example.com"
  description = "ssh key for EC2 machines"
  type        = string
}
Тобто було дозволено підключатись до 80 порту та порту ssh з будь-яких IP адрес (cidr_blocks = ["0.0.0.0/0"]). Звісно, за необхідності це можна змінити і, наприклад, обмежити підключення до ssh лише з власної адреси. Всі вихідні підключення (type = "egress") не лімітовані. Ключ create_before_destroy=true вказує на те, що у разі необхідності нова SG має бути створена перед видаленням старої. У змінну public-key необхідно помістити свій власний публічний ssh ключ.

Нарешті опишемо створення EC2 віртуальної машини:

$ vim ec2.tf
resource "aws_key_pair" "my-key" {
  key_name   = "my-key"
  public_key = var.public-key
}

resource "aws_instance" "web" {
  # Ubuntu Server 22.04 LTS in us-east-1
  ami                    = "ami-0044130ca185d0880"
  instance_type          = "t2.medium"
  key_name               = aws_key_pair.my-key.key_name
  vpc_security_group_ids = [aws_security_group.web-sg.id]

  tags = {
    Name = "web"
  }

  user_data = <<-EOF
            #!/bin/bash
            echo "<h1>Say hello to Terraform</h1>" > index.html
            nohup busybox httpd -f -p 8080 &
            EOF
}

# We wish to output public IP to bash session
output "web_public_ip" {
  value = aws_instance.web.public_ip
}
Ресурс aws_key_pair відповідає за створення нового ключа зі змінної var.public-key, що була описана вище. У якості образу ОС для вузлів я обрав Ubuntu ami-0044130ca185d0880, останній же AMI можна знайти за посиланням https://cloud-images.ubuntu.com/locator (імена образів різняться в залежності від регіону). Параметр vpc_security_group_ids прикріпить уже описану security group web-sg до нового EC2 інстансу web. user_data інструкція дозволяє після запуску віртуальної машини одноразово виконати команди, тому ми для наочності створимо тут index.html та запустимо з ним busybox httpd на 8080 порту.

Варто зауважити, що через те, що був опущений параметр subnet_id в ресурсі aws_instance, віртуальна машина web буде створена в default VPC, тобто в тому, що вже був створений після реєстрації в AWS, і в котрому створені лише публічні мережі (і для web EC2 вона буде обрана випадковим чином).

Директива output дозволить вивести публічну IP адресу майбутньої віртуальної машини прямо в термінал, проте вона також може використовуватись як інформація для майбутніх запусків terraform як datasource. Детальніше про це в наступному підрозділі.

Тепер відформатуємо написані темплейти (так, Terraform вміє форматувати свій код згідно кращим практикам):

$ cd simple-web
$ terraform fmt

Та ініціюємо Terraform:

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v4.66.1

Terraform has been successfully initialized!
...
 
Після чого буде створено нову директорію .terraform в котру завантажиться плагін для роботи з aws.

Перевіримо чи все в порядку з написаними темплейтами:

$ terraform plan

Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.
...
Plan: 6 to add, 0 to change, 0 to destroy.

Якщо ж все добре, запустимо створення описаних ресурсів і підтвердимо свої наміри:

$ terraform apply
...
Plan: 6 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes
...

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:

web_public_ip = 54.172.233.118

Terraform самостійно побудує граф залежностей (execution plan), що за чим варто створювати, і вже по цьому плану створить всі необхідні ресурси. Якщо ж ресурси не залежать один від одного - Terraform буде створювати їх паралельно, задля зменшення часу виконання поставлених цілей. Terraform використовує декларативний підхід до запуску коду, тобто його цікавить саме результат, котрого треба досягти, а не черга процедур які потрібно виконати для досягнення цього результату. Ansible та Chef навпаки використовують процедурний стиль, тобто вони крок за кроком виконують описані процедури. На мій погляд, це не погано і не добре, все залежить від ситуацій і потреб.

Декларативний спосіб чудово підходить для задач Terraform-у: описувати інфраструктуру. Виконувати якусь додаткову логіки з ним так само "зручно", як і описувати інфраструктуру з Ansible. Декларативний же підхід у випадку Puppet - це скоріше недолік, адже дуже складно слідкувати за залежностями запуску ресурсів.

Перевіримо чи справді 8080 порт відповідає на запити:

22 порт web серверу також доступний для підключення

Після підняття EC2 машини, Terraform також створив terraform.tfstate файл, в котрому він зберіг власний стан роботи: які саме ресурси було створено, описав відповідності власних внутрішніх назв та id ресурсів всередині AWS, додав секцію outputs із ec2.tf і т.п. У разі майбутніх змін вже побудованої інфраструктури Terraform буде перечитувати цей файл для розуміння, що саме необхідно буде змінити. terraform.tfstate - це дуже важливий файл, його потрібно зберігати в репозиторію з основним кодом чи завантажувати віддалено, наприклад, в AWS S3 із увімкненою версійністю.

Після експериментів важливо не забути видалити створені ресурси:

$ terraform plan --destroy
$ terraform destroy
...
Plan: 0 to add, 0 to change, 6 to destroy.

Do you really want to destroy?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes
...

Destroy complete! Resources: 6 destroyed


2.2. CREATING VPC WITH PUBLIC AND PRIVATE SUBNETS (NAT)


2.2.1. Creating Infrastructure With Terraform


Цього разу ми побудуємо дещо складнішу інфраструктуру. Буде створено окремий VPC, в котрому розмістяться 2 підмережі: публічна та приватна. В публічній буде розміщено bastion вузол, котрий буде доступний з мережі інтернет і через який буде можливий ssh доступ до всіх створених вузлів у новому VPC. У приватній мережі буде розміщено 2 web вузла, що були додані до пулу ELB балансувальника. Схема майбутньої мережі виглядатиме наступним чином:


Для спрощення попереднього підрозділу, було упущено створення віддалених блокувань і сховища для файлу стану (lock/remote state) Terraform. Насправді вони дуже важливі, особливо при роботі у великих командах. Локи унеможливлять одночасний запуск одних і тих же процедур, а завдяки віддаленому збереженню Terraform стану кожен оператор автоматично буде отримувати останній tfstate.

За допомогою вже налаштованого awscli, створимо необхідний S3 bucket та таблицю DynamoDB:

$ aws s3api create-bucket \
      --bucket my-tf-state-15 \
      --region us-east-1

$ aws s3api put-bucket-versioning \
      --bucket my-tf-state-15 \
      --versioning-configuration Status=Enabled

Для регіонів відмінних від us-east-1 необхідно також додавати аргумент --create-bucket-configuration LocationConstraint=<region>

$ aws dynamodb create-table \
      --region us-east-1 \
      --table-name my-inf-tflock \
      --attribute-definitions AttributeName=LockID,AttributeType=S \
      --key-schema AttributeName=LockID,KeyType=HASH \
      --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

Разом із типом провайдера опишемо їх в terraform.tf:

$ mkdir subnets-web
$ cat terraform.tf
# S3 bucket and DynamoDB table need to be created before

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
  backend "s3" {
    bucket         = "my-tf-state-15"
    dynamodb_table = "my-inf-tflock"
    key            = "my-inf.tfstate"
    region         = "us-east-1"
  }
}

provider "aws" {
  region                   = var.region
  shared_credentials_files = var.credentials
  # access_key               = "my-access-key"
  # secret_key               = "my-secret-key"
}
Значення регіону та адресу shared_credentials_file, де описані AWS_ACCESS_KEY_ID та AWS_SECRET_ACCESS_KEY, будуть взяті із темплейту vars.tf:

$ cat vars.tf
variable "region" {
  default = "us-east-1"
}

variable "ami-image" {
  type = map(any)

  # Ubuntu 16.04
  default = {
    us-east-1      = "ami-a4dc46db" # Virginia
    eu-central-1   = "ami-c7e0c82c" # Frankfurt
    ap-southeast-1 = "ami-81cefcfd" # Singapore
  }

  description = "only 3 regions (Virginia, Frankfurt, Singapore) to show the map feature"
}

variable "credentials" {
  default     = ["~/.aws/credentials"]
  description = "where your access and secret_key are stored, you create the file when you run the aws config"
}

variable "vpc-cidr" {
  default     = "172.28.0.0/16"
  description = "vpc cidr"
}

variable "subnet-public-cidr" {
  default     = "172.28.0.0/24"
  description = "cidr of the public subnet"
}

variable "subnet-private-cidr" {
  default     = "172.28.3.0/24"
  description = "cidr of the private subnet"
}

# replace it on your own
variable "public-key" {
  default     = "ssh-rsa AAAA...0eOsonJqBB my-email@example.com"
  description = "ssh key to use in the EC2 machines"
}

variable "dns-zone-name" {
  default     = "ipeacocks.internal"
  description = "internal dns name"
}
Окрім того тут є купа інших змінних, як от діапазон адрес публічних і приватних мереж, приватна dns-зона, ami-образ, що буде взятий за основу для створення EC2 віртуальних машин, та інше. Загалом параметр shared_credentials_file і, як наслідок, змінну credentials оголошувати було не обов'язково, адже у випадку налаштованого awscli він і так буде перечитуватись по указаному шляху. Як альтернатива AWS_ACCESS_KEY_ID та AWS_SECRET_ACCESS_KEY можна просто експортувати до змінних середовища, що може бути зручним у випадку управління декількома аккаунтами.

Як і було обіцяно створимо окремий VPC із підмережами:

$ cat network.tf
# Declare the data source
# All available zones (AZ) list
data "aws_availability_zones" "available" {}

resource "aws_vpc" "my-vpc" {
  cidr_block = var.vpc-cidr

  # these 2 values are for internal vpc dns resolution
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name      = "my-vpc"
    Terraform = 1
  }
}

resource "aws_subnet" "my-public-subnet" {
  vpc_id     = aws_vpc.my-vpc.id
  cidr_block = var.subnet-public-cidr

  tags = {
    Name      = "my-public-subnet"
    Terraform = 1
  }

  availability_zone = data.aws_availability_zones.available.names[0]
}

resource "aws_subnet" "my-private-subnet" {
  vpc_id     = aws_vpc.my-vpc.id
  cidr_block = var.subnet-private-cidr

  tags = {
    Name      = "my-private-subnet"
    Terraform = 1
  }

  availability_zone = data.aws_availability_zones.available.names[0]
}

resource "aws_internet_gateway" "my-internet-gw" {
  vpc_id = aws_vpc.my-vpc.id

  tags = {
    Name      = "my-internet-gateway"
    Terraform = 1
  }
}

resource "aws_eip" "my-private-subnet-nat-eip" {
  vpc = true

  tags = {
    Name      = "my-private-subnet-nat-eip"
    Terraform = 1
  }
}

# EIP of NAT Gateway needs to be in public network
resource "aws_nat_gateway" "my-private-subnet-nat" {
  allocation_id = aws_eip.my-private-subnet-nat-eip.id
  subnet_id     = aws_subnet.my-public-subnet.id
  depends_on    = [aws_internet_gateway.my-internet-gw]

  tags = {
    Name      = "my-private-subnet-nat"
    Terraform = 1
  }
}
У VPC my-vpc будуть знаходитись 2 підмережі: публічна my-public-subnet (всі вузли матимуть не постійну публічну постійну IP адресу і приватну) та приватна my-private-subnet (лише приватні адреси). Список регіонів буде отриманий автоматично завдяки внутрішнім ресурсам Terraform і його перший елемент стане зоною доступності (AZ) всіх вузлів.

Окрім самих підмереж необхідно створити Інтернет шлюз my-internet-gw у новому VPC задля доступу в мережу Internet як з публічної, так і з приватної мережі. Також треба створити в публічній мережі my-public-subnet NAT шлюз my-private-subnet-nat, призначити йому постійний публічний IP my-private-subnet-nat-eip та додати цей шлюз до приватної мережі my-private-subnet. Без цього Internet доступ в приватній мережі буде неможливий.

Щоб трафік вірно маршрутизувався опишемо правила роутингу:

$ cat routing.tf
resource "aws_route_table" "my-public-routes" {
  vpc_id = aws_vpc.my-vpc.id

  tags = {
    Name      = "my-public-routes"
    Terraform = 1
  }
}

resource "aws_route" "route-to-internet-gateway" {
  route_table_id         = aws_route_table.my-public-routes.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.my-internet-gw.id
}

resource "aws_route_table_association" "my-public-subnet" {
  subnet_id      = aws_subnet.my-public-subnet.id
  route_table_id = aws_route_table.my-public-routes.id
}

resource "aws_route_table" "my-private-routes" {
  vpc_id = aws_vpc.my-vpc.id

  tags = {
    Name      = "my-private-routes"
    Terraform = 1
  }
}

resource "aws_route" "route-to-nat-gateway" {
  route_table_id         = aws_route_table.my-private-routes.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.my-private-subnet-nat.id
}

resource "aws_route_table_association" "my-private-subnet" {
  subnet_id      = aws_subnet.my-private-subnet.id
  route_table_id = aws_route_table.my-private-routes.id
}
Отже, пакети, призначені для зовнішніх адрес, будуть направлятись прямо на Internet Gateway my-internet-gw для вузлів із публічної мережі my-public-subnet і на NAT Gateway my-private-subnet-nat у випадку приватної мережі my-private-subnet.

Тепер опишемо security groups для вузлів інфраструктури:

$ cat sgs.tf
 
# SG for bastion (ssh host)

resource "aws_security_group" "bastion-sg" {
  name   = "bastion-sg"
  vpc_id = aws_vpc.my-vpc.id

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name      = "bastion-sg"
    Terraform = 1
  }
}

resource "aws_security_group_rule" "allow_bastion_ssh_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.bastion-sg.id

  from_port   = "22"
  to_port     = "22"
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_bastion_icmp_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.bastion-sg.id

  from_port   = -1
  to_port     = -1
  protocol    = "icmp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_bastion_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.bastion-sg.id

  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

# SG for web servers

resource "aws_security_group" "web-sg" {
  name   = "web-sg"
  vpc_id = aws_vpc.my-vpc.id

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name      = "web-sg"
    Terraform = 1
  }
}

resource "aws_security_group_rule" "allow_web_ssh_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web-sg.id

  from_port   = "22"
  to_port     = "22"
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

# Allow traffic only from ELB on 8080 port
resource "aws_security_group_rule" "allow_web_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web-sg.id

  from_port                = "8080"
  to_port                  = "8080"
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.web-elb-sg.id
}

resource "aws_security_group_rule" "allow_web_icmp_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web-sg.id

  from_port   = -1
  to_port     = -1
  protocol    = "icmp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_web_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.web-sg.id

  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

# SG for ELB web-elb

resource "aws_security_group" "web-elb-sg" {
  name   = "web-elb-sg"
  vpc_id = aws_vpc.my-vpc.id

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name      = "web-elb-sg"
    Terraform = 1
  }
}

resource "aws_security_group_rule" "allow_web-elb_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web-elb-sg.id

  from_port   = "80"
  to_port     = "80"
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_web-elb_icmp_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.web-elb-sg.id

  from_port   = -1
  to_port     = -1
  protocol    = "icmp"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "allow_web-elb_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.web-elb-sg.id

  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}


Було описано три групи: для bastion, web та web-elb (майбутній балансувальник) вузлів, в яких було відкрито наступні порти/дозволені протоколи:

* 22 порт на всіх вузлах (хоча з мережі Інтернет буде видно лише порт бастіону)
* ICMP на всіх вузлах
* вхідний 80 порт на web-elb
* вхідний 8080 порт на web вузлах лише з security group балансувальника (тобто з самого балансувальника)
* вихідний трафік з вузлів (type = "egress") не обмежується

Я не ставив за мету описати якомога жорсткіші правила, тож тут достатньо простору для фантазії.

Опишемо створення EC2 віртуальних машин та балансувальника:

$ vim ec2.tf
 
resource "aws_key_pair" "my-key" {
  key_name   = "my-key"
  public_key = var.public-key
}

resource "aws_instance" "bastion" {
  ami = lookup(var.ami-image, var.region)

  # ami                  = data.aws_ami.ubuntu-latest.id
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.my-public-subnet.id
  vpc_security_group_ids = [aws_security_group.bastion-sg.id]
  key_name               = aws_key_pair.my-key.key_name

  tags = {
    Name      = "bastion"
    Terraform = 1
  }
}

resource "aws_eip" "bastion-eip" {
  instance   = aws_instance.bastion.id
  depends_on = [aws_instance.bastion]

  tags = {
    Name      = "bastion-eip"
    Terraform = 1
  }
}

resource "aws_instance" "web" {
  count                  = 2
  ami                    = lookup(var.ami-image, var.region)
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.my-private-subnet.id
  vpc_security_group_ids = [aws_security_group.web-sg.id]
  key_name               = aws_key_pair.my-key.key_name

  tags = {
    Name      = "web-${count.index}"
    Terraform = 1
  }

  user_data = <<-EOF
            #!/bin/bash
            echo "<h1>Say hello to Terraform from web-${count.index}</h1>" > index.html
            nohup busybox httpd -f -p 8080 &
            EOF

  root_block_device {
    volume_type = "gp2"
    volume_size = "10"
  }
}

resource "aws_elb" "web-elb" {
  name            = "web-elb"
  subnets         = [aws_subnet.my-public-subnet.id]
  security_groups = [aws_security_group.web-elb-sg.id]

  listener {
    instance_port     = 8080
    instance_protocol = "TCP"
    lb_port           = 80
    lb_protocol       = "TCP"
  }

  health_check {
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 4
    target              = "TCP:8080"
    interval            = 30
  }

  instances                   = aws_instance.web.*.id
  idle_timeout                = 90
  connection_draining         = true
  connection_draining_timeout = 90

  tags = {
    Name      = "web-elb"
    Terraform = 1
  }
}


Cпершу було додано ssh публічний ключ, що використовуватиметься для доступу до всіх віртуальних машин. Також було створено 3 віртуальні машини: web-0, web-1 та bastion. Завдяки параметру subnet_id, bastion розмістився в публічній мережі, а web-0 та web-1 - в приватній. Через параметр vpc_security_group_ids всім вузлам були призначені відповідні security групи.

web вузли були створені одним ресурсом aws_instance, завдяки параметру count = 2. Тож для зміни кількості вузлів необхідно лише збільшити чи зменшити це значення. Ім’я хостів також будуть генеруватись автоматично.

bastion отримав постійну IP адресу задля зручності доступу до нього, адже без Elastic IP, адреси вузлів публічних мереж змінюються після їх перевантаження.

web-0 та web-1 було додано до пулу балансувальника web-elb (параметр instances). Завдяки опції health_check банасувальник може припиняти подачу трафіку на проблемний хост. Також web-* вузли отримали збільшений до 10 ГБ розмір кореневого диску (root_block_device).

При старті web-* машин, завдяки user_data, запуститься busybox httpd на 8080 порту з простим html файлом. Балансувальник web-elb буде переадресовувати власний 80 на ці порти EC2.

Виходячи з імені регіону var.region, буде взято коректний AMI var.ami-image із vars.tf. AMI образ можна також отримати наступним чином:

data "aws_ami" "ubuntu-latest" {
  most_recent = true

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

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

  owners = ["099720109477"] # Canonical
}


І потім вже отримане значення додати до опису віртуальних машин в ec2.tf:

ami = data.aws_ami.ubuntu-latest.id

Проте я не впевнений чи це хороший варіант, адже з часом всі віртуальні машини інфраструктури будуть мати різний AMI id.

Оперувати IP адресами для доступу до вузлів зовсім не зручна справа. Тому створимо внурішню зону ipeacocks.internal (описана в vars.tf) та доменні імена web-0.ipeacocks.internal та web-1.ipeacocks.internal:

$ cat dns_dhcp.tf
 
resource "aws_vpc_dhcp_options" "my-dhcp" {
  domain_name         = var.dns-zone-name
  domain_name_servers = ["AmazonProvidedDNS"]

  tags = {
    Name      = "my-dhcp-server"
    Terraform = 1
  }
}

resource "aws_vpc_dhcp_options_association" "my-dhcp-attachment" {
  vpc_id          = aws_vpc.my-vpc.id
  dhcp_options_id = aws_vpc_dhcp_options.my-dhcp.id
}

/* DNS PART ZONE AND RECORDS */
resource "aws_route53_zone" "main" {
  name    = var.dns-zone-name
  comment = "Managed by Terraform"
}

resource "aws_route53_record" "web-domain" {
  # count   = aws_instance.web[count.index]
  count   = length(aws_instance.web)
  zone_id = aws_route53_zone.main.zone_id
  name    = "web-${count.index}.${var.dns-zone-name}"
  type    = "A"
  ttl     = "60"
  records = ["${element(aws_instance.web.*.private_ip, count.index)}"]
}


Окрім того в цьому файлі було створено окремий dhcp сервер для my-vpc VPC.

У описі кожного ресурсу я додавав тег 'Terraform = 1' для інформування користувачів консолі AWS, що ресурс був створений із залученням Terraform. Це дуже хороша практика, адже маючи тег можна легко отримати всі створені ресурси і, наприклад, видалити їх, маючи проблеми з Terraform (наприклад, у разі втраченого tfstate).

$ aws ec2 describe-instances --filter "Name=tag:Terraform,Values=1"
$ aws ec2 describe-vpcs --filter "Name=tag:Terraform,Values=1"
...
$ aws ec2 describe-subnets --filter "Name=tag:Terraform,Values=1"


Також розумним буде додавати тег середовища, для якого проходить налаштування інфраструктури, наприклад, Environment = "dev" чи навіть використовувати ім’я середовища у назвах ресурсів - Name = "${var.environment}-resource-name".

Опціонально можна створити output секцію:

$ cat output.tf
 
output "my-vpc-id" {
  value = aws_vpc.my-vpc.id
}

output "my-public-subnet-id" {
  value = aws_subnet.my-public-subnet.id
}

output "my-private-subnet-id" {
  value = aws_subnet.my-private-subnet.id
}

output "bastion-eip" {
  value = aws_eip.bastion-eip.public_ip
}

output "web-elb" {
  value = aws_elb.web-elb.dns_name
}


Завдяки їй, після створення всіх ресурсів, Terraform виведе id vpc, підмереж, адресу бастіону та балансувальника попереду web EC2 машин. Проте це не головне призначення output, адже завдяки їй ці значення автоматично внесуться до tfstate на s3 і їх можна буде використовувати для створення нових ресурсів окремо від описаних нами темплейтів (в іншій директорії/репозиторію і т.п.).

Ініціюємо та запускаємо Terraform:

$ terraform init
$ terraform plan

$ terraform apply
 
Acquiring state lock. This may take a few moments...
data.aws_availability_zones.available: Refreshing state...

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create
...
Plan: 36 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes
...
Apply complete! Resources: 36 added, 0 changed, 0 destroyed.
Releasing state lock. This may take a few moments...

Outputs:

bastion-eip = 18.209.83.24
my-private-subnet-id = subnet-8a8a20d6
my-public-subnet-id = subnet-de862c82
my-vpc-id = vpc-a0649ada
web-elb = web-elb-391033235.us-east-1.elb.amazonaws.com

Після чого звісно можна буде підключитись по ssh до bastion-у:

$ ssh ubuntu@18.209.83.24
Welcome to Ubuntu 16.04.4 LTS (GNU/Linux 4.4.0-1060-aws x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  Get cloud support with Ubuntu Advantage Cloud Guest:
    http://www.ubuntu.com/business/services/cloud

0 packages can be updated.
0 updates are security updates.


Last login: Sat Jul 14 15:13:14 2018 from 130.180.212.73
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

ubuntu@ip-172-28-0-170:~$

Та переглянути просту html-сторінку за адресою ELB:


2.2.2. Infrastructure Scheme Demonstration


Переглянемо консоль панель AWS після відпрацювання Terraform. Новий VPC my-vpc:

Приватна та публічна підмережі, що створені в цьому VPC:

Створений Internet Gateway для VPC та NAT Gateway, котрий додано до приватної мережі:

Маршрути для обслуговування трафіку в підмережах:

3 створені віртуальні машини:

2 з них, а саме web-0 та web-1, лежать в приватній мережі та знаходяться в пулі класичного балансувальника web-elb:

Як web-вузли, так і балансувальник мають окремі права на підключення:

NAT Gateway потребує EIP, а для бастіону він був доданий лише для зручності:

DNS зона *.ipeacocks.internal та DHCP-сервер:


2.2.3. Removing Infrastructure


Потужності AWS звісно коштують грошей. Тому після усіх тестів необхідно все видалити:

$ terraform destroy
Acquiring state lock. This may take a few moments...
...
Plan: 0 to add, 0 to change, 36 to destroy.

Do you really want to destroy?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes
...
Destroy complete! Resources: 36 destroyed.
Releasing state lock. This may take a few moments...


3. TERRAFORM CODE ORGANIZATION, MODULES


Підходів до організації структури директорій є багато. Один із основних і, як мені здається, найбільш вдалий - це поділ коду інфраструктури на директорії dev, prod і т.п., де вже знаходяться вкладені директорії підсистем, на кшталт баз даних, веб-серверів. Наприклад:


У директоріях stage, prod знаходиться описи інфраструктур проекту, а у mgmt - інфраструктура сервісів на кшталт Jenkins, GitLab, тобто котрі часто стосуються всіх середовищ (environment). Створення vpc/subnet описується окремо від основного коду (знаходяться в vpc директорії) і, завдяки інструкціям output, їх ID та інші важливі параметри потрапляють до віддаленого стейту, що вже будуть використовуватись в директоріях темплейтів для інших підсистем. Про принцип роботи output я писав трохи вище.

Звісно варто думати своєю головою при прийнятті таких важливих рішень. Наприклад, можливо виділення окремої директорії data-storage для опису баз даних буде лише зайвим, і краще все описати в основній директорії додатку. Але описувати все лише в одному tf файлі точно не варто.

Код опису інфраструктури часто організовують у вигляді модулів, що використовуються повторно. Але, як на мене, модулі Terraform досить контроверсійна річ і надалі може спричинити більше проблем ніж зручностей. Наприклад, опис підмереж для stage може бути значно іншим від підмереж для проду, і, у разі використання модулів, необхідно буде робити щось на зразок наслідування. Якщо є впевненість, що інфраструктури різних середовищ (dev, stage, prod) будуть відносно схожими - то, мабуть, використання модулів буде непоганою ідеєю. Знову ж, у кожного свої вподобання і погляд на те як має бути правильно.

Посилання:
https://www.terraform.io/intro/index.html
https://blog.gruntwork.io/a-comprehensive-guide-to-terraform-b3d32832baca
https://www.oreilly.com/learning/why-use-terraform
https://linuxacademy.com/howtoguides/posts/show/topic/13922-a-complete-aws-environment-with-terraform
https://thecode.pub/creating-your-cloud-servers-with-terraform-bfa01a499bad
https://nickcharlton.net/posts/terraform-aws-vpc.html
https://icicimov.github.io/blog/devops/Building-VPC-with-Terraform-in-Amazon-AWS/
https://www.terraformupandrunning.com/
https://www.reddit.com/r/devops/comments/93n78z/terraform_multiple_regions_and_environments_aws/
https://charity.wtf/2016/03/30/terraform-vpc-and-why-you-want-a-tfstate-file-per-env/
https://medium.com/@maxbeatty/using-terraform-to-manage-dns-records-b338f42b50dc
https://blog.opendigerati.com/terraform-static-sites-53bdc591709a
https://www.runatlantis.io/
https://medium.com/@hbarcelos/things-i-wish-i-knew-about-terraform-before-jumping-into-it-43ee92a9dd65
https://medium.com/@jessgreb01/how-to-terraform-locking-state-in-s3-2dc9a5665cb6
https://github.com/hashicorp/terraform/issues/12877#issuecomment-311649591

Best practices:
https://www.terraform-best-practices.com/key-concepts
https://github.com/ozbillwang/terraform-best-practices

Немає коментарів:

Дописати коментар