Terraform в реальной команде

Уроки на YouTube заканчиваются на «вот ваш VPC». Реальная команда — это десятки модулей, многоразовые structures, drift, миграции и code review. Здесь — как это работает.

Структура проекта

Минимум для команды

infra/
├── modules/                  # переиспользуемые модули
│   ├── vpc/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   ├── versions.tf
│   │   ├── README.md
│   │   └── examples/
│   ├── eks/
│   ├── rds/
│   ├── alb/
│   └── irsa/                 # IAM roles for service accounts
├── envs/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── backend.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   └── prod/
├── .pre-commit-config.yaml
├── .github/workflows/
│   ├── plan.yml
│   └── apply.yml
└── README.md

Когда переходить на Terragrunt

5+ environments / regions / accounts. Иначе boilerplate growing exponentially.

State — продакшен-настройка

S3 + DynamoDB backend

terraform {
  backend "s3" {
    bucket         = "company-tf-state-prod"
    key            = "envs/prod/eks/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "tf-lock"
    encrypt        = true
    kms_key_id     = "alias/tf-state-key"
  }
}

S3 bucket должен иметь:

State per layer

Не один state на всё. Разбивай:

Между state'ами — terraform_remote_state data source или явные variables.

Модуль — структура

# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = merge(var.tags, { Name = "${var.name}-vpc" })
}

resource "aws_subnet" "private" {
  for_each = { for idx, az in var.azs : idx => az }

  vpc_id            = aws_vpc.this.id
  availability_zone = each.value
  cidr_block        = cidrsubnet(var.cidr, 4, each.key + 8)

  tags = merge(var.tags, {
    Name = "${var.name}-private-${each.value}"
    "kubernetes.io/role/internal-elb" = "1"
  })
}
# modules/vpc/variables.tf
variable "name" {
  description = "Name prefix"
  type        = string
}

variable "cidr" {
  description = "VPC CIDR"
  type        = string
  validation {
    condition     = can(cidrhost(var.cidr, 0))
    error_message = "Must be valid CIDR."
  }
}

variable "azs" {
  description = "Availability zones"
  type        = list(string)
}

variable "tags" {
  type    = map(string)
  default = {}
}
# modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] }
output "vpc_cidr" { value = aws_vpc.this.cidr_block }

for_each vs count

Почти всегда for_each. Почему: при удалении элемента из середины count сдвигает всё, Terraform пересоздаёт лишнее.

# Плохо
resource "aws_iam_user" "users" {
  count = length(var.usernames)
  name  = var.usernames[count.index]
}

# Хорошо
resource "aws_iam_user" "users" {
  for_each = toset(var.usernames)
  name     = each.key
}

CI/CD пайплайн

На PR

  1. terraform fmt -check — форматирование
  2. terraform validate — синтаксис
  3. tflint — линтер
  4. tfsec / Checkov — security scan
  5. terraform plan — комментарий в PR с diff
  6. Review + approve

После merge

  1. terraform apply для dev — авто
  2. Для staging — авто после успешного dev
  3. Для prod — manual approval

Инструменты

Импорт существующих ресурсов

Унаследованная инфра — частая реальность. Не пересоздавай — импортируй.

# Terraform 1.5+ — декларативный import (в коде)
import {
  to = aws_s3_bucket.legacy
  id = "existing-bucket-name"
}

resource "aws_s3_bucket" "legacy" {
  bucket = "existing-bucket-name"
  # ... остальные параметры
}
# Старый способ — CLI
terraform import aws_s3_bucket.legacy existing-bucket-name
# Потом terraform plan — увидеть diff и подогнать код

Drift detection

Drift — расхождение state и реальности (кто-то правил руками).

Тесты

Уровни

  1. Static: fmt, validate, tflint, tfsec
  2. Unit: terraform test (с TF 1.6+)
  3. Integration: Terratest (Go) — поднимает реальную инфру, ассертит, сносит
  4. Policy: OPA / Conftest на Rego
# tests/vpc.tftest.hcl
run "vpc_creates_correctly" {
  command = plan

  variables {
    name = "test"
    cidr = "10.99.0.0/16"
    azs  = ["us-east-1a"]
  }

  assert {
    condition     = aws_vpc.this.cidr_block == "10.99.0.0/16"
    error_message = "CIDR mismatch"
  }
}

Best practices — собрано

  1. Никогда не правь руками после apply
  2. Pin версии провайдеров и модулей
  3. Один state — одна логическая единица
  4. plan в CI на каждый PR
  5. apply через CI с approval для prod
  6. Секреты — Vault/SSM/TF_VAR_*, не в коде
  7. for_each вместо count
  8. Единый naming convention
  9. Все ресурсы с тегами (Environment, Owner, ManagedBy)
  10. Code review с plan-выводом
  11. Pre-commit hooks: fmt + tflint + tfsec
  12. Modules в отдельном репо с tagged releases