Terraform: “One Ring to rule them all!” – управління бекендами проектів

Автор |  16/09/2023
 

Вже писав про питання управління бекендами у постах Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap та Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям, повернемось до цієї теми знов.

Отже, вибрав все ж варіант з менеджментом бекендів через окремий проект Terraform, де в змінних маємо список проектів, яким треба мати AWS S3 bucket та таблицю DynamoDB, та їхніх оточень – Dev/Prod.

Потім в циклі for_each проходимось по елементам списку проектів, і створюємо необхідні ресурси.

В такому випадку девелоперам, щоб запустити новий проект, не треба мати справу зі створенням ресурсів для бекенду state-файлів взагалі – вони або самі можуть просто додати нове значення в змінну і виконати terraform apply, чи попросити когось з DevOps-тіми, а потім просто додати значення да власного backend.tf.

Бекенд для самого проекту який менеджить всі бекенди створюється ним же – в перший раз з локальним бекендом, а після створення проекту – його стейт туди імпортується, і надалі вже використовується цей remote state.

Providers

Тут у нас буде тільки AWS:

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

provider "aws" {
  region  = "us-east-1"
  profile = "tf-admin"
  default_tags {
    tags = {
      component  = "devops"
      created-by = "terraform"
    }
  }
}

Файл backend.tf поки не описуємо – робимо все локально.

Виконуємо terraform init, і переходимо до змінних.

Variables – список проектів і оточень

Тут нам потрібна по факту одна змінна з типом map(list(string)), в якій ми описуємо список проектів, для яких будемо створювати ресурси, в тому числі включаємо в неї сам проект, який буде створювати всі ці ресурси.

І для кожного елементу з іменем проекту в значення включаємо список з іменами оточень цього проекту:

variable "projects" {
  description = "Project names with their environments to be used in S3 and DynamoDB resources"
  type        = map(list(string))

  default = {
    atlas-tf-backends-test = [
      "prod"
    ]
    atlas-eks-test = [
      "dev", "prod"
    ]
  }
}

Resources

Створення AWS S3 бакетів

Що нам треба, це для кожного проекту створити AWS S3 Bucket, включити йому Versioning, додати Encryption, і заборонити публічний доступ до об’єктів через S3 Bucket ACL.

Щодо Dev/Prod оточень: можна створювати окремі корзини на кожен Env кожного проекту, чи один бакет на проект, а вже в самому проекті використовувати різні ключі, тобто:

  • проект atlas-eks-test
  • корзина atlas-eks-test
    • при terraform init Dev-оточення використовуємо -backend-config="key=dev/atlas-eks.tfstate"
    • при terraform init для Prod-оточення використовуємо -backend-config=key=prod/atlas-eks.tfstate

Для таблиць DynamoDB створимо окремі таблиці для Dev/Prod, а всякі feature-енви можна буде деплоїти або без State Lock, бо вони тимчасові, і будуть деплоїтись з якогось одного Pull Request з GitHub Actions, або при потребі – створювати таблицю під час деплою проекту командою AWS CLI create-table.

Отже – в змінних маємо map зі списком проектів.

Для S3 використовуємо for_each, з якого отримуємо each.key, який буде містити ім’я проекту, тобто “atlas-eks-test” або “atlas-tf-backends-test“:

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend" {
  for_each = var.projects
  bucket   = "tf-state-backend-${each.key}"

  # 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
  }
}

Далі, для ресурсів aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration та aws_s3_bucket_public_access_block знов використовуємо for_each, але тепер ітерацію виконуємо по списку ресурсів aws_s3_bucket.state_backend, тобто весь код буде таким:

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend" {
  for_each = var.projects
  bucket   = "tf-state-backend-${each.key}"

  # 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
  }
}

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

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

  versioning_configuration {
    status = "Enabled"
  }
}

# enable S3 bucket encryption 
resource "aws_s3_bucket_server_side_encryption_configuration" "state_backend_encryption" {
  for_each = aws_s3_bucket.state_backend
  bucket   = each.value.id

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

# block S3 bucket public access
resource "aws_s3_bucket_public_access_block" "state_backend_acl" {
  for_each = aws_s3_bucket.state_backend
  bucket   = each.value.id

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

В ouputs додаємо відображення створених бакетів, використовуючи цикл for та String Templates:

output "state_backend_bucket_names" {
  value = "AWS S3 State Buckets:\n%{for name in aws_s3_bucket.state_backend}- ${name.bucket}\n%{endfor}"
}

Деплоїмо:

$ terraform apply
...
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:

state_backend_bucket_names = <<EOT
AWS S3 State Buckets:
- tf-state-backend-atlas-eks-test
- tf-state-backend-atlas-tf-backends-test

Міграція власного state-файлу

Далі додаємо параметри до backend.tf:

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-atlas-tf-backends-test"
    key            = "atlas-tf-backends.tfstate"
    region         = "us-east-1"
    profile        = "tf-admin"
    encrypt        = true
  }
}

І виконуємо terraform init ще раз, щоб перенести власний стейт з локального файлу terraform.tfstate до створеного S3 бакету:

$ terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes
...
Terraform has been successfully initialized!

Створення таблиць DynamdoDB

Якщо для S3 ми робили одну корзину на кожен проект, то для DynamoDB буде окрема таблиця на кожен Env кожного проекту (хоча можна мати і одну – тоді Terraform при створенні ключів сам задасть значення Env, див. Backends S3).

Для цього використаємо locals та цикли for, як описано у Nested for loops для map of lists:

...

locals {
  table_names_list = flatten([
    # for 'atlas-eks-test["dev", "prod"]:
    for project, envs in var.projects : [
      # for 'dev', 'prod':
      for env in envs :
      # create 'atlas-eks-test-dev' && 'atlas-eks-test-prod':
      "${project}-${env}"
    ]
  ])
}

# create DynamoDB table
resource "aws_dynamodb_table" "state_lock" {
  for_each = toset(local.table_names_list)
  name     = "tf-state-lock-${each.value}"

  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

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

  tags = {
    environment = var.environment
  }
}

Додамо outputs:

...

output "dynamodb_table_names" {
  value = "DynamoDB tables:\n%{for name in aws_dynamodb_table.state_lock}- ${name.name}\n%{endfor}"
}

Та деплоїмо:

$ terraform apply
...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

dynamodb_table_names = <<EOT
DynamoDB tables:
- tf-state-lock-atlas-eks-test-dev
- tf-state-lock-atlas-eks-test-prod
- tf-state-lock-atlas-tf-backends-test-prod

EOT
state_backend_bucket_names = <<EOT
AWS S3 State Buckets:
- tf-state-backend-atlas-eks-test
- tf-state-backend-atlas-tf-backends-test

EOT

Видалення проекту і його S3 та DynamoDB

Якщо якийсь проект більш не актуальний, і треба видалити його ресурси – то це буде робитись в три етапи apply:

  1. міняємо параметри aws_s3_bucket:
    • включаємо force_destroy – це потрібно, щоб видалити корзини, в яких включено Versioning і які мають об’єкти
    • відключаємо prevent_destroy – щоб дозволити видалення
  2. виконуємо apply, щоб застосувати зміни
    • видаляємо проект з var.projects
  3. виконуємо apply, щоб видалити корзину та пов’язані ресурси
    • повертаємо значення параметрів aws_s3_bucketforce_destroy та prevent_destroy
  4. виконуємо apply, щоб застосувати зміни

Тобто:

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend" {
  for_each = var.projects
  bucket   = "tf-state-backend-${each.key}"

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

  tags = {
    environment = var.environment
  }
}
...

Застосовуємо зміни на всі бакети:

$ terraform apply
...
Apply complete! Resources: 0 added, 2 changed, 0 destroyed.

Потім видаляємо ім’я проекту зі значень змінної projects:

variable "projects" {
  description = "Project names list with its environments to be used in S3 and DynamoDB nresources"
  type        = map(list(string))

  default = {
    atlas-tf-backends-test = [
      "prod"
    ]
  }
}

І виконуємо apply ще раз:

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

Outputs:

dynamodb_table_names = <<EOT
DynamoDB tables:
- tf-state-lock-atlas-tf-backends-test-prod

EOT
state_backend_bucket_names = <<EOT
AWS S3 State Buckets:
- tf-state-backend-atlas-tf-backends-test

EOT

Після чого повертаємо значення force_destroy та prevent_destroy, і виконуємо ще один apply.

Готово.