devops-курс Все курсы

✻ Урок 7.3 · Тема 7: IaC: Terraform и Ansible

Terraform: remote state, модули, окружения

⏱ 2 ч

Зачем это нужно

Пока state (файл состояния: список того, что Terraform уже создал, урок 7.1) лежит у тебя на ноутбуке, инфраструктура принадлежит одному человеку. Это как записная книжка прораба, которую он носит в кармане: бригада не знает, какие окна уже вставлены, а если прораб потерял книжку, стройка продолжает стоить денег, но никто не понимает, что на ней построено. Два человека с двумя разными книжками начнут вставлять одно и то же окно дважды. Два одновременных apply (команда, которая вносит изменения в облако) могут к тому же испортить сам файл состояния.

Копипаста кода для dev и stage (dev это окружение для разработки и проб, stage это «репетиция» перед боевым) быстро расходится: в одном файле поправили, в другом забыли, и окружения перестают быть похожими. Из-за этого на «репетиции» всё работает, а на боевой системе нет.

Решения два. Первое: общий state в бакете (bucket: хранилище файлов в облаке, как шкаф с ячейками; разбирали в уроке 6.2) с блокировкой, чтобы одновременно писал только один человек. Второе: модуль (module), то есть готовый «кирпич» кода, из которого собираются окружения: описал сеть и ВМ один раз, вызвал дважды. Подробно оба понятия разберём в теории. Это базовый уровень командной работы с Terraform, и про него спрашивают на собеседованиях.

Шаг проекта: state «Заметок» переезжает в бакет Object Storage (так Yandex Cloud называет хранилище файлов, аналог Amazon S3), сеть и ВМ выносятся в модуль infra/terraform/modules/notes-vm, а окружение dev вызывает этот модуль.

Что нужно знать

Картина целиком

Представь небольшую строительную фирму. Раньше у прораба была одна записная книжка (local state). Теперь фирма завела общий журнал работ на проходной (remote state в бакете): любой прораб сначала читает журнал, потом работает. На журнале висит табличка «занято» (блокировка): пока один прораб делает записи, второй ждёт у двери. У журнала есть архив старых версий (версионирование бакета): если кто-то испортил страницу, её можно восстановить.

А чтобы каждая новая стройка не начиналась с нуля, у фирмы есть типовой проект (модуль): «дом с сетью, сервером и охраной». Для каждой площадки (dev, stage) берут один типовой проект, подставляют свои размеры и получают свой дом и свой журнал.

flowchart LR
    T["ты или CI<br>terraform в envs/dev"] -->|"1. блокировка"| L["notes/dev/terraform.tfstate.tflock<br>табличка «занято»"]
    T -->|"2. читает и пишет"| D["notes/dev/terraform.tfstate<br>журнал dev, старые версии хранятся"]
    T -.->|"у stage свой ключ"| ST["notes/stage/terraform.tfstate<br>журнал stage"]
    T -->|"вызывает"| M["modules/notes-vm<br>сеть, диск, группа, ВМ"]
    E1["envs/dev<br>свои значения"] --> M
    E2["envs/stage<br>свои значения"] --> M

Части схемы:

  • remote state и блокировка - общий журнал и табличка «занято»;
  • модуль - типовой проект, который вызывают из окружений;
  • окружение - каталог с вызовом модуля, своим backend и своим state;
  • import и moved - два способа сказать Terraform «этот объект уже существует» и «он просто переехал на другой адрес»;
  • зеркало провайдеров - обходной путь, если сайт, откуда Terraform качает плагины, недоступен.

Теория

Почему локальный state не подходит для команды

Чтобы понимать, какую именно проблему решает всё остальное в уроке. По умолчанию state (terraform.tfstate) лежит рядом с кодом, на твоём диске. Для одного человека и одного ноутбука это работает.

Записная книжка прораба. Пока он один, книжки хватает. Когда на стройке три бригады, а книжка у одного в кармане, остальные работают вслепую.

В уроке 7.1 мы выяснили: Terraform сравнивает три вещи (код, state, реальность) и без state не знает, какие объекты облака «его». У локального файла три слабых места:

  1. Файла нет у коллеги. Он клонирует репозиторий, запускает plan и видит «нужно создать всё». Если он применит, получатся дубликаты или ошибка «имя занято».
  2. В нём секреты. Terraform записывает атрибуты ресурсов открытым текстом, в том числе пароли и ключи (урок 7.2 показал это на пароле БД). Положить такой файл в git нельзя.
  3. Нет защиты от одновременной работы. Два apply одновременно читают одну версию state и пишут разные результаты, выигрывает тот, кто записал последним, а ресурсы первого «теряются».

Пример: Аня и Борис работают с одним кодом. Аня применила изменение: в облаке появилась ВМ, в Аниной копии state появилась запись о ней. Борис на своём ноутбуке копии Аниного state не имеет. Он запускает terraform apply, Terraform сверяет код со своим (пустым) state и создаёт ВМ ещё раз. Результат: две ВМ вместо одной, счёт вырос, а если имя ВМ должно быть уникальным, apply упадёт с ошибкой конфликта.

Прикинь сам: Аня применила изменение и создала ВМ, у Бориса нет её state. Что произойдёт, если Борис запустит apply с тем же кодом?

Terraform сверит код со своим пустым state и решит, что ВМ ещё нет. Получатся две ВМ или ошибка «имя занято»: Борис не знает о том, что уже сделала Аня.

Осторожно: «Закоммитим state в git, и у всех будет один файл». Не выйдет: секреты в истории git остаются навсегда, а сливать два разных state-файла через merge нельзя (это не текст для людей, а внутренний формат со счётчиком версий).

Главное: локальный state живёт у одного человека, хранит секреты и не защищён от двух одновременных apply. Всё остальное в уроке решает эти три слабости.

Проверь понимание: почему нельзя положить terraform.tfstate в git, даже если репозиторий приватный?

Ответ

Во-первых, в state значения ресурсов лежат открытым текстом, включая пароли и ключи, а круг людей с доступом к репозиторию шире, чем к секретам. Во-вторых, у git нет блокировки: два человека закоммитят разные версии, а слить state-файл merge’ем нельзя. В-третьих, история git навсегда сохранит секрет, даже если файл потом удалить.

Значит, state нужно вынести в общее место. Разберём, как это делает remote backend.

Remote backend: общий state в бакете

Нужно место без трёх слабостей выше: общее для всех, с правами доступа, с историей версий. Такое место называется удалённым хранилищем состояния (remote backend). Backend (бэкенд) это «драйвер хранения»: кусок Terraform, который знает, где и как читать и писать state. По умолчанию backend local: файл на диске.

Общий журнал на проходной вместо личной записной книжки. Журнал один, лежит в известном месте, у входа проверяют пропуск. Оговорка: если проходная закрыта (бакет недоступен), работать с Terraform нельзя вообще, поэтому бакет должен быть надёжным.

Для S3-совместимых хранилищ (Yandex Object Storage, AWS S3, MinIO) используется backend s3. Бакет (bucket) это хранилище объектов: объект это файл плюс его имя-ключ (key), например notes/dev/terraform.tfstate. Каталогов в бакете на самом деле нет: слэши в ключе это просто часть имени, а консоли рисуют из них «папки» для удобства. Порядок работы такой:

  1. Ты запускаешь terraform plan или apply.
  2. Terraform по настройкам backend идёт в бакет и скачивает объект-state.
  3. Считает план, при apply вносит изменения в облако.
  4. Записывает новую версию state обратно в бакет.

Сам Terraform ничего локально надолго не хранит: в каталоге .terraform/ лежит только запись о том, какой backend настроен. Поэтому после смены backend нужен terraform init.

Чтобы пустить Terraform в бакет, нужен ключ доступа. В S3-мире это пара: идентификатор ключа (access_key) и секрет (secret_key), как логин и пароль для программы. Terraform читает их из переменных окружения AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY, даже когда хранилище не Amazon (S3-протокол общий, поэтому названия унаследованы). Ключ выпускается для сервисного аккаунта (учётка для программы, урок 6.1) с правами только на этот бакет: утечка такого ключа не даст доступа ко всему облаку.

Пример: Блок backend из задания 2, по строкам:

bucket = "notes-tfstate-CHANGE_ME"     имя бакета, где лежит state (имя уникально на весь Object Storage)
key    = "notes/dev/terraform.tfstate"  имя объекта внутри бакета: путь «проект / окружение / файл»
region = "ru-central1"                  регион; для Yandex формальность, но S3-протокол его требует
endpoints = { s3 = "https://storage.yandexcloud.net" }   адрес «окошка» хранилища (у AWS не нужен)
use_lockfile = true                     блокировка файлом в том же бакете (следующий раздел)
skip_* = true                           отключить проверки, рассчитанные на Amazon (аккаунт, регионы)

Ключ notes/dev/terraform.tfstate выбран не случайно: у окружения stage будет notes/stage/terraform.tfstate, а бакет общий. Состояния разные, хотя бакет один.

Прикинь сам: Ты запускаешь plan на новом ноутбуке, где ещё нет ни одного файла state. Откуда Terraform узнает, что уже создано?

Из бакета: настройки backend лежат в коде, и Terraform сам скачает объект notes/dev/terraform.tfstate. Локальный state не нужен, нужен только init и ключ доступа к бакету.

Осторожно:

  • «Backend можно параметризовать переменными». Нет: блок backend читается раньше, чем Terraform вычисляет переменные, поэтому имя бакета и ключ пишутся в коде буквально или передаются при init флагом -backend-config.
  • «Бакет для state создаст сам Terraform». Можно, но state этого бакета придётся хранить где-то ещё: курица и яйцо. Поэтому бакет для state создают один раз руками (или отдельным маленьким проектом), как в задании 1.

Главное: remote backend это общий журнал состояния в бакете: у каждого окружения свой ключ, а доступ даёт сервисный аккаунт только на этот бакет.

Проверь понимание: в бакете лежит объект notes/dev/terraform.tfstate. Есть ли в бакете каталог notes?

Ответ

Нет. В объектном хранилище есть только объекты с именами. Слэши в имени это часть ключа, а «папки» рисует интерфейс для удобства. Поэтому «создать каталог» не нужно: достаточно записать объект с нужным именем.

Общий журнал не спасает от двух людей, которые пишут в него одновременно. Для этого нужна блокировка.

Блокировка: табличка «занято»

Даже в общем журнале два прораба могут одновременно вписать разные данные в одну страницу. Блокировка (state lock) гарантирует, что в каждый момент state изменяет только один процесс.

Табличка «не беспокоить» на двери бухгалтерии: вошёл, повесил, закончил, снял. Оговорка: табличка не охранник, она работает, пока все её уважают. Если процесс умер и табличка осталась висеть, её придётся снять вручную (force-unlock).

Перед операцией, которая может изменить state, Terraform создаёт запись о блокировке, а после завершения удаляет её. Способа два:

  • Старый: отдельная таблица в базе данных DynamoDB (в Yandex это YDB в режиме, совместимом с DynamoDB). Блокировка это строка в таблице. Минус: лишний ресурс, который тоже нужно создать и оплачивать.
  • Новый: параметр use_lockfile = true (появился в Terraform 1.10). Блокировка это маленький файл <ключ>.tflock рядом со state в том же бакете. Создаётся он «условной записью»: хранилище принимает объект только если такого ещё нет. Поэтому способ работает лишь там, где хранилище условную запись поддерживает. Для Yandex Object Storage не считай это очевидным, а проверь на своём бакете (задание 2).

Схема работы:

sequenceDiagram
    participant A as Процесс A
    participant B as бакет
    participant C as Процесс B
    A->>B: создать .tflock
    B-->>A: ok, файла не было
    Note over A: работает, apply
    C->>B: создать .tflock
    B-->>C: отказ, файл уже есть (Error acquiring the state lock)
    A->>B: записать state
    A->>B: удалить .tflock

Пример: Сообщение об ошибке из задания 2, поле за полем:

Lock Info:
  ID:        3d2c6a1e-...   уникальный номер блокировки, нужен для force-unlock
  Path:      ...            какой state заблокирован
  Operation: OperationTypeApply   что делает владелец: apply, plan и т. д.
  Who:       user@laptop    кто держит (пользователь и машина)

Читать это нужно как вопрос «кто и зачем?»: если Who живой человек или CI-задача, которая ещё идёт, ждёшь. Если процесс завершился аварийно (закрыли ноутбук, убили kill -9, оборвалась сеть), блокировка осталась «висеть». Тогда terraform force-unlock <ID> её снимает.

Прикинь сам: Ты запустил apply и закрыл ноутбук, блокировка осталась. Что получит коллега и что он сделает первым?

Ошибку Error acquiring the state lock с полями ID, Who, Operation. Сначала он выяснит по Who, жив ли процесс, и только потом снимет табличку через force-unlock <ID>. Флаг -lock=false не ставят: он отключает всю защиту.

Осторожно: Блокировка берётся и на plan, не только на apply, поэтому параллельные запуски plan в CI могут мешать друг другу. Второе: force-unlock не чинит state, а только убирает табличку. Если убитый apply успел создать часть ресурсов, после разблокировки нужно прочитать plan: state мог остаться в промежуточном состоянии.

Главное: блокировка не даёт двум процессам менять state одновременно; новый способ это файл .tflock рядом со state (use_lockfile = true), а force-unlock снимает только зависшую табличку.

Проверь понимание: ты запустил apply, закрыл крышку ноутбука, сеть пропала. Коллега получает Error acquiring the state lock. Что он делает первым?

Ответ

Смотрит поле Who и время, выясняет, жив ли процесс (пишет владельцу). Если владелец подтвердил, что запуск мёртв, или ответа нет и процесс явно завис, снимает блокировку terraform force-unlock <ID>, затем запускает plan и проверяет состояние. Сразу -lock=false не отключают: так теряется вся защита.

Блокировка защищает от гонки, но не от порчи данных. От порчи спасает версионирование бакета.

Версионирование бакета: кнопка «отменить»

State можно повредить: неудачная запись, ошибочное удаление, чужая команда state rm. Версионирование (versioning) бакета хранит все прошлые версии объекта, и испорченный файл можно откатить. Без него потеря state означает ручной импорт каждого ресурса.

История изменений в текстовом редакторе: случайно всё стёр, открыл предыдущую версию. Оговорка: история занимает место и стоит денег, но state маленький, так что это копейки.

При включённом версионировании перезапись объекта создаёт новую версию, а старая остаётся с собственным идентификатором версии. «Удаление» тоже не стирает данные, а ставит метку удаления поверх. Восстановить значит скопировать нужную старую версию как текущую. Включать версионирование нужно заранее: задним числом прошлые версии не появятся.

Пример: В задании 1 вывод yc storage bucket get ... | jq '{name, versioning}' показывает "versioning": "VERSIONING_ENABLED". Это и есть проверка: если там VERSIONING_DISABLED, то после первой же аварии откатываться будет не к чему.

Прикинь сам: Версионирование включили в понедельник, state испортили во вторник. Можно ли откатиться? А если включить его в среду?

Во вторник испорченный state восстановить можно: старая версия лежит в бакете. Если включить версионирование в среду, откатиться не к чему: прошлые версии задним числом не появляются.

Осторожно: Версионирование не заменяет блокировку: оно спасает от последствий, а блокировка предотвращает гонку.

Главное: версионирование включают заранее: оно хранит все прошлые версии state и не заменяет блокировку.

С общим state разобрались. Теперь займёмся кодом: как не копировать его для каждого окружения.

Соответствие AWS

Что делаем Yandex Cloud AWS
Бакет для state Object Storage S3
Блокировка (старый способ) YDB (Document API) DynamoDB
Ключи для backend статический ключ сервисного аккаунта IAM access key
Права на state роль на бакет IAM policy на бакет
Шифрование ключ Yandex KMS SSE-KMS
Провайдер yandex-cloud/yandex hashicorp/aws
Backend в коде backend "s3" с endpoints backend "s3"

Backend s3 один и тот же. Отличается адрес endpoint и несколько флагов, отключающих проверки, специфичные для AWS. (IAM это система прав в AWS, аналог ролей в Yandex; KMS это сервис хранения ключей шифрования.)

Модуль: типовой проект

В уроках 7.1 и 7.2 у тебя в каталоге лежат сеть, диск, группа безопасности и ВМ. Когда понадобится stage, первая мысль: скопировать файлы. Копия начинает жить отдельно: в одну добавили правило, в другую забыли. Модуль (module) даёт одно описание, которое вызывают несколько раз с разными значениями.

Функция в программировании или типовой проект дома: чертёж один, а размеры и цвет стен выбирают при заказе. Оговорка: в отличие от функции, у модуля нет «возвращаемого значения» в привычном виде: наружу он отдаёт только то, что явно объявлено в output.

Модуль это обычный каталог с .tf файлами. Тот каталог, из которого ты запускаешь terraform, называется корневым модулем (root module), у нас это envs/dev. Из него блоком module вызывают дочерний модуль (child module):

flowchart TD
    R["envs/dev: корневой модуль<br>main.tf, backend.tf, moved.tf<br>здесь запускаешь terraform"] -->|"входы: my_ip_cidr, vm_cores"| C["modules/notes-vm: дочерний модуль<br>variables.tf, network.tf, vm.tf, outputs.tf"]
    C -->|"выход: public_ip"| O["module.notes_vm.public_ip<br>в envs/dev"]

Правила, которые спасают на практике:

  • Модуль знает только то, что ему передали. Значения идут через variable. Он не читает переменные вызывающего и не берёт чужой state.
  • Наружу выдаёт только output. Можно module.notes_vm.public_ip, нельзя module.notes_vm.yandex_compute_instance.notes.
  • Провайдера настраивают в корневом модуле, а не в дочернем: у нас versions.tf и providers.tf остаются в envs/dev, модуль их использует.
  • Выносить стоит то, что повторяется хотя бы дважды и меняется вместе. Модуль на один ресурс без логики это лишний слой.

Версию модуля фиксируют. У локального source (путь на диске) версии нет, поэтому правка модуля сразу видна всем окружениям, которые его вызывают. Для модуля из git пишут source = "git::https://.../notes-vm.git?ref=v1.2.0" (тег), для реестра Terraform version = "~> 1.2" (разбор такого ограничения был в уроке 7.1).

Пример: В модуле notes-vm объявлена переменная vm_cores со значением по умолчанию 2. Вызов:

module "notes_vm" {
  source   = "../../modules/notes-vm"   # путь от корневого модуля до каталога модуля
  vm_cores = 2                          # значение для variable "vm_cores" внутри модуля
}

Внутри модуля в ресурсе написано cores = var.vm_cores, и Terraform подставит 2. Если из envs/stage вызвать тот же модуль с vm_cores = 4, получится ВМ побольше, а код один. Terraform при init нужно «установить» модуль: для локального пути это просто запоминание ссылки, для git-модуля ещё и скачивание. Поэтому после добавления блока module нужен terraform init (иначе Error: Module not installed).

Прикинь сам: В модуле notes-vm у vm_cores значение по умолчанию 2. Из envs/stage вызвали его с vm_cores = 4. Сколько ядер у ВМ в stage и в dev?

В stage 4, в dev 2 (если там значение не задано). Код один, значения свои у каждого вызова.

Осторожно:

  • Модуль и окружение это не одно и то же. Модуль это «как устроено», окружение это «с какими значениями и в каком state».
  • «Вынес в модуль, значит, стало чище». Если модуль вызывают один раз и внутри нет логики, прибыли нет.

Главное: модуль это каталог с .tf файлами, который вызывают блоком module; он знает только то, что ему передали, и отдаёт наружу только output.

Проверь понимание: внутри модуля нужно знать my_ip_cidr (адрес, с которого разрешён SSH). Где его объявить и как передать?

Ответ

Объявить variable "my_ip_cidr" внутри модуля (это вход модуля) и передать значением в блоке module в корневом модуле: my_ip_cidr = var.my_ip_cidr. Сам корневой модуль, в свою очередь, тоже объявляет variable "my_ip_cidr" и получает значение из terraform.tfvars. Модуль сам «наружу за переменной» не ходит.

Если перенести ресурсы в модуль, их адреса поменяются. Что при этом будет с state, разберём дальше.

Адрес ресурса и блок moved

Это самое частое место, где новички ломают инфраструктуру при рефакторинге. Terraform находит ресурс в state по его адресу. Если адрес изменился, старый ресурс для него «исчез», а новый «появился».

Почтовый адрес. Если дом перенумеровали, почтальон, который ищет старый номер, не найдёт никого и решит, что жильцов нет. Запись в книге «дом 5 теперь дом 7» объясняет, что жильцы те же. Такая запись и есть moved.

Адрес складывается из пути: тип.имя для ресурса в корневом модуле, module.<имя вызова>.тип.имя внутри модуля, ["ключ"] для экземпляров for_each (экземпляр, instance: одна из копий ресурса, которые for_each создаёт по одной на каждый ключ, как три правила группы безопасности из урока 7.2). Когда ты переносишь ресурс в модуль, к адресу добавляется префикс. plan видит: старого адреса в коде нет (значит, удалить), нового адреса в state нет (значит, создать). Для сети или ВМ это «удалить и создать заново», то есть потеря машины и её данных.

Блок moved { from = ..., to = ... } сообщает Terraform: это один и тот же объект. Блок пишут в корневом модуле, потому что он отвечает за то, как выглядит состояние его дерева. Команда terraform state mv делает то же самое императивно, прямо в state, но без следа в коде, поэтому в команде предпочитают moved.

Пример: До переноса в state лежит ресурс с адресом yandex_compute_instance.notes. После переноса файла vm.tf в модуль и вызова module "notes_vm" ресурс в коде имеет адрес module.notes_vm.yandex_compute_instance.notes.

  • Без moved: plan покажет yandex_compute_instance.notes will be destroyed и module.notes_vm.yandex_compute_instance.notes will be created, итог Plan: N to add, 0 to change, N to destroy.
  • С moved: plan пишет yandex_compute_instance.notes has moved to module.notes_vm.yandex_compute_instance.notes, итог 0 to add, 0 to change, 0 to destroy.

Для ресурсов с for_each (у нас yandex_vpc_security_group_rule.ingress) блок moved пишется на весь ресурс одним блоком: Terraform сам сопоставит экземпляры ["ssh"], ["http"], ["https"].

Прикинь сам: Ты перенёс ВМ в модуль и запустил plan без блока moved. Какой будет итоговая строка плана?

Что-то вроде Plan: N to add, 0 to change, N to destroy: старый адрес исчез из кода (удалить), новый не найден в state (создать). Для ВМ это потеря машины и данных.

Осторожно: Блок moved не нужно удалять сразу после apply: если у коллеги или в другом окружении state ещё со старыми адресами, блок им тоже нужен. Обычно его оставляют на несколько релизов.

Главное: Terraform ищет ресурс в state по адресу; смена адреса выглядит как «удалить и создать», а блок moved объясняет, что объект тот же.

Проверь понимание: ты переименовал ресурс внутри модуля с notes на main. Что покажет plan и как исправить?

Ответ

Адрес поменялся, поэтому Terraform предложит удалить старый и создать новый (для ВМ это потеря машины). Исправление: блок moved { from = module.notes_vm.yandex_compute_instance.notes to = module.notes_vm.yandex_compute_instance.main }. Он сообщает, что ресурс тот же и просто сменил адрес.

Один код, много окружений: теперь решим, как их разделять, каталогами или workspaces.

Окружения: каталоги или workspaces

Окружение это полный независимый набор инфраструктуры (dev для проб, stage для репетиции, prod для настоящей работы). Нужно, чтобы эксперимент в dev не мог случайно изменить prod.

Каталоги: три отдельные комнаты с табличками на дверях, в каждой свой журнал. Workspaces: одна комната, а журналов три на одной полке, и ты сам помнишь, какой достал. Оговорка: в комнате с одной дверью проще всего ошибиться полкой.

Есть два подхода:

  • Каталоги: envs/dev/, envs/stage/. У каждого свой backend.tf с отдельным key и свой state, общий код лежит в модулях. Многословно, зато на экране видно, где ты (по пути в терминале), и права на state можно разделить: dev открыт всем, prod только CI.
  • Workspaces (рабочие пространства): один код и много state в одном backend, переключение командой terraform workspace select stage. Текущий workspace нигде в коде не виден, права на бакет общие.
flowchart LR
    subgraph K["Каталоги: видно по пути, где ты"]
        K1["envs/dev"] --> KS1["key = notes/dev/..."]
        K2["envs/stage"] --> KS2["key = notes/stage/..."]
    end
    subgraph W["Workspaces: видно только по workspace show"]
        W0["один каталог"] --> W1["workspace dev: env:/dev/..."]
        W0 --> W2["workspace stage: env:/stage/..."]
    end

Пример: Ты в envs/stage и случайно запустил terraform destroy. Худшее, что произойдёт: удалится stage. Тот же промах в workspace prod без проверки terraform workspace show удалит prod, потому что каталог и код те же самые.

Прикинь сам: Ты случайно запустил terraform destroy. В каком случае пострадает prod: если ты в каталоге envs/stage или в workspace prod?

В workspace prod: код и каталог те же, текущий workspace нигде не виден. В envs/stage удалился бы только stage, и это видно по пути в терминале.

Осторожно: «Workspaces это то же самое, что окружения». Workspace это только отдельный state. Различия в размерах, именах и адресах придётся задавать условиями в коде, и он быстро усложняется. Команды чаще выбирают каталоги, а workspaces оставляют для одинаковых временных копий (например, окружение на время ревью). В курсе идём через каталоги.

Главное: каталоги дают явное разделение (свой backend и свои права), workspaces дают только отдельный state; в курсе идём через каталоги.

Проверь понимание: зачем у dev и stage разные key в backend, если бакет один?

Ответ

Если ключ одинаковый, оба окружения будут читать и перезаписывать один и тот же state и «увидят» ресурсы друг друга: apply в stage начнёт менять или удалять ресурсы dev. Разные ключи дают разные независимые state в одном бакете.

У окружений есть общий риск: в state лежат секреты. Посмотрим, как их защитить.

Секреты и права вокруг state

Мы перенесли state в общий бакет, значит, теперь в нём лежат секреты для всех, у кого есть доступ к бакету. Удобство общего хранилища означает и общий риск: один неверно выданный доступ открывает пароли и ключи, которые Terraform записал в state.

Общий журнал на проходной, в котором, кроме списка окон, записаны коды от сейфов. Любой, кто может читать журнал, может и открыть сейфы. Поэтому журнал держат в закрытой комнате, а не на общем столе.

Защита state строится из четырёх слоёв:

  1. Права на бакет. Читать и писать state может только сервисный аккаунт Terraform и небольшая группа людей (урок 6.1: чем уже права, тем дешевле ошибка). Публичный доступ к бакету выключен.
  2. Шифрование. Хранилище шифрует объекты «на диске» (ключ управляется облачным сервисом KMS, строка в таблице соответствия AWS выше). Это защита от утечки самих дисков провайдера, но не от человека с правом чтения бакета.
  3. Версионирование. Старые версии state тоже содержат старые секреты, поэтому права на них те же.
  4. Минимум секретов в самом state. Учти: если Terraform сам прочитал пароль (через data-источник из хранилища секретов) или подставил его в metadata.user-data, значение всё равно попадёт в state. Надёжнее передавать ВМ только ссылку (например, id секрета), а сам пароль получать уже на ВМ из хранилища секретов (тема 9). Новые версии Terraform (1.10 и новее) умеют ещё «эфемерные» ресурсы, а с 1.11 «write-only» аргументы: такие значения в state не записываются, но работают они только там, где их поддерживает провайдер. Пока мы передаём пароль через TF_VAR_notes_db_password (урок 7.2), он окажется в state.

Пример: Проверка «открывается ли state без ключа». Без AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY любое обращение к бакету (terraform plan, yc storage s3api list-objects с пустым профилем) должно закончиться ошибкой доступа, а не содержимым файла. Если содержимое читается анонимно, бакет открыт всему интернету, и секреты из state считай утёкшими.

Прикинь сам: Бакет зашифрован на стороне хранилища. Может ли человек с правом чтения бакета увидеть пароль из state?

Может: хранилище расшифровывает объект для любого, кто имеет право чтения. Шифрование защищает диски провайдера, а не доступ.

Осторожно: «Бакет шифруется, значит, секреты в state в безопасности». Шифрование на стороне хранилища расшифровывается автоматически для любого, кто имеет право чтения. Защищает именно доступ, а не шифрование.

Главное: state с секретами защищают правами на бакет, отдельным узким ключом, закрытым публичным доступом и минимумом секретов внутри.

Проверь понимание: ты выдал сервисному аккаунту Terraform роль на весь каталог «чтобы точно работало». Что плохого, если его ключ утечёт?

Ответ

Злоумышленник получит доступ не только к бакету со state, но и ко всему каталогу: сможет создавать и удалять ВМ, читать данные, запускать платные ресурсы. Ключ для backend должен открывать только один бакет: тогда ущерб ограничен чтением state, а это уже повод сменить секреты в нём, но не потеря всего облака.

Бывает, что backend приходится менять. Как не потерять state при переезде, разберём дальше.

Смена backend: migrate-state и reconfigure

Backend не вечен: бакет переименовали, окружение разделили, key поправили. Terraform помнит, какой backend использовал в прошлый раз, и при несовпадении не гадает, куда ты хотел писать state. Иначе он мог бы молча начать работу с пустым state и предложить создать всё заново.

Переезд в другой офис. Перевезти архив со старого места (-migrate-state) или начать с пустых шкафов в новом (-reconfigure): решение нужно принять явно, оба варианта законны, но имеют разные последствия.

После init Terraform сохраняет настройки backend в .terraform/terraform.tfstate (служебный файл, не путай со state). Перед каждой операцией он сравнивает запись с backend.tf. Если они различаются, выдаётся Error: Backend configuration changed, и нужно запустить init с одним из флагов:

  • terraform init -migrate-state: скопировать state из старого места в новое. Если в новом месте уже что-то лежит, спросит, перезаписывать ли.
  • terraform init -reconfigure: просто переключиться на новый backend, ничего не копируя и забыв прежние настройки. Годится, когда state уже перенесён руками или не нужен.

Пример: Ты поменял в backend.tf key с notes/dev/terraform.tfstate на notes/dev2/terraform.tfstate. С -reconfigure Terraform пойдёт в новое место, найдёт там пустоту и решит, что ничего не создавал: plan предложит создать всё заново. С -migrate-state он сначала скопирует state в notes/dev2/..., и plan покажет No changes. Поэтому «не помогло, добавлю -reconfigure» не универсальный совет.

Прикинь сам: Ты поменял key в backend.tf и запустил init -reconfigure. Что покажет plan?

Предложит создать всё заново: по новому ключу state пуст, а -reconfigure ничего не копирует. Нужен был -migrate-state.

Осторожно: rm -rf .terraform как лекарство от этой ошибки. Каталог удалится, init пройдёт, но state при этом не скопирован: получится то же, что -reconfigure.

Главное: -migrate-state копирует state на новое место, -reconfigure просто переключает backend; выбирай осознанно.

Проверь понимание: в каком случае безопасен -reconfigure?

Ответ

Когда state в новом месте уже лежит (его перенесли руками или по смыслу это тот же объект, например сменился только способ подключения к тому же бакету). Если нужный state остался только в старом месте, -reconfigure даст пустую картину, и правильный флаг -migrate-state.

Иногда state приходится править напрямую. Для этого есть команды terraform state.

Ручные операции над state

Иногда state нужно поправить без изменения инфраструктуры: переименовать адрес, убрать ресурс из-под управления Terraform, посмотреть атрибуты. Для этого есть команды семейства terraform state.

Правка инвентарной книги без перестановки вещей на складе: переписать номер полки, вычеркнуть строку. Вещи остаются на месте, меняется только запись о них.

Основные команды:

  • terraform state list печатает адреса всех ресурсов (мы делали это в задании 2);
  • terraform state show <адрес> показывает атрибуты одного ресурса (осторожно: там могут быть секреты);
  • terraform state mv <откуда> <куда> меняет адрес (то же, что блок moved, но сразу и без следа в коде);
  • terraform state rm <адрес> убирает ресурс из state: объект в облаке остаётся жить, но Terraform о нём «забывает»;
  • terraform state pull печатает весь state (мы использовали его для страховки).

Любая запись в state идёт под блокировкой, а каждая запись это новая версия объекта в бакете: откат возможен.

Пример: Нужно временно вывести диск из-под Terraform, не удаляя его. terraform state rm yandex_compute_disk.data удалит запись. Следующий plan увидит в коде ресурс yandex_compute_disk.data, а в state его нет, и предложит создать диск заново. Значит, state rm нужно сочетать с удалением блока из кода (или блоком removed в новых версиях).

Прикинь сам: Ты выполнил terraform state rm yandex_compute_disk.data, а блок диска остался в коде. Что покажет следующий plan?

Предложит создать диск заново: в коде он есть, в state его нет. Сам диск в облаке не удалён и продолжает стоить денег.

Осторожно: «state rm удаляет ресурс». Он удаляет только запись, а сам объект в облаке продолжает работать и стоить денег. Для удаления самого ресурса нужен destroy (или убрать блок из кода и применить).

Главное: state rm удаляет только запись, state mv меняет адрес без следа в коде, а moved делает то же самое, но виден в Git.

Проверь понимание: чем terraform state mv отличается от блока moved?

Ответ

Результат тот же: ресурс сохраняется под новым адресом без пересоздания. Но state mv правит state немедленно и вручную, и в репозитории не остаётся следа, поэтому коллеги со своими копиями кода и state могут не узнать о переносе. Блок moved лежит в коде, попадает в git и применяется при обычном plan/apply в любом окружении.

А если запись о ресурсе потеряна совсем, а сам ресурс жив, пригодится импорт.

Import и потеря state

Terraform управляет только тем, что записано в его state. Ресурс, созданный руками в консоли, или весь объект после потери state для него не существует: apply попытается создать дубликат и упадёт на конфликте имён. Нужен способ сказать «вот этот объект облака соответствует этому блоку кода».

Инвентарная книга склада. Если вещь лежит на складе, но в книге её нет, никто не знает, что она есть. Импорт это запись в книгу: «вот этот ящик с номером 123 числится как блок X».

Два пути восстановления:

  1. Восстановить state из версии бакета (версионирование включают заранее). Самый быстрый способ: скопировать предыдущую версию объекта как текущую.
  2. Привязать существующие ресурсы заново. Старая форма: terraform import <адрес> <id>. С Terraform 1.5 можно записать блок import в коде, и привязка произойдёт при обычном apply:
import {
  to = module.notes_vm.yandex_vpc_network.notes   # адрес в коде
  id = "enp7example0000000000"                    # идентификатор объекта в облаке
}

Идентификатор берут из облака: yc vpc network list и аналогичные команды. Важно: импорт не пишет код. Он записывает в state реальные значения объекта, а описание ресурса в .tf ты создаёшь сам (с Terraform 1.5 есть помощник terraform plan -generate-config-out=файл.tf, который набрасывает заготовку, но её всё равно нужно проверять глазами; работает для блока import в корневом модуле, для ресурса внутри модуля код переноси вручную).

Пример: Ты импортировал сеть и запустил plan. Возможны два исхода:

  • No changes. Your infrastructure matches the configuration. - код совпадает с реальностью, работа закончена;
  • ~ update in-place с перечнем атрибутов - код отличается от реального объекта (например, в коде нет описания, а в облаке оно есть). Правишь код, пока не получишь No changes, либо осознанно применяешь, чтобы привести объект к коду.

Прикинь сам: После terraform import команда plan показывает ~ update in-place. Что это значит и что делать?

Код не совпадает с настоящим ресурсом: импорт записал в state реальные значения, а описание в .tf другое. Правь код, пока не получишь No changes.

Осторожно: «Import создаст ресурс». Нет, он не создаёт и не меняет ничего в облаке, только пишет строку в state. Второе: после потери state нельзя запускать apply «на пустом»: он создаст дубликаты. Сначала восстановление или импорт, затем plan.

Главное: import только пишет строку в state и ничего не создаёт и не пишет в код; после потери state сначала восстанавливают или импортируют, потом делают plan.

Проверь понимание: после terraform import команда plan показывает изменения. Что это значит?

Ответ

Код не совпадает с реальным ресурсом: импорт положил в state настоящие значения, а в коде другие. Правь код, пока plan не покажет No changes, либо осознанно применяй, если ресурс нужно привести к коду.

Остался вопрос воспроизводимости: откуда берутся провайдеры и как закрепить их версии.

Lock-файл, каталог .terraform и зеркало провайдеров

Провайдер (плагин, который умеет работать с API облака, урок 7.1) Terraform скачивает из интернета. Это создаёт две проблемы: версия через месяц может стать другой и сломать код, а сайт registry.terraform.io может быть недоступен (из России доступ к нему нестабилен).

Список покупок и магазин. Lock-файл это точный список «какой бренд, какой вес, контрольная пломба» (чтобы через месяц купить то же самое). Зеркало это второй магазин, где те же товары, когда первый закрыт.

Как это устроено:

  • .terraform.lock.hcl появляется после terraform init: записывает точную версию каждого провайдера и хеши (контрольные суммы) пакетов. При следующих init Terraform берёт именно эти версии и проверяет хеши, поэтому зеркало не сможет подложить другую сборку. Файл коммитят. Обновить версии осознанно: terraform init -upgrade.
  • .terraform/ это кэш: скачанные бинарники провайдеров и запись о настроенном backend. Собирается заново командой init, в git не нужен.
  • Зеркало провайдеров (provider mirror) это сервер с копией пакетов. Настраивается в файле ~/.terraformrc (он относится к твоему пользователю, а не к проекту) блоком provider_installation: network_mirror говорит, откуда качать провайдеры yandex-cloud/*, а direct оставляет остальные на прямую загрузку. Адрес зеркала меняется, поэтому в примере он помечен: проверь актуальный адрес в документации Yandex Cloud по Terraform.
flowchart TD
    I["terraform init"] --> A[".terraform.lock.hcl<br>какая версия и какие хеши"]
    I --> B["~/.terraformrc<br>откуда качать: зеркало или registry"]
    A --> D["качает провайдер, сверяет хеш"]
    B --> D
    D --> E["кладёт в .terraform/providers/"]

Пример: Без lock-файла через месяц init поставит провайдер новее, чем у коллеги, и один из вас увидит в плане лишние изменения. С lock-файлом оба получат 0.232.0. Если lock-файл составлен на macOS, а CI работает на Linux, в нём могут не оказаться хеши для Linux: тогда помогает terraform providers lock -platform=linux_amd64 -platform=darwin_arm64, который записывает хеши для нескольких платформ сразу.

Прикинь сам: Коллега закоммитил .terraform.lock.hcl, ты нет. Через месяц у вас разные версии провайдера. Почему?

Без lock-файла init берёт самую свежую версию в рамках ограничения из versions.tf, а у коллеги версия закреплена. План у вас покажет разные изменения.

Осторожно: Lock-файл не добавляют в .gitignore «потому что мешает»: он как раз и даёт воспроизводимость. Обратное тоже ошибка: коммитить .terraform/ (сотни мегабайт бинарников, которые и так скачаются).

Главное: lock-файл закрепляет версии и хеши провайдеров и лежит в Git; .terraform/ это кэш, его не коммитят; зеркало нужно, когда registry недоступен.

Проверь понимание: что произойдёт, если удалить .terraform.lock.hcl и запустить init через месяц?

Ответ

Terraform выберет самую новую версию провайдера, подходящую под ограничение в versions.tf (например, ~> 0.232 допускает все 0.x от 0.232 и выше), и напишет новый lock-файл. Версия может оказаться другой, чем у коллег и в CI, и plan покажет неожиданные изменения. Lock-файл нужен именно для того, чтобы версии не «плавали».

Соберём всё вместе: что происходит при одном plan в окружении с модулем и remote state.

Сквозной разбор: что происходит при plan с модулем и remote state

Соберём всё из урока в один запуск, чтобы части встали на места. Ты в envs/dev и выполняешь terraform plan.

1. init уже сделан: известен backend (бакет + ключ) и модуль ../../modules/notes-vm
2. plan берёт блокировку: создаёт notes/dev/terraform.tfstate.tflock в бакете
3. скачивает state: notes/dev/terraform.tfstate
4. применяет moved: если в state старые адреса, а в moved.tf есть блок, то
   в плане они отображаются как «has moved to ...»
5. раскрывает module "notes_vm": подставляет значения в variable модуля
   (my_ip_cidr, notes_db_password) и вычисляет ресурсы с адресами
   module.notes_vm.<тип>.<имя>
6. refresh: спрашивает API облака, как выглядит каждый ресурс сейчас
7. сравнивает код, state и облако, печатает план
8. снимает блокировку

Если на шаге 2 получена ошибка блокировки, дальше Terraform не идёт. Если на шаге 4 нет блока moved, на шаге 7 ты увидишь пары destroy и create. Если на шаге 5 не хватает значения переменной, план остановится с No value for required variable до обращения к облаку. Умение назвать шаг, на котором сломалось, это и есть диагностика.

Прикинь сам: На каком шаге plan остановится, если в модуль не передано значение обязательной переменной, и дойдёт ли он до облака?

На шаге 5, когда раскрывается модуль: будет No value for required variable. До обращения к облаку (шаг 6) дело не дойдёт.

Главное: умение назвать шаг, на котором сломался plan, и есть диагностика: блокировка, state, moved, модуль, refresh, сравнение.

Практика

Работаем в ~/notes/infra/terraform. Файлы versions.tf, providers.tf, network.tf, variables.tf, vm.tf, cloud-init.yaml.tftpl, outputs.tf и рабочий terraform.tfvars уже есть из уроков 7.1 и 7.2, а ресурсы из них созданы в облаке (сеть notes, подсеть notes, диск notes-data, группа notes-sg, ВМ notes-vm). Нужны Terraform, yc с настроенным профилем (урок 6.1), jq (программа для разбора JSON, урок 1.2) и SSH-ключ из урока 6.2. Если ты удалил ресурсы через terraform destroy после 7.2, сначала повтори apply из 7.2: нам нужно, чтобы в state было что переносить.

Трек без облака: вместо Object Storage поднимается MinIO (бесплатное S3-совместимое хранилище) в контейнере (версия не закреплена, проверь актуальный образ). Backend настраивается так же, меняется только endpoints.s3.

Задание 1. Бакет и ключ для state

Цель: создать бакет с версионированием и отдельный ключ, которым Terraform будет ходить в него.

Предскажи: можно ли создать бакет для state тем же Terraform? Ответ: нет, state бакета должен лежать в бакете, которого ещё нет (курица и яйцо), поэтому его создают вручную.

Шаги:

  1. Создай бакет. export кладёт значение в переменную окружения этого терминала, чтобы не набирать имя снова. yc storage bucket create --name ... просит облако создать бакет с таким именем. Имя уникально на весь Object Storage, поэтому замени CHANGE_ME своим суффиксом (например, инициалами и числом):
export TF_BUCKET="notes-tfstate-CHANGE_ME"
yc storage bucket create --name "$TF_BUCKET"
  1. Включи версионирование: оно хранит прошлые версии объектов, и при порче state есть путь назад.
yc storage bucket update --name "$TF_BUCKET" --versioning versioning-enabled
  1. Создай сервисный аккаунт для state. Это отдельная «учётка для программы», чтобы ключ Terraform открывал только бакет, а не всё облако:
yc iam service-account create --name notes-tf-state

Выдай ему права на бакет, а не на весь каталог. Флаг --grants задаёт разрешения на бакет, grantee-id это идентификатор аккаунта (поле id из yc iam service-account get). Точный синтаксис сверь с yc storage bucket update --help: он может меняться между версиями yc. Более грубая альтернатива: роль storage.editor на каталог, как в уроке 6.2 (проще, но шире, чем права на один бакет).

SA_ID="$(yc iam service-account get notes-tf-state --format json | jq -r .id)"
yc storage bucket update --name "$TF_BUCKET" \
  --grants "grant-type=grant-type-account,grantee-id=$SA_ID,permission=permission-full-control"

Разбор: $(...) подставляет вывод внутренней команды, jq -r .id достаёт поле id из JSON (-r убирает кавычки), обратный слэш \ в конце строки означает «команда продолжается на следующей».

  1. Выпусти статический ключ доступа и передай его окружению. Значения не печатай и не коммить. > перенаправляет вывод команды в файл, chmod 600 оставляет право чтения только тебе (урок 1.3):
yc iam access-key create --service-account-name notes-tf-state --format json > ~/.notes-tf-key.json
chmod 600 ~/.notes-tf-key.json
export AWS_ACCESS_KEY_ID="$(jq -r .access_key.key_id ~/.notes-tf-key.json)"
export AWS_SECRET_ACCESS_KEY="$(jq -r .secret ~/.notes-tf-key.json)"

Имена AWS_* это историческое наследие: backend s3 читает ключи из них, даже когда хранилище не Amazon.

Что должно получиться:

yc storage bucket get --name "$TF_BUCKET" --format json | jq '{name, versioning}'
{
  "name": "notes-tfstate-CHANGE_ME",
  "versioning": "VERSIONING_ENABLED"
}

ИИ: если yc выдал непонятную ошибку про права или имя бакета, вставь текст ошибки нейросети (без ключей): она подскажет, чего не хватает, но имена команд yc сверяй с yc storage --help.

Как читать вывод: name это имя твоего бакета. versioning должен быть VERSIONING_ENABLED. Если там VERSIONING_DISABLED, шаг 2 не сработал, и откатывать испорченный state будет не к чему. Полей у yc больше, мы выбрали два через jq '{name, versioning}'.

Объясни себе:

  1. Почему права выдаются на бакет, а не на весь каталог?
  2. Чем утечка этого ключа опаснее утечки одного terraform.tfstate без ключей?

Типичные ошибки:

  • ERROR: rpc error: code = AlreadyExists desc = Bucket already exists: имя занято кем-то другим. Добавь уникальный суффикс.
  • jq: command not found: не установлен jq. Поставь sudo apt install jq (Ubuntu) или brew install jq (macOS).
  • Ключ попал в git: считай его скомпрометированным, отзови (yc iam access-key delete) и выпусти новый.

Задание 2. Миграция state в бакет и проверка блокировки

Цель: перенести локальный state в бакет и убедиться, что второй параллельный запуск не пройдёт.

Предскажи: если в бакете по этому ключу уже лежит чужой state, что сделает terraform init -migrate-state?

Ответ

Terraform спросит, перезаписать ли существующий state локальным. Он ничего не склеивает. Ответ yes затрёт чужое состояние, поэтому читай вопрос и при сомнении отвечай no.

Шаги:

  1. В infra/terraform создай backend.tf. Каждая строка разобрана в теории («Remote backend»):
terraform {
  backend "s3" {
    bucket = "notes-tfstate-CHANGE_ME"   # то же имя, что в TF_BUCKET
    key    = "notes/dev/terraform.tfstate"
    region = "ru-central1"

    endpoints = {
      s3 = "https://storage.yandexcloud.net"
    }

    # блокировка файлом в бакете; полагайся на неё только после проверки ниже
    use_lockfile = true

    # проверки, специфичные для AWS, в Yandex не нужны
    skip_region_validation      = true
    skip_credentials_validation = true
    skip_requesting_account_id  = true
    skip_s3_checksum            = true
  }
}
  1. Перенеси state. Флаг -migrate-state говорит: «backend поменялся, скопируй существующий state в новое место». Ответь yes на вопрос о копировании:
terraform init -migrate-state
  1. Проверь, что state теперь в бакете. Первая команда просит бакет перечислить объекты, jq -r '.contents[].key' оставляет только имена; вторая показывает, какие ресурсы знает Terraform:
yc storage s3api list-objects --bucket "$TF_BUCKET" --format json | jq -r '.contents[].key'
terraform state list
  1. Проверь блокировку. Если изменений нет, apply завершится сразу, без вопроса, и блокировку удержать не выйдет. Поэтому сначала сделай безопасное изменение: в vm.tf в ресурс yandex_compute_instance.notes добавь метку labels = { env = "dev" } (метка, label, это пометка «ключ = значение» на ресурсе, работе ВМ она не мешает, а Terraform применит её на месте, без пересоздания). Потом в первом терминале запусти apply и не отвечай на вопрос Enter a value (Terraform ждёт ответа, удерживая блокировку):
terraform apply

Во втором терминале выставь те же AWS_* и TF_VAR_notes_db_password (тот же пароль, что в 7.2, иначе plan покажет изменение user-data) и запусти:

terraform plan
  1. В первом терминале ответь yes: метка безвредна, а код и state снова совпадут. Повтори plan во втором: теперь он проходит и пишет No changes.

Что должно получиться: после init -migrate-state (вывод сокращён, смотри на вопрос о копировании и последние строки):

Initializing the backend...
Do you want to copy existing state to the new backend?
  ...
  Enter a value: yes

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

terraform state list (порядок по алфавиту):

data.yandex_compute_image.ubuntu
yandex_compute_disk.data
yandex_compute_instance.notes
yandex_vpc_network.notes
yandex_vpc_security_group.notes
yandex_vpc_security_group_rule.egress
yandex_vpc_security_group_rule.ingress["http"]
yandex_vpc_security_group_rule.ingress["https"]
yandex_vpc_security_group_rule.ingress["ssh"]
yandex_vpc_subnet.notes

Во втором терминале, пока первый ждёт ответа:

Error: Error acquiring the state lock

Lock Info:
  ID:        3d2c6a1e-7f0b-4b5e-9d3a-0c6f7c1e2a44
  Path:      notes-tfstate-CHANGE_ME/notes/dev/terraform.tfstate
  Operation: OperationTypeApply
  Who:       user@laptop

Точный текст первой строки зависит от версии. Если второй plan прошёл без ошибки, хранилище не поддержало условную запись и use_lockfile тебя не защищает: запускай Terraform только из CI с очередью и не полагайся на флаг.

Как читать вывод: в выводе init главное Successfully configured the backend "s3": с этого момента state живёт в бакете. В list-objects должен быть notes/dev/terraform.tfstate (и, пока идёт apply, рядом notes/dev/terraform.tfstate.tflock: это и есть блокировка). В state list те же 10 строк, что были до переезда: если их меньше, миграция не прошла. В ошибке про блокировку смотри Who и Operation: они показывают, кто держит и что делает.

Объясни себе:

  1. Где физически лежит блокировка и что будет с ней, если процесс terraform убить?
  2. Почему skip_* флаги нужны для Yandex, но их не нужно копировать в конфиг для настоящего AWS?

Типичные ошибки:

  • Error: Failed to get existing workspaces: ... InvalidAccessKeyId: в этом терминале не выставлены AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY. Экспортируй их заново.
  • Error: Unsupported argument ... "endpoint": старый синтаксис. В Terraform 1.10 и новее пиши endpoints = { s3 = "..." }.
  • Error: Backend initialization required, please run "terraform init": после правки backend.tf не выполнен init.
  • plan показывает ~ update in-place у ВМ и user-data (sensitive value): в терминале другой TF_VAR_notes_db_password, не тот, с которым создавалась ВМ. Верни прежний пароль. Если его уже нет, такой apply безвреден для работающей машины (cloud-init повторно не запускается), но state запомнит новое значение.

Задание 3. Модуль notes-vm и окружение dev

Цель: вынести сеть, диск, группу безопасности и ВМ в модуль и вызвать его из envs/dev, не пересоздавая ресурсы.

Предскажи: если просто перенести файлы в модуль и вызвать его на том же state, что покажет plan: No changes или пересоздание? Что произошло с адресами ресурсов?

Ответ

Адреса стали вида module.notes_vm.yandex_vpc_network.notes. Для Terraform это новые ресурсы, а старые исчезли, поэтому plan предложит удалить всё и создать заново. Чтобы этого не случилось, нужны блоки moved (или terraform state mv).

Шаги:

  1. Сделай страховку и создай структуру. terraform state pull печатает текущий state, > пишет его в файл. mkdir -p создаёт каталоги (и не ругается, если они есть). git mv переносит файл и сообщает git, что это перенос. Файлы с секретами (terraform.tfvars) git не отслеживает, для них обычный mv:
cd ~/notes/infra/terraform
terraform state pull > ~/notes-state-before-module.json   # страховка перед переездом
mkdir -p modules/notes-vm envs/dev
git mv network.tf variables.tf vm.tf outputs.tf cloud-init.yaml.tftpl modules/notes-vm/
git mv versions.tf providers.tf backend.tf .terraform.lock.hcl terraform.tfvars.example envs/dev/
mv terraform.tfvars envs/dev/
rm -rf .terraform tfplan terraform.tfstate*

В последней строке удаляются старый кэш .terraform/, файл плана из 7.2 и локальные остатки state: всё нужное уже лежит в бакете. Правила .gitignore для terraform.tfvars и tfplan указывали на старый путь, обнови их:

printf '%s\n' 'infra/terraform/envs/*/terraform.tfvars' 'infra/terraform/envs/*/tfplan' >> ~/notes/.gitignore

Страховочный файл содержит секреты. Когда задание закончено, удали его: rm ~/notes-state-before-module.json.

  1. В modules/notes-vm файлы остались те же, что в 7.2, и менять в них почти ничего не нужно: variables.tf уже описывает входы (my_ip_cidr, ssh_public_key_path, image_family, vm_cores, data_disk_gb, notes_db_password), а outputs.tf выдаёт public_ip и data_disk_id. Это и есть интерфейс модуля. Шаблон cloud-init.yaml.tftpl лежит рядом с vm.tf, и путь ${path.module}/cloud-init.yaml.tftpl (path.module это каталог текущего модуля) продолжает работать.

  2. Создай вызов модуля, файл envs/dev/main.tf. Значения для входов модуля берутся из переменных корневого модуля (они объявлены ниже):

# Окружение dev вызывает типовой проект
module "notes_vm" {
  source = "../../modules/notes-vm"      # путь от этого каталога до модуля

  my_ip_cidr        = var.my_ip_cidr
  notes_db_password = var.notes_db_password
}

Дочернему модулю нужен свой versions.tf: каждый модуль сам говорит, откуда брать провайдера. Без него Terraform решит, что провайдер yandex принадлежит hashicorp (по умолчанию он ищет там), не найдёт его, и init упадёт. Создай modules/notes-vm/versions.tf (версию можно не указывать: её ограничивает корень):

terraform {
  required_providers {
    yandex = {
      source = "yandex-cloud/yandex"   # провайдер не из hashicorp, адрес указываем явно
    }
  }
}

Файл envs/dev/variables.tf (значения придут из terraform.tfvars и TF_VAR_notes_db_password, как в 7.2):

variable "my_ip_cidr" {
  type        = string
  description = "Внешний адрес с /32, откуда разрешён SSH"
}

variable "notes_db_password" {
  type        = string
  sensitive   = true
  description = "Пароль БД: только через TF_VAR, не в файле"
}

Файл envs/dev/outputs.tf: модуль отдаёт значения наружу только через output, а корневой модуль повторяет их, чтобы terraform output работал как раньше:

output "public_ip" {
  value = module.notes_vm.public_ip
}

output "data_disk_id" {
  value = module.notes_vm.data_disk_id
}
  1. Блоки moved лежат в корневом модуле, где менялись адреса. Файл envs/dev/moved.tf. Блок нужен для каждого управляемого ресурса (для data не нужен: это чтение, а не хранимый объект). Ресурс с for_each переносится одним блоком:
moved {
  from = yandex_vpc_network.notes
  to   = module.notes_vm.yandex_vpc_network.notes
}

moved {
  from = yandex_vpc_subnet.notes
  to   = module.notes_vm.yandex_vpc_subnet.notes
}

moved {
  from = yandex_compute_disk.data
  to   = module.notes_vm.yandex_compute_disk.data
}

moved {
  from = yandex_vpc_security_group.notes
  to   = module.notes_vm.yandex_vpc_security_group.notes
}

moved {
  from = yandex_vpc_security_group_rule.ingress
  to   = module.notes_vm.yandex_vpc_security_group_rule.ingress
}

moved {
  from = yandex_vpc_security_group_rule.egress
  to   = module.notes_vm.yandex_vpc_security_group_rule.egress
}

moved {
  from = yandex_compute_instance.notes
  to   = module.notes_vm.yandex_compute_instance.notes
}
  1. Установи модуль и проверь план. terraform init нужен, потому что появился блок module и backend теперь читается из нового каталога (state в бакете тот же, ключ не изменился):
cd envs/dev
export TF_VAR_notes_db_password="<тот же пароль, что в 7.2>"
terraform init
terraform plan
  1. Примени проверенный план: перемещения адресов записываются в state только командой apply (plan ничего не сохраняет). Перемещения не создают и не удаляют ресурсы, поэтому apply безопасен, если в плане нет will be destroyed:
terraform apply
terraform plan -detailed-exitcode; echo "код выхода: $?"

Должно быть No changes и код 0. Без apply проверка в задании 5 вернула бы код 2.

  1. Второе окружение делается по образцу: скопируй envs/dev в envs/stage без каталога .terraform (там запомнены настройки backend от dev, и после смены key init начал бы предлагать миграцию; например, cp -r envs/dev envs/stage && rm -rf envs/stage/.terraform), поменяй key в backend на notes/stage/terraform.tfstate, удали moved.tf (в stage переносить нечего). Чтобы имена ресурсов не конфликтовали с dev, в модуле добавь variable "name_suffix" (тип string, default = "") и замени имена в коде: "notes${var.name_suffix}" для сети и подсети, "notes-data${var.name_suffix}" для диска, "notes-sg${var.name_suffix}" для группы, "notes-vm${var.name_suffix}" в local.name. В envs/stage/main.tf передай name_suffix = "-stage". У dev значение по умолчанию пустое, поэтому его plan не изменится: проверь это, вернувшись в envs/dev. В stage запусти только init и plan; apply не нужен, он создал бы вторую ВМ за твои деньги. Модуль один, состояний два.

Что должно получиться: plan показывает перемещения, а не пересоздание (вывод сокращён до характерных строк):

Terraform will perform the following actions:

  # yandex_vpc_network.notes has moved to module.notes_vm.yandex_vpc_network.notes
  # yandex_vpc_subnet.notes has moved to module.notes_vm.yandex_vpc_subnet.notes
  # yandex_compute_instance.notes has moved to module.notes_vm.yandex_compute_instance.notes
  ...

Plan: 0 to add, 0 to change, 0 to destroy.

ИИ: нейросеть удобно просить написать moved.tf по списку адресов из terraform state list, но результат всегда проверяй командой plan: ждёшь 0 to destroy.

Как читать вывод: каждая строка X has moved to Y значит: Terraform понял, что это один и тот же объект, и только перепишет его адрес в state. Смотри на итог: 0 to add, 0 to destroy значит, что ничего не пересоздаётся. Если видишь will be destroyed и will be created для одной и той же ВМ или сети, значит для неё нет блока moved или в нём опечатка в адресе: не применяй, а дополни moved.tf. После apply строки про moved исчезнут, а plan будет показывать No changes.

Объясни себе:

  1. Почему moved пишут в корневом модуле, а не внутри вызываемого?
  2. Что будет с envs/stage, если ты поменяешь код модуля? Как защититься от этого в команде?

Типичные ошибки:

  • Error: Module not installed: после добавления блока module не выполнен terraform init.
  • Error: Unsupported argument ... An argument named "vm_cores" is not expected here: в вызове модуля есть аргумент, для которого в модуле нет variable.
  • Error: Reference to undeclared module: имя в ссылке (module.notes_vm) не совпадает с именем блока module.
  • Error: Missing required argument ... "my_ip_cidr" is required: обязательная переменная модуля не передана в блоке module.

Задание 4. Зеркало провайдеров и lock-файл

Цель: настроить установку провайдера через зеркало и зафиксировать версии.

Предскажи: какой файл появится после init и почему его коммитят, а каталог .terraform/ нет?

Ответ

Появится .terraform.lock.hcl с версиями и хешами провайдеров. Его коммитят, чтобы у всех и в CI стояли одни и те же версии. Каталог .terraform/ это скачанные бинарники и кэш, он собирается заново и в git не нужен.

Шаги:

  1. Создай ~/.terraformrc (файл настроек Terraform для всего твоего пользователя). Адрес зеркала в примере условный, проверь актуальный адрес в документации Yandex Cloud:
provider_installation {
  network_mirror {
    url     = "https://terraform-mirror.yandexcloud.net/"   # проверь актуальный адрес
    include = ["registry.terraform.io/yandex-cloud/*"]      # это качаем с зеркала
  }
  direct {
    exclude = ["registry.terraform.io/yandex-cloud/*"]      # всё остальное качаем напрямую
  }
}
  1. Пересобери провайдеры и проверь код без применения. rm -rf .terraform удаляет кэш, init -upgrade заново выбирает версии в рамках ограничений и обновляет lock-файл, fmt -recursive приводит оформление всех .tf под каталогом к стандарту, validate проверяет синтаксис и ссылки без обращения к облаку:
cd ~/notes/infra/terraform/envs/dev
rm -rf .terraform
terraform init -upgrade
terraform fmt -recursive ../..
terraform validate

Что должно получиться (вывод init сокращён: версия провайдера у тебя может быть новее):

Initializing the backend...
Initializing modules...
Initializing provider plugins...
- Finding yandex-cloud/yandex versions matching "~> 0.232"...
- Installing yandex-cloud/yandex v0.232.0...
...
Terraform has been successfully initialized!

После validate:

Success! The configuration is valid.

В git status .terraform.lock.hcl виден как изменённый или новый файл: его нужно добавить в коммит.

Как читать вывод: строка Installing yandex-cloud/yandex vX показывает выбранную версию, она должна попадать под ограничение из versions.tf. Отдельной строки про зеркало Terraform не печатает: о том, что оно работает, говорит сам факт успешной загрузки. Success! The configuration is valid. значит, что код синтаксически верен, но не гарантирует, что облако его примет (это покажет plan).

Объясни себе:

  1. Зачем в direct исключён провайдер Yandex, а остальные качаются напрямую?
  2. Что произойдёт, если удалить .terraform.lock.hcl и запустить init через месяц?

Типичные ошибки:

  • Error: Failed to query available provider packages ... no available releases match the given constraints: нужной версии провайдера нет на зеркале. Ослабь ограничение в versions.tf или смени зеркало.
  • Error: Failed to install provider ... context deadline exceeded: registry недоступен, а зеркало не подхватилось. Проверь опечатки в ~/.terraformrc.

Задание 5. Шаг проекта: инфраструктура «Заметок» в git

Цель: зафиксировать в ~/notes модуль, окружение dev и backend.

Предскажи: какие файлы из infra/terraform должны попасть в коммит, а какие нет?

Ответ

В коммит: modules/notes-vm/* (.tf и шаблон cloud-init), envs/dev/*.tf (включая backend.tf и moved.tf), terraform.tfvars.example и .terraform.lock.hcl. Не в коммит: .terraform/, *.tfstate*, рабочий terraform.tfvars с реальными значениями. Файл ключа ~/.notes-tf-key.json лежит вне репозитория.

Шаги:

  1. Проверь структуру. find ... -type f перечисляет файлы, -not -path '*/.terraform/*' пропускает кэш, sort упорядочивает вывод:
cd ~/notes
find infra/terraform -type f -not -path '*/.terraform/*' | sort
  1. Убедись, что dev не хочет ничего менять. Флаг -detailed-exitcode делает код выхода информативным: 0 изменений нет, 1 ошибка, 2 изменения есть. $? это код выхода последней команды:
cd infra/terraform/envs/dev
terraform plan -detailed-exitcode
echo "код выхода: $?"
  1. Закоммить. git status --short сначала покажет, что попадёт в коммит: убедись, что там нет tfvars и tfstate:
cd ~/notes
git status --short infra .gitignore
git add infra/terraform .gitignore
git commit -m "infra: модуль notes-vm, окружение dev, state в Object Storage"
git push

Что должно получиться: список файлов (порядок и набор могут отличаться; terraform.tfvars есть на диске, но .gitignore не пустит его в коммит):

infra/terraform/envs/dev/.terraform.lock.hcl
infra/terraform/envs/dev/backend.tf
infra/terraform/envs/dev/main.tf
infra/terraform/envs/dev/moved.tf
infra/terraform/envs/dev/outputs.tf
infra/terraform/envs/dev/providers.tf
infra/terraform/envs/dev/terraform.tfvars
infra/terraform/envs/dev/terraform.tfvars.example
infra/terraform/envs/dev/variables.tf
infra/terraform/envs/dev/versions.tf
infra/terraform/modules/notes-vm/cloud-init.yaml.tftpl
infra/terraform/modules/notes-vm/network.tf
infra/terraform/modules/notes-vm/outputs.tf
infra/terraform/modules/notes-vm/variables.tf
infra/terraform/modules/notes-vm/versions.tf
infra/terraform/modules/notes-vm/vm.tf

И результат плана:

No changes. Your infrastructure matches the configuration.
код выхода: 0

Код лежит в твоём репозитории ~/notes/infra/terraform.

Как читать вывод: в списке должны быть оба каталога, envs/dev и modules/notes-vm, а в корне infra/terraform никаких .tf не осталось. No changes и код 0 значат, что переезд в модуль ничего не поменял в облаке: так и должно быть при рефакторинге. Код 2 значит, что plan хочет что-то поменять: читай план, не игнорируй. В git status --short строки начинаются с R (перенос) или A (новый файл); ?? напротив terraform.tfvars или tfstate означает, что .gitignore их не закрыл.

Объясни себе:

  1. Что означает код выхода 2 у plan -detailed-exitcode?
  2. Почему backend.tf лежит в envs/dev, а не в модуле?

Типичные ошибки:

  • fatal: pathspec 'infra/terraform' did not match any files: ты не в репозитории ~/notes.
  • В git status виден terraform.tfstate или envs/dev/terraform.tfvars: в .gitignore нет нужного правила (шаблон *.tfstate* из урока 3.1, а для tfvars путь, добавленный в задании 3). Добавь шаблон и убери файл из индекса: git rm --cached.

Сломай и почини

Сценарии ручные: ломаешь командами, разбор в <details>. Работай в envs/dev.

Симптом

Выбери сценарий и воспроизведи:

  1. Конфликт state. В одном терминале запусти terraform apply и не отвечай, во втором сделай то же самое. Убей первый процесс через kill -9 и повтори terraform plan.
  2. Error: Backend configuration changed. Поменяй в backend.tf key на notes/dev2/terraform.tfstate и запусти terraform plan.
  3. Потеря state. Удали объект state из бакета: yc storage s3api delete-object --bucket "$TF_BUCKET" --key notes/dev/terraform.tfstate, затем terraform plan.

Гипотезы

  • Сценарий 1: блокировка осталась от убитого процесса, либо кто-то ещё реально работает.
  • Сценарий 2: state лежит в другом месте, а Terraform помнит старый backend в .terraform/.
  • Сценарий 3: Terraform видит пустой state и хочет создать всё заново.

Проверки

# объекты в бакете (файл блокировки, версии state)
yc storage s3api list-objects --bucket "$TF_BUCKET" --format json | jq -r '.contents[].key'
# что Terraform помнит о backend
jq '.backend.config' .terraform/terraform.tfstate

Разбор: первая команда перечисляет объекты бакета (в нём должны быть notes/dev/terraform.tfstate и, пока кто-то работает, .tflock). Вторая читает служебный файл .terraform/terraform.tfstate: это не твой state, а запись о том, какой backend Terraform запомнил при последнем init, и jq '.backend.config' достаёт из неё настройки backend.

Как читать вывод: если в бакете нет notes/dev/terraform.tfstate, state потерян (сценарий 3). Если .tflock есть, а процесса нет, блокировка зависла (сценарий 1). Если key в .backend.config не совпадает с backend.tf, вот причина сценария 2.

Исправление

Разбор сценариев

1. Блокировка от убитого процесса. Убедись, что никто больше не работает (спроси в чате, посмотри Who в сообщении). Затем сними блокировку по ID из ошибки:

terraform force-unlock 3d2c6a1e-7f0b-4b5e-9d3a-0c6f7c1e2a44

Дальше запусти plan: state мог остаться в промежуточном состоянии. force-unlock опасен, потому что разрешает параллельный apply. Хороший процесс: запуски идут только из CI с очередью.

2. Backend configuration changed. Terraform заметил, что описание backend не совпадает с запомненным, и не гадает, что ты хотел. Два честных варианта:

# перенести state в новое место
terraform init -migrate-state
# переключиться на другое место без копирования
terraform init -reconfigure

Ошибка защищает от ситуации, когда plan пойдёт по пустому state и предложит создать всё заново.

3. Потеря state. Сначала попробуй восстановить предыдущую версию объекта из бакета (см. list-object-versions, скачай нужную версию и загрузи её как текущую). Если версий нет, ресурсы живы в облаке, а Terraform о них не знает. Тогда код у тебя уже есть, остаётся импортировать:

terraform import module.notes_vm.yandex_vpc_network.notes "<id сети из yc vpc network list>"
terraform import module.notes_vm.yandex_compute_instance.notes "<id ВМ из yc compute instance list>"
terraform plan

Повторяй импорт для каждого ресурса, пока plan не покажет No changes. Вывод: версионирование бакета включают до аварии, а не после.

ИИ в помощь

Нейросеть быстро разбирает чужой plan и подсказывает блок moved или import, но она не видит твой бакет и state. Общие правила работы с ней: ИИ-помощник.

Задача: составить блок moved для переноса ресурсов в модуль.

Я перенёс ресурсы network.tf и vm.tf в модуль module "notes_vm". Вот список адресов из terraform state list:
<вставь адреса>. Напиши moved.tf, чтобы plan не пересоздавал ресурсы. Для for_each-ресурсов используй один блок на весь ресурс.

Проверь ответ: запусти terraform plan: должно быть 0 to add, 0 to destroy и строки has moved to. Типичная ошибка: нейросеть пишет блок moved в дочернем модуле или путает направление from и to.

Задача: понять, что делать с зависшей блокировкой.

terraform apply упал с Error acquiring the state lock. Вот поля Lock Info: <вставь без секретов>.
Как понять, жив ли процесс, и когда безопасно делать force-unlock?

Проверь ответ: убедись, что она советует сначала выяснить, кто держит блокировку (Who), а после разблокировки сделать plan. Типичная ошибка: совет сразу запускать с -lock=false, это отключает защиту.

Задача: проверить настройки безопасности state.

Вот мой backend.tf и список прав сервисного аккаунта: <вставь без ключей>. Что здесь опасно для state, который содержит секреты?

Проверь ответ: сверь с разделом про права вокруг state: права только на бакет, публичный доступ выключен, версионирование включено. Типичная ошибка: нейросеть считает шифрование хранилища достаточной защитой.

Словарик урока

Термин Простыми словами
remote backend (удалённое хранилище state) Общее место (у нас бакет), где Terraform хранит state вместо файла на ноутбуке.
backend s3 Вариант backend для S3-совместимых хранилищ: Yandex Object Storage, AWS S3, MinIO.
бакет (bucket) Хранилище файлов в облаке: «шкаф с ячейками», где объект достаётся по имени-ключу.
ключ объекта (key) Имя файла внутри бакета, например notes/dev/terraform.tfstate; слэши это часть имени, а не настоящие каталоги.
versioning (версионирование) Режим бакета, при котором прошлые версии объекта не стираются: испорченный state можно откатить.
state lock (блокировка) Табличка «занято»: пока один процесс меняет state, другие ждут или получают ошибку.
use_lockfile Параметр backend: блокировка файлом .tflock в том же бакете (Terraform 1.10 и новее).
force-unlock Команда, снимающая «зависшую» блокировку по её ID. Использовать, только убедившись, что владелец не работает.
-migrate-state Флаг init: скопировать существующий state из старого backend в новый.
-reconfigure Флаг init: переключиться на новый backend без копирования state.
статический ключ доступа Пара «идентификатор + секрет» сервисного аккаунта; действует, пока его не отозвали.
module (модуль) Каталог с .tf файлами, который вызывают блоком module: «типовой проект» с входами и выходами.
корневой модуль (root module) Каталог, из которого запускают terraform; у нас envs/dev.
дочерний модуль (child module) Модуль, который вызвали из другого; у нас modules/notes-vm.
source Поле блока module: откуда взять модуль (путь, git, реестр).
адрес ресурса Полное имя ресурса в state: тип.имя, для модуля с префиксом module.<имя>..
moved Блок кода: «этот ресурс не новый, он просто сменил адрес». Защищает от пересоздания при рефакторинге.
terraform state mv Команда, меняющая адрес ресурса прямо в state (то же, что moved, но без следа в коде).
окружение (environment) Независимый набор инфраструктуры: dev, stage, prod.
workspace Отдельный state внутри одного backend при том же коде.
terraform import / блок import Привязка уже существующего объекта облака к блоку кода; пишет только state, код пишешь сам.
.terraform.lock.hcl Файл с точными версиями и хешами провайдеров; коммитится в git.
.terraform/ Кэш: скачанные провайдеры и запись о backend; в git не кладут.
зеркало провайдеров (provider mirror) Сервер с копией пакетов провайдеров, если основной registry недоступен.
~/.terraformrc Пользовательский файл настроек Terraform (где скачивать провайдеры).
-detailed-exitcode Флаг plan: код выхода 0 (изменений нет), 1 (ошибка), 2 (изменения есть).
endpoint Адрес «окошка» сервиса (API или хранилища), к которому обращается программа.

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

Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [junior] [часто] Зачем хранить state Terraform удалённо и что такое блокировка?

Ответ

Локальный файл нельзя разделить с командой, и в нём лежат секреты. Удалённый state живёт в общем бакете с версионированием и доступом по правам. Блокировка не даёт двум apply работать одновременно: второй ждёт или падает с Error acquiring the state lock.

Что хотят услышать: секреты в state, общий источник правды, версионирование как страховка, lock, S3 плюс DynamoDB или use_lockfile.

Красный флаг: «храним state в git, репозиторий же приватный».

2. [junior] [часто] Как разделить окружения dev и prod: workspaces или каталоги?

Ответ

Каталоги с общими модулями: у каждого окружения свой backend, ключ и права, и видно, где я работаю. Workspaces компактнее, но легко перепутать текущий и применить изменения не туда, а права на бакет общие.

Что хотят услышать: плюсы и минусы обоих подходов, безопасность prod, права на state.

Красный флаг: «workspaces, потому что меньше файлов», без упоминания рисков.

3. [middle] Второй apply упал с Error acquiring the state lock. Что делаешь?

Ответ

Читаю сообщение: Who, Operation, время. Выясняю, работает ли владелец блокировки. Если запуск живой, жду. Если процесс убит или связь оборвалась, снимаю блокировку terraform force-unlock <ID> и сразу делаю plan, потому что state мог остаться в промежуточном состоянии.

Что хотят услышать: сначала выяснить, кто держит, потом force-unlock, после него plan, запуски через CI.

Красный флаг: «сразу -lock=false и применяю».

4. [middle] Кто-то удалил файл state из бакета. Что делаешь?

Ответ

Проверяю версионирование бакета и восстанавливаю предыдущую версию объекта. Если версий нет, ресурсы в облаке живы, а state пуст: сверяю код и делаю terraform import по каждому ресурсу, пока plan не покажет No changes. apply на пустом state не запускаю, иначе получу дубликаты или конфликт имён.

Что хотят услышать: версионирование, import, не запускать apply на пустом state, ограничение прав на запись в бакет.

Красный флаг: «создам всё заново, старые ресурсы удалю руками».

5. [middle] После переноса кода в модуль plan хочет удалить и создать ВМ. Почему и как исправить?

Ответ

Поменялись адреса ресурсов (появился префикс module.<имя>), а Terraform сопоставляет ресурсы по адресу. Исправляю блоками moved в корневом модуле или командой terraform state mv. После этого plan показывает только перемещение.

Что хотят услышать: адрес ресурса, moved, state mv, чтение плана до apply.

Красный флаг: «применил, ВМ пересоздалась, ничего страшного».

6. [middle] После правки backend.tf plan пишет Backend configuration changed. Чем -migrate-state отличается от -reconfigure?

Ответ

Terraform сравнил конфиг backend с запомненным в .terraform/ и требует явного решения. -migrate-state копирует state из старого места в новое. -reconfigure просто переключает на новый backend без копирования, это годится, когда state уже перенесли руками или он не нужен.

Что хотят услышать: защита от plan по пустому state, различие флагов, осторожность с вопросом «перезаписать?».

Красный флаг: «удалю .terraform/ и всё заработает» без понимания последствий.

7. [middle] Когда выносить код в модуль и как версионировать модули?

Ответ

Когда код повторяется и меняется вместе (dev и stage), а не на всякий случай. У модуля есть переменные и outputs, внутренности наружу не торчат. Версию фиксирую: для git ?ref=v1.2.0, для реестра version = "~> 1.2". Локальный source годится, пока модуль живёт в одном репозитории, но тогда правки видят все окружения, и выкатываю их через dev.

Что хотят услышать: интерфейс модуля, версионирование, риск «поправил модуль и сломал все окружения».

Красный флаг: модуль на один ресурс и ссылка на ветку main вместо тега.

8. [middle] Ресурс создан руками в консоли, его просят «взять под Terraform». Как?

Ответ

Пишу описание ресурса в коде, делаю terraform import <адрес> <id> или блок import (Terraform 1.5 и новее), потом plan и правлю код, пока не получу No changes. Применяю только после этого, чтобы не изменить ресурс неожиданно.

Что хотят услышать: import не пишет код, цель No changes, осторожность с apply.

Красный флаг: «удалю и создам заново», если это база с данными.

9. [middle] В CI нужен plan на каждый pull request. Какие подводные камни?

Ответ

Нужен доступ CI к state (для PR только чтение), ключи не хранятся в репозитории (OIDC или секреты CI), для последующего apply план сохраняют через plan -out. Блокировка берётся и на plan, поэтому параллельные PR могут мешать друг другу. Секреты в выводе плана надо маскировать.

Что хотят услышать: -out, права read-only, lock на plan, секреты в state и логах.

Красный флаг: «дадим CI админский ключ и отключим lock».

10. [middle] terraform init из РФ зависает на скачивании провайдера. Что делаешь?

Ответ

Проверяю сеть до registry, затем настраиваю зеркало в ~/.terraformrc: network_mirror для yandex-cloud/*, остальное напрямую. Хеши в lock-файле не дадут зеркалу подложить другую сборку. В CI использую тот же файл или кэширую каталог плагинов.

Что хотят услышать: .terraformrc, зеркало, кэш плагинов в CI, хеши в lock-файле.

Красный флаг: «качаю бинарник провайдера откуда попало и кладу руками».

11. [junior] Что коммитят из Terraform-каталога, а что нет?

Ответ

Коммитят .tf файлы и .terraform.lock.hcl (версии и хеши провайдеров, чтобы у всех и в CI были одни версии). Не коммитят .terraform/, *.tfstate* и *.tfvars с секретами.

Что хотят услышать: воспроизводимость версий, секреты в tfvars и state.

Красный флаг: «lock-файл в .gitignore, он мешает».

12. [junior] Что делают terraform state list, state show, state mv и state rm?

Ответ

Это команды для работы с state без изменения инфраструктуры. terraform state list показывает адреса ресурсов в state, terraform state show АДРЕС печатает атрибуты одного ресурса. terraform state mv меняет адрес ресурса в state, например после переименования или переноса в модуль, чтобы Terraform не пересоздавал его. terraform state rm убирает ресурс из state, сам ресурс в облаке остаётся, а Terraform о нём забывает. Перед mv и rm я делаю копию state (terraform state pull > backup.tfstate) и потом проверяю plan.

Что хотят услышать: работа только со state, mv для рефакторинга, rm не удаляет реальный ресурс, бэкап перед правкой, проверка plan после.

Красный флаг: «state rm удалит ВМ» или правка terraform.tfstate руками в редакторе.

13. [middle] Как передать вывод одного Terraform-проекта в другой и какие у terraform_remote_state минусы?

Ответ

Проект-источник объявляет output, а потребитель читает его через data "terraform_remote_state", указав тот же backend. Минус первый: потребителю нужен доступ на чтение к state источника, а state может хранить чувствительные данные. Минус второй: проекты жёстко связаны структурой outputs, переименовал output и сломал чужой plan. Часто проще взять нужное значение через обычный data source провайдера по имени или тегу, либо опубликовать его в общем хранилище параметров. Если связь всё же нужна, я оставляю в outputs только то, что реально читают соседи.

Что хотят услышать: output плюс remote_state, доступ к чужому state как риск, жёсткая связность, альтернатива через data sources.

Красный флаг: копирование значений руками из одного проекта в другой в tfvars.

14. [junior] Зачем ограничивать версии провайдера и что значит ~> 5.0?

Ответ

Провайдеры выпускают новые версии, и без ограничения завтрашний init может подтянуть версию с другим поведением. В required_providers я задаю ограничение version. Запись ~> 5.0 разрешает любые 5.x не ниже 5.0, но не 6.0, а ~> 5.1.0 разрешает только патчи 5.1.x. Фактически выбранные версии и их хэши фиксирует файл .terraform.lock.hcl, я его коммичу, чтобы у всей команды и CI были одни и те же версии. Обновляю осознанно: terraform init -upgrade, смотрю plan, коммичу новый lock-файл.

Что хотят услышать: ограничение в required_providers, смысл ~>, lock-файл в git, обновление только осознанно через -upgrade.

Красный флаг: не задавать версии вообще или удалять lock-файл, когда он мешает.

Проверено на версиях

  • Terraform: 1.16.4 (как в уроках 7.1 и 7.2; для use_lockfile нужна версия 1.10 или новее)
  • Провайдер yandex-cloud/yandex: 0.232.0 (в коде ~> 0.232)
  • Yandex Cloud CLI (yc): версия не закреплена, проверь актуальную версию на странице проекта
  • Ubuntu: 26.04 LTS и 24.04 LTS
  • Не прогонялось: работа с реальным бакетом Yandex Object Storage (создание, миграция, блокировка) и перенос ВМ в модуль в облаке. Выводы в заданиях 2, 3 и 5 сокращены и показывают формат, на строки которого нужно смотреть. Поддержку use_lockfile в Yandex Object Storage проверь сам (задание 2).
  • Формат команд yc storage bucket update --grants ... сверь с yc storage bucket update --help.

Итог урока: ты умеешь

  • умею создать бакет с версионированием и отдельным ключом сервисного аккаунта для state
  • умею перенести локальный state в S3-совместимый backend через terraform init -migrate-state
  • умею проверить, что блокировка работает, и снять зависшую через force-unlock
  • умею вынести сеть и ВМ в модуль и вызвать его из окружения без пересоздания ресурсов (moved)
  • умею развести dev и stage по каталогам с отдельными state
  • умею восстановить потерянный state из версии бакета или через terraform import
  • умею настроить зеркало провайдеров в ~/.terraformrc
  • умею объяснить на собеседовании разницу между workspaces и каталогами окружений

Дальше: Урок 7.4: Ansible: инвентарь, модули, ad-hoc

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 7 урок 7.3 2 ч курс 0/0 ← → уроки