Terraform и инфраструктура как код: HCL, state, модули, drift, альтернативы
В предыдущей статье — https://courses.digitable.life/post/devops/08-helm-and-gitops/ — мы довели до автоматизма то, что живёт внутри кластера: манифесты, Helm, ArgoCD, непрерывная сверка желаемого состояния с фактическим. Но кластер, база, сеть, DNS-зона, S3-бакет, IAM-роль и сам аккаунт в облаке кто-то должен был создать. Обычно этот «кто-то» — человек в веб-консоли, и именно здесь заканчивается воспроизводимость всей остальной цепочки.
Эта статья про то, как описать инфраструктуру кодом так, чтобы через два года новый инженер мог развернуть копию продакшена в чужом регионе за час, а не за три недели археологии.
Зачем нужна инфраструктура как код
Начнём с первопричины. Инфраструктура, настроенная руками, обладает свойством, которое губит команды медленно и незаметно: её состояние — это накопленная история всех действий, которые с ней совершали. Через год никто не знает, почему у security group открыт порт 9200, кто добавил третью подсеть и зачем на балансировщике висит сертификат от домена, которого больше нет.
Разворот такой инфраструктуры в новом регионе — это не «повторить», а «раскопать». Проверить изменение перед применением невозможно: единственный способ узнать, что случится — сделать это в проде.
IaC решает три конкретные задачи:
- Воспроизводимость. Инфраструктура становится функцией от кода: одинаковый код → одинаковый результат. Появляются dev/staging/prod, которые действительно похожи друг на друга.
- Ревью и аудит. Изменение сети проходит pull request.
git logотвечает на вопрос «кто и зачем открыл этот порт» за десять секунд, а не за неделю переписки. - Предпросмотр. Возможность увидеть «что именно поменяется» до того, как это произошло, — то, чего у консоли нет в принципе.
Здесь важно сразу назвать цену. IaC — это не бесплатно: вы меняете «пять минут кликов» на «тридцать минут кода, PR, ревью и apply». Для одного эксперимента это чистый убыток. Выигрыш появляется, когда действие повторяется, когда его нужно объяснить другим или откатить. Практический критерий: если ресурс проживёт дольше недели или его увидит кто-то кроме вас — он должен быть в коде.
Императивно, декларативно и почему это разные миры
Два принципиальных подхода:
- Императивный: «создай VPC, потом три подсети, потом NAT». Так работают скрипты на AWS CLI,
boto3, во многом Ansible. Вы описываете шаги. - Декларативный: «должна существовать VPC с такими подсетями». Инструмент сам вычисляет разницу между желаемым и текущим и делает минимально необходимые действия. Так работает Terraform, CloudFormation, Kubernetes.
Разница проявляется на втором запуске. Императивный скрипт при повторе либо упадёт («уже существует»), либо создаст дубликат — если только вы не написали проверку на каждый шаг вручную. Декларативный инструмент во второй раз скажет «изменений нет». Это свойство называется идемпотентностью, и ради него терпят весь остальной сопутствующий сложный аппарат: state, граф зависимостей, дрейф.
Модель Terraform: провайдеры, граф и цикл plan/apply
Terraform сам по себе не знает ни про AWS, ни про Kubernetes. Ядро умеет ровно три вещи: разобрать HCL, построить граф зависимостей и обойти его, вызывая провайдеры — отдельные бинарники, которые общаются по gRPC и транслируют абстрактные CRUD-операции в вызовы конкретного API.
+ переменные + .tfvars"] --> PARSE["Разбор и вычисление выражений"] STATE[("Backend со state
S3 / GCS / Postgres")] --> REFRESH PARSE --> GRAPH["Граф зависимостей
явных и неявных"] GRAPH --> REFRESH["Refresh: прочитать реальные
объекты через API провайдера"] REFRESH --> DIFF{"Сравнение
конфиг ↔ state ↔ реальность"} DIFF -->|нет разницы| NOOP["No changes"] DIFF -->|есть разница| PLAN["План: create / update /
replace / destroy"] PLAN --> REVIEW{"Ревью и апрув"} REVIEW -->|отклонён| STOP["Стоп"] REVIEW -->|принят| APPLY["Apply: обход графа,
parallelism=10 по умолчанию"] APPLY --> API["API облака"] APPLY --> WRITE["Запись нового state
под блокировкой"] WRITE --> STATE style HCL fill:#3d8bcd,color:#fff style STATE fill:#2a9d8f,color:#fff style PLAN fill:#e9a23b,color:#3a3f4b style APPLY fill:#e76f51,color:#fff
Три момента, которые стоит понять сразу, потому что из них растёт половина последующих проблем.
Граф строится из ссылок, а не из порядка строк. Если 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: главный источник и боли, и смысла
State — это JSON-файл, отображающий адрес ресурса в конфигурации (module.db.aws_db_instance.main) на идентификатор реального объекта (arn:aws:rds:eu-central-1:...:db:prod-main) плюс снимок всех его атрибутов.
Частый вопрос: зачем он нужен, если можно каждый раз спрашивать облако? Три ответа:
- Сопоставление. По API нельзя узнать, что вот этот бакет — это именно
aws_s3_bucket.logsиз вашего кода. Тегами это решается частично и не для всех типов ресурсов. - Обнаружение удалений. Если ресурс исчез из конфигурации, узнать об этом можно только сравнив с предыдущим состоянием. Без state Terraform не понял бы, что его надо удалить.
- Скорость. Атрибуты в 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 — стоит читать как учебник даже если вы не будете использовать модули оттуда: там видно, как устроены интерфейсы у зрелых модулей.
Организация репозитория: слои, среды и радиус поражения
Три классических способа разделить среды:
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 и реальностью. Источники, в порядке частоты:
- Ручное вмешательство в инциденте. В три часа ночи никто не открывает PR, чтобы поднять лимит подключений. И это правильно — но дрейф остаётся.
- Другие автоматизации. Автоскейлер меняет количество инстансов, служба обновлений — версию движка, Kubernetes-контроллер создаёт балансировщик под Service типа LoadBalancer.
- Само облако. Провайдер добавляет новые поля с дефолтами, дефолты меняются между версиями провайдера.
- Второй Terraform. Два разных state управляют пересекающимися ресурсами — самый неприятный случай, ресурсы начинают «перетягиваться».
или другой контроллер Drifted --> Managed: apply — вернуть к коду Drifted --> Managed: apply -refresh-only
принять реальность в state Drifted --> Managed: ignore_changes
(осознанно игнорировать) Managed --> Tainted: taint / replace_triggered_by Tainted --> Managed: apply пересоздаёт ресурс Managed --> Orphan: ресурс удалён в облаке
мимо Terraform Orphan --> Managed: apply создаёт заново Managed --> Unmanaged: removed block / state rm Managed --> [*]: destroy
При обнаружении дрейфа 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 $/мес» прямо в комментарии.
Типичные ошибки
- State в git. Он содержит секреты и порождает конфликты слияния, которые невозможно разрешить осмысленно. Только удалённый backend.
- Отсутствие версионирования у бакета со state. Первая же испорченная запись становится катастрофой.
terraform applyс ноутбука в прод. Разные версии CLI и провайдеров, локальные креды, отсутствие следа в аудите. Прод трогает только конвейер.countвместоfor_eachдля списков. См. выше: удаление элемента из середины уничтожает данные.-targetкак повседневный инструмент. Он оставляет state частично применённым и маскирует настоящую проблему. Документация прямо называет его средством для восстановления после ошибок.- Незафиксированные версии провайдеров. Мажорное обновление AWS-провайдера способно предложить пересоздать половину ресурсов. Пин +
.terraform.lock.hclв git. - Один гигантский state. Пятиминутный план, очередь из инженеров, радиус поражения размером с компанию.
- Гонка за DRY. Три уровня вложенности модулей и
for_eachпо картам карт «чтобы не дублировать» превращают конфигурацию в нечитаемую. В инфраструктурном коде явность важнее краткости: скопировать 20 строк между средами часто лучше, чем построить абстракцию. prevent_destroyне стоит на том, что нельзя терять. Бесплатная страховка на БД, бакеты со state и логами, KMS-ключи.- Terraform для того, что должно быть внутри кластера. Управлять Helm-релизами через
helm_releaseможно, но вы получите гибрид: приложения деплоятся конвейером Terraform, а не GitOps-контроллером, с блокировками и без непрерывной сверки. Граница простая: Terraform создаёт кластер и внешние зависимости, ArgoCD/Flux управляют содержимым. - Отсутствие
terraform fmtв конвейере. Диффы зарастают пробелами, ревью деградирует. - Секреты в
.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 Language Documentation — исчерпывающий справочник по HCL, meta-аргументам и блокам
moved/import/removed - Terraform: Up & Running, 3rd ed. — Yevgeniy Brikman, лучшая книга по организации кода, модулям и тестированию
- OpenTofu Documentation — в том числе шифрование state
- Terraform Registry — модули и провайдеры; исходники зрелых модулей полезно читать как примеры
- Recommended Terraform practices — рекомендации Google Cloud по структуре и стилю
- Open Policy Agent и Conftest — политики по плану
- Atlantis — открытая автоматизация pull-request-воркфлоу
- Infracost — оценка стоимости изменений в PR
- Martin Fowler, Infrastructure As Code — короткое изложение мотивации и принципов
Что дальше
Terraform создаёт ресурсы, но почти ничего не говорит о том, что происходит внутри машины после её создания: пакеты, конфигурационные файлы, сервисы, пользователи. Этим занимается отдельный класс инструментов, и граница между ними и IaC — постоянный источник споров.