Terraform: remote state з AWS S3 та state locking з DynamoDB

Автор |  29/08/2023

Готуємось переводити управління інфрастуктурою з AWS CDK на Terraform.

Про планування того, як воно все може виглядати писав у Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap, але тоді оминув одну досить важливу опцію – створення lock для state-файлів.

Блокування стейт-файлів використовується для того, щоб уникнути ситуацій, коли запускається кілька інстансів Terraform одночасно – інженерами або автоматично в CI/CD, і вони одночасно будуть намагатись внести зміни в один стейт-файл: при використанні lock, Terraform заблокує запуск іншого інстансу допоки перший інстанс не завершить свою роботу і не звільнить блокування.

У нашому випадку інфрастуктура вся в AWS, тому в ролі бекенду для зберігання стейтів буде використовуватись AWS S3, а для створення lock-файлів – таблиця в DynamoDB.

Документація – State Locking.

Отже, що ми зробимо:

  • таблиця DynamoDB та S3 бакет будуть менеджитись самим Терраформом
  • Terraform буде авторизуватись в AWS з AssumeRole
  • опишемо створення S3 bucket та таблиці DynamoDB
  • створимо ресурси використовуючи локальний стейт
  • імпортуємо локальний стейт в створений бакет
  • протестуємо, як працює State Lock

IAM Role

Terraform буде працювати через окрему IAM Role, див. Use AssumeRole to provision AWS resources across accounts.

Переходимо в IAM > Create Role, вибираємо Custom Trust Policy, і описуємо її:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Statement1",
      "Effect": "Allow",
      "Principal": {
          "AWS": "arn:aws:iam::123456789012:root"
          },
      "Action": "sts:AssumeRole"
    }
  ]
}

Замість 123456789012 вказуємо ID аккаунту, а в root позначаємо, що будь-який аутентифікований IAM User цього аккаунта зможете виконати sts:AssumeRole цієї ролі.

Поки задаємо AdministratorAccess, пізніше можна буде налаштувати права більш детально:

Зберігаємо роль:

Перевіримо, що вона працює.

В свій ~/.aws/config додаємо новий профайл:

[profile tf-admin]
role_arn = arn:aws:iam::492***148:role/tf-admin
source_profile = default

І виконуємо sts get-caller-identity з цим профайлом:

[simterm]

$ aws --profile tf-admin sts get-caller-identity
{
    "UserId": "ARO***ZEF:botocore-session-1693297579",
    "Account": "492***148",
    "Arn": "arn:aws:sts::492***148:assumed-role/tf-admin/botocore-session-1693297579"
}

[/simterm]

Окей, тепер можемо переходити до самого Terraform.

Налаштування Terraform-проекту

Додаємо версії модулів, котрі будемо використовувати.

Останню версію провайдеру AWS можна взяти тут>>>, а версію самого Terraform – тут>>>.

Створюємо файл versions.tf:

terraform {

  required_version = ">= 1.5"

  required_providers {
    aws = { 
      source  = "hashicorp/aws"
      version = ">= 5.14.0"
    }
  }
}

Додаємо файл providers.tf, де описуємо параметри підключення до AWS:

provider "aws" {
  region    = "us-east-1"
  assume_role {
    role_arn = "arn:aws:iam::492***148:role/tf-admin"
  }
}

Створюємо файл main.tf, поки що пустий, і перевіряємо, що Terraform може виконати AssumeRole.

Виконуємо ініціалізцію:

[simterm]

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 5.14.0"...
- Installing hashicorp/aws v5.14.0...
- Installed hashicorp/aws v5.14.0 (signed by HashiCorp)
...

[/simterm]

То робимо terraform plan:

[simterm]

$ terraform plan

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

[/simterm]

Все добре – Terraform підключився до нашого AWS-аккаунту.

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

Для корзини, де будуть зберігатись state-файли, потрібно мати:

  1. encryption: для AWS S3 включено по дефолту, але можна налаштувати з власним ключем з AWS KMS
  2. access control: закрити публічний доступ до об’єктів в корзині
  3. versioning: налаштувати версіонування, щоб мати історію змін в стейт-файлах

Створюємо файл backend.tf, і описуємо створення KMS ключа та корзини:

resource "aws_kms_key" "tf_lock_testing_state_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" "tf_lock_testing_state_bucket" {
  bucket = "tf-lock-testing-state-bucket"

  lifecycle {
    prevent_destroy = true
  }
}

# enable S3 bucket versioning
resource "aws_s3_bucket_versioning" "tf_lock_testing_state_versioning" {
  bucket = aws_s3_bucket.tf_lock_testing_state_bucket.id

  versioning_configuration {
    status = "Enabled"
  }
}

# enable S3 bucket encryption 
resource "aws_s3_bucket_server_side_encryption_configuration" "tf_lock_testing_state_encryption" {
  bucket = aws_s3_bucket.tf_lock_testing_state_bucket.id

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

# block S3 bucket public access
resource "aws_s3_bucket_public_access_block" "tf_lock_testing_state_acl" {
  bucket                  = aws_s3_bucket.tf_lock_testing_state_bucket.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Далі, там же додаємо створення DynamoDB таблиці для state lock:

...
# create DynamoDB table
resource "aws_dynamodb_table" "tf_lock_testing_state_ddb_table" {
  name         = "tf-lock-testing-state-ddb-table"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

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

Перевіряємо, чи все правильно описали:

[simterm]

$ terraform plan

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_dynamodb_table.tf_lock_testing_state_ddb_table will be created
  + resource "aws_dynamodb_table" "tf_lock_testing_state_ddb_table" {
      + arn              = (known after apply)
      + billing_mode     = "PAY_PER_REQUEST"
      + hash_key         = "LockID"
...

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

[/simterm]

І виконуємо terraform apply, щоб створити ресурси:

[simterm]

$ terraform apply
...
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: 5 added, 0 changed, 0 destroyed.

[/simterm]

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

Та таблицю DynamoDB:

Налаштування Terraform Backend та State Lock

Тепер можемо додати бекенд з параметром dynamodb_table для створення lock.

До файлу backend.tf додаємо блок terraform.backend.s3:

terraform {
  backend "s3" {
    bucket         = "tf-lock-testing-state-bucket"
    key            = "tf-lock-testing-state-bucket.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-lock-testing-state-ddb-table"
    encrypt        = true
  }  
}
...

Виконуємо terraform init ще раз, та імпортуємо локальний state в корзину:

[simterm]

$ terraform init

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

Releasing state lock. This may take a few moments...

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]

Перевіряємо DynamoDB тепер – маємо ключ:

І стейт в S3:

Якщо переглянути таблицю DynamoDB під час виконання plan чи apply – можна побачити сам lock з полями Operation та хто саме виконує операцію:

Тестування State Lock

Додаємо файл main.tf с ресурсом EC2:

resource "aws_instance" "ec2_lock_test" {
    ami = "ami-0d2fcfe4f5c4c5b56"
    instance_type = "t2.micro"
    tags = {
      Name = "EC2 Instance with remote state"
    }
}

Копіюємо всі файли проекту в новий каталог:

[simterm]

$ mkdir test-lock
$ cp -r * test-lock/
cp: cannot copy a directory, 'test-lock', into itself, 'test-lock/test-lock'

[simterm]

В поточному каталозі запускаємо terraform apply, але не відповідаємо yes, щоб створений в DynamoDB lock залишався:

[simterm]

$ terraform apply 
Acquiring state lock. This may take a few moments...
...

[/simterm]

Переходимо в другий каталог, і там запускаємо init та apply ще раз:

[simterm]

$ cd test-lock/
$ terraform init && terraform apply
...
Acquiring state lock. This may take a few moments...
╷
│ Error: Error acquiring the state lock
│ 
│ Error message: ConditionalCheckFailedException: The conditional request failed
│ Lock Info:
│   ID:        98dd894b-065f-8f63-f695-d4dcea702807
│   Path:      tf-lock-testing-state-bucket/tf-lock-testing-state-bucket.tfstate
│   Operation: OperationTypeApply
...

[/simterm]

Та маємо помилку створення блокування, бо вже є процесс, який користується нашим state-файлом.

Terraform State Lock trics

force-unlock

Іноді буває, що Terraform не звільняє lock, наприклад, якщо при виконанні операції відвалився інтернет.

Тоді можемо звільти стейт за допомогою force-unlock, якому передаємо Lock ID:

[simterm]

$ terraform force-unlock 98dd894b-065f-8f63-f695-d4dcea702807
Do you really want to force-unlock?
  Terraform will remove the lock on the remote state.
...
  Enter a value: yes

Terraform state has been successfully unlocked!

[/simterm]

lock-timeout

Іноді треба, щоб Terraform не зупиняв роботу, як тільки побачить, що lock-запис вже є. Наприклад, в CI-пайплайні можуть бути одночасно запущені дві джоби, і тоді друга запиниться з полмилкою.

В такому випадку можемо додати lock-timeout – тоді Terraform зачекає заданий період часу, і спробує виконати lock ще раз:

[simterm]

$ terraform apply -lock-timeout=180s

[/simterm]

Готово.