CI/CD, инфраструктура и облака Terraform и инфраструктура как код: HCL, state, модули, drift, альтернативы
0%

Terraform и инфраструктура как код: HCL, state, модули, drift, альтернативы

Terraform и инфраструктура как код: HCL, state, модули, drift, альтернативы

В предыдущей статье — https://courses.digitable.life/post/devops/08-helm-and-gitops/ — мы довели до автоматизма то, что живёт внутри кластера: манифесты, Helm, ArgoCD, непрерывная сверка желаемого состояния с фактическим. Но кластер, база, сеть, DNS-зона, S3-бакет, IAM-роль и сам аккаунт в облаке кто-то должен был создать. Обычно этот «кто-то» — человек в веб-консоли, и именно здесь заканчивается воспроизводимость всей остальной цепочки.

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

Зачем нужна инфраструктура как код

Начнём с первопричины. Инфраструктура, настроенная руками, обладает свойством, которое губит команды медленно и незаметно: её состояние — это накопленная история всех действий, которые с ней совершали. Через год никто не знает, почему у security group открыт порт 9200, кто добавил третью подсеть и зачем на балансировщике висит сертификат от домена, которого больше нет.

Разворот такой инфраструктуры в новом регионе — это не «повторить», а «раскопать». Проверить изменение перед применением невозможно: единственный способ узнать, что случится — сделать это в проде.

IaC решает три конкретные задачи:

  1. Воспроизводимость. Инфраструктура становится функцией от кода: одинаковый код → одинаковый результат. Появляются dev/staging/prod, которые действительно похожи друг на друга.
  2. Ревью и аудит. Изменение сети проходит pull request. git log отвечает на вопрос «кто и зачем открыл этот порт» за десять секунд, а не за неделю переписки.
  3. Предпросмотр. Возможность увидеть «что именно поменяется» до того, как это произошло, — то, чего у консоли нет в принципе.

Здесь важно сразу назвать цену. IaC — это не бесплатно: вы меняете «пять минут кликов» на «тридцать минут кода, PR, ревью и apply». Для одного эксперимента это чистый убыток. Выигрыш появляется, когда действие повторяется, когда его нужно объяснить другим или откатить. Практический критерий: если ресурс проживёт дольше недели или его увидит кто-то кроме вас — он должен быть в коде.

Императивно, декларативно и почему это разные миры

Два принципиальных подхода:

  • Императивный: «создай VPC, потом три подсети, потом NAT». Так работают скрипты на AWS CLI, boto3, во многом Ansible. Вы описываете шаги.
  • Декларативный: «должна существовать VPC с такими подсетями». Инструмент сам вычисляет разницу между желаемым и текущим и делает минимально необходимые действия. Так работает Terraform, CloudFormation, Kubernetes.

Разница проявляется на втором запуске. Императивный скрипт при повторе либо упадёт («уже существует»), либо создаст дубликат — если только вы не написали проверку на каждый шаг вручную. Декларативный инструмент во второй раз скажет «изменений нет». Это свойство называется идемпотентностью, и ради него терпят весь остальной сопутствующий сложный аппарат: state, граф зависимостей, дрейф.

Модель Terraform: провайдеры, граф и цикл plan/apply

Terraform сам по себе не знает ни про AWS, ни про Kubernetes. Ядро умеет ровно три вещи: разобрать HCL, построить граф зависимостей и обойти его, вызывая провайдеры — отдельные бинарники, которые общаются по gRPC и транслируют абстрактные CRUD-операции в вызовы конкретного API.

Три момента, которые стоит понять сразу, потому что из них растёт половина последующих проблем.

Граф строится из ссылок, а не из порядка строк. Если aws_subnet.private упоминает aws_vpc.main.id, Terraform выведет зависимость сам. Порядок ресурсов в файле не значит ничего; имена файлов не значат ничего — весь каталог склеивается в одну конфигурацию. Когда зависимость реальна, но не выражена ссылкой (например, IAM-политика должна существовать до запуска инстанса, который её использует, но напрямую не ссылается), её объявляют через depends_on.

Обход графа параллельный. По умолчанию до 10 одновременных операций (-parallelism=N). Это причина, по которой при 1 200 ресурсах вы упираетесь в rate limit API облака, а не в CPU.

Terraform не наблюдает за инфраструктурой непрерывно. Он смотрит на мир только в момент запуска. Это фундаментальное отличие от контроллеров Kubernetes и от ArgoCD из https://courses.digitable.life/post/devops/08-helm-and-gitops/: там reconciliation loop крутится постоянно, здесь — по требованию. Отсюда и берётся дрейф как отдельная тема.

State: главный источник и боли, и смысла

Три мира Terraform: конфигурация, state и реальная инфраструктура

State — это JSON-файл, отображающий адрес ресурса в конфигурации (module.db.aws_db_instance.main) на идентификатор реального объекта (arn:aws:rds:eu-central-1:...:db:prod-main) плюс снимок всех его атрибутов.

Частый вопрос: зачем он нужен, если можно каждый раз спрашивать облако? Три ответа:

  1. Сопоставление. По API нельзя узнать, что вот этот бакет — это именно aws_s3_bucket.logs из вашего кода. Тегами это решается частично и не для всех типов ресурсов.
  2. Обнаружение удалений. Если ресурс исчез из конфигурации, узнать об этом можно только сравнив с предыдущим состоянием. Без state Terraform не понял бы, что его надо удалить.
  3. Скорость. Атрибуты в state позволяют строить план, не опрашивая всё подряд (-refresh=false).

Backend, блокировки и что делать, когда всё зависло

Локальный terraform.tfstate годится ровно до второго участника. В команде нужен удалённый backend с блокировкой.

# backend.tf
terraform {
  required_version = "~> 1.10"

  backend "s3" {
    bucket       = "acme-tfstate-prod"
    key          = "20-platform/eu-central-1/terraform.tfstate"
    region       = "eu-central-1"
    encrypt      = true
    kms_key_id   = "arn:aws:kms:eu-central-1:111122223333:key/8f2c..."
    use_lockfile = true # нативная блокировка S3, DynamoDB больше не нужна (Terraform >= 1.10)
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.70" # верхнюю границу мажора фиксируем всегда
    }
  }
}

До Terraform 1.10 блокировку S3-бэкенда делали через отдельную таблицу DynamoDB (dynamodb_table) — этот вариант всё ещё работает и встречается в 90 % существующих репозиториев. Нативный use_lockfile опирается на условную запись в S3 и убирает лишний ресурс и лишние права.

Требования к бакету со state — не формальность:

# версионирование обязательно: это ваш единственный способ откатить порчу state
aws s3api put-bucket-versioning --bucket acme-tfstate-prod \
  --versioning-configuration Status=Enabled

# публичный доступ закрыть наглухо: в state лежат пароли в открытом виде
aws s3api put-public-access-block --bucket acme-tfstate-prod \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Когда конвейер убили посреди apply, блокировка остаётся висеть:

$ terraform plan
│ Error: Error acquiring the state lock
│ Lock Info:
│   ID:        7c0f1a2b-9d3e-4f55-8a11-6b2c9e0d4a77
│   Operation: OperationTypeApply
│   Who:       runner@fv-az1043-233
│   Created:   2026-07-16 09:41:12.334 +0000 UTC

Прежде чем снимать блокировку — убедитесь, что процесс действительно мёртв. Снятая блокировка при живом apply — это два процесса, пишущих один state, и почти гарантированная его порча.

terraform force-unlock 7c0f1a2b-9d3e-4f55-8a11-6b2c9e0d4a77

Хирургия по state

Иногда состояние надо править напрямую. Основные операции:

terraform state list                      # что вообще под управлением
terraform state show aws_db_instance.main # атрибуты одного ресурса
terraform state pull > backup.tfstate     # ВСЕГДА делайте это перед любой операцией ниже
terraform state mv 'aws_s3_bucket.logs' 'module.logging.aws_s3_bucket.this'
terraform state rm 'aws_iam_user.legacy'  # забыть ресурс, НЕ удаляя его в облаке

Современный Terraform позволяет делать почти то же самое декларативно — и это сильно лучше, потому что операция проходит ревью, попадает в git и одинаково выполняется у всех:

# Переименование/перенос без пересоздания (Terraform 1.1+)
moved {
  from = aws_s3_bucket.logs
  to   = module.logging.aws_s3_bucket.this
}

# Взять существующий ресурс под управление (Terraform 1.5+)
import {
  to = aws_db_instance.main
  id = "prod-main"
}

# Убрать из state, не трогая в облаке (Terraform 1.7+)
removed {
  from = aws_iam_user.legacy
  lifecycle { destroy = false }
}

Блоки moved, import и removed после успешного apply можно удалить из кода. Практика: держите их в репозитории один-два релиза, потом чистите.

Для массового импорта существующей инфраструктуры есть генерация конфигурации:

terraform plan -generate-config-out=generated.tf

Она пишет заготовку HCL для всех ресурсов из import-блоков. Заготовку придётся вычищать руками — генератор выводит все атрибуты, включая вычисляемые, — но это на порядок быстрее ручного описания.

Секреты в state: неприятная правда

В state все значения лежат в открытом виде. Пароль от RDS, приватный ключ, содержимое aws_secretsmanager_secret_version — всё это в JSON. sensitive = true прячет значение только в выводе CLI, но не в файле.

Практические следствия:

  • Бакет со state шифруется (SSE-KMS) и доступен строго ограниченному набору ролей. Доступ на чтение state ≈ доступ ко всем секретам инфраструктуры.
  • Пароли лучше не генерировать Terraform-ом, а создавать вне его: пусть облако само сгенерирует (manage_master_user_password = true у RDS с ротацией через Secrets Manager) или значение придёт из внешнего хранилища.
  • Terraform 1.10+ даёт ephemeral resources, а 1.11+ — write-only аргументы (password_wo): значения проходят через процесс, но не записываются в state. Это правильное направление, но покрытие в провайдерах пока частичное — проверяйте документацию конкретного ресурса.
  • OpenTofu с версии 1.7 умеет сквозное шифрование state штатно, включая план-файлы. Для многих команд это решающий аргумент в его пользу.

HCL по существу

HCL — не язык программирования, а язык описания с выражениями. Циклов нет, есть трансформации коллекций; ветвлений нет, есть тернарный оператор и for-фильтры.

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment должен быть dev, staging или prod."
  }
}

variable "subnets" {
  description = "Карта подсетей: имя -> параметры"
  type = map(object({
    cidr = string
    az   = string
    public = optional(bool, false) # значение по умолчанию для поля объекта
  }))
}

locals {
  # Общие теги — единственное место, где они определены
  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Repo        = "acme/infra"
  }
  public_subnets = { for k, v in var.subnets : k => v if v.public }
}

resource "aws_subnet" "this" {
  for_each = var.subnets

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az
  tags              = merge(local.tags, { Name = "${var.environment}-${each.key}" })
}

output "private_subnet_ids" {
  description = "ID приватных подсетей для верхних слоёв"
  value       = [for k, s in aws_subnet.this : s.id if !var.subnets[k].public]
}

Теги удобнее задавать один раз на уровне провайдера — тогда их не забудут:

provider "aws" {
  region = var.region
  default_tags { tags = local.tags }
}

count против for_each — самая дорогая ошибка новичка

count адресует экземпляры по числовому индексу, for_each — по строковому ключу. Разница проявляется при удалении элемента из середины списка.

# ПЛОХО: users = ["alice", "bob", "carol"]
resource "aws_iam_user" "bad" {
  count = length(var.users)
  name  = var.users[count.index]
}

Удаляем alice. Terraform видит: индекс 0 был alice, стал bob → изменить; индекс 1 был bob, стал carol → изменить; индекс 2 → удалить.

Plan: 0 to add, 2 to change, 1 to destroy.

Для IAM-пользователей это переименование, для RDS-инстанса или EBS-тома — уничтожение данных. Правильно так:

# ХОРОШО: ключ стабилен и не зависит от позиции
resource "aws_iam_user" "good" {
  for_each = toset(var.users)
  name     = each.key
}

Теперь удаление alice даёт ровно Plan: 0 to add, 0 to change, 1 to destroy. Правило: count — только для «включить/выключить» (count = var.enabled ? 1 : 0), во всех остальных случаях for_each. И ключи for_each должны быть известны на этапе плана: если ключ вычисляется из атрибута ещё не созданного ресурса, вы получите Invalid for_each argument.

lifecycle: тонкая настройка поведения

resource "aws_db_instance" "main" {
  # ...
  lifecycle {
    prevent_destroy = true                       # защита от случайного destroy
    ignore_changes  = [engine_version]           # версию двигает служба обновлений, не Terraform
    create_before_destroy = true                 # для ресурсов, которые нельзя терять ни на секунду
    replace_triggered_by  = [aws_kms_key.db.id]  # пересоздать при смене ключа
    precondition {
      condition     = var.environment != "prod" || var.backup_retention_period >= 7
      error_message = "В проде срок хранения бэкапов не может быть меньше 7 дней."
    }
  }
}

ignore_changes — обоюдоострый инструмент: он глушит дрейф вместо того, чтобы его чинить. Используйте его только там, где поле легитимно меняет кто-то другой (автоскейлер меняет desired_count, служба обновлений — минорную версию движка). Каждый ignore_changes заслуживает комментария с объяснением.

Провайдеры и lock-файл

.terraform.lock.hcl фиксирует точные версии провайдеров и их контрольные суммы. Он коммитится в git. Классическая боль: инженер на macOS/arm64 закоммитил lock только со своей платформой, а Linux-раннер падает с checksums list has no SHA-256 hash for provider. Лечится так:

terraform providers lock \
  -platform=linux_amd64 -platform=darwin_arm64 -platform=darwin_amd64

Модули: как не построить абстракцию, которую все ненавидят

Модуль — просто каталог с .tf-файлами, принимающий входы и отдающий выходы. Любая конфигурация уже является модулем (корневым).

modules/postgres/
├── main.tf        # ресурсы
├── variables.tf   # входы с описаниями и валидацией
├── outputs.tf     # выходы
├── versions.tf    # required_providers и required_version
└── README.md      # что делает, пример вызова, ограничения
module "db" {
  source  = "git::ssh://git@github.com/acme/tf-modules.git//postgres?ref=v2.3.1"
  # или из реестра:
  # source  = "terraform-aws-modules/rds/aws"
  # version = "~> 6.5"

  name              = "orders-${var.environment}"
  instance_class    = var.environment == "prod" ? "db.r6g.xlarge" : "db.t4g.medium"
  subnet_ids        = module.network.private_subnet_ids
  multi_az          = var.environment == "prod"
  backup_retention  = var.environment == "prod" ? 30 : 1
}

Версионируйте модули по тегам всегда. ?ref=main означает, что ваш прод меняется, когда кто-то мержит PR в другом репозитории. Это не «удобно», это утрата контроля.

Теперь про то, где модули ломаются на практике.

Антипаттерн Симптом Что делать
Модуль-обёртка над одним ресурсом 12 переменных, которые один в один прокидываются в aws_s3_bucket Использовать ресурс напрямую
«Универсальный» модуль на все среды 60 переменных, ветвление count в каждом ресурсе, никто не рискует его менять Разделить на 2–3 узких модуля
Глубокая вложенность (4+ уровня) Чтобы понять, откуда берётся тег, нужно открыть шесть файлов Не глубже двух уровней
Провайдер объявлен внутри модуля Модуль нельзя использовать дважды с разными регионами Провайдеры — только в корне, в модуль передавать через providers = {}
Модуль как «слой» со своим backend Модули не имеют state, вы получите неработающую конструкцию Слой — это отдельная корневая конфигурация

Хорошая эвристика: модуль оправдан, если он инкапсулирует решение, а не набор ресурсов. terraform-aws-modules/vpc/aws полезен потому, что скрывает «как правильно разложить подсети, таблицы маршрутизации, NAT и flow logs», а не потому, что объединяет несколько ресурсов.

Публичный реестр — registry.terraform.io — стоит читать как учебник даже если вы не будете использовать модули оттуда: там видно, как устроены интерфейсы у зрелых модулей.

Организация репозитория: слои, среды и радиус поражения

Монолитный state против слоёного

Три классических способа разделить среды:

Workspaces (terraform workspace new staging) — один код, несколько state внутри одного backend. Соблазнительно, но плохо масштабируется: среды почти всегда отличаются не только значениями переменных (в проде есть реплика и WAF, в dev — нет), и код быстро зарастает var.environment == "prod" ? ... : .... Плюс общий backend означает общие права доступа. Workspaces хороши для эфемерных сред на PR, а не для «dev/staging/prod».

Каталоги на среду — скучно, дублирующе и работает лучше всего:

infra/
├── modules/                     # переиспользуемые модули
└── live/
    ├── prod/
    │   ├── 10-network/          # свой backend, свой state
    │   ├── 20-platform/
    │   └── 30-services/orders/
    └── staging/
        └── ...

Дублирование main.tf между средами — это фича: изменение выкатывается сначала в staging, потом отдельным PR в prod. При workspaces «одинаковый код» означает, что вы не можете применить изменение в одной среде и не применить в другой.

Terragrunt — обёртка, убирающая дублирование backend-конфигураций и умеющая строить граф между слоями (run-all apply). Полезна на 5+ средах и десятках слоёв. Плата: ещё один инструмент, ещё один DSL, ещё одна вещь, которую надо знать новому инженеру, и трудности при отладке (terragrunt генерирует временные каталоги). Для 3 сред и 4 слоёв — избыточно.

Связь между слоями — строго в одну сторону, сверху вниз:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-tfstate-prod"
    key    = "10-network/eu-central-1/terraform.tfstate"
    region = "eu-central-1"
  }
}

# Альтернатива без чтения чужого state — поиск по тегам.
# Медленнее, но не требует прав на чужой бакет и не ломается при рефакторинге нижнего слоя.
data "aws_subnets" "private" {
  filter { name = "vpc-id"; values = [data.aws_vpc.main.id] }
  tags = { Tier = "private" }
}

terraform_remote_state даёт жёсткую связь: переименовали output внизу — сломали всё сверху. Data-источники по тегам слабее связывают слои, но зависят от дисциплины тегирования. На практике: внутри одной команды — remote_state, между командами — data-источники или явные параметры.

Дрейф: почему он неизбежен и что с ним делать

Дрейф — расхождение между state и реальностью. Источники, в порядке частоты:

  1. Ручное вмешательство в инциденте. В три часа ночи никто не открывает PR, чтобы поднять лимит подключений. И это правильно — но дрейф остаётся.
  2. Другие автоматизации. Автоскейлер меняет количество инстансов, служба обновлений — версию движка, Kubernetes-контроллер создаёт балансировщик под Service типа LoadBalancer.
  3. Само облако. Провайдер добавляет новые поля с дефолтами, дефолты меняются между версиями провайдера.
  4. Второй Terraform. Два разных state управляют пересекающимися ресурсами — самый неприятный случай, ресурсы начинают «перетягиваться».

При обнаружении дрейфа Terraform печатает отдельный блок ещё до плана:

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:

  # aws_security_group.web has been changed
  ~ resource "aws_security_group" "web" {
        id = "sg-0abc1234def567890"
      + ingress {
          + cidr_blocks = ["0.0.0.0/0"]
          + from_port   = 22
          + protocol    = "tcp"
          + to_port     = 22
        }
    }

Дальше у вас три законных стратегии, и выбор — вопрос политики, а не вкуса:

  • Вернуть к коду — обычный apply. Правильно для правил доступа, шифрования, политик. То, что кто-то открыл SSH в мир, должно быть закрыто автоматически.
  • Принять реальностьterraform apply -refresh-only, который записывает фактическое состояние в state, не трогая облако. Правильно, когда изменение было верным и его надо задним числом отразить в коде.
  • Игнорировать полеignore_changes. Правильно для полей, которыми легитимно владеет другая система.

Автоматическое обнаружение дрейфа

Ключевой приём — -detailed-exitcode: код 0 — изменений нет, 1 — ошибка, 2 — есть изменения.

# .github/workflows/drift.yml
name: drift-detection
on:
  schedule: [{ cron: "0 6 * * 1-5" }] # будни, 06:00 UTC
  workflow_dispatch:

permissions:
  id-token: write   # для OIDC
  contents: read
  issues: write

jobs:
  detect:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        layer: [10-network, 20-platform, 30-services/orders]
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/tf-readonly
          aws-region: eu-central-1
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: 1.10.5 }

      - name: Plan
        id: plan
        working-directory: live/prod/${{ matrix.layer }}
        run: |
          terraform init -input=false
          terraform plan -detailed-exitcode -lock=false -no-color -out=tfplan \
            | tee plan.txt
        continue-on-error: true

      - name: Open issue on drift
        if: steps.plan.outputs.exitcode == 2
        env: { GH_TOKEN: "${{ github.token }}" }
        working-directory: live/prod/${{ matrix.layer }}
        run: |
          gh issue create \
            --title "Дрейф в prod/${{ matrix.layer }}" \
            --label drift \
            --body "$(printf '```\n%s\n```' "$(tail -c 60000 plan.txt)")"

Важная деталь: -lock=false на read-only плане — чтобы ночная проверка не блокировала работу людей. И роль строго read-only: у детектора дрейфа нет причин иметь право что-то менять.

Что делать с найденным дрейфом — вопрос организационный. Работающая практика: дрейф в слоях безопасности и сети чинится в тот же день, дрейф в приложенческих слоях разбирается на еженедельном ревью. Без такого правила issue с меткой drift за полгода накопится сто штук, и все перестанут их читать.

Конвейер: plan на PR, apply на merge

Основной рабочий процесс. Ключевые свойства: план сохраняется в файл и именно он применяется (иначе между ревью и применением мир мог измениться), доступ идёт через OIDC без долгоживущих ключей, apply защищён окружением с ручным апрувом.

# .github/workflows/terraform.yml
name: terraform
on:
  pull_request:
    paths: ["live/prod/20-platform/**", "modules/**"]
  push:
    branches: [main]
    paths: ["live/prod/20-platform/**", "modules/**"]

permissions:
  id-token: write
  contents: read
  pull-requests: write

env:
  TF_IN_AUTOMATION: "true"
  WORKDIR: live/prod/20-platform

concurrency:
  group: tf-prod-20-platform   # два apply одного слоя одновременно не запускаются
  cancel-in-progress: false

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults: { run: { working-directory: "live/prod/20-platform" } }
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.10.5
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/tf-plan
          aws-region: eu-central-1

      - run: terraform fmt -check -recursive -diff
      - run: terraform init -input=false
      - run: terraform validate

      - name: Статический анализ
        run: |
          tflint --recursive --format compact
          checkov -d . --framework terraform --compact --quiet

      - name: Plan
        run: terraform plan -input=false -lock-timeout=5m -out=tfplan

      - name: Проверка политик по плану
        run: |
          terraform show -json tfplan > plan.json
          conftest test --policy ../../../policy plan.json

      - name: Оценка стоимости
        run: infracost breakdown --path plan.json --format table

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan-${{ github.sha }}
          path: live/prod/20-platform/tfplan
          retention-days: 5

  apply:
    if: github.ref == 'refs/heads/main'
    needs: plan
    runs-on: ubuntu-latest
    environment: production        # здесь настраивается ручной апрув и список ревьюеров
    defaults: { run: { working-directory: "live/prod/20-platform" } }
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: 1.10.5, terraform_wrapper: false }
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/tf-apply
          aws-region: eu-central-1
      - uses: actions/download-artifact@v4
        with:
          name: tfplan-${{ github.sha }}
          path: live/prod/20-platform
      - run: terraform init -input=false
      - run: terraform apply -input=false -auto-approve -lock-timeout=10m tfplan

Тонкости, которые обычно узнают на своей шкуре:

  • concurrency обязателен. Без него два мержа подряд запускают два apply, второй упирается в блокировку и падает по таймауту, оставляя изменение неприменённым.
  • Сохранённый план протухает. Если между plan и apply в state что-то поменялось, apply откажется: Saved plan is stale. Это не баг, а защита — перезапустите план.
  • План — чувствительный артефакт. В нём видны значения переменных и часть атрибутов. Не публикуйте его в комментариях PR открытого репозитория. Пользуйтесь сокрытием чувствительных значений и коротким retention артефактов.
  • Две роли, а не одна. У плана — read-only, у apply — запись. Иначе любой, кто может открыть PR, получает право на чтение секретов через хитрый output.

О настройке OIDC и общей гигиене конвейера подробнее — https://courses.digitable.life/post/devops/17-security-in-pipeline/, о выборе самой платформы CI — https://courses.digitable.life/post/devops/03-modern-ci-platforms/.

Политики, тесты и проверки

Три уровня контроля, каждый ловит свой класс ошибок:

1. Линтеры и статический анализ конфигурации — до плана, за секунды.

terraform fmt -check -recursive     # форматирование
terraform validate                  # синтаксис и типы
tflint --recursive                  # неверные типы инстансов, deprecated-аргументы
trivy config .                      # или checkov / tfsec — небезопасные конфигурации

2. Политики по плану — самое ценное, потому что политика видит результат, а не текст. Rego + Conftest:

# policy/s3.rego
package main

deny contains msg if {
    resource := input.resource_changes[_]
    resource.type == "aws_s3_bucket"
    "create" in resource.change.actions
    not startswith(resource.change.after.bucket, "acme-")
    msg := sprintf("Бакет %s должен начинаться с префикса acme-", [resource.change.after.bucket])
}

deny contains msg if {
    resource := input.resource_changes[_]
    resource.type == "aws_db_instance"
    "delete" in resource.change.actions
    msg := sprintf("Удаление БД %s требует ручного апрува вне конвейера", [resource.address])
}

Альтернативы: Sentinel (только в платных тарифах HCP Terraform), встроенные политики Spacelift/env0, или просто jq по plan.json — для трёх правил полноценный движок политик избыточен.

3. Нативные тесты Terraform (terraform test, начиная с 1.6) — прогоняют настоящий plan/apply в изолированной среде:

# tests/naming.tftest.hcl
variables {
  environment = "staging"
  region      = "eu-central-1"
}

run "план_проходит_валидацию" {
  command = plan   # без создания ресурсов, быстро

  assert {
    condition     = aws_s3_bucket.logs.bucket == "acme-logs-staging"
    error_message = "Имя бакета собрано неверно"
  }
}

run "прод_требует_multi_az" {
  command   = plan
  variables { environment = "prod" }

  assert {
    condition     = aws_db_instance.main.multi_az == true
    error_message = "В проде БД обязана быть multi-AZ"
  }
}
$ terraform test
tests/naming.tftest.hcl... in progress
  run "план_проходит_валидацию"... pass
  run "прод_требует_multi_az"... pass
tests/naming.tftest.hcl... teardown
Success! 2 passed, 0 failed.

command = plan не создаёт ресурсов и стоит секунды — такие тесты гоняют на каждом PR. command = apply создаёт реальную инфраструктуру и уничтожает её после теста: полезно для модулей, но это минуты и деньги, поэтому ставьте их в ночной прогон. Для сложных сценариев с проверкой «а работает ли оно» (HTTP-запрос к поднятому балансировщику) остаётся Terratest на Go.

Альтернативы: что выбрать под свой масштаб

Сначала контекст. В августе 2023 года HashiCorp сменила лицензию Terraform с MPL 2.0 на BUSL 1.1, что запрещает использование продукта конкурентами. В ответ сообщество создало форк OpenTofu, который перешёл под крыло Linux Foundation, а в январе 2024 вышел его первый стабильный релиз. В феврале 2025 года HashiCorp была куплена IBM. Для большинства команд это не меняет ничего юридически (обычное использование BUSL разрешает), но означает, что у экосистемы теперь два развивающихся ядра.

Инструмент Язык Мультиоблако Порог входа Эксплуатационная нагрузка Стоимость Когда брать
Terraform HCL да средний средняя CLI бесплатно (BUSL); HCP Terraform от free до платных тарифов Дефолт: максимальная экосистема, легко нанять людей
OpenTofu HCL да средний средняя MPL 2.0, полностью бесплатно Нужен шифрованный state, беспокоит лицензия или вендор
Pulumi TS/Python/Go/C# да выше среднего средняя OSS бесплатно; Pulumi Cloud платный по ресурсам Команда сильна в языке, нужна настоящая логика и тесты на привычном стеке
CDKTF TS/Python/Go да высокий выше средней бесплатно Хотите код на языке, но state и провайдеры Terraform. Учтите: слой генерации добавляет отладочной боли
CloudFormation YAML/JSON нет (AWS) средний высокая бесплатно, платите только за ресурсы Жёсткое требование «только нативные сервисы AWS», нужны StackSets
Bicep Bicep DSL нет (Azure) низкий низкая бесплатно Azure-only: интеграция и what-if лучше, чем у Terraform
Crossplane YAML (CRD) да высокий высокая бесплатно, коммерческий Upbound У вас уже GitOps и Kubernetes как платформа, нужен непрерывный reconcile
Ansible YAML да низкий высокая для облака бесплатно Настройка ОС внутри машин — да; создание облачных ресурсов — редко оправдано

Несколько честных замечаний по каждому.

Terraform vs OpenTofu. Синтаксис и state совместимы, миграция обычно сводится к замене бинарника. Расхождения нарастают: у OpenTofu — шифрование state, ранняя оценка переменных в блоке backend, поддержка OCI-реестров для модулей; у Terraform — Stacks и часть новых возможностей в HCP. Практический совет: если вы начинаете сегодня и вам не нужен HCP Terraform — OpenTofu безопасный выбор. Если у вас 200 сотрудников и корпоративный контракт — не устраивайте миграцию ради идеологии.

Pulumi. Главное преимущество не «настоящий язык», а нормальное переиспользование и обычные юнит-тесты. Главный риск — то же самое: в HCL невозможно написать трёхуровневую фабрику абстракций, а в TypeScript очень даже. Инфраструктурный код, который надо отлаживать, — плохой инфраструктурный код. Плюс state: бесплатно можно хранить самому (S3), но большинство берёт Pulumi Cloud, и это платно по количеству ресурсов.

Crossplane. Меняет модель целиком: облачные ресурсы становятся объектами Kubernetes, а контроллер непрерывно приводит их к желаемому состоянию — то есть дрейф чинится сам, без ночного cron. Цена — вы теперь эксплуатируете кластер, от которого зависит вся инфраструктура (включая, возможно, сам этот кластер), а диагностика ошибок идёт через kubectl describe и статусы CRD, что заметно менее внятно, чем текст плана. Разумно там, где уже есть зрелая платформенная команда — см. https://courses.digitable.life/post/devops/06-kubernetes/.

CloudFormation. Единственное реальное преимущество перед Terraform — отсутствие state как вашей заботы (его хранит AWS) и StackSets для развёртывания по сотням аккаунтов. Взамен: медленные операции, откаты, которые сами застревают (UPDATE_ROLLBACK_FAILED), и заметное отставание в поддержке новых сервисов от того, что даёт провайдер AWS для Terraform.

Сколько стоит автоматизация Terraform

CLI бесплатен, платят за оркестрацию: очередь, блокировки, апрувы, аудит, RBAC, хранение планов.

Вариант Порядок цены Что получаете Что тратите
Голый CI (GitHub Actions/GitLab) + S3 ~0 (только минуты раннеров) Полный контроль Свои скрипты, свои апрувы, свои комментарии в PR
Atlantis (self-hosted, open source) Хостинг ~20 $–40/мес Комментарии atlantis plan/apply в PR, очередь, блокировки Свой сервис: обновления, доступность, доступ к прод-кредам
HCP Terraform Free до нескольких сотен управляемых ресурсов; далее по числу ресурсов в месяц SaaS, state, RBAC, Sentinel в старших тарифах Растёт линейно с инфраструктурой; сверяйтесь с актуальным прайсом
Spacelift / env0 / Scalr Сотни $/мес и выше Политики OPA, дрейф-детекция, стеки и зависимости Дорого на малом масштабе, зависимость от вендора

Практическое правило: до 3–5 инженеров хватает голого CI; на 5–20 обычно ставят Atlantis или берут SaaS; выше — SaaS почти всегда дешевле, чем время инженеров на поддержание своего. Считать надо не «цену лицензии», а цену лицензии плюс часы на эксплуатацию — методику разбираем в https://courses.digitable.life/post/devops/15-cloud-cost-and-tradeoffs/.

Отдельно про Infracost: показывает в PR, на сколько долларов в месяц изменится счёт. Эффект от него часто больше, чем от любых политик — инженеры перестают выбирать db.r6g.4xlarge «на всякий случай», когда видят «+1 840 $/мес» прямо в комментарии.

Типичные ошибки

  1. State в git. Он содержит секреты и порождает конфликты слияния, которые невозможно разрешить осмысленно. Только удалённый backend.
  2. Отсутствие версионирования у бакета со state. Первая же испорченная запись становится катастрофой.
  3. terraform apply с ноутбука в прод. Разные версии CLI и провайдеров, локальные креды, отсутствие следа в аудите. Прод трогает только конвейер.
  4. count вместо for_each для списков. См. выше: удаление элемента из середины уничтожает данные.
  5. -target как повседневный инструмент. Он оставляет state частично применённым и маскирует настоящую проблему. Документация прямо называет его средством для восстановления после ошибок.
  6. Незафиксированные версии провайдеров. Мажорное обновление AWS-провайдера способно предложить пересоздать половину ресурсов. Пин + .terraform.lock.hcl в git.
  7. Один гигантский state. Пятиминутный план, очередь из инженеров, радиус поражения размером с компанию.
  8. Гонка за DRY. Три уровня вложенности модулей и for_each по картам карт «чтобы не дублировать» превращают конфигурацию в нечитаемую. В инфраструктурном коде явность важнее краткости: скопировать 20 строк между средами часто лучше, чем построить абстракцию.
  9. prevent_destroy не стоит на том, что нельзя терять. Бесплатная страховка на БД, бакеты со state и логами, KMS-ключи.
  10. Terraform для того, что должно быть внутри кластера. Управлять Helm-релизами через helm_release можно, но вы получите гибрид: приложения деплоятся конвейером Terraform, а не GitOps-контроллером, с блокировками и без непрерывной сверки. Граница простая: Terraform создаёт кластер и внешние зависимости, ArgoCD/Flux управляют содержимым.
  11. Отсутствие terraform fmt в конвейере. Диффы зарастают пробелами, ревью деградирует.
  12. Секреты в .tfvars, закоммиченных в репозиторий. Значения приходят из переменных окружения TF_VAR_*, из хранилища секретов CI или из Vault-провайдера.

Чек-лист продакшн-готовности

  • Удалённый backend с блокировкой, шифрованием и версионированием
  • required_version и верхние границы версий провайдеров зафиксированы, lock-файл в git и содержит все нужные платформы
  • State разбит на слои по частоте изменений и владельцу; ориентир — 100–300 ресурсов на state
  • Прод меняется только через конвейер; у CI — OIDC без долгоживущих ключей и раздельные роли на plan и apply
  • Применяется сохранённый план, а не пересобранный на месте
  • concurrency/очередь не дают двум apply одного слоя пересечься
  • fmt, validate, tflint, сканер безопасности и проверка политик по plan.json — на каждом PR
  • terraform test с command = plan для модулей на PR, apply-тесты — ночью
  • Ночное обнаружение дрейфа с read-only ролью и понятным владельцем реакции
  • prevent_destroy на всех ресурсах с состоянием
  • Модули версионируются тегами; ?ref=main нигде не встречается
  • Infracost или его аналог показывает изменение стоимости в PR
  • Написано и проверено: как восстановить state из версии бакета

Мини-итог

Terraform устроен просто: граф ресурсов, провайдеры как плагины и state как отображение кода на реальные объекты. Практически всё сложное в нём растёт из одного места — state существует отдельно и от кода, и от реальности, а значит требует блокировок, разделения на слои, защиты как у хранилища секретов и регулярной сверки с миром.

Отсюда же и главные инженерные решения: где провести границы state (по частоте изменений и владельцу), как обнаруживать дрейф (ночной план с -detailed-exitcode) и что с ним делать (безопасность — чинить, автоскейлинг — игнорировать). Инструмент выбирайте по масштабу, а не по моде: Bicep для чистого Azure проще Terraform, Crossplane оправдан только при зрелой платформенной команде, а голый CI с S3 честно закрывает потребности команды до пяти человек.

Источники

Что дальше

Terraform создаёт ресурсы, но почти ничего не говорит о том, что происходит внутри машины после её создания: пакеты, конфигурационные файлы, сервисы, пользователи. Этим занимается отдельный класс инструментов, и граница между ними и IaC — постоянный источник споров.

Дальше: Ansible, Puppet, Chef и управление конфигурацией.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов