Terraform в деталях: state, модули, workspaces
В краткой версии ты уже поднял сервер Hetzner декларативным конфигом и понял главное: Terraform не «выполняет скрипт», а сравнивает желаемое состояние с реальным и строит план. Теперь посмотрим, что происходит под капотом этой магии — потому что именно недопонимание внутреннего устройства state приводит к самым болезненным инцидентам: дублирующимся серверам, потерянным DNS-записям и «apply, который всё сломал». В продакшене Terraform — это не утилита, а система с источником истины, конкурентным доступом и жизненным циклом, который ты обязан контролировать.
Мы пройдём путь от атомов языка HCL до полной инфраструктуры pet-проекта: блоки, выражения, ресурсы и data-источники, локалы и выводы. Разберём state — где Terraform хранит карту «код ↔ облако» и почему local-файл terraform.tfstate годится только для экспериментов. Настроим remote backend с блокировкой, разложим по полочкам init/plan/apply/destroy, научимся модулям, workspaces, dynamic blocks и count/for_each. Завершим двумя практиками выживания: drift-detection в CI и импортом уже существующей инфраструктуры.
HCL: анатомия блока
Заголовок раздела «HCL: анатомия блока»HCL (HashiCorp Configuration Language) — декларативный язык конфигурации (исчерпывающий разбор синтаксиса — в документации языка Terraform). Он не исполняется сверху вниз: Terraform сначала парсит все файлы .tf в директории, строит граф зависимостей и только потом решает порядок операций. Каждый блок имеет вид:
# <тип блока> "<лейбл 1>" "<лейбл 2>" { тело }resource "hcloud_server" "pet" { name = "pet-prod" # аргумент: имя = выражение}Выражения — это не только строки. Ссылки на другие ресурсы (hcloud_server.pet.ipv4_address), условия (var.env == "prod" ? "cx22" : "cx11"), коллекции и функции (merge, lookup, cidrsubnet, templatefile). Именование ресурса — его адрес внутри кода и state: переименование ресурса pet в main Terraform воспримет как «старый удалить, новый создать», то есть пересоздаст сервер. Переименование — всегда осознанное действие с terraform state mv или moved-блоком.
# moved — явное переименование без пересоздания (Terraform 1.1+)moved { from = hcloud_server.pet to = hcloud_server.main}Провайдеры: Hetzner, DigitalOcean, AWS
Заголовок раздела «Провайдеры: Hetzner, DigitalOcean, AWS»Провайдер — плагин, который знает API конкретного облака. Объявляется в блоке terraform, а его экземпляр — в блоке provider. Пример на трёх провайдерах одновременно:
terraform { required_version = ">= 1.6" required_providers { hcloud = { source = "hetznercloud/hcloud" version = "~> 1.45" # ~> = «от 1.45.0 до 2.0.0» } digitalocean = { source = "digitalocean/digitalocean" version = "~> 2.40" } aws = { source = "hashicorp/aws" version = "~> 5.0" } }}
provider "hcloud" { token = var.hcloud_token # TF_VAR_hcloud_token из окружения}
provider "digitalocean" { token = var.do_token}
provider "aws" { region = "eu-central-1" # доступ — через env AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY}Разные провайдеры — одинаковая модель: resource "<тип>" "<имя>". Сравни создание сервера в трёх облаках:
# Hetzner: сервер + его приватная сетьresource "hcloud_server" "pet" { name = "pet-prod" server_type = "cx22" # 2 vCPU / 4 GB image = "ubuntu-24.04" location = "fsn1" ssh_keys = [hcloud_ssh_key.admin.id]}
# DigitalOcean: droplet с user_data (cloud-init)resource "digitalocean_droplet" "pet" { name = "pet-prod" size = "s-2vcpu-4gb" image = "ubuntu-24-04-x64" region = "fra1" ssh_keys = [digitalocean_ssh_key.admin.id]
user_data = file("${path.module}/cloud-init.yml")}
# AWS: EC2 в конкретной подсети и security groupresource "aws_instance" "pet" { ami = data.aws_ami.ubuntu.id # data-источник ниже instance_type = "t3.medium" subnet_id = aws_subnet.public.id vpc_security_group_ids = [aws_security_group.pet.id]
tags = { Name = "pet-prod", Env = "prod" }}Версии провайдеров фиксируй всегда: мажорное обновление провайдера может изменить поведение ресурсов, и terraform init -upgrade без ревью чейнджлога — классический источник инцидентов.
Ресурсы, data-источники, локалы, выводы
Заголовок раздела «Ресурсы, data-источники, локалы, выводы»Четыре кирпича любой конфигурации:
resource— создаёт/изменяет/удаляет объект в облаке. Имеет жизненный цикл.data— только читает существующий объект (чужой VPC, AMI-образ, зона DNS) без управления им.locals— вычисляемые внутри модуля значения, чтобы не дублировать выражения.output— значения, которые Terraform печатает после apply и отдаёт другим системам.
# Data-источник: найти официальный AMI Ubuntu 24.04 в AWSdata "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-noble-24.04-amd64-server-*"] }}
locals { name_prefix = "${var.project}-${var.env}" # pet-prod common_tags = { Project = var.project, Env = var.env }}
resource "hcloud_server" "pet" { name = "${local.name_prefix}-api" labels = local.common_tags}
# Вывод — интерфейс модуля наружуoutput "server_ip" { value = hcloud_server.pet.ipv4_address description = "Публичный IP для Ansible-inventory"}State: источник истины и его защита
Заголовок раздела «State: источник истины и его защита»State — JSON-карта, где Terraform хранит соответствие: hcloud_server.pet ↔ реальный ID сервера 1234567, его IP, приватные ключи, пароли (модель state детально разобрана в официальной документации). Без state Terraform не может управлять существующими ресурсами — он их просто не помнит. В state лежат секреты в открытом виде (пароли БД, приватные ключи), поэтому правило железное: state не в git, не в мессенджерах, не на десктопе.
Local state (terraform.tfstate рядом с кодом) — только для локальных экспериментов. Как только в команде больше одного человека или запуск идёт из CI, нужен remote backend. Канонический вариант — S3-совместимый бакет с блокировкой:
terraform { backend "s3" { bucket = "darkpix-tfstate" key = "pet/terraform.tfstate" # путь внутри бакета region = "eu-central-1" encrypt = true # шифрование на стороне бакета
# DynamoDB больше не нужен: use_lockfile использует S3-объект-лок use_lockfile = true # блокировка одновременных apply }}Блокировка решает гонку: два terraform apply из разных мест одновременно испортили бы state. С use_lockfile = true второй процесс получает ошибку «Error acquiring the state lock» и ждёт, пока первый не отпустит. Для Hetzner есть нативный бэкенд hcloud, у DigitalOcean — spaces-совместимый S3, у Terraform Cloud/Enterprise — бесплатный remote state с UI-просмотром и run-логами.
Доступ к бакету — только у CI и админов через отдельные IAM-ключи с минимальными правилами (s3:GetObject/PutObject на один префикс). Сам бакет: версионирование включено (откат state после плохого apply), публичный доступ запрещён, шифрование обязательно.
Workflow: init → plan → apply → destroy
Заголовок раздела «Workflow: init → plan → apply → destroy»Четыре команды, из которых состоит жизнь:
terraform init # скачать провайдеры, настроить backend, модулиterraform plan # дифф: что создать/изменить/уничтожить — читаем ВСЕГДАterraform apply # применить план (по умолчанию переспрашивает)terraform apply -auto-approve # только из CI после review планаterraform destroy # удалить всё, что описано (dev-окружения только!)init — идемпотентен, безопасно запускать каждый раз в CI. plan не меняет ничего — это твоё окно ревью: смотри на + create, ~ update in-place, -/+ replace (пересоздание = даунтайм) и Plan: 3 to add, 1 to change, 0 to destroy. Сохраняй план в артефакт для apply: terraform plan -out=tfplan && terraform apply tfplan — гарантирует, что применится ровно то, что ревьюили, а не свежий дифф.
Модули и версионирование
Заголовок раздела «Модули и версионирование»Модуль — директория с .tf-файлами, принимающая входные переменные и отдающая outputs. Корневая директория — тоже модуль (корневой). Правило: модуль не знает про окружение — env, размеры и токены передаются извне.
infra/├── main.tf # собирает модули под конкретное окружение├── variables.tf├── outputs.tf└── modules/ └── server/ ├── main.tf ├── variables.tf └── outputs.tfvariable "name" { type = string }variable "server_type" { type = string }variable "ssh_key_ids" { type = list(string) }variable "enable_backups" { type = bool, default = false }
# modules/server/main.tfresource "hcloud_server" "this" { name = var.name server_type = var.server_type image = "ubuntu-24.04" ssh_keys = var.ssh_key_ids backups = var.enable_backups}
# modules/server/outputs.tfoutput "ipv4" { value = hcloud_server.this.ipv4_address }output "server_id" { value = hcloud_server.this.id }
# main.tf — корневой модульmodule "pet_server" { source = "./modules/server" name = "pet-prod" server_type = var.env == "prod" ? "cx22" : "cx11" ssh_key_ids = [hcloud_ssh_key.admin.id] enable_backups = var.env == "prod"}
output "pet_ip" { value = module.pet_server.ipv4}Для переиспользования между репозиториями модуль публикуется в git и подключается с версией — тегом:
module "vpc" { source = "git::https://github.com/darkpix/tf-modules.git//modules/network?ref=v1.2.0" # ?ref=v1.2.0 — НЕ main! Без ref Terraform кэширует main намертво cidr = "10.0.0.0/16"}Версионирование модуля позволяет обновлять потребителей по одному: сначала staging на v1.3.0, потом prod. Менять интерфейс модуля (переименование переменных, удаление outputs) — только с новой мажорной версией.
Workspaces: изоляция окружений
Заголовок раздела «Workspaces: изоляция окружений»Workspace — изолированный state поверх одного кода. Физически это отдельные файлы state в бакете (env:/staging/pet/terraform.tfstate).
terraform workspace new stagingterraform workspace new prodterraform workspace select stagingterraform workspace list # * — текущийterraform apply # пишет в state stagingОкружения отличаются значениями переменных, которые принято держать в файлах staging.tfvars / prod.tfvars. Опасность workspaces: один код, но разное поведение от переменных. Для pet-проекта двух workspace хватает с головой; при росте команды переходят на директории envs/staging, envs/prod (явнее, проще review в PR).
env = "prod"server_type = "cx22"dynamic blocks, count, for_each
Заголовок раздела «dynamic blocks, count, for_each»Три механизма, убирающие копипасту:
count — N копий ресурса по индексу (список):
resource "hcloud_server" "api" { count = var.api_replicas # 2 name = "pet-api-${count.index}" # pet-api-0, pet-api-1 server_type = "cx11" image = "ubuntu-24.04"}output "api_ips" { value = hcloud_server.api[*].ipv4_address }for_each — ресурс на элемент коллекции (предпочтительнее count: удаление из середины списка не сдвигает индексы и не пересоздаёт хвост):
resource "hcloud_firewall" "rules" { for_each = toset(["80", "443", "22"]) name = "allow-${each.key}"
rule { direction = "in" protocol = "tcp" port = each.value source_ips = ["0.0.0.0/0"] }}dynamic — генерирует повторяющиеся вложенные блоки из коллекции (внутри ресурса, где for_each недоступен; документация):
resource "aws_security_group" "pet" { name = "pet-sg" description = "Правила из переменной"
dynamic "ingress" { for_each = var.allowed_ports # [80, 443, 22] content { from_port = ingress.value to_port = ingress.value protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } }}Drift detection в CI и импорт инфраструктуры
Заголовок раздела «Drift detection в CI и импорт инфраструктуры»Реальность расходится с кодом: кто-то открыл порт в консоли облака, изменил тип сервера «на пять минут». Лечение — регулярный plan по расписанию:
name: drifton: schedule: [{ cron: "0 6 * * *" }] workflow_dispatch:jobs: plan: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - run: terraform init -input=false env: { TF_VAR_hcloud_token: ${{ secrets.HCLOUD_TOKEN }} } - run: terraform plan -detailed-exitcode -no-color env: { TF_VAR_hcloud_token: ${{ secrets.HCLOUD_TOKEN }} }-detailed-exitcode возвращает 0 (нет изменений), 1 (ошибка) или 2 (есть дрейф) — job падает, и ты получаешь уведомление «код ≠ реальность». Для prod-apply — отдельный workflow с ручным запуском и обязательным ревью плана в PR.
Обратная задача — инфраструктура существует, а кода нет (legacy, ручное создание). Terraform умеет «усыновлять» ресурсы:
# 1. Описываем ресурс пустым блоком# main.tfresource "hcloud_server" "legacy" { name = "old-prod" server_type = "cx11" image = "ubuntu-24.04" location = "fsn1" # не запускай apply до import! Будет создан дубликат}
# 2. Узнаём ID у провайдера (веб-консоль или API)# 3. Импортируем в state — код остаётся девственно чистымterraform import hcloud_server.legacy 1234567
# 4. Теперь сравниваем: terraform plan покажет дифф кода и реальности.# Подгоняем код до "No changes" — инфраструктура теперь под управлением.Импорт — не «одна кнопка»: Terraform не знает, какие атрибуты ресурса задавались при создании, поэтому план после import почти всегда предлагает что-то поменять. Итеративно выравнивай код, пока не получишь пустой plan.
Полный пример: сервер + DNS-запись + вывод IP
Заголовок раздела «Полный пример: сервер + DNS-запись + вывод IP»Собираем всё вместе — воспроизводимый кусок pet-проекта:
terraform { required_providers { hcloud = { source = "hetznercloud/hcloud", version = "~> 1.45" } }}
# main.tfresource "hcloud_ssh_key" "admin" { name = "darkpix-admin" public_key = file("~/.ssh/id_ed25519.pub")}
resource "hcloud_server" "pet" { name = "pet-prod" server_type = "cx22" image = "ubuntu-24.04" location = "fsn1" ssh_keys = [hcloud_ssh_key.admin.id]
# cloud-init: первая настройка при создании — базовая гигиена user_data = <<-EOF #cloud-config package_update: true packages: [fail2ban, ufw] runcmd: - ufw allow OpenSSH - ufw --force enable EOF}
# DNS-запись через того же провайдера (Hetzner DNS)resource "hcloud_dns_record" "pet" { zone_id = data.hcloud_dns_zone.main.id name = "pet" value = hcloud_server.pet.ipv4_address type = "A" ttl = 300}
data "hcloud_dns_zone" "main" { name = "darkpix.dev"}
# outputs.tfoutput "server_ip" { value = hcloud_server.pet.ipv4_address description = "IP для Ansible-inventory"}
output "fqdn" { value = "${hcloud_dns_record.pet.name}.darkpix.dev"}export TF_VAR_hcloud_token="твой-токен"terraform initterraform plan -out=tfplan # ревьюим: 3 to add, 0 to change, 0 to destroyterraform apply tfplan# Apply complete! Outputs:# server_ip = "116.203.10.20"# fqdn = "pet.darkpix.dev"
# IP уходит в inventory, Ansible продолжает настройку — см. следующую главуТипичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- State в git.
terraform.tfstateпопадает в репозиторий вместе с приватными ключами и паролями БД в открытом виде. Плохо:git add .без.gitignore. Хорошо: remote backend с шифрованием с первого дня,.gitignoreс*.tfstate*,crash.log,.terraform/. - Ручная правка в консоли облака. Открыл порт в веб-интерфейсе «на минуту» — через неделю
applyего удалит, и всё сломается. Плохо: чинить в консоли. Хорошо: правка в коде → PR → apply; консоль — только read-only диагностика. applyбез чтения плана в prod. В плане было-/+ hcloud_server.pet (tainted/переименован)— то есть пересоздание сервера с даунтайном, а ты нажал enter. Хорошо:plan -out=tfplan, ревью,apply tfplan; в prod — обязательный approve второго человека.countвместоfor_eachдля разнородных ресурсов. Удалил второй элемент из середины списка — Terraform пересоздал все ресурсы с бо́льшими индексами. Хорошо:for_each = toset(...)илиfor_each = { for s in var.servers : s.name => s }— адресация по ключу стабильна.- Модуль без версии из git.
source = "git::...?ref=main"— все окружения живут на плавающей main, обновление модуля ломает prod при следующем apply. Хорошо:?ref=v1.2.0и обновление по одному окружению. - Переименование ресурса руками в коде. Terraform видит «удалить старый, создать новый» — сносит боевой сервер. Хорошо: блок
movedилиterraform state mvдо правки кода. terraform destroyв shared workspace. Выполнил destroy, не заметив, что сидишь вprod, а не вstaging. Хорошо: явные префиксы имен в tfvars per workspace, защитаlifecycle { prevent_destroy = true }на критичных ресурсах.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Зачем Terraform нужен state? Почему нельзя каждый раз смотреть в облако? State — кэш соответствия ресурсов кода реальным ID облака и единственный источник истины о том, что «принадлежит» конфигурации. Опрос облака (refresh) не покажет, какой ресурс какому блоку принадлежит, особенно после ручных правок; state хранит и метаданные (зависимости, провайдеры), без которых план невозможен. Плюс в state лежат атрибуты, которые API не возвращает обратно.
- Что произойдёт, если потерять state? Terraform «забудет» всю инфраструктуру: следующий apply создаст дубликаты ресурсов, старые останутся сиротами и продолжат списывать деньги. Восстановление — только через
terraform importкаждого ресурса по ID. Поэтому remote backend с версионированием и блокировкой — обязателен. - Чем модуль отличается от workspace? Модуль — переиспользуемый кускок кода с входами/выходами. Workspace — изоляция state одного и того же кода под разные окружения. Дублировать код под окружения (антипаттерн) — не то же, что workspace; а выносить в модуль — не то же, что разделять окружения.
- Как работает блокировка state в S3?
use_lockfile = trueсоздаёт в бакете объект-лок (<key>.tflock): первый процесс его создаёт, второй при попытке acquire получает ошибку и ждёт. Гарантирует, что plan/apply не перемешают записи двух операторов. Блокировка снимается автоматически после завершения операции; при аварийном завершении лок живёт до TTL и снимаетсяforce-unlock. countvsfor_each: когда что?count— для N одинаковых ресурсов, где порядок не важен и удаление только с конца.for_each— когда ресурсы разнородны или адресация должна быть стабильной по ключу; удаление элемента из середины не трогает остальные. Для почти всего предпочтительнееfor_each.- Что такое drift и как с ним бороться? Drift — расхождение реальной инфраструктуры и кода (ручные правки, изменения вне Terraform). Борьба: ничего не менять в консоли, регулярный
terraform plan -detailed-exitcodeв CI по расписанию, алерт при exit code 2, лечение — коммитом с правкой кода, а не повторной ручной правкой. - Как безопасно переименовать ресурс? Через блок
moved { from, to }(Terraform обновит state без пересоздания) илиterraform state mv <old> <new>до правки кода. Без этого Terraform планирует destroy+create — для stateful-ресурсов это потеря данных. - Как устроен
terraform applyпод капотом? Парсинг конфигурации → refresh (чтение реального состояния ресурсов) → построение графа зависимостей (по ссылкам иdepends_on) → план (create/update/delete/replace) → apply в топологическом порядке с параллельностью по ветвям графа → обновление state. Поэтому порядок блоков в файлах не важен — важны ссылки.
Практика
Заголовок раздела «Практика»- Вынеси конфигурацию pet-сервера из этой главы в модуль
modules/serverс входамиname,server_type,ssh_key_ids,enable_backupsи выводомipv4. Корневой код собирает два вызова модуля:staging(cx11, без бэкапов) иprod(cx22, с бэкапами). - Настрой remote backend: S3-совместимый бакет с
encrypt = true,use_lockfile = trueи включённым версионированием. Докажи блокировку: запусти дваterraform applyодновременно из разных терминалов — второй должен получить ошибку state lock. - Напиши workflow drift-detection из примера, добавь план как артефакт и проверь: открой порт в веб-консоли Hetzner → job упал на plan → верни всё кодом → job зелёный.
- Реализуй DNS-запись через Hetzner DNS API (
hcloud_dns_record) сttl = 60, а IP сервера — черезoutput. После apply проверь резолвинг:dig +short pet.darkpix.devвозвращает выведенный IP. - Найди в облаке один существующий ресурс (или создай вручную через консоль), опиши его пустым блоком, выполни
terraform importи доведи код до пустого плана. Зафиксируй, какие атрибуты пришлось «угадать». - Смоделируй disaster recovery: скопируй state из бакета в сторону, выполни
terraform destroyна staging, верни state на место и прогониplan— убедись, что Terraform снова видит ресурсы без пересоздания.
Что почитать
Заголовок раздела «Что почитать»- Terraform Language documentation — ресурсы, переменные, выражения
- State: purpose, backends, locking — официальное объяснение модели state
- Module composition — как проектировать интерфейсы модулей
- Import: existing resources и moved blocks
- Hetzner Cloud provider — примеры ресурсов и data-источников
- Terraform in CI/CD best practices — ревью планов, drift, безопасность