Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям

Автор |  31/08/2023
 

Виходить такая собі серія постів про підготовку до використання Terraform на проекті.

Отже, в першій частині думалось про те, як організувати підготовку backend для проекту, тобто виконати його bootstrap, та трохи – як менеджити Dev/Prod оточення в цілому, див. Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap. В другій – як налаштувати State Lock та про remote state в цілому, див. Terraform: remote state з AWS S3 та state locking з DynamoDB.

Рішення по типу Terraform Cloud, Terragrunt, Spacelift, Atlantis та Cluster.dev поки лишимо осторонь – проект ще малий, і вносити додаткові утіліти не хочеться. Почнемо з простого, а як воно все взлетить – то вже будемо думати про подібні рішення.

Тепер спробуємо все зібрати в кучу, і набросати план майбутньї автоматизації.

Отже, про що треба подумати:

  • керування бекендом, або project bootstrap: бакет(и) для state-файлів та таблицю(і) DynamoDB для state lock:
    • можна створювати руками для кожного проекту
    • можна створити окремий проект/репозиторій, і в ньому менеджити всі бекенди
    • можна створювати в рамках кожного проекту на початку роботи в коді самого проекту
  • розділення по Dev/Prod оточенням:
    • Terraform Workspaces: built-in фіча Terraform, мінімум дублікації коду, але можуть бути складнощі з навігацією, може використовувати тільки один backend (проте з окремими директоріями в ньому), складноші роботи з модулями
    • Git branches: built-in фіча Git, простота навігації по коду, можливість мати окремі бекенди, але багато дублікації коду, морока с переносом коду між оточеннями, складнощі роботи з модулями
    • Separate Directories: максимальная ізоляція і можливість мати окремі бекенди та провайдери, але можлива дублікація коду
    • Third-party tools: Terragrunt, Spacelif, Atlantis тощо – чудово, але потребує додаткового часу на вивчення інженерами та імплементацію

Сьогодні спробуємо підхід з менеджементом бакету для бекенду з коду самого проекту, а Dev/Prod робити через окремі директорії.

Керування бекендом, або project bootstrap

Тут будемо використовувати підхід зі створенням бекенду в рамках кожного проекту на старті.

Тобто:

  1. спочатку описуємо створення бакету та таблиці Динамо
  2. створюємо ресурси
  3. налаштовуємо блок terraform.backend{}
  4. імпортуємо стейт
  5. описуємо та створюємо всі інші ресурси

Розділення по Dev/Prod оточенням з окремими директоріями

Як все може виглядати з окремими каталогами?

Можемо створити структуру:

  • global
    • main.tf: створення ресурсів для бекенду – S3, Dynamo
  • environments
    • dev
      • main.tf: тут включаємо потрібні модулі (дублються з Prod, але відрізняється під час розробки та тестування нового модулю)
      • variables.tf: декларуємо змінні, загальні (дублюються з Prod) та специфічні до оточення
      • terraform.tfvars: значення змінних, загальні (дублюються з Prod) та специфічні до оточення
      • providers.tf: налаштування підключення до AWS/Kubernetes, специфічні до оточення (осолибво корисно, коли Dev/Prod це різні акаунти AWS)
      • backend.tf: налаштування зберігання state-файлів, специфічні до оточення
    • prod
      • <аналогічно Dev>
  • modules
    • vpc
      • main.tf – описуємо модулі
  • backend.hcl – загальні параметри для state backend

Тоді можемо деплоїти окремі оточення або виконуючи cd environments/dev && terraform aplly, або terraform aplly -chdir=environments/dev. Бекенд можемо передавати через terraform init -backend-config=backend.hcl.

Ну і давайте спробуємо, і подивимось, як воно може виглядати в роботі.

Створення бекенду

Тут будемо робити бекенд з коду самого проекту, але мені все ж вважається кращим менеджмент AWS ресурсів для бекендів винести окремим проектом в окремому репозиторії, бо зі схемою наведеною нижче створення нового проекту виглядає трохи complecated – якщо це будуть робити самі девелопери, то їм доведеться робити окремі кроки, і для цього потрібно буде писати окрему доку. Краще нехай при старті проекту передадуть нам його ім’я, “девопси” зроблять корзину та DynamoDB таблицю, а далі девелопери вже просто захардкодять їхні імена в свої конфіги.

Створюємо директорії:

[simterm]

$ mkdir -p envs_management_test/{global,environments/{dev,prod},modules/vpc}

[/simterm]

Получаємо таку структуру:

[simterm]

$ tree envs_management_test/
envs_management_test/
├── environments
│   ├── dev
│   └── prod
├── global
└── modules
    └── vpc

[/simterm]

У каталозі envs_management_test/global нам треба описати створення бакету та таблиці для локів.

Тут теж питання: робити одну корзини під кожен енв – чи одну, і стейти в ній розділяти ключами?

Multiple S3 buckets

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

  • створюємо змінну з типом list, в цей список вносимо імена оточень
  • потім при створенні ресурсів – використовуємо цей список, щоб в циклі пройтись по кожному індексу в ньому

Тобто, variables.tf може бути таким:

variable "environments" {
  description = "Environments names"
  type        = set(string)
  default     = ["dev", "prod", "global"]
}

А у файлі main.tf створюємо ресурси так:

resource "aws_kms_key" "state_backend_kms_key" {
  description             = "This key is used to encrypt bucket objects"
  deletion_window_in_days = 10
}

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend_bucket" {
  for_each = var.project_names
  bucket = "tf-state-backend-${each.value}"

  # to drop a bucket, set to `true`
  force_destroy = false
  lifecycle {
    # to drop a bucket, set to `false`
    prevent_destroy = true
  }

  tags = {
    environment = var.environment
  }
}

# enable S3 bucket versioning
resource "aws_s3_bucket_versioning" "state_backend_versioning" {
  for_each = aws_s3_bucket.state_backend_bucket
  bucket  = each.value.id

  versioning_configuration {
    status = "Enabled"
  }
}
...

Єдиний S3 для оточень

Але щоб не ускладнювати код – поки зробимо одну корзину, а потім для кожного оточення задамо власний key в його backend.

Використаємо змінну для імені:

variable "project_name" {
  description = "The project name to be used in global resources names"
  type        = string
  default     = "envs-management-test"
}

І в main.tf описуємо самі ресурси – тут код той самий, щоб використовувався в попередньому пості:

resource "aws_kms_key" "state_backend_bucket_kms_key" {
  description             = "Encrypt the state bucket objects"
  deletion_window_in_days = 10
}

# create state-files S3 bukets per each Env
resource "aws_s3_bucket" "state_backend_bucket" {
  bucket = "tf-state-bucket-${var.project_name}"

  lifecycle {
    prevent_destroy = true
  }
}

# enable S3 bucket versioning per each Env's bucket
resource "aws_s3_bucket_versioning" "state_backend_bucket_versioning" {
  bucket = aws_s3_bucket.state_backend_bucket.id

  versioning_configuration {
    status = "Enabled"
  }
}

# enable S3 bucket encryption per each Env's bucket
resource "aws_s3_bucket_server_side_encryption_configuration" "state_backend_bucket_encryption" {
  bucket = aws_s3_bucket.state_backend_bucket.id

  rule {
    apply_server_side_encryption_by_default {
      kms_master_key_id = aws_kms_key.state_backend_bucket_kms_key.arn
      sse_algorithm = "aws:kms"
    }
    bucket_key_enabled = true
  }
}

# block S3 bucket public access per each Env's bucket
resource "aws_s3_bucket_public_access_block" "state_backend_bucket_acl" {
  bucket = aws_s3_bucket.state_backend_bucket.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# create DynamoDB table per each Env
resource "aws_dynamodb_table" "state_dynamo_table" {
  name = "tf-state-lock-${var.project_name}"

  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

Створюємо ресурси:

[simterm]

$ terraform init && terraform apply

[/simterm]

Налаштування динамічного State Backend

Далі нам треба налаштувати бекенд для global.

Але щоб потім не повторювати один і той самий конфіг для Dev && Prod – загальні параметри бекенду винесемо окремим файлом.

В корні проекту створюємо backend.hcl:

bucket         = "tf-state-bucket-envs-management-test"
region         = "us-east-1"
dynamodb_table = "tf-state-lock-envs-management-test"
encrypt        = true

В директорії global додаємо backend.tf:

terraform {
  backend "s3" {
    key = "global/terraform.tfstate"
  }  
}

Виконуємо ініціалізацію ще раз, та через -backend-config передаємо шлях до файлу с параметрами бекенду:

[simterm]

$ terraform init -backend-config=../backend.hcl

Initializing the backend...
Acquiring state lock. This may take a few moments...
Do you want to copy existing state to the new backend?
...
  Enter a value: yes
...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

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

Terraform has been successfully initialized!

[/simterm]

Перевіряємо корзину:

[simterm]

$ aws s3 ls tf-state-bucket-envs-management-test/global/
2023-08-30 16:57:10       8662 terraform.tfstate

[/simterm]

Перший стейт-файл є, чудово.

Створення та використання модулів

Додамо власний модуль для VPC. Тут просто для приклада, в продакшені будемо використовувати AWS VPC Terraform module.

В файлі modules/vpc/main.tf описуємо саму VPC:

resource "aws_vpc" "vpc" {
  cidr_block       = var.vpc_cidr

  tags = {
    environment = var.environment
    created-by  = "terraform"
  }   
}

Там же додаємо файл modules/vpc/variables.tf:

variable "vpc_cidr" {
  description = "VPC CIDR"
  type        = string
}

variable "environment" {
  type = string
}

Далі описуємо змінні vpc_cidr та environment в файлах environments/dev/variables.tf та environments/prod/variables.tf:

variable "vpc_cidr" {
  description = "VPC CIDR"
  type        = string
}

variable "environment" {
  type = string
}

У файлі environments/dev/terraform.tfvars значення для них:

vpc_cidr    = "10.0.1.0/24"
environment = "dev"

І в environments/prod/terraform.tfvars інші значення:

vpc_cidr    = "10.0.2.0/24"
environment = "prod"

В обох environments створюємо main.tf, де включаємо модуль VPC:

module "vpc" {
  source      = "../../modules/vpc"
  vpc_cidr    = var.vpc_cidr
  environment = var.environment
}

Додаємо providers.tf аналогічний тому, що маємо в global:

[simterm]

$ cp global/providers.tf environments/dev/
$ cp global/providers.tf environments/prod/

[/simterm]

І в кожному створюємо власний backend.tf, але з різними key.

Dev:

terraform {
  backend "s3" {
    key = "dev/terraform.tfstate"
  }  
}

Та Prod:

terraform {
  backend "s3" {
    key = "prod/terraform.tfstate"
  }  
}

Тепер у нас виходить така структура каталогів та файлів:

І тепер можемо деплоїти ресурси.

Спочатку Dev:

[simterm]

$ cd environments/dev/
$ terraform init -backend-config=../../backend.hcl
$ terraform apply

[/simterm]

І повторюємо для Prod:

[simterm]

$ cd ../prod/
$ terraform init -backend-config=../../backend.hcl
$ terraform apply

[/simterm]

Перевіряємо бакет стейтів:

[simterm]

$ aws s3 ls tf-state-bucket-envs-management-test/
                           PRE dev/
                           PRE global/
                           PRE prod/

Та самі стейти:

[simterm]

$ aws s3 ls tf-state-bucket-envs-management-test/dev/
2023-08-30 17:32:07       1840 terraform.tfstate

[/simterm]

І чи створились VPC:

Динамічні оточення

Добре – схема з окремими диреткоріями для Dev/Prod виглядає робочю.

Але як бути для динамічних оточень, тобто коли ми хочемо створити інфрастуктуру проекту під час створення Pull Request в Git, для тестів?

Тут можемо використати такий флоу:

  1. бранчуємось від мастер-бранчу
  2. робимо свої зміни в коді environments/dev/
  3. ініціалізуємо новий бекенд
  4. і деплоїмо з terraform apply -var з новими значеннями змінних

Ініціалізуємо новий стейт. Додаємо -reconfigure, бо робимо локально, і тут вже є .terraform. У випадку, коли це буде виконуватись з GitHub Actions – директорія буде чистою, і можна виконувати просто init.

У другому параметрі -backend-config передаємо ключ для стейту – в якій директорії корзини зберігати файл:

[simterm]

$ terraform init -reconfigure -backend-config=../../backend.hcl -backend-config="key=pr1111/terraform.tfstate"

[/simterm]

Тепер деплоїмо з -var або передаємо через змінні як TF_VAR_vpc_cidr, див. Environment Variables – в пайплайні це можна досить просто зробити:

[simterm]

$ terraform apply -var vpc_cidr=10.0.3.0/24 -var environment=pr1111

[/simterm]

Перевіряємо стейти – маємо новий каталог pr1111:

[simterm]

 $ aws s3 ls tf-state-bucket-envs-management-test/
                           PRE dev/
                           PRE global/
                           PRE pr1111/

[/simterm]

Готово.

Корисні посилання