Terraform State Management: как мы навели порядок в трёх окружениях стартапа

Ситуация: три окружения, один стейт, полный хаос

Стартап «КлаудСтарт» обратился к нам в itfresh.ru, когда их инфраструктура стала неуправляемой. Три окружения (dev, staging, prod) — и все три описаны в одном Terraform-проекте с одним state-файлом.

Проблемы нарастали как снежный ком:

  • Страх перед terraform apply — один plan показывал все ресурсы трёх окружений (280+ ресурсов). Изменение в dev могло случайно затронуть prod. Однажды инженер удалил staging-базу, применив план для dev.
  • State хранился локально — файл terraform.tfstate лежал на ноутбуке lead-разработчика. Никто другой не мог запустить terraform plan. При смене ноутбука state чудом не потеряли — он был в Dropbox.
  • Нет блокировки — два инженера одновременно запустили terraform apply, получили corrupted state. Восстановление заняло 8 часов.
  • Секреты в state — пароли БД, API-ключи хранились в plain text в terraform.tfstate, который лежал в Dropbox. Мы обнаружили это при аудите.
  • Ручные ресурсы — 30% инфраструктуры было создано вручную через консоль облака. Terraform о них не знал.

Remote state: S3 + DynamoDB

Первый шаг — перенести state в безопасное удалённое хранилище. Стандартное решение для AWS: S3 для хранения + DynamoDB для блокировки.

Создаём инфраструктуру для state (bootstrap — единственное, что делаем вручную):

# bootstrap/main.tf — ресурсы для хранения state
# Этот модуль применяется один раз с local backend,
# затем state самого bootstrap тоже мигрируется в S3

provider "aws" {
  region = "eu-central-1"
}

# S3 бакет для state-файлов
resource "aws_s3_bucket" "terraform_state" {
  bucket = "cloudstart-terraform-state"

  lifecycle {
    prevent_destroy = true  # Никогда не удалять случайно
  }

  tags = {
    Name        = "Terraform State"
    ManagedBy   = "terraform-bootstrap"
  }
}

# Версионирование — можно откатить к предыдущему state
resource "aws_s3_bucket_versioning" "state_versioning" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"
  }
}

# Шифрование at rest
resource "aws_s3_bucket_server_side_encryption_configuration" "state_encryption" {
  bucket = aws_s3_bucket.terraform_state.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.terraform_state.arn
    }
    bucket_key_enabled = true
  }
}

# KMS ключ для шифрования
resource "aws_kms_key" "terraform_state" {
  description             = "KMS key for Terraform state encryption"
  deletion_window_in_days = 30
  enable_key_rotation     = true
}

# Запрет публичного доступа
resource "aws_s3_bucket_public_access_block" "state_public_access" {
  bucket = aws_s3_bucket.terraform_state.id

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

# DynamoDB таблица для блокировки
resource "aws_dynamodb_table" "terraform_locks" {
  name         = "cloudstart-terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

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

  tags = {
    Name      = "Terraform State Locks"
    ManagedBy = "terraform-bootstrap"
  }
}

Теперь настраиваем backend в каждом модуле:

# environments/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "cloudstart-terraform-state"
    key            = "prod/infrastructure.tfstate"  # Уникальный путь для каждого окружения
    region         = "eu-central-1"
    dynamodb_table = "cloudstart-terraform-locks"
    encrypt        = true
    kms_key_id     = "arn:aws:kms:eu-central-1:123456789:key/abc-def-123"
  }
}

Миграция с local на remote backend:

# Переносим существующий state в S3
terraform init -migrate-state

# Terraform спросит:
# Do you want to copy existing state to the new backend?
# Enter "yes"

# Проверяем, что state доступен
terraform state list
# aws_instance.web
# aws_db_instance.main
# ... все ресурсы на месте

# Удаляем локальный state (он уже в S3)
rm terraform.tfstate terraform.tfstate.backup

Workspace vs Directory: выбираем структуру

Два подхода к разделению окружений в Terraform:

Подход 1: Workspaces. Один набор .tf файлов, переключение между окружениями через terraform workspace select:

# Workspaces — один код, разные state-файлы
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

terraform workspace select prod
terraform plan -var-file=prod.tfvars

# В коде используем terraform.workspace:
resource "aws_instance" "web" {
  instance_type = terraform.workspace == "prod" ? "m5.2xlarge" : "t3.medium"
  count         = terraform.workspace == "prod" ? 3 : 1

  tags = {
    Environment = terraform.workspace
  }
}

Подход 2: Directory per environment. Отдельная директория для каждого окружения с собственным backend и variables:

# Структура проекта — directory per environment
infrastructure/
├── modules/              # Переиспользуемые модули
│   ├── networking/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── compute/
│   ├── database/
│   └── monitoring/
├── environments/
│   ├── dev/
│   │   ├── main.tf       # Вызов модулей с dev-параметрами
│   │   ├── backend.tf    # S3 key: dev/infrastructure.tfstate
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   ├── main.tf
│   │   ├── backend.tf    # S3 key: staging/infrastructure.tfstate
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       ├── backend.tf    # S3 key: prod/infrastructure.tfstate
│       ├── variables.tf
│       └── terraform.tfvars
└── global/               # Ресурсы общие для всех окружений
    ├── iam/
    ├── dns/
    └── ecr/

Мы выбрали directory per environment по нескольким причинам:

  • Окружения изолированы полностью — ошибка в dev не может затронуть prod state.
  • Prod может иметь ресурсы, которых нет в dev (WAF, CloudTrail, дополнительные реплики).
  • В CI/CD проще: cd environments/prod && terraform apply.
  • Workspace не поддерживает разные provider versions между окружениями.

Пример main.tf для prod-окружения:

# environments/prod/main.tf
module "networking" {
  source = "../../modules/networking"

  environment   = "prod"
  vpc_cidr      = "10.0.0.0/16"
  azs           = ["eu-central-1a", "eu-central-1b", "eu-central-1c"]
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnets = ["10.0.10.0/24", "10.0.11.0/24", "10.0.12.0/24"]
}

module "database" {
  source = "../../modules/database"

  environment    = "prod"
  instance_class = "db.r6g.xlarge"
  multi_az       = true
  backup_retention_period = 30
  subnet_ids     = module.networking.private_subnet_ids
  vpc_id         = module.networking.vpc_id
}

module "compute" {
  source = "../../modules/compute"

  environment    = "prod"
  instance_type  = "m5.2xlarge"
  min_size       = 3
  max_size       = 10
  desired_size   = 3
  subnet_ids     = module.networking.private_subnet_ids
  vpc_id         = module.networking.vpc_id
  db_endpoint    = module.database.endpoint
}

State migration, import и moved blocks

30% инфраструктуры «КлаудСтарт» было создано вручную. Нужно было импортировать эти ресурсы в Terraform state без пересоздания.

# terraform import — импортируем существующий ресурс
# Сначала описываем ресурс в .tf файле:
resource "aws_instance" "legacy_api" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "m5.xlarge"
  subnet_id     = module.networking.private_subnet_ids[0]
  # ... остальные параметры
}

# Затем импортируем по ID из AWS:
terraform import aws_instance.legacy_api i-0abc123def456789
# aws_instance.legacy_api: Importing...
# aws_instance.legacy_api: Import successful!

# Terraform 1.5+: import blocks (декларативный импорт)
import {
  to = aws_instance.legacy_api
  id = "i-0abc123def456789"
}

import {
  to = aws_db_instance.legacy_db
  id = "cloudstart-prod-db"
}

import {
  to = aws_s3_bucket.user_uploads
  id = "cloudstart-user-uploads"
}

# Генерация .tf кода для импортируемого ресурса:
terraform plan -generate-config-out=generated_imports.tf

Moved blocks (Terraform 1.1+) позволяют рефакторить state без пересоздания ресурсов:

# Переименование ресурса
moved {
  from = aws_instance.web
  to   = aws_instance.api_server
}

# Перемещение в модуль
moved {
  from = aws_instance.api_server
  to   = module.compute.aws_instance.api_server
}

# Перемещение из count в for_each
moved {
  from = aws_instance.worker[0]
  to   = aws_instance.worker["worker-1"]
}
moved {
  from = aws_instance.worker[1]
  to   = aws_instance.worker["worker-2"]
}

Ручные операции со state (использовать с осторожностью — всегда делайте backup):

# Резервная копия state перед любыми манипуляциями
terraform state pull > state-backup-$(date +%Y%m%d-%H%M%S).json

# Просмотр ресурсов в state
terraform state list
terraform state show aws_instance.api_server

# Перемещение ресурса между state-файлами
terraform state mv -state-out=../other/terraform.tfstate \
  aws_instance.worker module.compute.aws_instance.worker

# Удаление ресурса из state (не удаляет из облака!)
terraform state rm aws_instance.deprecated_server
# Ресурс продолжает работать, но Terraform о нём больше не знает

Sensitive data в state и защита

Terraform state содержит все значения ресурсов в plain text, включая пароли и ключи. Это фундаментальная особенность Terraform — state должен хранить полное состояние для корректной работы.

Наш подход к защите:

# 1. Шифрование state at rest (S3 + KMS)
# Уже настроено в backend — все state-файлы шифруются KMS ключом

# 2. Маркируем переменные как sensitive
variable "db_password" {
  type      = string
  sensitive = true  # Не показывается в plan/apply output
}

resource "aws_db_instance" "main" {
  password = var.db_password
  # ...
}

# 3. Используем data sources для получения секретов из Vault/SSM
data "aws_ssm_parameter" "db_password" {
  name            = "/cloudstart/prod/db/password"
  with_decryption = true
}

resource "aws_db_instance" "main" {
  password = data.aws_ssm_parameter.db_password.value
  # Пароль всё равно попадёт в state, но не в Git (.tfvars)
}

# 4. IAM Policy для ограничения доступа к state bucket
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::cloudstart-terraform-state/prod/*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalTag/team": "sre"
        }
      }
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::cloudstart-terraform-state/dev/*"
    }
  ]
}

# 5. Ограничиваем доступ к DynamoDB таблице блокировок
# Только CI/CD и SRE-роль могут блокировать/разблокировать state

Критично: никогда не коммитьте terraform.tfstate или .tfvars с секретами в Git. В .gitignore обязательно:

# .gitignore для Terraform-проекта
*.tfstate
*.tfstate.backup
*.tfvars
!example.tfvars
.terraform/
.terraform.lock.hcl
crash.log

Terragrunt: DRY-конфигурация для трёх окружений

Даже с directory-per-environment подходом оставалась проблема: backend configuration и provider configuration дублировались в каждом окружении. Terragrunt решает это:

# terragrunt.hcl (корневой) — общие настройки
remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket         = "cloudstart-terraform-state"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "eu-central-1"
    dynamodb_table = "cloudstart-terraform-locks"
    encrypt        = true
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <

Структура проекта с Terragrunt:

infrastructure/
├── terragrunt.hcl              # Корневой конфиг
├── modules/                    # Terraform-модули
│   ├── networking/
│   ├── compute/
│   └── database/
├── dev/
│   ├── terragrunt.hcl          # include root
│   ├── networking/
│   │   └── terragrunt.hcl      # source = modules/networking
│   ├── compute/
│   │   └── terragrunt.hcl
│   └── database/
│       └── terragrunt.hcl
├── staging/
│   └── ...                     # Аналогично dev
└── prod/
    ├── terragrunt.hcl
    ├── networking/
    │   └── terragrunt.hcl
    ├── compute/
    │   └── terragrunt.hcl
    └── database/
        └── terragrunt.hcl

# prod/database/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../modules/database"
}

dependency "networking" {
  config_path = "../networking"
}

inputs = {
  environment             = "prod"
  instance_class          = "db.r6g.xlarge"
  multi_az                = true
  backup_retention_period = 30
  subnet_ids              = dependency.networking.outputs.private_subnet_ids
  vpc_id                  = dependency.networking.outputs.vpc_id
}

Terragrunt автоматически разрешает зависимости: networking создаётся до database, database до compute:

# Применить всё окружение с учётом зависимостей
cd prod
terragrunt run-all apply

# Показать граф зависимостей
terragrunt graph-dependencies
# networking → database → compute

# Применить только один модуль
cd prod/database
terragrunt apply

CI/CD с Terraform state

Terraform в CI/CD — это план в merge request и применение при мерже:

# .gitlab-ci.yml — Terraform CI/CD pipeline
variables:
  TF_ROOT: environments/${CI_ENVIRONMENT_NAME}
  TF_STATE_NAME: ${CI_ENVIRONMENT_NAME}

stages:
  - validate
  - plan
  - apply

.terraform_base:
  image: hashicorp/terraform:1.7.4
  before_script:
    - cd ${TF_ROOT}
    - terraform init -input=false

validate:
  extends: .terraform_base
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check -recursive
  rules:
    - if: $CI_MERGE_REQUEST_ID

plan:
  extends: .terraform_base
  stage: plan
  script:
    - terraform plan -out=tfplan -input=false
    - terraform show -no-color tfplan > plan.txt
  artifacts:
    paths:
      - ${TF_ROOT}/tfplan
      - ${TF_ROOT}/plan.txt
    reports:
      terraform: ${TF_ROOT}/tfplan
  rules:
    - if: $CI_MERGE_REQUEST_ID

# Apply только для prod — ручной trigger после approve
apply:prod:
  extends: .terraform_base
  stage: apply
  variables:
    CI_ENVIRONMENT_NAME: prod
  script:
    - terraform apply -auto-approve -input=false tfplan
  environment:
    name: prod
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual  # Требует ручного нажатия кнопки
  resource_group: terraform-prod  # Только один apply одновременно

Ключевой момент — resource_group: GitLab гарантирует, что только один terraform apply для prod запущен в любой момент времени. Это дополнительная защита поверх DynamoDB locking.

Результаты и выводы

После реорганизации Terraform-инфраструктуры «КлаудСтарт» получил:

МетрикаДоПосле
State-файлы1 локальный, в Dropbox6 remote (3 env × 2 state groups), S3+KMS
Ресурсов вне Terraform30%0% (всё импортировано)
Concurrent access incidents2-3 в месяц0 (DynamoDB locking)
Время terraform plan8 мин (280 ресурсов)45 сек (40-60 ресурсов per state)
Секреты в .tfvars в Git12 паролей0 (все в SSM Parameter Store)
Инциденты из-за Terraform1-2 в месяц0 за 4 месяца

Ключевые рекомендации:

  • Remote state с блокировкой — не опция, а обязательное требование с первого дня проекта.
  • Directory per environment безопаснее workspaces для продакшен-нагрузок — полная изоляция state.
  • terraform import + moved blocks — ваши инструменты для наведения порядка в существующей инфраструктуре.
  • Terragrunt окупается при 3+ окружениях — убирает дублирование backend и provider конфигурации.
  • CI/CD с terraform plan в MR + manual apply в prod — стандартный безопасный pipeline.

Если вашему проекту нужна помощь с организацией Terraform — обращайтесь к нам в itfresh.ru.

Часто задаваемые вопросы

Terraform потеряет знание обо всех созданных ресурсах. При следующем apply он попытается создать всё заново, получит ошибки (ресурсы уже существуют). Восстановление: если S3 с версионированием — откатить к предыдущей версии объекта. Если state потерян полностью — придётся импортировать каждый ресурс через terraform import. Для 100 ресурсов это 1-2 дня работы. Именно поэтому версионирование S3 бакета критически важно.
Directory per environment для проектов, где окружения существенно различаются (prod имеет WAF, multi-AZ, другие instance types). Workspaces — для идентичных окружений с минимальными различиями (только размер инстансов). На практике 80% проектов лучше работают с directory подходом, потому что prod со временем обрастает ресурсами, которых нет в dev.
Команда terraform force-unlock LOCK_ID снимает блокировку. Lock ID показывается в ошибке при попытке apply. Перед разблокировкой убедитесь, что другой процесс действительно завершился — иначе два параллельных apply испортят state. В DynamoDB можно проверить запись в таблице блокировок: кто и когда поставил блокировку.
Да, через terraform init -migrate-state. Меняете конфигурацию backend в .tf файле и запускаете init. Terraform предложит скопировать state из старого backend в новый. Поддерживается миграция между любыми backend-ами: local → S3, S3 → GCS, Consul → S3 и так далее. Важно: делайте backup state перед миграцией через terraform state pull > backup.json.
Terraform modules — переиспользуемые блоки инфраструктуры (networking, database). Terragrunt — обёртка над Terraform, которая решает проблемы уровнем выше: DRY backend configuration, зависимости между модулями (dependency), run-all для применения всего окружения, генерация provider/backend файлов. Terragrunt вызывает Terraform, а не заменяет его.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#terraform#terraform state#remote backend#s3 backend#dynamodb locking#terraform import#moved blocks#terragrunt
Комментарии 0

Оставить комментарий

загрузка...