Модуль: DevOps · Уровень: Middle+/Senior

TL;DR#

  • IaC — инфраструктура описана кодом, версионируется в git, применяется детерминированно. Уходим от ручного «кликанья в консоли» (snowflake-серверов) к воспроизводимым, ревьюимым изменениям.
  • Terraform декларативен: вы описываете желаемое состояние, движок сам считает дельту между ним, текущим состоянием (state) и реальностью (refresh) и применяет минимальный набор изменений.
  • State — JSON-файл, сопоставляющий ресурсы в коде с реальными объектами в облаке (по ID). Без него Terraform не знает, что уже создано. Хранить удалённо (S3+DynamoDB, GCS, Terraform Cloud) с блокировкой (lock), иначе два одновременных apply испортят инфраструктуру.
  • Цикл: init (плагины+бэкенд) → plan (dry-run, показывает дельту) → apply (применяет). plan обязателен в review.
  • Providers — плагины к API (aws, google, kubernetes). Modules — переиспользуемые наборы ресурсов с input/output. Drift — расхождение реальности и state (кто-то поменял руками).
  • Best practices: remote state с локом, маленькие изолированные state’ы, модули, отсутствие секретов в state/коде в открытом виде, plan в CI, иммутабельность.

Теория#

Декларативность и граф зависимостей#

Вы пишете, что должно быть, а не как это сделать (в отличие от императивных скриптов). Terraform строит граф зависимостей ресурсов (по ссылкам resource.attr) и применяет их в правильном порядке, параллеля независимые.

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "app" {
  vpc_id     = aws_vpc.main.id     # ссылка => subnet зависит от vpc, создастся после
  cidr_block = "10.0.1.0/24"
}

State: что это и зачем#

State — это маппинг «ресурс в конфиге ↔ реальный объект в провайдере (по ID) + закэшированные атрибуты». Терраформ использует его, чтобы:

  • знать, что уже существует (иначе создавал бы дубликаты);
  • считать дельту для plan;
  • хранить зависимости и метаданные.

State содержит чувствительные данные (пароли БД, ключи, приватные поля ресурсов) в открытом виде — относитесь к нему как к секрету.

Remote state + locking#

Локальный terraform.tfstate непригоден для команды: не шарится, нет блокировки, легко потерять. Решение — remote backend:

terraform {
  backend "s3" {
    bucket         = "my-tf-state"
    key            = "prod/network/terraform.tfstate"
    region         = "eu-central-1"
    dynamodb_table = "tf-locks"     # блокировка через DynamoDB
    encrypt        = true
  }
}

Блокировка (lock) критична: при apply Terraform берёт lock (запись в DynamoDB / условный объект в GCS / Terraform Cloud). Если кто-то уже применяет — второй apply падает с «state is locked». Без лока два параллельных apply дадут гонку и порчу state. (В новых версиях S3-бэкенд умеет нативную блокировку через lockfile, но DynamoDB-вариант всё ещё распространён.)

# если процесс убит и lock завис — снять вручную (осторожно!)
terraform force-unlock <LOCK_ID>

Рабочий цикл#

terraform init        # скачивает providers, настраивает backend
terraform fmt         # форматирование
terraform validate    # синтаксис/типы
terraform plan -out=plan.tfplan   # dry-run: что создастся/изменится/удалится
terraform apply plan.tfplan       # применяет ровно этот plan (без сюрпризов)
terraform destroy     # сносит всё из state

Чтение plan:

  • + create, ~ update in-place, - destroy, -/+ replace (destroy+create — опасно для stateful-ресурсов!), <= read.

Применяйте сохранённый -out plan, чтобы apply сделал ровно то, что показал plan (между plan и apply реальность могла измениться).

Providers#

Плагин, реализующий CRUD к API конкретной системы. Пиньте версии — обновление провайдера может поменять поведение/схему.

terraform {
  required_version = ">= 1.6"
  required_providers {
    aws    = { source = "hashicorp/aws", version = "~> 5.0" }
    google = { source = "hashicorp/google", version = "~> 5.0" }
  }
}

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

# несколько инстансов одного провайдера через alias
provider "aws" {
  alias  = "us"
  region = "us-east-1"
}

Модули#

Модуль — переиспользуемый набор ресурсов с входами (variable) и выходами (output). Корневой каталог — тоже модуль (root module).

# modules/service/variables.tf
variable "name"        { type = string }
variable "image"       { type = string }
variable "min_count"   { type = number, default = 1 }

# modules/service/main.tf -> ресурсы сервиса ...

# modules/service/outputs.tf
output "service_url" { value = aws_lb.this.dns_name }
# использование
module "api" {
  source    = "./modules/service"     # или git/registry источник, пиньте версию: "...?ref=v1.2.0"
  name      = "api"
  image     = "ghcr.io/org/api:sha-abc"
  min_count = 3
}

# обращение к выходу
output "api_url" { value = module.api.service_url }

Модули дают DRY и стандартизацию (один модуль «сервис» на десятки команд). Не плодите глубокую вложенность — это усложняет понимание.

Переменные, count, for_each#

# for_each предпочтительнее count для коллекций:
# при удалении элемента из середины count "сдвигает" индексы и пересоздаёт лишнее
resource "aws_s3_bucket" "b" {
  for_each = toset(["logs", "uploads", "backups"])
  bucket   = "myorg-${each.key}"
}

Drift#

Drift — расхождение между state/конфигом и реальной инфраструктурой: кто-то поменял ресурс вручную в консоли, или внешний процесс. Terraform обнаруживает дрейф при refresh (по умолчанию в начале plan): сравнивает state с реальностью.

terraform plan            # покажет drift как изменения, которые "вернут" к конфигу
terraform plan -refresh-only   # только показать дрейф, не предлагая менять конфиг
terraform apply -refresh-only  # обновить state под реальность, не трогая ресурсы

Реакция на дрейф: либо вернуть как в коде (apply), либо узаконить изменение в коде. Лечение причины — запретить ручные правки (IAM), всё через Terraform.

import / state операции#

Если ресурс создан вне Terraform — его можно завести в state без пересоздания:

terraform import aws_s3_bucket.b my-existing-bucket   # CLI-способ
# или декларативно (Terraform 1.5+), import как код:
import {
  to = aws_s3_bucket.b
  id = "my-existing-bucket"
}
terraform state list                # что в state
terraform state mv  A  B            # переименовать в state без пересоздания
terraform state rm  aws_x.y         # убрать из state (НЕ удаляет реальный ресурс)

state rm + import — стандартный приём при рефакторинге (переименование ресурса/перенос в модуль) без destroy/create.

Подводные камни / gotchas#

  • Локальный state в команде = катастрофа: рассинхрон, потеря, нет блокировки. Всегда remote + lock.
  • State — это секрет: пароли/ключи лежат в нём открыто. Шифрование backend, ограничение доступа к бакету, не коммитить *.tfstate в git.
  • Apply без сохранённого plan: между plan и apply реальность/конфиг могли измениться — применяйте -out план.
  • -/+ replace stateful-ресурсов (БД, диск): незаметно в plan можно снести prod-БД. Внимательно читайте plan; prevent_destroy = true в lifecycle для критичных.
  • Изменение чувствительного аргумента форсит replace: например, смена availability_zone инстанса. Знайте, какие поля ForceNew.
  • count vs for_each: удаление элемента из середины списка с count сдвигает индексы и пересоздаёт «не те» ресурсы. Для коллекций — for_each со стабильными ключами.
  • Гигантский монолитный state: медленный plan, большой blast radius, конфликт локов в команде. Дробите по доменам/окружениям.
  • Дрейф из-за ручных правок: state расходится, следующий apply «откатывает» чужие изменения или ломается. Дисциплина: всё через Terraform, ручные права урезаны.
  • Секреты в коде/переменных в открытом виде: используйте secret manager / sensitive = true (но это лишь скрывает в выводе, в state всё равно открыто).
  • Незапиненные провайдеры/модули: молчаливое обновление меняет поведение. Пиньте version и ref.
  • Зависимость, не выраженная ссылкой: Terraform не угадает порядок — используйте depends_on явно.

Вопросы на собеседовании#

В: Что такое Terraform state и почему его нельзя потерять? О: Это маппинг ресурсов из конфига на реальные объекты в провайдере (по ID) плюс кэш их атрибутов. Без state Terraform не знает, что уже создано, и при apply создаст дубликаты или не сможет посчитать дельту. Поэтому state хранят удалённо, шифруют, бэкапят и защищают как секрет (в нём лежат пароли/ключи открытым текстом).

В: Зачем нужна блокировка state и что будет без неё? О: Лок не даёт двум apply идти одновременно. Без него параллельные применения читают один и тот же state, делают конфликтующие изменения и перезаписывают результаты друг друга — получаем повреждённый state и рассинхрон с реальностью. Реализуется через DynamoDB (S3-backend), нативный lockfile, GCS-условную запись или Terraform Cloud.

В: В чём разница между plan и apply и почему стоит сохранять plan? О: plan — dry-run: делает refresh, считает дельту между желаемым (конфиг) и текущим (state/реальность) состоянием и показывает, что создаст/изменит/удалит. apply — применяет. Если применять plan -out=file, то apply file сделает ровно показанное; иначе между plan и apply реальность может измениться, и apply сделает не то, что вы ревьюили.

В: Что такое drift и как с ним работать? О: Drift — расхождение между state/кодом и реальной инфраструктурой (обычно из-за ручных изменений в консоли). Terraform ловит его при refresh в начале plan. Реакция: либо apply вернёт инфраструктуру к коду, либо узакониваем изменение в коде; apply -refresh-only синхронизирует state под реальность не трогая ресурсы. Стратегически — запретить ручные правки, всё через Terraform.

В: Чем декларативный подход (Terraform) лучше императивных скриптов? О: Вы описываете желаемое состояние, а движок сам считает минимальную дельту и порядок (по графу зависимостей), идемпотентно. Императивный скрипт надо писать с учётом текущего состояния («если есть — пропусти»), он не идемпотентен и не знает, как откатить/изменить. Декларативность даёт воспроизводимость, plan-предпросмотр и управление дрейфом.

В: Как разбивать state и почему один большой state — плохо? О: Большой монолитный state: медленный plan/apply (refresh всех ресурсов), огромный blast radius (ошибка/лок затрагивает всё), конфликты локов в команде. Дробят по окружениям (dev/stage/prod) и доменам (network, data, compute), связывая через remote state data source или явные input’ы. Маленький state = быстрый, изолированный, безопаснее.

В: Чем for_each лучше count и когда что использовать? О: count адресует ресурсы по индексу — удаление элемента из середины списка сдвигает индексы и пересоздаёт «не те» ресурсы. for_each адресует по стабильному ключу (map/set), поэтому добавление/удаление элемента трогает только его. count уместен для «N одинаковых» или условного создания (count = var.enabled ? 1 : 0), for_each — для коллекций именованных ресурсов.

В: Как завести существующий (созданный вручную) ресурс под управление Terraform? О: Через terraform import (CLI) или декларативный import {} блок (1.5+): он добавляет ресурс в state, сопоставляя с реальным по ID, не пересоздавая. Затем нужно написать соответствующий resource-блок так, чтобы plan показал «нет изменений». Это же используется при рефакторинге: state mv/state rm+import переносят ресурсы без destroy/create.

На что копают на senior+#

  • Организация на масштабе: структура репозиториев (моно vs много), окружения (workspaces vs отдельные state vs Terragrunt), promotion изменений по env.
  • Управление секретами: что попадает в state, шифрование backend, интеграция с Vault/secret manager, ротация, недопущение plaintext.
  • CI/CD для Terraform: автоматический plan на PR с комментарием дельты, apply через approval, политики (OPA/Sentinel) на запрет опасных изменений, drift-детект по расписанию.
  • Безопасность изменений: prevent_destroy, понимание ForceNew-полей, защита stateful-ресурсов, separate state для критичных слоёв.
  • Версионирование и совместимость: пиннинг провайдеров/модулей, upgrade-стратегия, чтение CHANGELOG провайдеров.
  • Альтернативы и границы: Pulumi/CDK (императивный IaC на языке), Crossplane (k8s-native), где Terraform не лучший инструмент (конфигурация приложений vs инфраструктура).