Тема 09

Инфраструктура как код: Terraform и OpenTofu

Инфраструктура как код: 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 и сотни других.

Архитектура Terraform и OpenTofu
Ядро читает конфигурацию HCL и файл состояния, строит граф и вызывает провайдеры, которые обращаются к API платформ

Схема на рисунке выше показывает поток данных: конфигурация и состояние входят в ядро, ядро по протоколу 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, те же провайдеры. Дальше проекты развиваются независимо, и различия накапливаются. Основные из них сведены в таблицу; версии указаны по журналам изменений проектов.

ВозможностьOpenTofuTerraform
ЛицензияMPL 2.0BSL 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-anotes-web-aнет
web[1]notes-web-bnotes-web-cпересоздание (имя контейнера неизменяемо)
web[2]notes-web-cnotes-web-dпересоздание
web[3]notes-web-dnotes-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 (что есть на самом деле). Последовательность шагов показана на рисунке ниже.

Последовательность шагов tofu plan
Plan читает state, обновляет сведения об объектах через провайдер, сравнивает с конфигурацией и получает разность по каждому экземпляру

Сначала ядро захватывает блокировку 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.

Блокировка state в S3-бэкенде при работе двух инженеров
Инженер A создаёт объект .tflock условной записью и держит блокировку; запрос инженера B получает 412 и завершается ошибкой до освобождения блокировки

Последовательность на рисунке выше соответствует документации 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 из лабораторной работы будет рассмотрен как самостоятельная система хранения.

Литература

  1. Morris K. Infrastructure as Code: Dynamic Systems for the Cloud Age. 2nd ed. — O'Reilly Media, 2020.
  2. Brikman Y. Terraform: Up & Running: Writing Infrastructure as Code. 3rd ed. — O'Reilly Media, 2022.
  3. HashiCorp. HashiCorp adopts Business Source License. — 10.08.2023. https://www.hashicorp.com/blog/hashicorp-adopts-business-source-license (дата обращения: 22.10.2026).
  4. OpenTofu. The OpenTofu Manifesto. — https://github.com/opentofu/manifesto (дата обращения: 22.10.2026).
  5. Cloud Native Computing Foundation. OpenTofu. — https://www.cncf.io/projects/opentofu/ (дата обращения: 22.10.2026).
  6. HashiCorp. Terraform Plugin Protocol. — https://github.com/hashicorp/terraform/blob/main/docs/plugin-protocol/README.md (дата обращения: 22.10.2026).
  7. 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).
  8. OpenTofu Documentation. Command: plan. — https://opentofu.org/docs/cli/commands/plan/ (дата обращения: 22.10.2026).
  9. OpenTofu Documentation. Refactoring. — https://opentofu.org/docs/language/modules/develop/refactoring/ (дата обращения: 22.10.2026).
  10. OpenTofu Documentation. Import. — https://opentofu.org/docs/language/import/ (дата обращения: 22.10.2026).
  11. OpenTofu Documentation. State and Plan Encryption. — https://opentofu.org/docs/language/state/encryption/ (дата обращения: 22.10.2026).
  12. OpenTofu Documentation. Backend Type: s3. — https://opentofu.org/docs/language/settings/backends/s3/ (дата обращения: 22.10.2026).
  13. HashiCorp. Terraform v1.11 Changelog. — https://github.com/hashicorp/terraform/blob/v1.11/CHANGELOG.md (дата обращения: 22.10.2026).
  14. OpenTofu Documentation. Workspaces. — https://opentofu.org/docs/language/state/workspaces/ (дата обращения: 22.10.2026).