Инфраструктура как код: Terraform и OpenTofu
В предыдущих темах инфраструктура создавалась командами и манифестами, но каждый раз в одной узкой области. В теме 1 облако было определено через самообслуживание: ресурс выдаётся по запросу к API, без участия оператора провайдера. В теме 6 Docker Compose описывал в одном файле сервисы, сети и тома одного хоста и приводил хост к этому описанию командой docker compose up. В теме 7 Kubernetes довёл идею до предела: желаемое состояние хранится в etcd, а контроллеры непрерывно сравнивают его с фактическим и устраняют расхождение. Однако всё, на чём работает сам кластер, — виртуальные сети, подсети, виртуальные машины, балансировщики, бакеты, DNS-записи, учётные записи сервисов — по-прежнему остаётся набором объектов облачного API, которые кто-то должен создать, изменить и однажды удалить.
Открытым остаётся вопрос, как управлять этими объектами так же воспроизводимо, как кодом приложения: хранить описание в системе контроля версий, проверять изменения до их применения, повторять окружение по требованию и знать, чем облако отличается от описания. Ответ на него — подход «инфраструктура как код» и его самый распространённый инструмент, Terraform, вместе с открытым ответвлением OpenTofu. Механизм этих инструментов отличается от Kubernetes принципиально: здесь нет постоянно работающего контроллера, согласование выполняется по команде, а связь между описанием и реальными объектами хранится в отдельном файле состояния. Почти все трудности практики — дрейф, пересоздание, блокировки, секреты — вытекают именно из этого устройства.
Изложение построено так. Сначала рассматриваются задача и архитектура инструмента, а также история 2023 года, из-за которой у Terraform появился двойник. Затем разбирается язык конфигурации: переменные, выражения, count и for_each, модули. Третий раздел посвящён тому, как из конфигурации получается план: граф зависимостей, обновление сведений об объектах, разность и её символы. Четвёртый — файлу состояния: что в нём лежит, почему он содержит секреты и как хранить его в S3-совместимом хранилище с блокировкой. Пятый раздел описывает жизнь конфигурации во времени: дрейф, импорт и рефакторинг, окружения и конвейер проверок. В конце собраны ограничения подхода и типичные ошибки. Примеры команд даны для OpenTofu (tofu); у Terraform те же подкоманды вызываются как terraform.
Инфраструктура как код и путь от Terraform к OpenTofu
Задача: воспроизводимость вместо ручных действий
Ручная настройка облака через веб-консоль кажется самым быстрым путём, пока объектов немного. Трудности появляются, когда окружение нужно повторить (второе окружение для тестирования, второй регион), когда нужно понять, кто и зачем изменил правило межсетевого экрана, и когда сервер, настраивавшийся годами, нужно восстановить после потери. К. Моррис называет такие серверы «снежинками» (англ. snowflake servers): каждый уникален, и никто не знает его полного описания 1. Даже при аккуратной работе возникает дрейф конфигурации (англ. configuration drift) — постепенное расхождение между тем, как система задумана и документирована, и тем, как она на самом деле устроена.
Инфраструктура как код (англ. infrastructure as code, IaC) — подход, при котором инфраструктура описывается текстовыми файлами, обрабатываемыми программой, и изменяется только через изменение этих файлов 1. Из такого определения следуют знакомые по разработке программ практики. Описание хранится в Git, поэтому у каждого изменения есть автор, время, причина и возможность отката. Изменение проходит ревью в запросе на слияние так же, как изменение кода. Описание можно проверить автоматически до применения: синтаксис, типы, политики безопасности. Наконец, одно и то же описание, применённое с разными параметрами, даёт одинаково устроенные окружения — разработки, тестирования и рабочее.
Ключевое свойство, без которого практики не работают, — идемпотентность (англ. idempotency): повторное применение того же описания к системе, уже приведённой к нему, не меняет ничего. Скрипт create-vm.sh, запущенный дважды, создаст две машины; декларативный инструмент на второй запуск ответит «изменений нет». Именно идемпотентность позволяет запускать применение регулярно и не бояться последствий.
Декларативное описание против сценария
Инструменты автоматизации инфраструктуры делятся на две группы. В императивном (процедурном) подходе описываются шаги: «создать сеть, затем подсеть, затем машину; если машина уже есть — пропустить». Так устроены скрипты на Bash с вызовами облачного CLI и программы на Python с SDK. Автор сценария сам отвечает за порядок, за проверку «а не создано ли уже» и за обработку частичных отказов. В декларативном подходе описывается результат: «должны существовать сеть с CIDR 10.10.0.0/16, подсеть в ней и три машины с таким-то образом». Инструмент сам выясняет, что уже есть, вычисляет недостающие действия и их порядок.
Terraform и OpenTofu декларативны, но их декларативность отличается от кубернетесовской. Контроллер Kubernetes (тема 7) работает постоянно: если кто-то удалит под, контроллер ReplicaSet заметит это через watch и создаст новый в течение секунд. Terraform — программа, которую запускают, она выполняет одно согласование и завершается. Между запусками за инфраструктурой никто не следит, и дрейф накапливается незаметно до следующего plan. Это не недостаток реализации, а следствие области применения: облачные API не дают дешёвого механизма подписки на изменения сотен типов объектов, а многие изменения инфраструктуры (удаление базы данных, смена сети) настолько опасны, что их нельзя выполнять автоматически без участия человека.
Декларативное описание не отменяет порядка действий — оно переносит его вычисление в инструмент. Подсеть нельзя создать раньше сети, потому что API подсети требует идентификатор сети. Инструмент извлекает такие зависимости из ссылок между ресурсами и строит граф, по которому затем выполняет действия (раздел о графе ниже).
Ядро, провайдеры и протокол плагинов
Архитектура Terraform и OpenTofu двухуровневая. Ядро (англ. core) — один исполняемый файл на Go — читает конфигурацию, вычисляет выражения, хранит и читает состояние, строит граф и решает, какие действия выполнить. Ядро ничего не знает об облаках. Знание о конкретном API заключено в провайдерах (англ. providers) — отдельных программах-плагинах, по одной на платформу: AWS, Yandex Cloud, Kubernetes, Docker, GitHub и сотни других.
Схема на рисунке выше показывает поток данных: конфигурация и состояние входят в ядро, ядро по протоколу gRPC вызывает провайдеры, провайдеры выполняют HTTP-запросы к API. Провайдер — обычная программа, которую ядро запускает как дочерний процесс; после запуска плагин печатает в стандартный вывод строку рукопожатия с номером порта на петлевом интерфейсе, и ядро подключается к нему как клиент gRPC 6. Протокол задаёт небольшой набор вызовов: получить схему ресурсов, проверить конфигурацию, прочитать текущее состояние объекта (ReadResource), спланировать изменение (PlanResourceChange), применить его (ApplyResourceChange), импортировать существующий объект. Благодаря этому разделению одно ядро работает с любыми платформами, а провайдер для новой системы может написать её разработчик, не трогая ядро. Тот же протокол используют и Terraform, и OpenTofu, поэтому провайдеры у них общие.
Провайдеры скачиваются командой init из реестра (англ. registry): у Terraform это registry.terraform.io, у OpenTofu — registry.opentofu.org. Адрес провайдера в конфигурации состоит из пространства имён и имени (kreuzwerker/docker, yandex-cloud/yandex), к которым ядро добавляет имя реестра по умолчанию. Выбранные версии и контрольные суммы архивов записываются в файл .terraform.lock.hcl, который хранят в Git: благодаря ему все участники и конвейер CI используют одну и ту же сборку провайдера, а подмена архива обнаруживается по несовпадению суммы.
tofu init # скачать провайдеры и модули, настроить бэкенд
tofu providers # какие провайдеры требует конфигурация и её модули
cat .terraform.lock.hcl # выбранные версии и хеши архивов
Смена лицензии в 2023 году и появление OpenTofu
С момента выпуска в 2014 году Terraform распространялся по лицензии Mozilla Public License 2.0 (MPL 2.0) — открытой лицензии со слабым копилефтом. Вокруг него выросла экосистема: тысячи провайдеров и модулей, а также компании, продававшие сервисы запуска Terraform для команд (Spacelift, env0, Scalr и другие), — прямые конкуренты собственного сервиса HashiCorp, Terraform Cloud.
10 августа 2023 года HashiCorp объявила о переводе своих продуктов, включая Terraform, на Business Source License 1.1 (BSL) начиная со следующих выпусков 3. BSL — лицензия с открытым исходным кодом, но не открытая в смысле определения Open Source Initiative: она разрешает использование, изменение и распространение, за исключением использования в продукте, конкурирующем с продуктами HashiCorp. Через четыре года после выпуска каждая версия переходит под MPL 2.0. Для большинства пользователей, применяющих Terraform к своей инфраструктуре, условия не изменились, но для компаний, встраивающих Terraform в свои сервисы, неопределённость формулировки «конкурирующий продукт» создала юридический риск.
Ответом стал манифест OpenTF, опубликованный через несколько дней группой компаний и разработчиков с требованием вернуть открытую лицензию 4. Требование не было принято, и авторы манифеста создали ответвление (англ. fork) от последней версии под MPL 2.0 — линии 1.5. В сентябре 2023 года проект был переименован в OpenTofu и передан под управление Linux Foundation, первая стабильная версия OpenTofu 1.6.0 вышла в январе 2024 года, а в апреле 2025 года проект принят в CNCF на уровне Sandbox 5. Лицензия OpenTofu — MPL 2.0, управление — технический руководящий комитет из представителей нескольких компаний.
Ответвление было сделано на уровне языка и протокола, поэтому до версий 1.5–1.6 Terraform и OpenTofu практически взаимозаменяемы: те же файлы .tf, тот же формат state, те же провайдеры. Дальше проекты развиваются независимо, и различия накапливаются. Основные из них сведены в таблицу; версии указаны по журналам изменений проектов.
| Возможность | OpenTofu | Terraform |
|---|---|---|
| Лицензия | MPL 2.0 | BSL 1.1 (с 1.6) |
| Шифрование state и плана на стороне клиента | есть с 1.7 (блок encryption) | нет; шифрование только средствами хранилища |
Блок removed (убрать из state без удаления) | с 1.7 | с 1.7 |
for_each в блоке import | с 1.7 | с 1.7 |
Переменные и locals в блоке backend и в source модулей | с 1.8 (ранние вычисления) | нет |
Файлы .tofu, перекрывающие одноимённые .tf | с 1.8 | неприменимо |
for_each в блоке provider | с 1.9 | нет |
Опция -exclude (обратная -target) | с 1.9 | нет |
Блокировка S3-бэкенда без DynamoDB (use_lockfile) | с 1.10 | с 1.10, стабильно с 1.11 |
| Эфемерные ресурсы и атрибуты только для записи | с 1.11 | с 1.10 и 1.11 |
Интеграция с HCP Terraform (блок cloud) | нет | есть |
Практический вывод для читателя: конфигурацию, не использующую особенностей одной из сторон, можно применять обоими инструментами, а state, записанный одной версией, читается другой, если она не старше. Переход в обратную сторону после использования особых возможностей (например, после включения шифрования state в OpenTofu) уже невозможен без миграции. В курсе используется OpenTofu версии 1.10 и новее.
Язык конфигурации HCL
Блоки, ресурсы и адреса
Конфигурация записывается на HCL (HashiCorp Configuration Language) — языке, состоящем из блоков, аргументов и выражений. Все файлы .tf одного каталога образуют один модуль и читаются вместе, порядок файлов и блоков в них не важен: деление на main.tf, variables.tf, outputs.tf — соглашение для людей. Каталог, в котором запускается tofu, называется корневым модулем (англ. root module).
Основной блок — ресурс (англ. resource): описание одного объекта, которым управляет инструмент. У ресурса два ярлыка — тип, определяемый провайдером, и локальное имя, выбираемое автором. Вместе они образуют адрес ресурса: docker_network.app, yandex_vpc_subnet.a. Адрес — главный идентификатор в мире Terraform: по нему ресурс находится в state, на него ссылаются другие ресурсы, его указывают в командах.
terraform {
required_version = ">= 1.10.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6" # 4.6, 4.7, ..., но не 5.0
}
}
}
provider "docker" {} # локальный демон Docker
resource "docker_network" "app" {
name = "notes-net"
ipam_config {
subnet = "172.29.9.0/24"
}
}
resource "docker_container" "db" {
name = "notes-db"
image = "postgres:17.11-alpine"
networks_advanced {
name = docker_network.app.name # ссылка: сначала сеть, потом контейнер
}
}
Блок terraform задаёт требования к версии ядра и к провайдерам. Ограничение ~> 4.6 допускает любые версии 4.x не ниже 4.6: смена старшего номера провайдера может менять схему ресурсов, поэтому её разрешают только явно. Аргументы ресурса бывают трёх видов: обязательные (name), необязательные и вычисляемые (англ. computed) — значения, которые становятся известны только после создания объекта, например id. Выражение docker_network.app.name — ссылка на атрибут другого ресурса; из таких ссылок строится граф зависимостей.
Кроме аргументов, определённых провайдером, у любого ресурса есть мета-аргументы, обрабатываемые ядром: count, for_each, depends_on, provider, lifecycle. Они управляют не объектом облака, а тем, как ядро работает с ресурсом.
Переменные, выходные значения, локальные значения и источники данных
Чтобы одна конфигурация обслуживала несколько окружений, её параметризуют. Входная переменная (англ. input variable) объявляется блоком variable с типом, описанием, значением по умолчанию и, при необходимости, правилами проверки:
variable "project" {
description = "Префикс имён объектов"
type = string
default = "notes"
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,15}$", var.project))
error_message = "3–16 символов: строчные латинские буквы, цифры, дефис."
}
}
variable "web_nodes" {
description = "Веб-узлы: имя -> порт на хосте"
type = map(number)
default = { a = 8091, b = 8092, c = 8093 }
}
variable "db_password" {
type = string
sensitive = true # не печатать значение в выводе plan и apply
}
Значения переменных задаются из нескольких источников, и при совпадении побеждает более поздний в таком порядке: переменные окружения TF_VAR_имя, файл terraform.tfvars, файлы *.auto.tfvars в лексическом порядке, затем опции -var и -var-file в порядке их следования в командной строке. Переменная без значения по умолчанию и без заданного значения запрашивается интерактивно, а в конвейере (-input=false) приводит к ошибке — это полезно, чтобы обязательный параметр нельзя было забыть.
Выходное значение (англ. output value) — то, что модуль сообщает наружу: адрес балансировщика, идентификатор сети. Выходы корневого модуля печатаются после apply и читаются командой tofu output, выходы вложенного модуля доступны вызывающему как module.имя.выход. Локальное значение (англ. local value) — именованное выражение внутри модуля, которое избавляет от повторений: общий набор меток, вычисленное имя, слияние словарей.
locals {
labels = {
"app" = "notes"
"project" = var.project
}
}
output "web_urls" {
value = { for k, c in docker_container.web : k => "http://127.0.0.1:${c.ports[0].external}" }
}
Источник данных (англ. data source) — блок data, который не создаёт объект, а читает существующий: образ, зону, сеть, созданную другой командой. Классический пример — идентификатор образа ВМ. Его можно записать в конфигурацию строкой, но такой идентификатор устареет при выпуске следующего образа и не существует в другом облаке или каталоге. Правильнее выбрать образ по семейству и позволить провайдеру найти его актуальную версию:
data "yandex_compute_image" "ubuntu" {
family = "ubuntu-2204-lts" # последняя версия образа семейства
}
resource "yandex_vpc_network" "main" {
name = "notes-net"
}
resource "yandex_vpc_subnet" "a" {
name = "notes-subnet-a"
zone = "ru-central1-a"
network_id = yandex_vpc_network.main.id
v4_cidr_blocks = ["10.10.1.0/24"]
}
resource "yandex_compute_instance" "vm" {
for_each = toset(["web-1", "web-2", "web-3"])
name = each.key
zone = "ru-central1-a"
platform_id = "standard-v3"
resources {
cores = 2
memory = 4
}
boot_disk {
initialize_params {
image_id = data.yandex_compute_image.ubuntu.id
size = 20
}
}
network_interface {
subnet_id = yandex_vpc_subnet.a.id
nat = true
}
lifecycle {
ignore_changes = [boot_disk[0].initialize_params[0].image_id]
}
}
У источника данных есть обратная сторона: он читается при каждом plan, и когда в семействе появится новый образ, атрибут image_id машин изменится. Для загрузочного диска это означает пересоздание машины. Поэтому в примере изменение образа у существующих машин игнорируется (мета-аргумент lifecycle разобран ниже): новый образ получат только новые машины. Выбор между «всегда актуальным образом» и «стабильными машинами» — инженерное решение, и его надо принимать явно.
count и for_each: адресация экземпляров
Чтобы создать несколько однотипных объектов, ресурсу задают мета-аргумент count (число) или for_each (словарь или множество строк). Ресурс тогда превращается в набор экземпляров (англ. instances) с собственными адресами. При count экземпляры нумеруются: docker_container.web[0], docker_container.web[1], а внутри блока доступен count.index. При for_each экземпляры адресуются ключами: docker_container.web["a"], а внутри блока доступны each.key и each.value.
Разница кажется косметической, пока список не начинают менять. В state экземпляр привязан к адресу. Если адрес — позиция в списке, то удаление элемента из середины сдвигает все последующие элементы на одну позицию, и для ядра это выглядит как изменение атрибутов у каждого из них. Рассмотрим числовой пример. Пусть пять веб-узлов описаны так:
variable "web_nodes" { default = ["a", "b", "c", "d", "e"] }
resource "docker_container" "web" {
count = length(var.web_nodes)
name = "notes-web-${var.web_nodes[count.index]}"
# ...
}
Из списка удаляют узел b. Список становится ["a", "c", "d", "e"], его длина — 4. Ядро сравнивает экземпляры по индексам:
| Адрес | Было | Стало | Действие |
|---|---|---|---|
web[0] | notes-web-a | notes-web-a | нет |
web[1] | notes-web-b | notes-web-c | пересоздание (имя контейнера неизменяемо) |
web[2] | notes-web-c | notes-web-d | пересоздание |
web[3] | notes-web-d | notes-web-e | пересоздание |
web[4] | notes-web-e | — | удаление |
Итог плана — Plan: 3 to add, 0 to change, 4 to destroy: чтобы убрать один узел, инструмент удалит четыре контейнера и создаст три. В общем виде удаление элемента с позиции k из списка длины n при count затрагивает n - k + 1 экземпляров, из которых n - k пересоздаются: для n = 5, k = 2 это 3 пересоздания и одно удаление, а для удаления первого элемента списка из 100 машин — 99 пересозданий. Эту ловушку подробно разбирает Е. Брикман 2. С for_each по множеству или словарю тот же шаг удаляет ровно один экземпляр web["b"], а адреса остальных не меняются: Plan: 0 to add, 0 to change, 1 to destroy.
Отсюда правило: count уместен для действительно взаимозаменяемых экземпляров, у которых номер ничего не значит, а также в идиоме условного ресурса count = var.enabled ? 1 : 0. Если у экземпляров есть собственная идентичность — имя, зона, роль, — используется for_each с ключом, который эту идентичность выражает. При этом ключ должен определять все атрибуты, зависящие от экземпляра. Если перейти на for_each = toset(var.web_nodes), но порт по-прежнему вычислять через позицию элемента в списке, удаление b всё равно сдвинет порт узла c, и тот будет пересоздан. Поэтому в примерах этой темы узлы задаются словарём «имя → порт».
Ключи for_each должны быть известны на этапе планирования. Выражение вида for_each = toset(aws_instance.x[*].id) недопустимо: идентификаторы появятся только после создания, а ядру нужно знать адреса экземпляров заранее. Ключами служат значения из переменных и констант, а не вычисляемые атрибуты.
Модули: входы, выходы, источники и версии
Когда описание повторяется (веб-узлы в нескольких окружениях, типовая сеть для каждого проекта), его оформляют модулем — каталогом с файлами .tf, у которого есть входные переменные и выходные значения. Модуль вызывается блоком module; аргумент source указывает, откуда взять код, остальные аргументы задают значения его переменных:
.
├── main.tf # корневой модуль: сеть, образ, вызов модуля
├── variables.tf
├── outputs.tf
└── modules/
└── web/
├── main.tf # resource "docker_container" "node" { for_each = var.nodes ... }
├── variables.tf # name_prefix, image_id, network_name, nodes
├── outputs.tf # urls, container_names
└── versions.tf # required_providers без блока provider
module "web" {
source = "./modules/web"
name_prefix = var.project
image_id = docker_image.nginx.image_id
network_name = docker_network.app.name
nodes = var.web_nodes
}
output "web_urls" {
value = module.web.urls
}
Ресурсы внутри модуля получают адреса с префиксом вызова: module.web.docker_container.node["a"]. Модуль — граница инкапсуляции: вызывающий видит только переменные и выходы, а внутренние ресурсы модуля для него закрыты. Хороший модуль описывает одно понятие предметной области («группа веб-узлов», «сеть проекта с подсетями в трёх зонах») и не принимает десятки переменных, повторяющих аргументы ресурсов: такой модуль не скрывает сложность, а лишь переименовывает её.
Источник модуля может быть локальным путём (./modules/web), адресом в реестре (terraform-yandex-modules/vpc/yandex с аргументом version), репозиторием Git с указанием тега (git::https://example.com/net.git?ref=v1.4.0), архивом по HTTP или в S3. Для удалённых источников версия фиксируется так же строго, как версия провайдера: модуль без закреплённой версии может измениться между двумя запусками конвейера. Блок provider в переиспользуемых модулях не пишут: модуль объявляет в required_providers, какой провайдер ему нужен, а конфигурацию провайдера (учётные данные, регион) получает от корневого модуля. Модуль можно вызывать с count и for_each — например, один вызов на каждую зону доступности; тогда адреса принимают вид module.zone["ru-central1-a"].yandex_vpc_subnet.this.
Граф, план и применение
Построение графа зависимостей
Прежде чем что-либо делать, ядро строит граф зависимостей — ориентированный ациклический граф (англ. directed acyclic graph, DAG), вершины которого — ресурсы, источники данных, провайдеры, переменные и выходы, а рёбра — отношения «должно быть готово раньше». Большинство рёбер неявные: они возникают из ссылок в выражениях. Аргумент network_id = yandex_vpc_network.main.id у подсети порождает ребро от подсети к сети, аргумент image_id = data.yandex_compute_image.ubuntu.id — ребро от машины к источнику данных.
Граф примера из предыдущего раздела показан на рисунке выше. Обход графа выполняется параллельно: ядро запускает операцию над вершиной, как только завершены все её зависимости, и держит одновременно не больше заданного числа операций — по умолчанию 10, значение меняется опцией -parallelism 8. Пусть сеть создаётся 5 с, подсеть 3 с, каждая ВМ 40 с, а чтение образа 1 с и выполняется одновременно с созданием сети. Тогда при параллельном обходе три машины создаются одновременно, и время apply складывается из длины самого длинного пути в графе: 5 + 3 + 40 = 48 с. Последовательный сценарий потратил бы 5 + 3 + 3 \cdot 40 = 128 с. Если машин 30, а параллелизм 10, машины создаются тремя волнами: 5 + 3 + \lceil 30/10 \rceil \cdot 40 = 128 с. Увеличить -parallelism можно, но упрётся это обычно в лимиты частоты запросов облачного API.
Граф выводится командой tofu graph в формате DOT языка Graphviz. Команда не обращается к API: ей достаточно конфигурации и схем провайдеров. Ниже — сокращённый вывод OpenTofu 1.13 для исходной конфигурации лабораторной работы 9 (сеть, образы, веб-узлы, база данных):
tofu graph > graph.dot
dot -Tsvg graph.dot > graph.svg # нужен пакет graphviz
tofu graph -type=plan-destroy # граф для удаления
digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] docker_container.db (expand)" [label = "docker_container.db", shape = "box"]
"[root] docker_container.web (expand)" [label = "docker_container.web", shape = "box"]
"[root] docker_network.app (expand)" [label = "docker_network.app", shape = "box"]
"[root] provider[\"registry.opentofu.org/kreuzwerker/docker\"]" [label = "...", shape = "diamond"]
"[root] var.web_nodes" [label = "var.web_nodes", shape = "note"]
...
"[root] docker_container.db (expand)" -> "[root] docker_network.app (expand)"
"[root] docker_container.db (expand)" -> "[root] random_password.db (expand)"
"[root] docker_container.web (expand)" -> "[root] docker_image.nginx (expand)"
"[root] docker_container.web (expand)" -> "[root] docker_network.app (expand)"
"[root] docker_container.web (expand)" -> "[root] var.web_nodes (expand, reference)"
"[root] docker_image.nginx (expand)" -> "[root] provider[\"registry.opentofu.org/kreuzwerker/docker\"]"
...
Вершины-прямоугольники — ресурсы, ромбы — провайдеры, «заметки» — переменные. Пометка (expand) означает, что на этом этапе вершина представляет ресурс целиком: при планировании она раскрывается в экземпляры по count или for_each. Стрелка в DOT направлена от зависимого к зависимости («контейнеру нужна сеть»), а операции создания выполняются в обратном направлении. При удалении порядок обращается: сначала удаляются машины, затем подсеть, затем сеть. Если граф содержит цикл (ресурс A ссылается на B, а B на A), ядро отказывается строить план и сообщает Error: Cycle, перечисляя вершины цикла; опция -draw-cycles подсвечивает такие рёбра в выводе tofu graph.
Явный мета-аргумент depends_on нужен только тогда, когда зависимость существует, но не выражена ссылкой. Типичный случай — права доступа: машине выдаётся сервисный аккаунт, а роль этому аккаунту назначается отдельным ресурсом. Машина ссылается на аккаунт, но не на назначение роли, и без depends_on может стартовать раньше, чем роль вступит в силу. Злоупотреблять depends_on не следует: лишние рёбра удлиняют критический путь и делают план менее точным, так как всё зависящее от ресурса с изменениями считается изменяемым.
Что делает plan: обновление, сравнение, разность
Команда tofu plan отвечает на вопрос «что нужно сделать, чтобы реальность совпала с конфигурацией», ничего не меняя. Ей нужны три источника сведений: конфигурация (желаемое), state (что инструмент создал раньше и под какими идентификаторами) и текущие данные из API (что есть на самом деле). Последовательность шагов показана на рисунке ниже.
Сначала ядро захватывает блокировку state (если бэкенд её поддерживает) и читает его. Затем выполняется обновление (англ. refresh): для каждого экземпляра из state ядро вызывает у провайдера ReadResource, передавая сохранённые атрибуты, а провайдер запрашивает объект у API и возвращает фактические значения. Если объект исчез, провайдер сообщает об этом, и экземпляр будет запланирован к созданию заново. Результат обновления хранится в памяти: обычный plan state не перезаписывает, а изменения, найденные при обновлении, попадают в state только вместе с последующим apply 8.
Далее для каждого экземпляра ядро вычисляет конфигурацию (подставляет переменные и уже известные атрибуты) и вызывает у провайдера PlanResourceChange с прежним состоянием и новой конфигурацией. Провайдер возвращает планируемое состояние и список атрибутов, изменение которых невозможно без замены объекта (requires_replace): у контейнера Docker это, например, образ и имя, у ВМ — загрузочный диск, у подсети — CIDR. Значения, которые станут известны только после создания, помечаются как (known after apply). Сопоставив прежнее и планируемое состояние, ядро определяет действие для каждого экземпляра.
Сохранённый план — файл, получаемый tofu plan -out=tfplan, — фиксирует эти действия вместе со снимком state, на основе которого они вычислены. tofu apply tfplan выполняет ровно сохранённые действия, не пересчитывая разность. Если за это время state изменился (кто-то другой выполнил apply), план считается устаревшим и применяться не будет: нужно спланировать заново. Именно эта связка «план, который видел ревьюер, и есть то, что применится» лежит в основе конвейера, описанного в пятом разделе.
Символы плана и пересоздание
Каждый экземпляр в выводе плана помечается символом действия. Легенда печатается в начале вывода и выглядит так:
| Символ | Действие | Когда возникает |
|---|---|---|
+ | create | экземпляра нет в state или объект исчез |
~ | update in-place | изменились атрибуты, которые API позволяет менять у живого объекта |
- | destroy | экземпляр есть в state, но не в конфигурации |
-/+ | destroy and then create replacement | изменился атрибут из requires_replace; объект удаляется, затем создаётся новый |
+/- | create replacement and then destroy | то же, но с create_before_destroy = true |
<= | read | источник данных читается во время apply, так как зависит от ещё не созданного |
Ниже — вывод OpenTofu 1.13 для служебного ресурса terraform_data, у которого изменение аргумента triggers_replace всегда вызывает замену (идентификатор сокращён):
OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement
OpenTofu will perform the following actions:
# terraform_data.slow must be replaced
-/+ resource "terraform_data" "slow" {
~ id = "1ebfdbc6-..." -> (known after apply)
- input = "5" -> null
- output = "5" -> null
+ triggers_replace = "6"
}
Plan: 1 to add, 0 to change, 1 to destroy.
У ресурсов обычных провайдеров рядом с атрибутом, из-за которого замена неизбежна, план печатает комментарий # forces replacement. Итоговая строка считает замену дважды — как одно создание и одно удаление. Поэтому Plan: 3 to add, 0 to change, 3 to destroy может означать как три независимых удаления и три создания, так и три пересоздания — читать нужно сами действия, а не итог.
Символ -/+ — самый опасный в выводе плана. Для веб-узла без состояния пересоздание означает несколько секунд недоступности одного узла. Для базы данных, диска или бакета оно означает удаление данных: новый объект будет пустым. При этом причина пересоздания может выглядеть безобидно — переименование, смена зоны, новая версия образа из источника данных, изменение формата аргумента после обновления провайдера. Поэтому при ревью плана в первую очередь ищут -/+ и - у ресурсов, хранящих данные, и выясняют, какой атрибут вызвал замену.
Мета-аргумент lifecycle
Поведение ядра при изменениях настраивается блоком lifecycle внутри ресурса:
resource "docker_container" "db" {
# ...
lifecycle {
prevent_destroy = true # любой план с удалением — ошибка
create_before_destroy = false
ignore_changes = [env] # не откатывать изменения env
replace_triggered_by = [terraform_data.db_version]
}
}
create_before_destroy меняет порядок замены: сначала создаётся новый объект, затем удаляется старый (+/-). Так обновляют объекты, на которые ссылаются другие (например, шаблон группы машин), без окна недоступности. Условие применимости — два объекта должны иметь возможность существовать одновременно: если имя объекта уникально в облаке, создать второй с тем же именем не получится, и имя приходится делать вычисляемым (суффикс, name_prefix).
prevent_destroy = true превращает любой план, включающий удаление этого экземпляра (в том числе -/+), в ошибку. Это страховка для баз данных и хранилищ, но не абсолютная: если удалить из конфигурации сам ресурс вместе с блоком lifecycle, защиты больше нет. ignore_changes перечисляет атрибуты, отличия которых в реальности ядро не будет устранять: теги, которые расставляет система учёта затрат, число узлов группы, которым управляет автомасштабирование, пароль, сменённый вне инструмента. Значение all отключает обновление всех атрибутов и почти всегда скрывает проблему вместо её решения. replace_triggered_by задаёт обратное: пересоздать ресурс, если изменился другой ресурс или атрибут, даже когда собственные аргументы не изменились.
Рядом с lifecycle используются проверки precondition и postcondition: условие с сообщением об ошибке, которое проверяется до планирования ресурса или после его создания. Например, можно потребовать, чтобы образ из источника данных был архитектуры x86_64, и получить понятную ошибку на этапе plan, а не странный отказ API на этапе apply.
Состояние и удалённый бэкенд
Что хранится в state
Файл состояния (англ. state) — JSON-документ, в котором инструмент хранит соответствие между адресами конфигурации и реальными объектами. Без него ядро не может знать, что контейнер 3f9c… в Docker и docker_container.web["a"] в конфигурации — один и тот же объект: у многих API нет надёжного способа найти «свой» объект по описанию, а имена не всегда уникальны. Фрагмент state:
{
"version": 4,
"terraform_version": "1.13.2",
"serial": 8,
"lineage": "6b1f0e64-1c3a-3e0c-5a4e-0d4f7e1b2c9a",
"outputs": {},
"resources": [
{
"module": "module.web",
"mode": "managed",
"type": "docker_container",
"name": "node",
"provider": "provider[\"registry.opentofu.org/kreuzwerker/docker\"]",
"instances": [
{
"index_key": "a",
"schema_version": 2,
"attributes": {
"id": "3f9c2e...",
"name": "notes-web-a",
"image": "sha256:...",
"restart": "unless-stopped"
},
"dependencies": ["docker_image.nginx", "docker_network.app"]
}
]
}
]
}
Поле serial увеличивается при каждой записи state, lineage — случайный идентификатор, присвоенный при создании state и не меняющийся потом. Пара этих полей позволяет обнаружить опасные ситуации: попытку записать state с меньшим номером поверх большего или state другой «родословной» поверх чужого. В attributes хранятся все атрибуты объекта, включая вычисляемые, в dependencies — зависимости, нужные для правильного порядка удаления, даже если ресурс уже исчез из конфигурации.
Команды для работы с state не требуют чтения файла руками:
tofu state list # адреса всех экземпляров
tofu state show 'module.web.docker_container.node["a"]'
tofu state pull > snapshot.json # копия текущего state из бэкенда
tofu state mv 'docker_container.web' 'module.web.docker_container.node'
tofu state rm 'docker_volume.tmp' # забыть объект, не удаляя его
Адреса с квадратными скобками и кавычками нужно заключать в одинарные кавычки: иначе оболочка интерпретирует скобки как шаблон имён файлов (в zsh это приводит к ошибке no matches found), а двойные кавычки внутри ключа будут сняты.
Локальный файл terraform.tfstate годится для одного человека и учебного стенда. В команде он порождает три проблемы. Во-первых, у каждого своя копия: инженер, не получивший последнюю версию, построит план относительно устаревшего state и предложит пересоздать уже существующие объекты. Во-вторых, два одновременных apply записывают state независимо, и изменения одного теряются. В-третьих, state содержит секреты, поэтому хранить его в Git нельзя.
Секреты в state и шифрование state в OpenTofu
В state записываются все атрибуты объектов, включая чувствительные: пароль, сгенерированный ресурсом random_password, начальный пароль администратора базы данных, закрытый ключ из tls_private_key, токен доступа, переданный в переменную окружения контейнера. Флаг sensitive = true у переменной или выхода и пометка атрибута как чувствительного в схеме провайдера влияют только на вывод: значение заменяется пометкой (sensitive value) в выводе plan и apply и пометкой <sensitive> в общем выводе tofu output. В самом state оно хранится открытым текстом (пример вывода для конфигурации лабораторной работы):
tofu output # db_password = <sensitive>
tofu output -raw db_password # явный запрос печатает значение
tofu state pull | grep -o 'POSTGRES_PASSWORD=[^"]*'
POSTGRES_PASSWORD=Xk3vQ9mR2pL7wN4tB8sY
Следовательно, доступ к state равносилен доступу ко всем секретам инфраструктуры, и защищать его нужно соответственно: ограниченные права на бакет, шифрование на стороне хранилища, журнал доступа, отказ от хранения в Git. Это защищает state от посторонних, но не от тех, кто запускает tofu: им ключи для чтения state нужны по определению.
OpenTofu с версии 1.7 добавляет шифрование state и файлов плана на стороне клиента 11. Ядро шифрует документ перед передачей бэкенду, поэтому в бакете лежит шифротекст, бесполезный без ключа, даже для администратора хранилища. Ключ получается от поставщика ключей (англ. key provider): из ключевой фразы функцией PBKDF2 или из системы управления ключами (AWS KMS, GCP KMS, OpenBao). Шифрование выполняет метод — сейчас это AES-GCM.
variable "state_passphrase" {
type = string
sensitive = true # задаётся через TF_VAR_state_passphrase, не короче 16 символов
}
terraform {
encryption {
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
method "unencrypted" "migrate" {}
state {
method = method.aes_gcm.main
fallback { # прочитать ещё незашифрованный state
method = method.unencrypted.migrate
}
}
}
}
Блок fallback нужен при переходе: существующий state ещё не зашифрован, и OpenTofu по умолчанию отказывается читать незашифрованные данные, если шифрование настроено, чтобы злоумышленник не мог подложить подделанный открытый state. После первого apply state записан в зашифрованном виде, fallback убирают и добавляют enforced = true. Документация подчёркивает ограничения: потеря ключа означает потерю state, шифрование не защищает от повреждения файла и от подсовывания старой зашифрованной версии, и оно не скрывает значения от того, кто запускает tofu 11. У Terraform такого механизма нет; там полагаются на шифрование хранилища и права доступа.
Оба проекта движутся и в сторону того, чтобы секреты не попадали в state вовсе. Эфемерные ресурсы (англ. ephemeral resources) существуют только во время одного запуска: например, секрет читается из хранилища секретов и передаётся провайдеру, не сохраняясь. Атрибуты только для записи (англ. write-only attributes) принимают значение при создании объекта, но не записываются в state. Эти возможности есть в OpenTofu с версии 1.11 и в Terraform с 1.10–1.11, но работают только с провайдерами, которые их поддерживают.
Удалённый бэкенд S3 и блокировка
Бэкенд (англ. backend) определяет, где хранится state и как он блокируется. По умолчанию используется local — файл в рабочем каталоге. Для команды state переносят в общее хранилище; самый распространённый вариант — бэкенд s3, работающий с Amazon S3 и с любым совместимым сервером: Yandex Object Storage, RustFS, SeaweedFS, Ceph RGW. Устройство объектных хранилищ, API S3 и подпись запросов рассматриваются в теме 10; здесь достаточно того, что state — это объект в бакете, а ключ объекта задаётся в конфигурации бэкенда.
Важнейшая функция общего бэкенда — блокировка состояния (англ. state locking): операция, которая может записать state, сначала захватывает блокировку и держит её до конца. Второй процесс, обнаружив блокировку, завершается с ошибкой или ждёт (опция -lock-timeout). Долгое время бэкенд s3 не умел блокировать средствами самого S3, потому что S3 не давал атомарной операции «создать, если не существует». Блокировку хранили в таблице Amazon DynamoDB, указанной аргументом dynamodb_table, — отдельном сервисе, которого нет у большинства S3-совместимых хранилищ. В августе 2024 года Amazon S3 получил условную запись (англ. conditional writes): запрос PutObject с заголовком If-None-Match: * выполняется, только если объекта с таким ключом нет, иначе сервер отвечает 412 Precondition Failed 7. На этой операции построена нативная блокировка: аргумент use_lockfile = true появился в Terraform 1.10, стал стабильным в 1.11 (одновременно аргументы DynamoDB объявлены устаревшими) 13, а в OpenTofu поддерживается с версии 1.10, причём OpenTofu не планирует отказываться от DynamoDB-блокировки 12.
Последовательность на рисунке выше соответствует документации OpenTofu; на учебном сервере её воспроизводит часть 5 лабораторной работы. Инженер A начинает apply: ядро записывает рядом со state объект с суффиксом .tflock условным запросом и получает успех. Блокировка — это JSON с идентификатором, типом операции, пользователем и временем. Инженер B запускает apply в своей копии репозитория, его условная запись того же ключа отвергается, и он видит (имя пользователя заменено):
Error: Error acquiring the state lock
Error message: operation error S3: PutObject, https response error
StatusCode: 412, RequestID: c8c11f17-9de0-48cc-ab64-ea3a477d4196, HostID: ,
api error PreconditionFailed: At least one of the pre-conditions you
specified did not hold
Lock Info:
ID: b92a6545-72a0-eed0-b500-78f73980f0d9
Path: tfstate/lab09/terraform.tfstate
Operation: OperationTypeApply
Who: student@lab
Version: 1.13.2
Created: 2026-10-21 21:07:06.791671225 +0000 UTC
Когда A заканчивает, ядро записывает новый state (с увеличенным serial) и удаляет объект блокировки. Если процесс A был убит, блокировка остаётся; снимает её tofu force-unlock <ID> — только после того, как убедились, что владелец действительно не работает. Опция -lock-timeout=60s заставляет B не падать сразу, а повторять попытку: когда A освободит блокировку, B прочитает уже обновлённый state и построит план относительно него.
Конфигурация бэкенда для S3-совместимого сервера в актуальном синтаксисе выглядит так:
terraform {
backend "s3" {
bucket = "tfstate"
key = "notes/prod/terraform.tfstate"
region = "us-east-1" # для не-AWS значение формальное, но обязательно
endpoints = {
s3 = "http://127.0.0.1:9000" # для Yandex: "https://storage.yandexcloud.net"
}
use_path_style = true # http://host/bucket/key
skip_credentials_validation = true # не вызывать AWS STS
skip_region_validation = true # не сверять регион со списком AWS
skip_requesting_account_id = true # не запрашивать ID аккаунта через IAM/STS
skip_metadata_api_check = true # не обращаться к метаданным EC2
skip_s3_checksum = true # не добавлять контрольные суммы, которых сервер может не знать
use_lockfile = true # объект notes/prod/terraform.tfstate.tflock
}
}
Аргумент endpoints с вложенным ключом s3 заменил устаревший endpoint, а use_path_style — устаревший force_path_style 12. Адресация по пути нужна серверам, которые не настроены на виртуальные хосты вида bucket.host. Группа skip_* отключает проверки, рассчитанные на AWS: без них бэкенд при инициализации обратится к STS или IAM, которых у S3-совместимого сервера нет. Ключи доступа в блок не записывают: блок backend не может ссылаться на переменные в Terraform, а в Git они не должны попасть в любом случае. Их задают переменными окружения AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY или профилем AWS CLI. Перед использованием use_lockfile с конкретным сервисом нужно убедиться, что он поддерживает условную запись: без неё блокировка либо не будет работать, либо каждая попытка получит ошибку.
К бакету для state предъявляются ещё два требования. Версионирование бакета должно быть включено: при ошибочной записи или удалении state предыдущая версия восстанавливается из истории объекта. Права на бакет должны быть у ограниченного круга — у роли конвейера и у нескольких администраторов, — так как state содержит секреты. Смена бэкенда или его параметров выполняется командой tofu init -migrate-state, которая копирует существующий state в новое место; tofu init -reconfigure забывает старый бэкенд без копирования.
Жизнь конфигурации: дрейф, рефакторинг, окружения и конвейер
Дрейф и plan -refresh-only
Дрейф возникает, когда объект изменили в обход конфигурации: через консоль при аварии, скриптом, другим инструментом, самой платформой (автоматическое обновление версии, перенос машины). Обычный plan обнаруживает дрейф попутно: после обновления сведений он видит, что фактическое значение отличается от конфигурации, и предлагает вернуть объект к описанию. Перед списком действий выводится раздел о внешних изменениях (вывод сокращён):
Note: Objects have changed outside of OpenTofu
OpenTofu detected the following changes made outside of OpenTofu since the
last "tofu apply" which may have affected this plan:
# module.web.docker_container.node["a"] has changed
~ resource "docker_container" "node" {
id = "3f9c2e..."
~ restart = "unless-stopped" -> "no"
...
}
Чтобы посмотреть только на дрейф, не смешивая его с изменениями конфигурации, используется режим tofu plan -refresh-only 8. Он строит план, единственная цель которого — обновить state по фактическому состоянию объектов, не трогая сами объекты. tofu apply -refresh-only после подтверждения записывает обновлённые сведения в state. Устаревшая команда tofu refresh (и terraform refresh) делала то же без показа плана и без подтверждения. Она объявлена устаревшей, потому что при ошибочных учётных данных (например, указан другой аккаунт, где объектов нет) молча записала бы в state, что все объекты исчезли.
Обнаружив дрейф, инженер выбирает одну из трёх реакций. Если ручное изменение ошибочно, выполняется обычный apply, и объект возвращается к описанию. Если изменение правильное (например, при аварии увеличили размер диска), его переносят в конфигурацию, и следующий план становится пустым. Если атрибутом законно управляет кто-то другой (автомасштабирование, внешняя система тегов), его вносят в ignore_changes. Регулярный запуск tofu plan -detailed-exitcode по расписанию превращает обнаружение дрейфа в мониторинг: код выхода 0 означает «изменений нет», 2 — «есть изменения», 1 — ошибку.
Импорт существующих объектов и рефакторинг адресов
Инфраструктура редко начинается с чистого листа: объекты, созданные вручную или другим инструментом, нужно взять под управление, не пересоздавая их. Для этого служит блок import: он сообщает ядру, что объект с данным идентификатором в API соответствует данному адресу в конфигурации 10.
resource "docker_volume" "pgdata" {
name = "notes-pgdata"
}
import {
to = docker_volume.pgdata
id = "notes-pgdata" # формат идентификатора определяет провайдер
}
Импорт выполняется в рамках обычного цикла: plan показывает действие import и, если описание отличается от объекта, ещё и изменения, которые будут применены сразу после импорта. Цель — добиться плана, в котором есть только импорт и нет ~ или -/+: это значит, что описание совпадает с реальностью. OpenTofu и Terraform умеют сгенерировать черновик описания по импортируемому объекту командой tofu plan -generate-config-out=generated.tf; документация помечает эту возможность как экспериментальную, а результат требует ручной чистки 10. Старая команда tofu import адрес идентификатор делает то же, но сразу меняет state без плана и ревью, поэтому для команды предпочтителен блок. Блок import поддерживает for_each для импорта многих объектов сразу (в OpenTofu с 1.7).
Обратная задача — рефакторинг, то есть изменение адресов без изменения объектов. Переименование ресурса, перенос его в модуль, переход от count к for_each для ядра выглядят как удаление старого адреса и создание нового. Блок moved сообщает, что объект просто переехал 9:
moved {
from = docker_container.web
to = module.web.docker_container.node
}
moved {
from = module.web.docker_container.node[0]
to = module.web.docker_container.node["a"]
}
При следующем plan ядро переносит записи в state на новые адреса и показывает has moved to вместо пересоздания. Блоки можно выстраивать в цепочки (A → B, затем B → C), и их рекомендуется не удалять из опубликованных модулей: пользователь, обновляющийся со старой версии, без них получит план с удалением. Блок moved может ссылаться на объекты вложенных модулей вызывающего модуля, но не на объекты родителя 9. Императивный аналог — tofu state mv, который сразу меняет state; блок предпочтительнее по той же причине, что и с импортом: изменение проходит через план и ревью. Наконец, блок removed убирает ресурс из state, не удаляя объект (например, при передаче объекта под управление другой конфигурации), — это декларативная замена tofu state rm.
Окружения: workspaces против каталогов
Одна и та же инфраструктура нужна в нескольких экземплярах: разработка, тестирование, рабочее окружение. Terraform и OpenTofu предлагают для этого два основных способа.
Рабочие пространства (англ. workspaces) позволяют хранить несколько state для одной конфигурации и одного бэкенда. Команда tofu workspace new staging создаёт пространство, tofu workspace select prod переключает, а в конфигурации доступно имя текущего пространства terraform.workspace. В бэкенде s3 state пространства, отличного от default, хранится по ключу env:/имя/ключ (префикс задаётся аргументом workspace_key_prefix) 12. Способ дешёв, но документация прямо предупреждает: рабочие пространства не подходят для разделения окружений, требующих разных учётных данных и прав доступа 14. Все пространства используют одну конфигурацию бэкенда и одни учётные данные, разница между окружениями прячется в выражениях вида terraform.workspace == "prod" ? 3 : 1, а ошибка в выборе пространства перед apply ничем не отличается внешне от правильной работы.
Второй способ — отдельные каталоги корневых модулей на каждое окружение, вызывающие общие модули; сравнение обоих способов изоляции state подробно дано у Е. Брикмана 2:
infra/
├── modules/
│ ├── network/
│ └── web/
└── envs/
├── staging/ # backend: key = "notes/staging/terraform.tfstate"; terraform.tfvars
└── prod/ # backend: key = "notes/prod/terraform.tfstate", другой бакет и роль
| Критерий | Workspaces | Каталоги на окружение |
|---|---|---|
| Где различия окружений | в выражениях с terraform.workspace | в файлах terraform.tfvars и вызовах модулей |
| Учётные данные и бэкенд | общие | свои у каждого окружения |
| Риск применить не туда | высокий: окружение — невидимое состояние CLI | ниже: окружение — каталог, видимый в пути и в ревью |
| Различия в составе ресурсов | неудобно (count по имени пространства) | естественно |
| Дублирование | нет | небольшое: вызовы модулей повторяются |
| Типичное применение | временные копии для экспериментов и запросов на слияние | постоянные окружения с разными правами |
На практике рабочие окружения разносят по каталогам (а часто и по разным облачным аккаунтам или каталогам облака с разными ролями), а рабочие пространства используют для короткоживущих копий. Инструменты вроде Terragrunt автоматизируют дублирующуюся часть каталожного подхода.
Конвейер проверки и применения
Если изменения инфраструктуры проходят через запросы на слияние, проверки и применение выполняет конвейер CI. Типичная последовательность показана на рисунке ниже.
Первые шаги выполняются за секунды и не требуют доступа к облаку. tofu fmt -check -recursive проверяет каноническое форматирование и тем самым убирает споры об отступах из ревью. tofu init -backend=false и tofu validate проверяют синтаксис, типы и корректность ссылок. Линтер tflint с наборами правил для провайдеров находит ошибки, которые validate не видит: для AWS — например, несуществующий тип машины. Сканер конфигурации (trivy config, checkov) ищет небезопасные настройки: открытые всему Интернету группы безопасности, бакеты без шифрования, отсутствие журналирования. Сканирование образов и цепочки поставок подробно рассматривается в теме 11.
Затем выполняется tofu plan -out=tfplan с ролью, имеющей права только на чтение, а текстовый вывод (tofu show tfplan) публикуется в обсуждение запроса на слияние. Ревьюер читает план, а не только разницу в коде: одна строка в модуле может развернуться в сотню пересозданий. После одобрения и слияния конвейер с ролью, имеющей права на запись, выполняет tofu apply tfplan — применяет ровно тот план, который видел ревьюер. Если между планом и применением state изменился, сохранённый план не применится, и цикл повторится с шага plan. Такое разделение прав ограничивает ущерб от компрометации: код в непроверенном запросе на слияние не получает прав на изменение инфраструктуры. Сам план при этом тоже содержит секреты, поэтому файл tfplan хранят как защищённый артефакт, а в OpenTofu его можно зашифровать тем же блоком encryption.
Ограничения и типичные ошибки интерпретации
Чего не гарантирует инфраструктура как код
Пустой план не означает, что инфраструктура исправна. Он означает лишь, что атрибуты, которые провайдер умеет читать, совпадают с конфигурацией. Содержимое машины (пакеты, файлы, процессы), данные в базе и настройки, не представленные в схеме провайдера, остаются вне поля зрения. Terraform управляет «коробками» инфраструктуры, а их содержимым — образы (тема 4), системы управления конфигурацией и Kubernetes (темы 7–8).
Успешный план не гарантирует успешного применения. План вычисляется по схеме провайдера, а не по всем правилам API: квота на число машин, занятое имя бакета, недоступный в зоне тип диска выясняются только при обращении к API во время apply. Если ошибка возникла посередине, часть объектов уже создана и записана в state, а часть нет. Транзакции и отката нет: исправляют ошибку и запускают apply снова, а идемпотентность обеспечивает, что созданное повторно создаваться не будет. Убитый посреди apply процесс может оставить объект, созданный в облаке, но не записанный в state; такие объекты потом находят по меткам и импортируют или удаляют вручную.
Согласование происходит только при запуске. Между запусками Terraform не защищает от изменений, и инфраструктура, которую никто давно не планировал, почти наверняка дрейфовала. Отсюда регулярные запуски plan по расписанию и ограничение ручных прав в консоли.
Сам state — единая точка отказа и единственный источник правды о том, какие объекты чьи. Потеря state не удаляет инфраструктуру, но лишает инструмент знания о ней: следующий apply попытается создать всё заново и, скорее всего, упрётся в конфликты имён. Версионирование бакета и ограниченные права — не формальность, а условие восстановимости.
Типичные ошибки
- Индексы
countвместо ключей. Список сcountдля объектов, имеющих идентичность, приводит к каскаду пересозданий при удалении элемента из середины (числовой пример во втором разделе). Признак ошибки в плане —-/+у экземпляров, которые никто не собирался трогать, с изменением имени на имя соседа. - «
sensitiveзащищает секрет». Флаг скрывает значение в выводе, но в state и файле плана оно лежит открытым текстом. Защищать нужно хранилище state, а в OpenTofu — дополнительно включать шифрование state. - State в Git или на ноутбуке. Локальный state в команде приводит к планам относительно устаревших данных и к потере изменений при одновременной работе; в Git он к тому же раскрывает секреты. Нужен удалённый бэкенд с блокировкой и версионированием.
- Привычка к
-target. Опция ограничивает план частью графа и предназначена для восстановления после аварий. Регулярное её использование приводит к тому, что конфигурация и state расходятся в непроверенных местах, а полный план со временем становится непредсказуемым. Адрес в-targetс квадратными скобками заключают в одинарные кавычки:-target='docker_container.web[0]'. - Чтение итоговой строки вместо действий.
Plan: 1 to add, 1 to destroyможет означать пересоздание базы данных. Читать нужно действия по экземплярам и причину# forces replacement. - Незакреплённые версии провайдеров и модулей. Без ограничения версии и без файла
.terraform.lock.hclв Git новая старшая версия провайдера может изменить схему и превратить пустой план в план с пересозданиями. - Удаление
movedи заменаimportна ручные команды. Блоки, удалённые раньше, чем все пользователи модуля применили их, возвращают план к удалению и созданию. Ручныеstate mvиimportбез плана не проходят ревью. - Рабочие пространства как граница безопасности. Пространства одного бэкенда используют общие учётные данные: ошибка в выборе пространства затрагивает рабочее окружение.
ignore_changes = allи широкиеdepends_on. Первое скрывает дрейф, второе удлиняет граф и делает изменения одного ресурса «заразными» для всех зависящих.
Итоги темы
Инфраструктура как код переносит в управление облаком практики разработки программ: описание в системе контроля версий, ревью, автоматические проверки, воспроизводимость. Terraform и его ответвление OpenTofu, появившееся после перехода Terraform на лицензию BSL в 2023 году, реализуют декларативную модель: ядро сравнивает описание с фактическим состоянием и выполняет недостающие действия, а знание о конкретных API вынесено в провайдеры, общающиеся с ядром по gRPC. В отличие от Kubernetes, согласование выполняется только по команде, поэтому дрейф между запусками — нормальное явление, которое нужно регулярно обнаруживать.
Язык HCL разделяет параметры (variable), вычисления (locals), чтение внешних данных (data) и интерфейс модуля (output). Главное понятие языка — адрес экземпляра: именно к нему привязан объект в state. Поэтому выбор между count и for_each — не вопрос вкуса: позиционные индексы при удалении элемента из середины дают каскад пересозданий, а ключи for_each сохраняют идентичность. Модули инкапсулируют типовые блоки инфраструктуры и вызываются с закреплёнными версиями.
Из ссылок между ресурсами строится граф, который определяет порядок и параллелизм действий: время применения определяется длиной самого длинного пути, а не числом ресурсов. План складывается из чтения state, обновления сведений через провайдер и вычисления разности; из символов плана наибольшего внимания требует -/+, означающий удаление и повторное создание объекта, а для данных — их потерю. Блок lifecycle позволяет изменить порядок замены, запретить удаление и игнорировать атрибуты, которыми управляет кто-то другой.
State — центральная и самая уязвимая часть системы. Он хранит соответствие адресов и объектов и все атрибуты объектов, включая секреты, которые sensitive не шифрует. В команде его выносят в удалённый бэкенд: для S3 это бакет с версионированием, ограниченными правами и нативной блокировкой use_lockfile, основанной на условной записи; OpenTofu добавляет шифрование state на стороне клиента. Дрейф изучают с помощью plan -refresh-only, существующие объекты принимают блоком import, а рефакторинг выполняют блоками moved, чтобы изменения адресов проходили через план и ревью. Окружения с разными правами разносят по каталогам, а применение доверяют конвейеру, который выполняет ровно тот план, который видел ревьюер.
Бэкенд state опирается на объектное хранилище, но устройство самого S3 — модель бакетов и объектов, подпись запросов, версионирование, согласованность и коды стирания — оставалось за кадром. Это предмет темы 10, где тот же сервер RustFS из лабораторной работы будет рассмотрен как самостоятельная система хранения.
Литература
- Morris K. Infrastructure as Code: Dynamic Systems for the Cloud Age. 2nd ed. — O'Reilly Media, 2020.
- Brikman Y. Terraform: Up & Running: Writing Infrastructure as Code. 3rd ed. — O'Reilly Media, 2022.
- HashiCorp. HashiCorp adopts Business Source License. — 10.08.2023. https://www.hashicorp.com/blog/hashicorp-adopts-business-source-license (дата обращения: 22.10.2026).
- OpenTofu. The OpenTofu Manifesto. — https://github.com/opentofu/manifesto (дата обращения: 22.10.2026).
- Cloud Native Computing Foundation. OpenTofu. — https://www.cncf.io/projects/opentofu/ (дата обращения: 22.10.2026).
- HashiCorp. Terraform Plugin Protocol. — https://github.com/hashicorp/terraform/blob/main/docs/plugin-protocol/README.md (дата обращения: 22.10.2026).
- Amazon Web Services. Amazon S3 User Guide. Add preconditions to S3 operations with conditional requests. — https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html (дата обращения: 22.10.2026).
- OpenTofu Documentation. Command: plan. — https://opentofu.org/docs/cli/commands/plan/ (дата обращения: 22.10.2026).
- OpenTofu Documentation. Refactoring. — https://opentofu.org/docs/language/modules/develop/refactoring/ (дата обращения: 22.10.2026).
- OpenTofu Documentation. Import. — https://opentofu.org/docs/language/import/ (дата обращения: 22.10.2026).
- OpenTofu Documentation. State and Plan Encryption. — https://opentofu.org/docs/language/state/encryption/ (дата обращения: 22.10.2026).
- OpenTofu Documentation. Backend Type: s3. — https://opentofu.org/docs/language/settings/backends/s3/ (дата обращения: 22.10.2026).
- HashiCorp. Terraform v1.11 Changelog. — https://github.com/hashicorp/terraform/blob/v1.11/CHANGELOG.md (дата обращения: 22.10.2026).
- OpenTofu Documentation. Workspaces. — https://opentofu.org/docs/language/state/workspaces/ (дата обращения: 22.10.2026).