✻ Урок 7.2 · Тема 7: IaC: Terraform и Ansible
Terraform: переменные, ВМ и outputs
Содержание урока
Зачем это нужно
В уроке 7.1 ты описал сеть и подсеть, но значения были вписаны прямо в код. Для одной ВМ это терпимо, а для трёх окружений превращается в копипасту: правишь один файл, забываешь другой. Так в «тестовой» копии легко оставить адрес боевой базы, и тест пойдёт в прод. Поэтому значения живут отдельно от кода.
Есть и вторая причина. IP-адрес созданной машины (её «номер» в сети, урок 2.1) нужен дальше всем: по нему заходят по SSH (защищённое подключение к удалённой машине), на него указывает DNS-имя (телефонная книга интернета: имя в адрес), к нему идёт деплой (выкладка новой версии приложения). Доставать его руками из консоли облака значит снова кликать.
На работе так выглядит каждый Terraform-репозиторий: variables.tf со входами, outputs.tf с выходами, а между ними ресурсы. Здесь же первая настоящая ловушка: секрет, попавший в state (файл, где Terraform помнит созданное, урок 7.1).
Шаг проекта: в infra/terraform/ появляются variables.tf, vm.tf, cloud-init.yaml.tftpl и outputs.tf. Они описывают ВМ notes-vm, диск данных, группу безопасности, а IP ты получаешь командой terraform output. Что здесь что:
- группа безопасности (security group): набор правил «кого пускать к ВМ и на какой порт», как список на проходной: пускаем только своих и только в нужную дверь. Без неё ВМ либо закрыта для всех, либо открыта всему интернету. Ты делал её руками в уроке 6.2, здесь описываешь кодом;
- cloud-init: программа внутри образа ВМ, которая при первом включении читает «записку» от облака и выполняет её: создать пользователя, положить файл. Без неё новую ВМ пришлось бы настраивать руками после создания;
cloud-init.yaml.tftpl: шаблон этой записки, текст с дырками, в которые Terraform подставляет значения (как бланк, куда вписывают имя и дату);- output (выход): значение, которое Terraform печатает после
apply, например IP. Как ответ «готово, адрес такой-то».
Что нужно знать
- Урок 7.1: IaC и Terraform: цикл
init,plan,apply, state, ссылки между ресурсами, сеть и подсетьnotes, которые мы используем здесь. Если забыл, что такое state, перечитай раздел про него. - Урок 6.2: ВМ, сеть и диски в облаке: то же самое ты уже делал руками через
yc: ВМ, диск данных, группа безопасности. Теперь это код. Ресурсы с теми же именами из 6.2 должны быть удалены, иначе облако откажет из-за занятого имени. - Урок 2.1: адреса и подсети: запись CIDR (адрес сети плюс длина маски через
/) вроде203.0.113.7/32и адрес0.0.0.0/0(«весь интернет»). - Урок 1.3: права и sudo: права
640 root:notesна конфиг, которые мы зададим через cloud-init.
Картина целиком
Представь рецепт борща, написанный не для одной кастрюли, а «на N порций». В рецепте есть параметры: сколько порций, есть ли мясо. Ты подставляешь их при готовке. Есть заготовки, которые считаются один раз (бульон нужен в трёх местах). А в конце рецепт сообщает результат: «готово, это 3 литра». Каталог Terraform работает так же. Аналогия ломается в одном: у рецепта нет «страховки», а у Terraform есть, например запрет удалять диск.
flowchart LR
V["variable<br>значения снаружи"] --> L["locals<br>заготовки"]
V --> R["resource<br>ВМ, диск, группа"]
D["data<br>найти образ ОС"] --> R
L --> R
R --> O["output<br>IP и id диска"]
LC["lifecycle<br>не удалять диск"] -.-> R
CI["cloud-init<br>что ВМ сделает сама"] -.-> R
Слева входы: переменные и вопрос к облаку (data). В середине ресурсы, к которым привязаны страховка lifecycle и записка cloud-init. Справа результат, который нужен дальше.
Отдельная нить урока: секрет (пароль БД) проходит через переменные, шаблон и ВМ. Мы проследим, где он окажется, и увидим, почему Terraform плохое место для хранения секретов.
Теория
Переменные: вход, который меняется без правки кода
Тебе нужны два окружения, тестовое и боевое, и они отличаются числом ядер и адресом. Копировать для этого весь каталог?
Подумай о настройках в телефоне: устройство одно, а яркость и громкость у каждого свои. Переменная (variable) в Terraform это такая настройка: код один, а значения приходят снаружи. Аналогия ломается тем, что у переменной нет «кнопки в меню»: значение надо передать до запуска, и способов несколько.
Переменная объявляется блоком variable "имя" { ... }. В блоке можно указать:
type: тип значения:string(текст),number(число),bool(trueилиfalse),list(...)(список),map(...)(набор пар «ключ = значение»);default: значение по умолчанию. Если его нет, переменная обязательная;description: подсказка для человека;validation: проверка значения (следующий раздел);sensitive = true: «не печатай значение на экран».
В коде значение читают как var.имя. Значения приходят из нескольких мест, и при конфликте побеждает более сильный источник:
flowchart LR
A["default<br>в блоке"] --> B["TF_VAR_имя<br>окружение"]
B --> C["terraform.tfvars<br>файл"]
C --> D["*.auto.tfvars<br>по алфавиту"]
D --> E["-var-file и -var<br>в команде"]
Слабее всего default, сильнее всего флаги в команде. Каждый следующий способ перекрывает предыдущие, как в кофейне: в меню написано «300 мл», ты сказал «побольше», а у кассы уточнил «нет, 400», и сделают последнее.
*.auto.tfvars это любые файлы, чьё имя заканчивается на .auto.tfvars: Terraform подхватывает их сам. Флаги -var и -var-file сами не подхватываются, их пишут в команде: terraform plan -var vm_cores=4.
Пример с числами для переменной vm_cores (в блоке default = 2):
| Что задано | Что покажет plan |
|---|---|
ничего, работает default |
cores = 2 |
TF_VAR_vm_cores=3 |
cores = 3 |
TF_VAR_vm_cores=3 и в terraform.tfvars vm_cores = 4 |
cores = 4 (файл сильнее окружения) |
то же и флаг -var vm_cores=8 |
cores = 8 (флаг сильнее всего) |
Теперь все источники сразу. В variables.tf у vm_cores стоит default = 2, в terraform.tfvars написано vm_cores = 4, в терминале выполнено export TF_VAR_vm_cores=8. Запуск terraform plan даст 4: файл сильнее окружения, окружение сильнее default. Запуск terraform plan -var vm_cores=16 даст 16: флаг сильнее всех. Итог виден в плане у ВМ как cores = 16.
Если у переменной нет default и значение не передали, интерактивный запуск спросит его в терминале, а запуск без вопросов (-input=false, так работает CI) упадёт с ошибкой:
Error: No value for required variable
on variables.tf line 1:
1: variable "my_ip_cidr" {
The root module input variable "my_ip_cidr" is not set, and has no default
value. Use a -var or -var-file command line argument to provide a value for
this variable.
Прикинь сам: в
terraform.tfvarsстоитvm_cores = 2, а в окруженииTF_VAR_vm_cores=4. Сколько ядер будет в плане?
Два: файл terraform.tfvars сильнее переменной окружения. Чтобы победило окружение, убери строку из файла или передай -var vm_cores=4.
Осторожно: переменные Terraform это не переменные bash. var.vm_cores живёт внутри Terraform, а в окружении оболочки появляется только то, что ты сам сделал через export TF_VAR_vm_cores=.... Ещё путают приоритет: «файл лежит рядом с кодом, значит главнее». Главнее то, что ближе к командной строке. Поэтому секрет через окружение работает, только когда этой переменной нет в файле (как у нашего notes_db_password).
Осторожно: переменная без default и без переданного значения не пустая, она отсутствует: plan переспросит или упадёт.
Главное: переменная отделяет «что настраивается» от «как устроено», а при конфликте побеждает источник, что ближе к командной строке:
-var, затем файлы, затем окружение, затемdefault.
Проверь понимание: в
terraform.tfvarsстоитvm_cores = 4, а ты запустилterraform plan -var vm_cores=8. Сколько ядер получит ВМ?
Ответ
Восемь. Флаг -var приоритетнее файла terraform.tfvars. Файл, в свою очередь, приоритетнее переменной окружения TF_VAR_vm_cores и default.
Значение можно передать любым способом. Но что, если оно неверное?
validation: проверка значения до обращения к облаку
Ты вписал адрес с опечаткой. Облако вернёт странную ошибку через минуту. А хуже: применит опасное значение, например откроет SSH всему интернету.
Вспомни форму на сайте, которая не даёт отправить телефон из пяти цифр. Блок validation внутри переменной делает то же самое. Аналогия ломается тем, что форма проверяет только вид значения, а не то, что телефон существует: так и validation не знает, твой ли это адрес.
В блоке два поля: condition (условие, должно быть истинным) и error_message (что написать, если ложное). Функция can(выражение) возвращает true, если выражение вычислилось без ошибки, и false, если упало. Так проверяют формат: cidrhost("203.0.113.7/32", 0) вычисляется, а cidrhost("hello", 0) падает.
Условие из задания 1: can(cidrhost(var.my_ip_cidr, 0)) && var.my_ip_cidr != "0.0.0.0/0". Знак && значит «и»: должны выполниться обе части. При my_ip_cidr = "0.0.0.0/0" первая часть верна (это валидный CIDR), вторая нет, поэтому вывод такой:
Error: Invalid value for variable
on variables.tf line 1:
1: variable "my_ip_cidr" {
├────────────────
│ var.my_ip_cidr is "0.0.0.0/0"
Нужен CIDR вроде 203.0.113.7/32, и не весь интернет.
This was checked by the validation rule at variables.tf:3,3-13.
Строка с рамкой показывает, какое значение не прошло. Дальше твой error_message, потом место правила.
Прикинь сам: что вернёт
can(cidrhost("hello", 0))и почему это полезно вvalidation?
false: строка hello не является CIDR, и cidrhost падает с ошибкой, а can превращает эту ошибку в false. Без can пользователь увидел бы техническую ошибку функции вместо твоего понятного error_message.
Осторожно: validation защитит только от тех ошибок, что ты придумал заранее. Срабатывает она на plan, но не на terraform validate: та команда не знает значений переменных.
Главное:
validationостанавливает опечатку и опасное значение наplan, понятным текстом и до обращения к облаку.
Часть значений особенная: их нельзя показывать никому. Разберём, как с ними обращаться.
sensitive и путь секрета
Пароль базы попал в переменную. Вывод plan в логе CI читают все, у кого есть доступ к проекту. Как не показать пароль?
Представь, что закрасил номер карты маркером на копии документа: сама карта осталась у тебя в кармане. Пометка sensitive = true заставляет Terraform печатать (sensitive value) вместо значения в plan, apply и output. Аналогия ломается тем, что закрашена только копия, которую показали другим, а оригинал лежит в state.
Пометка передаётся дальше по цепочке: всё, что построено из секретной переменной (в том числе текст user-data целиком), тоже считается секретным. Но в файл terraform.tfstate значение записывается как есть, открытым текстом, и в файл плана tfplan (terraform plan -out=tfplan) тоже.
Пример с числами: пароль p/w+1= из TF_VAR_pw подставлен в шаблон. План печатает input = (sensitive value), а поиск по state находит p%2Fw%2B1%3D в двух местах (в атрибуте и в его копии-выводе). Пароль виден в файле, хотя в терминале был скрыт.
Откуда берётся значение: TF_VAR_имя в окружении оболочки. Оно живёт до закрытия терминала и не попадает ни в какой файл в репозитории. Поэтому секрет передают так, а не через terraform.tfvars, который легко закоммитить по ошибке.
Прикинь сам: переменная помечена
sensitive = true. Можно ли теперь коммититьterraform.tfstateв Git?
Нельзя. sensitive скрывает значение только в выводе команд, а в state оно лежит как есть, и любой, кто прочитает файл, увидит секрет. Поэтому *.tfstate* в .gitignore, а в командной работе state живёт в закрытом бакете (урок 7.3).
Осторожно: путают две вещи. «Метка sensitive шифрует»: нет, она скрывает вывод. «В приватном репозитории можно коммитить state»: нельзя, историю Git читают многие, а секрет из истории не убрать.
Главное:
sensitiveпрячет секрет от экрана, но не из state и не из файла плана; секрет передают черезTF_VAR_..., а не через файл в репозитории.
Теперь научимся убирать повторение и доставать результаты наружу.
locals и output: заготовки и результат
В трёх местах нужен один и тот же префикс имён. Писать его три раза значит однажды забыть поправить четвёртый. А после apply нужен IP машины, и искать его в консоли не хочется.
Для повторяющегося есть locals: это записка на холодильнике «в этом месяце платим 5000», на которую ссылаются все счета. Блок locals { имя = выражение } объявляет заготовки, читают их как local.имя (единственное число, local, а не locals). Для результата есть output, итоговая строка в квитанции: блок output "имя" { value = ... }. Аналогия ломается в одном: locals снаружи изменить нельзя, это внутренняя кухня, а значения output снаружи прочитать можно.
Terraform печатает output после apply, а прочитать его позже можно командой terraform output имя. Ключ -raw печатает строку без кавычек, ключ -json печатает всё в формате JSON:
$ terraform output public_ip
"203.0.113.55"
$ terraform output -raw public_ip
203.0.113.55
Первая форма подходит человеку, вторая скрипту: строка сразу пригодна для ssh ubuntu@$(terraform output -raw public_ip). Форма -json вместе с jq достаёт отдельные поля, когда outputs сложные (карта, список).
Прикинь сам: зачем скрипту деплоя нужен ключ
-raw?
Без него значение печатается в кавычках ("203.0.113.55"), и кавычки попадут в команду ssh как часть адреса. -raw печатает чистую строку.
Осторожно: output тоже подчиняется sensitive, и секрет через него в логи не передают, а в state он лежит открытым.
Главное:
localsхранят заготовку, посчитанную один раз, аoutputотдаёт результат наружу (-rawдля скриптов).
Откуда же брать значения, которые создал не ты, например свежий образ ОС?
data source: прочитать существующее вместо ручного id
Тебе нужен образ Ubuntu для ВМ. Вписать его идентификатор руками? Образ обновляется, id меняется, и код устаревает.
Ресурс это «построить дом», а data это «узнать адрес существующей почты»: ты не строишь почту, только спрашиваешь, где она. Аналогия ломается тем, что узнавать можно только про то, что уже есть.
Блок data "тип" "имя" { ... } читает существующее и выдаёт атрибуты, как ресурс, а читают их как data.тип.имя.атрибут. Он выполняется во время plan и ничего не создаёт и не удаляет. В задании 2 data "yandex_compute_image" "ubuntu" ищет самый свежий образ по семейству (family): название линейки образов вроде ubuntu-2404-lts.
Явный depends_on = [...] нужен редко: когда связь есть, а ссылки в коде нет (например, ресурс должен появиться после выдачи роли, id которой он не использует).
Пример с числами. Сегодня семейство ubuntu-2404-lts возвращает образ с id = A, через месяц выйдет обновление, и то же имя вернёт id = B. В state у ВМ записан A. Следующий plan увидит, что нужен B, и посчитает это изменением: смена образа загрузочного диска означает пересоздание (-/+, forces replacement), то есть замену рабочей машины без повода. Решение это блок lifecycle, о нём ниже.
Прикинь сам: чем
resource "yandex_vpc_network" "x"отличается отdata "yandex_vpc_network" "x"?
resource создаёт сеть и отвечает за её жизнь. data находит уже существующую сеть и даёт её атрибуты (например id), но не создаёт и не удаляет.
Осторожно: data ничего не создаёт. Если объекта нет, plan завершится ошибкой, а не создаст его.
Главное:
dataчитает чужое или существующее вместо ручного id, но «мир» может измениться между запусками, и тогдаplanпокажет изменение.
Теперь о том, как сделать несколько одинаковых ресурсов, не копируя блоки.
count и for_each: несколько одинаковых ресурсов
Нужны три правила группы безопасности: порты 22, 80 и 443. Копировать блок три раза значит повторяться и забывать править один из них.
Представь два способа выстроить людей. count это очередь с номерами: №0, №1, №2. for_each это список по фамилиям: Иванов, Петров, Сидоров. Уберёшь человека из середины очереди, и все номера за ним сдвинутся. Уберёшь Петрова из списка, Иванов и Сидоров останутся на месте. Аналогия ломается тем, что фамилии должны быть уникальны и известны заранее.
count = N создаёт N экземпляров с адресами имя[0], имя[1], …, внутри используют count.index. for_each = карта создаёт по одному экземпляру на ключ, адреса имя["ключ"], внутри each.key и each.value. В state экземпляры адресуются этими именами.
Пример с числами на локальном ресурсе terraform_data. Создали три экземпляра по списку портов [22, 80, 443], потом убрали 80 из середины: [22, 443]. План при count:
# terraform_data.rule[1] will be updated in-place
~ input = 80 -> 443
# terraform_data.rule[2] will be destroyed
(because index [2] is out of range for count)
Plan: 0 to add, 1 to change, 1 to destroy.
Terraform не удалил «80», а переписал экземпляр №1 (был 80, стал 443) и удалил №2. Для правил безопасности это лишнее изменение работающей защиты. Та же правка при for_each по карте {ssh=22, http=80, https=443} без ключа http:
# terraform_data.rule["http"] will be destroyed
Plan: 0 to add, 0 to change, 1 to destroy.
Только то, что нужно. Поэтому для наборов разнородных вещей (правила, пользователи) берут for_each, а count оставляют для «создать 0 или 1»: count = var.enabled ? 1 : 0 (запись условие ? да : нет).
Ограничение for_each: ключи должны быть известны на этапе plan. Значения могут быть неизвестны до apply, а ключи нет: Terraform обязан заранее знать, сколько экземпляров создавать. Ошибка при попытке взять ключом id ещё не созданного ресурса:
Error: Invalid for_each argument
on main.tf line 8, in resource "terraform_data" "broken":
8: for_each = toset([terraform_data.disk.id])
├────────────────
│ terraform_data.disk.id is a string, known only after apply
The "for_each" set includes values derived from resource attributes that
cannot be determined until apply, ...
Прикинь сам: три правила SG созданы через
countпо списку[22, 80, 443]. Ты убрал порт 80 из середины. Что покажетplan?
Индекс 1 теперь занимает порт 443, а индекс 2 исчезает. Terraform покажет изменение правила 1 (с 80 на 443) и удаление правила 2 вместо простого удаления порта 80. Для правил на живой инфраструктуре это лишние изменения и риск короткого разрыва. С for_each по карте удалилось бы только http.
Осторожно: count и for_each не взаимозаменяемы. Различие проявляется при изменении списка, а не при создании.
Главное: для набора разных вещей бери
for_each(адрес по ключу),countоставь для «0 или 1»; ключиfor_eachдолжны быть известны уже наplan.
Одно неверное yes может стереть диск с данными. Что страхует от этого?
lifecycle: страховка от разрушения
По умолчанию Terraform выполняет любой подтверждённый план, и одно неверное yes удалит диск с данными. Нужны предохранители.
Это защитная крышка на красной кнопке. Блок lifecycle { ... } внутри ресурса меняет поведение Terraform. Аналогия ломается тем, что крышка защищает только от нажатия именно этой кнопки, а не от того, что кто-то выключит здание рубильником (консоль облака).
prevent_destroy = true: любой план, который удалит ресурс (включаяterraform destroy), завершится ошибкой. Ставим на диск с данными;create_before_destroy = true: при замене новый объект создаётся до удаления старого. Нужен, когда простой недопустим;ignore_changes = [атрибут, ...]: Terraform не считает расхождением изменения этих атрибутов. Ставим на то, что меняется само (новый образ в семействе) или что нельзя менять пересозданием.
Вывод terraform destroy с prevent_destroy:
Error: Instance cannot be destroyed
on main.tf line 1:
1: resource "terraform_data" "disk" {
Resource terraform_data.disk has lifecycle.prevent_destroy set, but the plan
calls for this resource to be destroyed. To avoid this error and continue
with the plan, either disable lifecycle.prevent_destroy or reduce the scope
of the plan using the -target option.
Terraform построил план удаления, увидел защищённый ресурс и отказался что-либо делать: ничего не удалено.
В задании 2 у диска данных стоит prevent_destroy, а при подключении к ВМ указано auto_delete = false. Это две разные защиты: первая не даёт удалить диск Terraform, вторая не даёт облаку удалить диск вместе с ВМ.
Прикинь сам: зачем на диск данных ставить и
prevent_destroy, иauto_delete = false?
prevent_destroy не даёт Terraform удалить диск. auto_delete = false не даёт облаку удалить диск вместе с ВМ, когда ВМ пересоздаётся. Для дополнительного диска это значение и так по умолчанию, но явная запись читается как намерение.
Осторожно: prevent_destroy защищает только от Terraform. Из консоли диск удалить можно, а из кода блок можно просто убрать. Это ремень безопасности, не сейф.
Главное:
lifecycleставит предохранители:prevent_destroyпротив удаления,create_before_destroyпротив простоя,ignore_changesпротив лишних расхождений.
Проверь понимание: на диск повесили
prevent_destroy = true. Можно ли теперь удалить диск?
Ответ
Через Terraform нет, пока не снять защиту из кода. Из консоли облака удалить можно: prevent_destroy это страховка от ошибочного destroy, а не сейф.
Теперь опишем доступ к ВМ из сети.
Группа безопасности: правила входа и выхода
ВМ с публичным адресом видна всему интернету. Кому и куда разрешать подключаться?
Представь список на проходной: «в дом номер 22 пускать только Иванова, в дом номер 80 пускать всех». Это группа безопасности (security group, SG): набор правил, кто и куда может подключаться. Без неё либо закрыто всё, либо открыто всё. Аналогия ломается тем, что список работает на входе в сетевой интерфейс ВМ, а не внутри системы, поэтому файрвол на самой ВМ по-прежнему нужен.
Правило состоит из направления (ingress входящий трафик, egress исходящий), протокола (TCP), порта и списка адресов-источников в записи CIDR (см. урок 2.1). Запись 203.0.113.7/32 значит «ровно один адрес», а 0.0.0.0/0 значит «любой адрес в интернете».
В задании 2 три входящих правила: SSH (22) только с твоего адреса (var.my_ip_cidr), HTTP (80) и HTTPS (443) со всего интернета, чтобы работал будущий сайт. Исходящее правило одно, разрешающее всё: серверу нужны пакеты и образы. Итого 4 правила плюс сама группа.
Прикинь сам: что значит правило с адресом
203.0.113.7/32и портом 22?
Подключаться по SSH к ВМ разрешено только с одного адреса, 203.0.113.7. С остальных адресов соединение блокируется группой безопасности.
Осторожно: соблазн открыть SSH всем «на минутку» опасен. Боты сканируют весь интернет, и открытый SSH подвергается перебору паролей в первые минуты. Поэтому в validation из задания 1 запрещён 0.0.0.0/0.
Главное: SSH открывай только со своего адреса (
/32), а на весь интернет (0.0.0.0/0) выставляй только то, что должно быть публичным (80 и 443).
ВМ создана и защищена, но пока пустая. Как её настроить при первом запуске?
cloud-init, templatefile и секрет в state
Свежая ВМ пустая. Нужно, чтобы при первом запуске в ней появился конфиг и пользователь, и чтобы это делалось автоматически, а не через ручной вход.
Это записка для нового сотрудника, которую он прочитает в первый рабочий день: «создай учётку, положи конфиг сюда». Аналогия ломается тем, что во второй день записку уже никто не читает, поэтому поменять её задним числом нельзя.
Облако передаёт ВМ текст в поле user-data (метаданные ВМ), а программа cloud-init, которая уже стоит в образе, при первой загрузке читает его и выполняет. Формат: YAML с первой строкой #cloud-config. Раздел write_files создаёт файлы, runcmd запускает команды.
Чтобы вставить пароль в шаблон, используется функция templatefile("путь", { имя = значение }): она читает файл-шаблон и подставляет ${имя}. Внимание: ${db_password} в шаблоне это синтаксис Terraform, а не bash, и подставляет его Terraform до отправки в облако.
Путь пароля в задании 2:
flowchart TD
E["TF_VAR_notes_db_password<br>переменная окружения"] --> V["var.notes_db_password"]
V -->|"urlencode()"| T["шаблон<br>cloud-init.yaml.tftpl"]
V --> S["state<br>открытым текстом"]
T --> M["metadata.user-data ВМ<br>видна в консоли облака"]
S --> P["файл tfplan<br>если делали -out"]
Секрет начинает путь в окружении, а заканчивает минимум в трёх местах: в state, в файле плана и в метаданных ВМ.
urlencode() нужна, потому что пароль попадёт в URL postgresql://notes:ПАРОЛЬ@..., а символы /, +, = в URL ломают разбор. Пример с числами: urlencode("a/b+c=") даёт a%2Fb%2Bc%3D.
Полностью проблему в Terraform не убрать. Её снижают: state в закрытом бакете (7.3), секрет, который сервис берёт из хранилища во время работы (тема 9), и учебный пароль, который меняется после первого входа. В проекте это записано как долг: «state Terraform с секретами», закрывается в 7.3.
Прикинь сам: ты поправил файл
cloud-init.yaml.tftplи сделалapply. Изменится ли конфиг на работающей ВМ?
Нет: cloud-init выполняет user-data только при первом запуске ВМ. Terraform обновил метаданные, но машина их заново не читает. Нужно либо пересоздать ВМ осознанно (-replace), либо менять конфиг живой машины другим инструментом (Ansible).
Осторожно: правка cloud-init.yaml.tftpl не перенастроит живую ВМ. Настройку живых машин делают Ansible-ом (урок 7.4).
Главное: cloud-init настраивает ВМ один раз при первом запуске, а секрет, прошедший через шаблон, оказывается в state, в файле плана и в метаданных ВМ.
Осталось разобраться, в каком порядке Terraform всё это создаёт.
Порядок создания: как Terraform понимает, что за чем
ВМ нельзя создать раньше подсети, а правило группы безопасности раньше самой группы: им не к чему привязаться. Порядок ты руками не пишешь. Откуда его берёт Terraform?
Подумай о строительстве дома: фундамент, стены, крыша. Прораб не идёт по списку сверху вниз, он видит, что «стены стоят на фундаменте», и сам выстраивает очередь. Аналогия ломается в одном: Terraform берётся за независимые части одновременно (по умолчанию до десяти действий параллельно).
Как это устроено:
- Terraform читает все
.tfфайлы каталога как один набор и порядок файлов и строк не учитывает. - Когда в одном ресурсе стоит ссылка на другой (
subnet_id = yandex_vpc_subnet.notes.id), появляется зависимость (dependency): «сначала подсеть, потом ВМ». Она называется неявной, потому что ты её не объявлял, она следует из ссылки. - Из всех зависимостей строится граф: точки это ресурсы, стрелки это «нужно после». Ресурсы без стрелок между ними создаются параллельно.
- При
destroyочередь идёт в обратную сторону: сначала ВМ, потом подсеть.
flowchart TD
N["vpc_network.notes"] --> SU["vpc_subnet"]
N --> SG["security_group.notes"]
DK["compute_disk.data"]
SU --> VM["compute_instance.notes"]
SG --> VM
DK --> VM
Диск, подсеть и группа безопасности не зависят друг от друга, поэтому создаются одновременно (группа и подсеть только после сети). ВМ ждёт всех троих.
В ВМ есть security_group_ids = [yandex_vpc_security_group.notes.id]. Значение .id появится только после создания группы. Пока группы нет, Terraform пишет в плане (known after apply): «узнаю после создания». Поэтому ключи for_each нельзя строить из таких значений: ключи нужны уже при plan, а id будет только при apply.
Для случая, когда ссылки нет, а порядок важен, есть аргумент depends_on = [ресурс]. В нашем коде он не нужен: все связи видны через ссылки.
Прикинь сам: ты поменял местами файлы
network.tfиvm.tf. Изменится ли порядок создания ресурсов?
Нет: порядок строится по ссылкам между ресурсами, а не по расположению в файлах.
Осторожно: порядок блоков в файле не задаёт порядок создания. И depends_on «на всякий случай» ставить не стоит: лишние зависимости замедляют работу и запутывают код. Сначала попробуй выразить связь ссылкой.
Главное: порядок создания следует из ссылок между ресурсами, независимое делается параллельно, а удаляется всё в обратном порядке.
Почти всё в выражениях Terraform делается небольшими встроенными функциями. Посмотрим на те, что встретятся в практике.
Функции Terraform: маленькие помощники в выражениях
В конфигурации часто нужно превратить одно значение в другое: раскрыть ~ в путь, прочитать файл, подставить значения в шаблон. Писать для этого свой код не нужно.
Это кнопки инженерного калькулятора: нажал, подал число, получил результат. В HCL встроены функции (functions): свои писать нельзя, набор фиксирован, но он покрывает почти всё нужное. Аналогия ломается тем, что функции Terraform работают ещё со строками, списками и картами, а не только с числами.
Вызов выглядит как имя(аргумент, аргумент), результат подставляется туда, где нужно значение. Проверять функции удобно в terraform console: вводишь выражение, видишь результат, ничего не создаётся. Функции из нашего кода:
| Вызов | Что делает | Результат |
|---|---|---|
pathexpand("~/.ssh/id_ed25519.pub") |
заменяет ~ на домашний каталог |
/home/user/.ssh/id_ed25519.pub |
file("путь") |
читает файл целиком в строку | содержимое ключа |
templatefile("путь", {имя = значение}) |
читает шаблон и подставляет ${имя} |
готовый текст |
toset(["a", "b", "a"]) |
превращает список в множество без повторов | ["a", "b"] |
urlencode("a/b+c=") |
экранирует спецсимволы для URL | a%2Fb%2Bc%3D |
Рядом стоит path.module: это не функция, а встроенное значение, каталог текущего .tf файла. Без него путь к шаблону зависел бы от того, откуда запустили Terraform.
Прикинь сам: что будет, если написать
file("~/.ssh/key.pub")безpathexpand?
Terraform ищет каталог с буквальным именем ~ и падает с no file exists at: раскрывать ~ сам он не умеет.
Осторожно: templatefile не выполняет любой код. Он только подставляет значения и понимает простые %{ if } и %{ for }. Поэтому db_password в него передают отдельным аргументом: шаблон это отдельный файл, он не видит переменные сам, и по вызову сразу видно, какие данные попадут в файл.
Главное: функции Terraform преобразуют значения прямо в выражениях;
pathexpand,file,templatefileиurlencodeнужны уже в этом уроке.
Последняя тема урока: что делать, когда облако и код разошлись.
Дрейф: когда облако и код разошлись
Рано или поздно кто-то поправит правило в консоли руками «по-быстрому». Код об этом не знает. Что покажет plan?
Подумай о чертеже квартиры и самой квартире: жильцы снесли стену, чертёж остался прежним. Пока никто не сверяет, всё выглядит нормально. Аналогия ломается тем, что Terraform сверяет сам при каждом plan. Расхождение, появившееся вне Terraform, называется дрейфом (drift).
Как это устроено:
planсначала обновляет знание о реальности: спрашивает облако о текущем состоянии каждого ресурса из state (это называется refresh, «освежить»).- Затем сравнивает результат с кодом.
- Разницу показывает как изменения:
~у изменяемого атрибута,+у руками удалённого ресурса.
Кто-то в консоли открыл порт 8080 для всех, в коде такого правила нет. Следующий plan предложит убрать лишнее. apply уберёт правило, и всё, что на нём держалось, сломается. Поэтому сначала выясняют, зачем правили: если правка нужна, её переносят в код обычным pull request.
Прикинь сам:
planпоказал изменение, хотя код не менялся. Назови две возможные причины.
Первая: ресурс поменяли вне Terraform (дрейф). Вторая: облако само обновило атрибут, например image_id при выходе нового образа в семействе; поэтому в нашем коде стоит ignore_changes.
Осторожно: plan смотрит не только в код. Он сверяет три вещи: код, state и облако (см. урок 7.1). А дрейф не «поломка Terraform»: это сигнал, что процесс позволил обойти код.
Главное: дрейф это правка мимо кода;
planего видит и предлагает вернуть по коду, поэтому сначала разберись, зачем правили.
Соответствие AWS
| Что делаем | Terraform (Yandex Cloud) | AWS |
|---|---|---|
| ВМ | yandex_compute_instance |
aws_instance (EC2) |
| Диск | yandex_compute_disk |
aws_ebs_volume |
| Группа безопасности | yandex_vpc_security_group и yandex_vpc_security_group_rule |
aws_security_group и aws_vpc_security_group_ingress_rule |
| Поиск образа | data "yandex_compute_image" (family) |
data "aws_ami" (filter, most_recent) |
| Cloud-init | metadata = { user-data = ... } |
user_data |
| Публичный IP | network_interface { nat = true } |
associate_public_ip_address или Elastic IP |
Практика
Все команды запускаются в ~/notes/infra/terraform/. Нужны файлы из 7.1: versions.tf, providers.tf, network.tf. В network.tf сеть и подсеть называются yandex_vpc_network.notes и yandex_vpc_subnet.notes, авторизация провайдера идёт через переменные окружения (YC_TOKEN, YC_CLOUD_ID, YC_FOLDER_ID), как в 7.1. Если открыл новый терминал, выполни export из 7.1 заново.
На треке без облака читай задания как разбор кода и проверяй их через terraform validate; apply там пропусти, локальная замена ВМ описана в уроке 6.2. OpenTofu (tofu) принимает те же файлы и те же команды.
Что проверено, а что нет: весь код заданий 1-3 (файлы variables.tf, vm.tf, outputs.tf, шаблон) прошёл terraform fmt -check и terraform validate с провайдером Yandex Cloud, а поведение переменных, for_each, prevent_destroy, templatefile и outputs проверено на локальных ресурсах. Создание ВМ в облаке (apply, SSH) не прогонялось: вывод таких команд ниже приведён по формату Terraform, значения в нём примерные.
Задание 1. Переменные и tfvars без секретов в Git
Цель: вынести настраиваемые значения в variables.tf, а конкретные значения хранить там, где Git их не увидит.
Предскажи:
- Что произойдёт, если запустить
terraform planбез значения дляmy_ip_cidr? - Что произойдёт при
my_ip_cidr = "0.0.0.0/0"?
Ответ
- У переменной нет
default: Terraform в интерактивном режиме спросит значение, а с флагом-input=false(как в CI) упадёт сNo value for required variable. - Сработает
validation: план остановится с текстомerror_messageдо обращения к облаку.
Шаги:
- Создай
variables.tf. Разбор: уmy_ip_cidrиnotes_db_passwordнетdefault, значит они обязательные;validationпроверяет формат адреса,sensitive = trueпрячет пароль из вывода.
variable "my_ip_cidr" {
description = "Твой адрес в формате CIDR, откуда разрешён SSH"
type = string
validation {
condition = can(cidrhost(var.my_ip_cidr, 0)) && var.my_ip_cidr != "0.0.0.0/0"
error_message = "Нужен CIDR вроде 203.0.113.7/32, и не весь интернет."
}
}
variable "ssh_public_key_path" {
description = "Путь к публичному SSH-ключу"
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "image_family" {
description = "Семейство образа ОС (для Ubuntu 26.04 найди имя командой yc compute image list --folder-id standard-images)"
type = string
default = "ubuntu-2404-lts"
}
variable "vm_cores" {
description = "Число vCPU"
type = number
default = 2
}
variable "data_disk_gb" {
description = "Размер диска данных, ГБ"
type = number
default = 10
}
variable "notes_db_password" {
description = "Пароль БД «Заметок» (только через TF_VAR или tfvars вне git)"
type = string
sensitive = true
}
- Узнай свой внешний адрес и создай
terraform.tfvars. Разбор команд:curl -s https://ifconfig.meпечатает твой внешний адрес (-sубирает индикатор загрузки),cat > файл <<'TFV' ... TFVзаписывает текст между метками в файл (кавычки вокругTFVотключают подстановки),openssl rand -base64 24генерирует 24 случайных байта в виде текста,exportкладёт значение в окружение этого терминала:
curl -s https://ifconfig.me
cat > terraform.tfvars <<'TFV'
my_ip_cidr = "203.0.113.7/32"
TFV
# Пароль не пишем в файл: передаём через окружение, только в этой сессии
export TF_VAR_notes_db_password="$(openssl rand -base64 24)"
В my_ip_cidr подставь свой адрес и допиши /32 («ровно один адрес»).
- Добавь рабочий файл значений в
.gitignore(правило*.tfstate*есть с урока 3.1) и проверь оба пути.printf '%s\n' строка >> файлдописывает строку в конец файла,git check-ignore -v путьпоказывает, какое правило.gitignoreсработало для пути. В репозиторий вместо рабочего файла кладём пример без реальных значений:
cd ~/notes
printf '%s\n' 'infra/terraform/terraform.tfvars' >> .gitignore
git check-ignore -v infra/terraform/terraform.tfvars infra/terraform/terraform.tfstate
cd ~/notes/infra/terraform
cat > terraform.tfvars.example <<'TFV'
# Скопируй в terraform.tfvars и подставь свои значения
my_ip_cidr = "203.0.113.7/32"
TFV
terraform fmt
Что должно получиться:
.gitignore:14:infra/terraform/terraform.tfvars infra/terraform/terraform.tfvars
.gitignore:6:*.tfstate* infra/terraform/terraform.tfstate
Номера строк у тебя будут другие.
Как читать вывод: формат строки файл:номер строки:правило<TAB>путь. Если для пути строки нет, значит ни одно правило его не игнорирует и файл попадёт в коммит. Обе проверки нашли правило: рабочий tfvars и state защищены.
Чтобы увидеть работу validation, попробуй terraform plan -var my_ip_cidr=0.0.0.0/0: будет ошибка Invalid value for variable с текстом из error_message (реальный вывод разобран в теории).
ИИ: попроси нейросеть написать
variables.tfпо описанию, но проверь, что уnotes_db_passwordстоитsensitive = true, а в коде нет значения пароля: модели любят вписать «пример» прямо вdefault.
Объясни себе:
- Почему пароль передан через
TF_VAR_, а не записан вterraform.tfvars? - Зачем в Git лежит
.tfvars.example, если рабочий файл игнорируется?
Типичные ошибки:
Error: No value for required variable: не задана переменная безdefault: добавь её вterraform.tfvarsили экспортируйTF_VAR_....git check-ignoreмолчит и завершается с кодом 1: правило не совпало с путём: проверь путь относительно корня репозитория.
Задание 2. ВМ, диск, группа безопасности и cloud-init как код
Цель: описать то, что в 6.2 ты создавал командами yc, и прочитать план.
Предскажи:
- Сколько ресурсов покажет
planв строкеPlan: N to add, если сеть и подсеть из 7.1 уже созданы? - Появится ли пароль в выводе
plan?
Ответ
- Семь: диск (1), группа безопасности (1), три правила ingress и одно egress (4), ВМ (1).
- Нет. Пароль входит в
user-data, а он собран изsensitiveпеременной, поэтому Terraform помечает весь атрибут как(sensitive value).
Шаги:
- Создай
vm.tf. Комментарии в коде объясняют каждый блок; главное:for_eachстроит по правилу на каждый ключ картыlocal.ingress_rules,prevent_destroyзащищает диск,ignore_changesне даёт смене образа пересоздать ВМ,core_fraction = 20означает «гарантированно 20% процессорного времени» (дешевле, для учёбы достаточно).
# Образ Ubuntu ищем по семейству: свежий id без правки кода
data "yandex_compute_image" "ubuntu" {
family = var.image_family
}
locals {
name = "notes-vm"
# Правила входящего трафика: ключ это имя правила, индексы не сдвигаются
ingress_rules = {
ssh = { port = 22, cidrs = [var.my_ip_cidr] }
http = { port = 80, cidrs = ["0.0.0.0/0"] }
https = { port = 443, cidrs = ["0.0.0.0/0"] }
}
}
# Диск данных живёт отдельно от ВМ и защищён от случайного destroy
resource "yandex_compute_disk" "data" {
name = "notes-data"
type = "network-ssd"
zone = yandex_vpc_subnet.notes.zone
size = var.data_disk_gb
lifecycle {
prevent_destroy = true
}
}
resource "yandex_vpc_security_group" "notes" {
name = "notes-sg"
network_id = yandex_vpc_network.notes.id
}
resource "yandex_vpc_security_group_rule" "ingress" {
for_each = local.ingress_rules
security_group_binding = yandex_vpc_security_group.notes.id
direction = "ingress"
description = "notes ${each.key}"
protocol = "TCP"
port = each.value.port
v4_cidr_blocks = each.value.cidrs
}
# Наружу разрешено всё: серверу нужны пакеты и образы
resource "yandex_vpc_security_group_rule" "egress" {
security_group_binding = yandex_vpc_security_group.notes.id
direction = "egress"
description = "notes egress"
protocol = "ANY"
from_port = 0
to_port = 65535
v4_cidr_blocks = ["0.0.0.0/0"]
}
resource "yandex_compute_instance" "notes" {
name = local.name
zone = yandex_vpc_subnet.notes.zone
platform_id = "standard-v3"
resources {
cores = var.vm_cores
memory = 2
core_fraction = 20
}
boot_disk {
initialize_params {
image_id = data.yandex_compute_image.ubuntu.id
size = 20
}
}
secondary_disk {
disk_id = yandex_compute_disk.data.id
auto_delete = false
}
network_interface {
subnet_id = yandex_vpc_subnet.notes.id
nat = true
security_group_ids = [yandex_vpc_security_group.notes.id]
}
metadata = {
ssh-keys = "ubuntu:${file(pathexpand(var.ssh_public_key_path))}"
user-data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
db_password = urlencode(var.notes_db_password)
})
}
# Новый образ в семействе не должен пересоздавать живую ВМ
lifecycle {
ignore_changes = [boot_disk[0].initialize_params[0].image_id]
}
}
Разбор мест, где легко ошибиться: pathexpand() раскрывает ~ в домашний каталог (без неё file("~/...") ищет каталог с буквальным именем ~); file() читает файл целиком; metadata - набор пар, которые облако передаёт ВМ; network_interface[0] - первая (и единственная) сетевая карта, счёт с нуля.
- Создай шаблон
cloud-init.yaml.tftpl. Разбор:write_filesсоздаёт файл/etc/notes/notes.envсо строкой подключения к БД (пароль подставит Terraform вместо${db_password}),runcmdсоздаёт системного пользователяnotes, каталог данных и выдаёт группеnotesправо читать конфиг (доступы как в уроке 1.3: конфиг640 root:notes, данные750 notes:notes). Адрес БД127.0.0.1:5432здесь условный: упражнение учит шаблонам и секретам, локальный PostgreSQL на ВМ не ставится. В теме 6 БД это Managed-кластер (FQDN кластера, порт 6432), в уроках 7.5 и 7.6 это сервисdbв compose; в реальном стенде подставляй адрес оттуда.
#cloud-config
# Выполняется один раз, при первом запуске ВМ
write_files:
- path: /etc/notes/notes.env
permissions: "0600"
owner: root:root
content: |
PORT=8080
STORE=postgres
DATABASE_URL=postgresql://notes:${db_password}@127.0.0.1:5432/notes
runcmd:
# Системный пользователь и каталог данных
- groupadd --system notes
- useradd --system --gid notes --home-dir /var/lib/notes --shell /usr/sbin/nologin notes
- install -d -m 0750 -o notes -g notes /var/lib/notes
# Конфиг читают root и группа notes
- chgrp notes /etc/notes/notes.env
- chmod 0640 /etc/notes/notes.env
- Проверь код и сохрани план.
-out=tfplanзаписывает план в файл, чтобыapplyвыполнил именно его. Файл содержит секреты, в Git его не кладём: добавьtfplanв.gitignore:
printf '%s\n' 'infra/terraform/tfplan' >> ~/notes/.gitignore
terraform fmt
terraform validate
terraform plan -out=tfplan
Что должно получиться:
Success! The configuration is valid.
...
+ metadata = {
+ "ssh-keys" = <<-EOT
ubuntu:ssh-ed25519 AAAA...
EOT
+ "user-data" = (sensitive value)
}
...
Plan: 7 to add, 0 to change, 0 to destroy.
Строка Success! получена реальным запуском validate. Полный plan с облаком не прогонялся: детали (id, образ) зависят от твоего облака.
Как читать вывод:
"user-data" = (sensitive value)- пароль скрыт в плане, хотя он внутри: метка секретности передалась от переменной к шаблону;Plan: 7 to add- семь новых ресурсов: диск, группа, четыре правила, ВМ (сеть и подсеть уже есть из 7.1);- если бы увидел
to destroyу сети или подсети, значит вnetwork.tfчто-то изменилось: остановись и найди причину.
Объясни себе:
- Почему правила SG вынесены в отдельный ресурс с
for_each, а не записаны блоками внутри SG? - Зачем диску
prevent_destroy, а ВМignore_changesнаimage_id? - В каких трёх местах окажется пароль после
apply?
Типичные ошибки:
Error: Reference to undeclared resourceпроyandex_vpc_subnet.notes: вnetwork.tfиз 7.1 ресурсы названы иначе: приведи ссылки к своим именам.Error: Invalid function argument ... no file exists at: не найден публичный ключ: проверьls ~/.ssh/*.pubи путь вssh_public_key_path. Если ключа нет, создай:ssh-keygen -t ed25519(урок 1.7).- Облако отвечает, что ВМ, диск или группа с именем
notes-vm,notes-dataилиnotes-sgуже существуют: остались ресурсы из 6.2. Удали их черезyc(yc compute instance delete notes-vm, аналогично для диска и группы) и повтори. - Ошибка про сочетание платформы, числа ядер и
core_fraction(standard-v3допускает не любые комбинации): оставь 2 ядра и 20%.
Задание 3. Output, apply и вход по SSH
Цель: применить план, получить IP из output и зайти на ВМ, не открывая консоль облака.
Предскажи: сразу после apply запустить terraform plan. Что он покажет?
Ответ
No changes. Your infrastructure matches the configuration. Код и state совпали (идемпотентность). Если план показывает изменения без правок кода, ищи атрибут, который облако меняет само: повод для ignore_changes.
Шаги:
- Создай
outputs.tf:
output "public_ip" {
description = "Публичный IP ВМ notes-vm"
value = yandex_compute_instance.notes.network_interface[0].nat_ip_address
}
output "data_disk_id" {
description = "Id диска данных"
value = yandex_compute_disk.data.id
}
- Составь план заново (файл изменился), примени и зайди по SSH. Разбор:
terraform apply tfplanвыполняет сохранённый план без вопросаyes;-rawпечатает значение без кавычек;$(...)подставляет адрес в команду;-o StrictHostKeyChecking=accept-newсам добавляет отпечаток нового сервера вknown_hostsпри первом подключении (урок 1.7); в кавычках после адреса - команда, которуюsshвыполнит на ВМ:cloud-init status --waitждёт конца первой настройки,sudo ls -lпоказывает права конфига:
terraform plan -out=tfplan
terraform apply tfplan
terraform output public_ip
ssh -o StrictHostKeyChecking=accept-new ubuntu@"$(terraform output -raw public_ip)" \
'cloud-init status --wait; sudo ls -l /etc/notes/notes.env'
terraform plan
- Проверь выражения в
terraform console(интерактивная строка Terraform; выход: Ctrl+D). Это чтение без изменений. Введи по строке:
terraform console
data.yandex_compute_image.ubuntu.name
urlencode("a/b+c=")
local.ingress_rules.ssh.port
Что должно получиться:
Apply complete! Resources: 7 added, 0 changed, 0 destroyed.
Outputs:
data_disk_id = "fhm1abcd2efg3hijk4lm"
public_ip = "203.0.113.55"
status: done
-rw-r----- 1 root notes 96 Sep 29 10:12 /etc/notes/notes.env
No changes. Your infrastructure matches the configuration.
Id диска, адрес и время у тебя будут другие. В консоли ожидай строку с именем образа, "a%2Fb%2Bc%3D" и 22 (значения urlencode и обращения к local проверены реальным запуском).
Как читать вывод:
Resources: 7 added- совпадает с тем, что показалplan;status: done- cloud-init закончил, ВМ настроена (runningзначит «ещё работает»,errorзначит сбой);-rw-r----- 1 root notes- права640, владелецroot, группаnotes: секрет читают только они;- последний
planпустой: раз ничего не менялось,ignore_changesсработал и образ не считается расхождением.
ИИ: если
sshне пускает, вставь нейросети текст ошибки и правила группы безопасности (без ключей). Она быстро назовёт причину, но адресmy_ip_cidrпроверь сам командойcurl -s https://ifconfig.me.
Объясни себе:
- Чем
terraform output -rawудобнее обычногоterraform outputв скрипте? - Что покажет
terraform plan, если вручную удалить правило SG в консоли?
Типичные ошибки:
ssh: connect to host 203.0.113.55 port 22: Operation timed out: SSH закрыт группой безопасности:my_ip_cidrэто уже не твой адрес (провайдер выдал другой): поменяй значение и сделайapply.ubuntu@203.0.113.55: Permission denied (publickey).: в ВМ попал другой ключ: проверьssh_public_key_pathили укажи ключ явноssh -i ~/.ssh/id_ed25519 ....
Задание 4. Шаг проекта: код инфраструктуры в репозитории и уборка
Цель: зафиксировать код в Git без секретов и проверить, что защита диска работает.
Предскажи: сработает ли terraform destroy на такой конфигурации?
Ответ
Нет. Terraform построит план удаления, дойдёт до yandex_compute_disk.data и остановится с ошибкой Instance cannot be destroyed (prevent_destroy). Это защита, а не поломка. Сообщение проверено на локальном ресурсе.
Шаги:
- Зафиксируй код и убедись, что секретов в коммите нет.
git ls-filesпечатает отслеживаемые Git файлы,grep -E 'a|b'ищет любое из двух слов,||выполняет правую команду, только если левая ничего не нашла:
cd ~/notes
git add infra/terraform/variables.tf infra/terraform/vm.tf \
infra/terraform/cloud-init.yaml.tftpl infra/terraform/outputs.tf \
infra/terraform/terraform.tfvars.example infra/terraform/.terraform.lock.hcl .gitignore
git commit -m "infra: ВМ notes-vm, диск, группа безопасности и outputs в Terraform"
git ls-files infra/terraform | grep -E 'tfstate|terraform.tfvars$' || echo "чисто"
git status --short
- Увидь срабатывание защиты:
cd infra/terraform
terraform destroy
- Чтобы не платить за учебные ресурсы, закомментируй
prevent_destroyвvm.tf(осознанно, на время) и повториterraform destroy, затем проверьyc compute instance listиyc compute disk list. Верниprevent_destroy = trueдо коммита.
Что должно получиться:
чисто
...
Error: Instance cannot be destroyed
...
Destroy complete! Resources: 9 destroyed.
Девять это семь ресурсов урока плюс сеть и подсеть из 7.1. В списках yc не должно остаться notes-vm и notes-data. Если хочешь оставить сеть для следующего урока, она пересоздаётся одной командой apply, так что удалять всё безопасно.
Как читать вывод:
чисто- в списке отслеживаемых файлов нет state и рабочегоtfvars;- пустой
git status --short- всё закоммичено, ничего лишнего; - ошибка
Instance cannot be destroyedи ни одногоDestroying...выше неё - защита сработала до начала удаления.
Объясни себе:
- Почему
.terraform.lock.hclкоммитится, а.terraform/иterraform.tfstateнет? - Чем
destroyбезprevent_destroyопасен для диска с данными?
Типичные ошибки:
Error: Instance cannot be destroyed: сработалprevent_destroy: это ожидаемо; снимай блок осознанно.- В
git statusвиденterraform.tfvars: правило.gitignoreне подхватило файл: проверь путь и выполниgit rm --cached infra/terraform/terraform.tfvars.
Сломай и почини
Сценарии выполняются вручную, командами и правками из текста. Скриптов break.sh для этого урока нет: всё делается правками кода. Исходное состояние: применённый код из задания 3 (если ты уже сделал destroy, подними его заново командой terraform apply). Ошибки сценариев 1 и 2 проверены на локальных ресурсах с теми же конструкциями, поведение в Yandex Cloud устроено так же, потому что вычисляет их сам Terraform до обращения к облаку.
Симптом
- Ты хочешь создать правило SG для каждого диска, и получаешь
Error: Invalid for_each argumentна этапеplan. planпоказывает-/+ destroy and then create replacementдляnotes-vm, хотя код не менялся.- Ревьюер открыл Pull Request и говорит, что в репозитории лежит рабочий пароль БД.
Сценарий 1 воспроизведи временным ресурсом yandex_vpc_security_group_rule.broken с for_each = toset([yandex_compute_disk.data.id]).
Для сценария 2 закомментируй lifecycle { ignore_changes ... } у ВМ и сравни id образа: terraform console и data.yandex_compute_image.ubuntu.id против terraform state show yandex_compute_instance.notes. Для сценария 3:
grep -c 'DATABASE_URL' terraform.tfstate
Разбор: grep -c считает строки с совпадением, terraform.tfstate это файл состояния из 7.1 (Terraform хранит в нём всё, что знает о созданных ресурсах).
Гипотезы
- Сценарий 1: значение неизвестно до
apply; опечатка в имени ресурса; неверный тип аргумента. - Сценарий 2: изменился атрибут, который нельзя поменять на месте; кто-то правил ВМ в консоли; в семействе вышел новый образ.
- Сценарий 3: секрет попал в
tfvars, в user-data, в state; поможет лиsensitive?
Проверки
- Сценарий 1: прочитай ошибку целиком, там сказано «cannot be determined until apply».
- Сценарий 2: найди в плане атрибут с пометкой
# forces replacement. - Сценарий 3:
git ls-files | grep -E 'tfvars|tfstate',git log -p -S'DATABASE_URL' --oneline, иgrepпо state из симптома.
Исправление
Разбор сценариев
1. Invalid for_each argument. Ошибка выглядит так:
Error: Invalid for_each argument
on vm.tf line 118, in resource "yandex_vpc_security_group_rule" "broken":
118: for_each = toset([yandex_compute_disk.data.id])
The "for_each" set includes values derived from resource attributes that
cannot be determined until apply, and so Terraform cannot determine the
full set of keys that will identify the instances of this resource.
Ключи for_each должны быть известны при plan, а id ещё не созданного диска неизвестен. Исправление: строить ключи из статичных данных (карта в locals, как local.ingress_rules), а неизвестные значения оставлять в значениях (each.value). Блок broken удали.
2. Пересоздание ВМ. Смена image_id даёт forces replacement: загрузочный диск ВМ на месте не подменить. Образ в семействе обновился, data source вернул новый id. Исправление: ignore_changes = [boot_disk[0].initialize_params[0].image_id] (есть в коде из задания 2). Если ВМ нужно обновить осознанно, делай это командой terraform apply -replace=yandex_compute_instance.notes и читай план до конца. Запомни и про user-data: cloud-init выполняется только при первом запуске, правка шаблона живую ВМ сама не перенастроит (в плане смотри пометку ~ или -/+). Такие изменения проводят конфигурацией (Ansible, урок 7.4) или осознанной заменой ВМ.
3. Секрет в state и в git. grep -c вернёт число больше нуля: пароль лежит в state открытым текстом, потому что входит в user-data. Если terraform.tfvars с паролем попал в коммит, git rm не хватит: значение осталось в истории. Порядок: считать пароль скомпрометированным и сменить, затем очистить историю (git filter-repo) и включить сканер секретов в CI, как в уроке 3.4. Профилактика: TF_VAR_notes_db_password из окружения, *.tfstate* и terraform.tfvars в .gitignore, state в закрытом бакете (7.3), а сам секрет доставлять в ВМ через хранилище (9.2). sensitive = true только скрывает значение в выводе.
ИИ в помощь
Нейросеть хорошо объясняет синтаксис HCL и разбирает чужой план, но не видит твоё облако и твой state. Общие правила работы с ней: ИИ-помощник.
Задача: написать переменную с проверкой значения.
Напиши для Terraform переменную my_ip_cidr типа string с validation:
значение должно быть корректным CIDR и не равняться 0.0.0.0/0. Сообщение об ошибке на русском.
Используй can(cidrhost(...)). Версия Terraform 1.16.
Проверь ответ: подставь 0.0.0.0/0 и hello, оба должны остановить plan. Типичная ошибка: проверка без can, и на мусорном значении ты увидишь ошибку функции вместо своего сообщения.
Задача: разобрать план с пересозданием ВМ.
Вот план terraform plan: <вставь план без паролей и токенов>.
Почему ВМ пересоздаётся (forces replacement)? Какой атрибут это вызвал и как
этого избежать (ignore_changes или другой способ)? Что при этом случится с данными на диске?
Проверь ответ: найди в плане строку # forces replacement и убедись, что названа именно она. Типичная ошибка: нейросеть советует поставить ignore_changes = all, и Terraform перестаёт видеть любые расхождения.
Задача: понять, где утекает секрет.
Вот мой код: <variables.tf, vm.tf, cloud-init.yaml.tftpl без значений секретов>.
Перечисли все места, где окажется пароль БД: state, план, метаданные ВМ, логи. Как это снизить?
Проверь ответ: сверь список с разделом про cloud-init в теории (state, файл плана, метаданные ВМ). Типичная ошибка: нейросеть считает, что sensitive = true убирает секрет из state.
Словарик урока
| Термин | Простыми словами |
|---|---|
переменная (variable) |
Входной параметр кода: значение, которое можно поменять, не правя сами ресурсы. |
default |
Значение переменной по умолчанию; без него переменная обязательная. |
validation |
Проверка значения переменной, которая срабатывает до обращения к облаку. |
sensitive |
Пометка «значение секретное»: Terraform не печатает его в выводе, но в state оно остаётся открытым текстом. |
tfvars |
Файл, где записаны значения переменных (имя = значение). |
TF_VAR_имя |
Переменная окружения, из которой Terraform берёт значение переменной имя. |
locals |
Именованные заготовки внутри кода: вычисляются один раз и используются в нескольких местах. |
output |
Значение, которое Terraform печатает после apply и отдаёт скриптам через terraform output. |
data source (data) |
Чтение уже существующего объекта (например, актуального образа ОС) без его создания. |
count |
Создать N копий ресурса; экземпляры различаются номером. |
for_each |
Создать по экземпляру на каждый ключ карты или множества; ключи стабильны. |
lifecycle |
Блок правил поведения ресурса: prevent_destroy, ignore_changes. |
prevent_destroy |
Запрет удалять ресурс через Terraform: план с удалением завершится ошибкой. |
ignore_changes |
Список атрибутов, изменение которых Terraform не считает расхождением. |
| группа безопасности (security group) | Облачный фильтр трафика: правила, что можно входящему и исходящему на ВМ. |
CIDR (/32) |
Запись диапазона адресов; /32 означает ровно один адрес (урок 2.1). |
| cloud-init | Программа в образе ВМ: при первом запуске читает user-data и настраивает машину. |
templatefile() |
Функция: берёт шаблон файла и подставляет значения вместо ${...}. |
| дрейф (drift) | Расхождение между кодом и реальностью, появившееся из-за ручных правок в облаке. |
forces replacement |
Пометка в плане: атрибут нельзя поменять на месте, ресурс удалят и создадут заново. |
terraform console |
Интерактивная строка для проверки выражений без изменения инфраструктуры. |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Чем resource отличается от data?
Ответ
resource создаёт объект и ведёт его жизненный цикл, data только читает существующий: например, находит id актуального образа или сети, созданной другой командой. Data source ничего не создаёт и не удаляет.
Что хотят услышать: чтение существующего, пример (образ, сеть), выполняется во время plan.
Красный флаг: «это одно и то же, просто другой синтаксис».
2. [junior] [часто] Как передать в Terraform значение переменной и что победит при конфликте?
Ответ
Через default, terraform.tfvars, *.auto.tfvars, -var-file, -var и переменные окружения TF_VAR_*. Самый сильный флаг -var, самый слабый default. В CI обычно использую TF_VAR_*, чтобы не писать секреты в файлы.
Что хотят услышать: порядок приоритетов, TF_VAR_, секреты не в git.
Красный флаг: «только через файл tfvars в репозитории».
3. [middle] Ты убрал один элемент из середины списка, созданного через count, и plan хочет пересоздать несколько ресурсов. Почему и что делать?
Ответ
Terraform адресует экземпляры по индексу: web[0], web[1]. Убрал элемент из середины, индексы сдвинулись, и с точки зрения state ресурсы поменялись местами. Перевожу на for_each по карте, чтобы ключи были стабильными, а для существующей инфраструктуры переношу адреса блоком moved или terraform state mv, чтобы ничего не пересоздавать.
Что хотят услышать: count против for_each, стабильные ключи, moved, чтение плана до apply.
Красный флаг: «просто применю и посмотрю».
4. [middle] Ты нашёл в репозитории закоммиченный terraform.tfstate, а пароль в переменной был sensitive. Насколько всё плохо?
Ответ
Плохо: sensitive скрывает значение только в выводе команд, а в state оно лежит открытым текстом. Считаю пароль скомпрометированным, меняю его, убираю state из истории, добавляю *.tfstate* в .gitignore. Дальше state переезжает в закрытый бакет с шифрованием и ограниченным доступом, а секреты по возможности не проходят через Terraform вообще.
Что хотят услышать: state хранит секреты открытым текстом, ротация, чистка истории, remote state, внешнее хранилище секретов.
Красный флаг: «sensitive шифрует значение».
5. [middle] Прод: после plan видишь -/+ destroy and then create replacement у боевой ВМ. Твои действия?
Ответ
Останавливаюсь и не применяю. Нахожу в плане строку # forces replacement: какой атрибут вызвал замену. Если это дрейф, например новый образ в семействе, ставлю ignore_changes или фиксирую образ. Если замена нужна, планирую её: данные на отдельном диске с prevent_destroy, при необходимости create_before_destroy, работа в окно.
Что хотят услышать: чтение плана, forces replacement, ignore_changes, отдельный диск данных, окно работ.
Красный флаг: «apply, потом разберёмся».
6. [middle] plan падает с Invalid for_each argument ... cannot be determined until apply. Что это и как чинить?
Ответ
Ключи for_each должны быть известны на этапе plan, а я построил их из атрибута ещё не созданного ресурса. Переделываю: ключи из статичных данных (карта в locals или входная переменная), неизвестные значения только в значениях. Крайняя мера: применить сначала часть через -target, но это разовое решение, не привычка.
Что хотят услышать: ключи против значений, «known after apply», locals, -target как исключение.
Красный флаг: «добавлю depends_on».
7. [middle] Как сделать так, чтобы случайный terraform destroy не снёс диск с данными?
Ответ
Ставлю lifecycle { prevent_destroy = true } на диск, подключаю его к ВМ с auto_delete = false, диск живёт отдельно от ВМ. Понимаю границы: защита работает только внутри Terraform, из консоли диск удалить можно, поэтому добавляю права IAM и снапшоты по расписанию.
Что хотят услышать: prevent_destroy, отдельный диск, ограничение блока, IAM и бэкапы.
Красный флаг: «prevent_destroy защищает от любого удаления».
8. [middle] Ты изменил user-data у ВМ в Terraform, apply прошёл, а на машине ничего не поменялось. Почему?
Ответ
Cloud-init выполняется при первом запуске. Terraform обновил метаданные (или пересоздал ВМ, смотря что показал план), но на уже работающей машине скрипт заново не запустился. Либо пересоздаю ВМ осознанно через -replace, либо изменения такого рода веду через Ansible, а cloud-init оставляю для начальной подготовки.
Что хотят услышать: cloud-init один раз, разделение «создать» и «настроить», -replace, Ansible.
Красный флаг: «значит Terraform сломан».
9. [middle] Коллега руками поменял правило SG в консоли. Что покажет plan и как поступишь?
Ответ
План покажет изменение, возвращающее правило к коду: это drift. Сначала выясняю, зачем правили. Если правка нужна, переношу её в код обычным PR, если нет, apply вернёт состояние. Чтобы не повторялось, ограничиваю права на ручные изменения и включаю регулярный plan.
Что хотят услышать: drift, код как источник правды, PR, ограничение прав, регулярный plan.
Красный флаг: «просто перезапишу, не разбираясь».
10. [junior] Как передать IP созданной ВМ в скрипт деплоя?
Ответ
Объявляю output, в скрипте читаю terraform output -raw public_ip, а для нескольких значений terraform output -json и jq. Так адрес не копируется руками из консоли.
Что хотят услышать: output, -raw, -json, jq, использование в inventory или деплое.
Красный флаг: «смотрю в консоли облака и копирую».
11. [junior] Чем variable, locals и output отличаются друг от друга?
Ответ
variable - входной параметр конфигурации: его значение приходит снаружи (-var, terraform.tfvars, TF_VAR_имя). locals - именованное выражение внутри кода: считается из других значений и нужно, чтобы не повторять одно и то же в десяти местах; снаружи его не задать. output - значение наружу: печатается после apply, читается командой terraform output, а у модуля передаётся вызывающему коду. Если нужно вывести адрес созданной ВМ, я делаю output, если собрать общий префикс имён из двух переменных, делаю local.
Что хотят услышать: variable это вход, local это внутреннее вычисление, output это выход, у модуля output единственный способ отдать значение наверх.
Красный флаг: «это одно и то же, просто разный синтаксис» или попытка задать local через -var.
12. [middle] Что делают ignore_changes и create_before_destroy в блоке lifecycle и когда они нужны?
Ответ
ignore_changes = [tags] говорит Terraform не считать расхождение по этим атрибутам поводом для изменения. Я беру его, когда атрибут меняет кто-то другой, например автоскейлер или внешняя система ставит свои метки. create_before_destroy = true меняет порядок замены: сначала создаётся новый ресурс, потом удаляется старый, это нужно, чтобы не было окна без ресурса. Подводный камень: на время замены живут оба, поэтому уникальные поля вроде имени или фиксированного адреса могут конфликтовать. Я не заворачиваю в ignore_changes всё подряд: так код перестаёт описывать реальность.
Что хотят услышать: ignore_changes для атрибутов, которыми управляет кто-то другой, create_before_destroy меняет порядок замены, конфликт уникальных имён, осторожность с ignore_changes.
Красный флаг: «ставлю ignore_changes = all, чтобы plan был чистым».
13. [middle] Зачем делать terraform plan -out=tfplan и потом terraform apply tfplan?
Ответ
Так apply применяет ровно тот план, который человек посмотрел и одобрил. Без файла apply строит план заново, и между ревью и применением инфраструктура или state могли измениться. Если состояние успело поменяться, Terraform откажется применять устаревший план, и это хорошо. В CI я делаю plan -out на pull request, а apply запускаю после одобрения с тем же файлом. Файл плана может содержать значения секретов в открытом виде, поэтому его нельзя выкладывать в общедоступные артефакты.
Что хотят услышать: применяется одобренный план, защита от расхождений между ревью и apply, устаревший план отвергается, файл плана чувствителен.
Красный флаг: «apply в CI без плана, он же сам всё покажет».
Проверено на версиях
- Terraform: 1.16.4 (лицензия BSL)
- OpenTofu: 1.12.6 (совместимая замена, команды
tofuте же) - Провайдер
yandex-cloud/yandex: 0.232.0 (в коде~> 0.232),validateпройден; провайдерыlocal2.9.1 иrandom3.9.1 использовались для локальных проверок - Ubuntu на ВМ: 24.04 LTS (для 26.04 поменяй
image_family) - yc CLI: версия не закреплена, проверь актуальную версию на странице проекта
- Не прогонялось: создание ВМ и вход по SSH в реальном облаке (только
init,fmt,validate)
Итог урока: ты умеешь
- умею вынести значения в
variableсtype,defaultиvalidation - умею передать значение через
tfvarsиTF_VAR_и не закоммитить секрет - умею найти образ ОС через
dataи проверить выражение вterraform console - умею описать ВМ, диск и группу безопасности с
for_each - умею находить в плане
forces replacementи гасить лишнее черезignore_changes - умею защитить диск данных через
prevent_destroy - умею получить IP через
outputи зайти на ВМ по SSH - умею объяснить, почему
sensitiveне защищает state
Дальше: Урок 7.3: Terraform: remote state, модули, окружения
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.