🎁 Бонус-урок. Terraform, OpenTofu чи Pulumi: що обрати в 2026
#terraform_курс · бонус
Весь курс ми писали на HCL і казали «Terraform». Тепер питання, яке ставлять на кожному старті проєкту: чи справді саме він? Розкладу за ситуаціями, без релігійних воєн.
Що змінилось за останні роки
→ Серпень 2023: HashiCorp змінила ліцензію Terraform з MPL 2.0 на BUSL 1.1. Це не OSI-схвалена відкрита ліцензія: вона обмежує використання у продуктах, що конкурують із HashiCorp.
→ Лютий 2025: IBM завершила придбання HashiCorp. Ліцензія від цього не змінилась, але з'явились питання про довгострокову незалежність roadmap.
→ OpenTofu: форк Terraform під MPL 2.0 і нейтральним управлінням (Linux Foundation, а з квітня 2025 проєкт у CNCF на рівні sandbox).
Практичний наслідок для більшості команд такий: BUSL не забороняє внутрішнє використання, а забороняє пропонувати конкурентний сервіс на базі Terraform. Але юристи великих компаній і державні закупівлі часто реагують саме на ліцензію. Це не юридична консультація, тож питання з ліцензією краще звіряти з вашою юридичною службою.
Terraform
Найбільша екосистема, документація, готові модулі й люди, які його знають
Найлегше наймати й навчати
Мінус: BUSL, власник тепер IBM
OpenTofu
Для більшості конфігурацій drop-in заміна: той самий HCL, ті самі провайдери, ті самі команди
Ліцензія MPL 2.0 і нейтральне управління
Має власні можливості, яких немає у відкритому Terraform: нативне шифрування state (з версії 1.7), ранні обчислення змінних (локали й змінні в backend і джерелах модулів, з 1.8), for_each на рівні провайдерів, прапорець -exclude
Мінус: менша спільнота, а розбіжності між проєктами ростуть, тож «повна сумісність» зі часом слабшає
Pulumi
Інфраструктура справжньою мовою: TypeScript/JavaScript, Python, Go, C#, Java (є й YAML)
Ліцензія Apache 2.0 для ядра
Цикли, абстракції, юніт-тести звичайними тестовими фреймворками
Мінус: вищий поріг входу, і легко написати «занадто розумний» код, який потім ніхто не розбере
Як обирати
→ Нова команда, звичайний проєкт: Terraform або OpenTofu, а різницю вирішує ліцензійна позиція компанії.
→ Юристи чи закупівлі питають про ліцензію або потрібна нейтральна ліцензія: OpenTofu.
→ Потрібне нативне шифрування state: OpenTofu. (У Terraform лишається шифрування на рівні backend, наприклад S3 + KMS, як у Уроці 2.)
→ Усе працює на Terraform і нічого не болить: лишайтесь. Міграція без причини коштує спринту.
→ Сильні розробники, багато динамічної логіки, хочеться тестувати інфраструктуру: Pulumi. Але чесно оцініть, чи всі потягнуть це підтримувати.
→ Змішана команда з джунами: HCL (Terraform або OpenTofu). Обмеженість HCL тут перевага: важче написати щось надто хитре.
Якщо вирішили мігрувати на OpenTofu
Ставтесь до цього як до ризикованої зміни з Уроку 7:
Окремою зміною, не змішуючи з іншими.
Спершу staging, який дзеркалить prod.
Запустіть plan з OpenTofu на наявному state: він має показати нуль змін. Якщо ні, розберіться, перш ніж рухатись далі.
Перевірте, що ваші інструменти (CI, обгортки, лінтери) підтримують OpenTofu.
Увага: щойно ви скористаєтесь функцією, якої немає в Terraform (наприклад, нативним шифруванням state), повернутись назад буде складно. Це дверi в один бік, тож не вмикайте такі речі в день міграції.
Що з цього курсу переноситься
→ До OpenTofu практично все: уроки 1-7 застосовні майже дослівно.
→ До Pulumi переносяться ідеї, а не синтаксис: state лишається state (його зберігає Pulumi Cloud або власний backend), plan це pulumi preview, apply це pulumi up, а модулі це components.
Головна думка
Різниця між Terraform і OpenTofu для більшості команд це питання ліцензії та політики, а не технології. Pulumi це інша філософія: «код», а не «конфігурація». Обирайте за тим, хто це підтримуватиме через два роки, і не мігруйте заради моди.
Цифри версій і набір функцій у цьому швидкому проєкті змінюються часто, тож перед рішенням звіряйтесь з офіційними changelog Terraform, OpenTofu та Pulumi.
Що у вас у проді? Хтось уже мігрував на OpenTofu, як пройшло? 👇
🎓 Урок 7. Безпечні зміни в проді: import, moved, дрейф
#terraform_курс · урок 7 із 7 (фінал)
Ми пройшли шлях від «Terraform звіряє три картини правди» до модулів і середовищ. Фінальний урок про те, що відбувається в живому проді, де код, state і реальність постійно намагаються розійтися. Усе зводиться до трьох ситуацій і трьох інструментів.
Ситуація 1. Ресурс існує в хмарі, але Terraform про нього не знає → import
Колись когось створили руками в консолі, і тепер ресурс треба взяти під керування кодом.
Сучасний спосіб (Terraform 1.5+) це блок import у коді:
import {
to = aws_s3_bucket.logs
id = "my-app-logs"
}
plan покаже will be imported, а ви перегляньте його в PR, як будь-яку іншу зміну. Це головна перевага над старою командою terraform import, яка змінює state негайно й непомітно для рев'ю.
Три правила:
→ Блок resource треба написати самому. Terraform можна попросити згенерувати чернетку (terraform plan -generate-config-out=generated.tf), але це лише відправна точка, яку обов'язково перечитати.
→ Одразу після імпорту plan має показувати нуль змін. Якщо він пропонує щось змінити, ваш код не збігається з реальним ресурсом, і apply змінить живий ресурс. Спершу підлаштуйте код.
→ Імпорт змінює лише state. Реальний ресурс на цьому кроці не змінюється.
Протилежна ситуація: ресурс треба вивести з-під керування, не видаляючи. Для цього є блок removed з lifecycle { destroy = false } (Terraform 1.7+) або terraform state rm. Без цього просте видалення блоку з коду закінчується destroy.
Ситуація 2. Рефакторинг коду → moved
Ви вже знаєте його з Челенджів #5 і #6 та Уроку 4: перейменування, винесення в модуль, перехід з count на for_each. Правило одне: змінили адресу, напишіть moved, перегляньте plan: має бути нуль змін. І міграцію тримайте окремою зміною від будь-яких інших.
Ситуація 3. Реальність розійшлась зі state → дрейф
Хтось змінив ресурс руками в консолі, скрипт щось підправив, автоскейлінг змінив кількість. Terraform про це не дізнається сам (пам'ятаєте Урок 1: він звіряє лише коли ви запускаєте plan).
Як знайти:
terraform plan -refresh-only
Показує лише розбіжності між state і реальністю, без змін у коді. А terraform apply -refresh-only оновить state відповідно до реальності, нічого не змінюючи в інфраструктурі (це заміна застарілої команди terraform refresh).
Як знаходити регулярно, а не випадково: запланований запуск у CI із terraform plan -detailed-exitcode (Урок 3): код виходу 2 означає, що щось розійшлось.
Далі треба вирішити, а не проігнорувати:
→ зміна небажана: застосуйте код, і реальність повернеться до заявленого;
→ зміна легітимна: оновіть код під неї (або -refresh-only, якщо це лише дрібний атрибут);
→ атрибутом навмисно керує інша система (наприклад, автоскейлінг): lifecycle { ignore_changes = [...] }, але вузько й свідомо, бо це мовчазна дірка.
Чекліст для ризикованої зміни в проді
Копія state: версіонування бакета ввімкнено, а перед ручними операціями terraform state pull > backup.tfstate.
Одна зміна за раз, окремо від решти (міграція, імпорт або рефакторинг окремим PR).
plan -out=tfplan і застосовуєте саме його (Урок 3).
Читаєте насамперед число destroy і forces replacement.
Спершу staging, який дзеркалить prod (Урок 6).
prevent_destroy на критичному як остання сітка.
apply у проді лише через CI із окремою роллю.
Головний висновок уроку (і всього курсу)
import бере існуючий ресурс під керування, а після нього plan має бути порожнім.
moved супроводжує будь-яку зміну адреси.
Дрейф шукається планово (-refresh-only, -detailed-exitcode) і вирішується свідомо.
Безпечна зміна це маленька, переглянута, виконана через CI і з відкатом на рівні state.
Terraform небезпечний не сам по собі, а коли ми довіряємо зеленому apply і не читаємо plan. Весь курс по суті про одне: адреса це ідентичність, state це пам'ять, а план треба читати.
Питання для закріплення: після import plan показує «1 to change». Що це означає і чому не варто одразу робити apply? 👇
🎓 Урок 6. Workspaces та environments: dev, staging, prod без копіювання коду
#terraform_курс · урок 6 із 7
Ми вже вміємо виносити код у модулі (Урок 4) і параметризувати їх (Урок 5). Лишилось питання: як мати кілька середовищ з однієї кодової бази й не наступити на граблі, які коштують дорожче за всі попередні.
Підхід 1: CLI workspaces
terraform workspace new staging
terraform workspace select prod
Workspace це окремий state для тієї самої конфігурації. Код один, а terraform.workspace дає ім'я поточного workspace, яке можна використати в коді. З S3-backend стани лежать за ключами на кшталт env:/staging/….
Звучить ідеально, але є серйозне обмеження. Документація Terraform прямо каже, що CLI workspaces не підходять для розділення середовищ, яким потрібні різні облікові дані й контроль доступу. Причини такі:
→ Усі workspaces використовують один backend і ті самі credentials. Хто може apply у dev, технічно може й у prod.
→ Помилка в одну команду. Ви забули, що вибраний prod, і apply пішов туди. Це той самий сюжет, що з kubectl delete у чужому контексті.
→ Різницю між середовищами починають ховати в умовах на кшталт terraform.workspace == "prod" ? 3 : 1. Код стає нечитабельним, а розбіжності непомітними.
Де workspaces доречні: тимчасові копії одного й того ж середовища (preview-середовище під гілку, експеримент), в одному акаунті, з однаковими правами.
У кожному середовищі свій backend, свій state, свої змінні, а вся логіка винесена в спільні модулі, які викликає тонкий main.tf. Дублюється лише «обв'язка» на кілька десятків рядків.
Плюси:
→ Різні state, різні credentials: prod можна замкнути, щоб його міняв лише CI із окремою роллю.
→ Видно, у якому середовищі ви працюєте: це поточна тека, а не прихований стан CLI.
→ Різниця між середовищами це дані (tfvars і виклики модулів), а не гілки в логіці.
Мінус: тека на середовище означає кілька init/plan. Це нормальна ціна за ізоляцію.
Підхід 3: одна конфігурація, різні backend і tfvars
terraform init -backend-config=prod.backend.hcl -reconfigure
terraform plan -var-file=prod.tfvars
Код один, а state і значення підміняються параметрами. Менше дублювання, але вимагає дисципліни: легко виконати plan з prod.tfvars проти backend іншого середовища. Тому такий підхід варто обгортати скриптом чи CI, а не покладатись на пам'ять.
Правило вибору
→ Різний радіус ураження чи різний доступ (dev проти prod): окремі state і окремі credentials. Це підходи 2 або 3.
→ Тимчасові копії того самого середовища: workspaces.
Гігієна для будь-якого підходу
→ Тримайте структуру staging максимально схожою на prod. Якщо в dev усе розбито на багато state, а в prod один, репетиція в dev перевіряє іншу процедуру, ніж та, що піде в prod. Staging має бути справжньою репетицією.
→ prod лише через CI із власною роллю, а локально хай буде лише читання (прив'язка до Уроку про OIDC в CI/CD-курсі).
→ Показуйте поточне середовище в промпті. Той самий принцип, що з kube-ps1 у пості про видалений namespace.
→ Різницю між середовищами тримайте в змінних, а не в умовах на terraform.workspace.
Головний висновок уроку
Workspace це окремий state для тієї самої конфігурації, але без ізоляції доступу.
Для dev/staging/prod беріть окремі state й credentials: тека на середовище з шаром спільних модулів, або одна конфігурація з параметризованим backend.
Різниця між середовищами має бути даними, а не логікою.
Структуру staging тримайте дзеркальною до prod.
Наступного уроку (четвер), фінал основного курсу: безпечні зміни в проді: import, moved, дрейф і що робити, коли реальність розійшлась зі state.
Питання для закріплення: чому CLI workspaces погано підходять, щоб відділити prod від dev, навіть якщо код однаковий? 👇
git add -p показує зміни шматками, і ви вибираєте y чи n для кожного: у комміт потрапляє лише потрібне, а не весь файл. commit --amend --no-edit дописує щойно додане до останнього комміту без зміни повідомлення (лише для комітів, які ще не запушено).
🧩 Розбір Челенджу #6: count рахує номери, а не машини
У п'ятницю з трьох воркерів видаляли worker-2, а plan обіцяв «1 to change, 1 to destroy». Дякую всім, хто розбирав. Ось що відбувається насправді.
Що показав plan
~ aws_instance.worker[1] tags: "worker-2" -> "worker-3" (in-place)
- aws_instance.worker[2] will be destroyed
1. Яку машину знищить apply?
Ту, що стоїть під номером [2], тобто справжній worker-3. З count Terraform впізнає екземпляр за номером у списку, а не за іменем:
Адреса Було Після видалення з списку
[0] worker-1 worker-1 (без змін)
[1] worker-2 тепер «має бути worker-3» → тег змінюється
[2] worker-3 елемента більше нема → знищити
2. Що станеться з worker-2?
Нічого поганого для неї: машина продовжує працювати, лише тег змінюється на worker-3. Результат подвійно неправильний: машина, яку хотіли прибрати, лишилась під чужим іменем, а робоча, яку хотіли зберегти, знищена разом із усім, що було на її диску.
Ця поведінка залежить від умови задачі: машини відрізняються лише тегом Name. Якщо б різниця торкалась атрибутів, які змінити на місці неможливо (наприклад, user_data з іменем), то замість update було б replace. Але механізм той самий: ідентичність це номер.
3. Як переписати: for_each зі стабільними ключами
resource "aws_instance" "worker" {
for_each = toset(var.workers)
ami = var.ami
instance_type = "t3.medium"
tags = { Name = each.key }
}
Адреси тепер aws_instance.worker["worker-1"], ["worker-2"], ["worker-3"]: ключ це ім'я. Видалили worker-2 зі списку, і plan показує знищення лише ["worker-2"]. Решта не зачеплена, бо їхні адреси не залежать від позиції.
Нюанс: ключі for_each мають бути відомі під час plan. Якщо список залежить від атрибутів, що з'являться лише після створення чогось іншого, for_each скаржитиметься, і тоді структуру треба переглянути. Для різних налаштувань на екземпляр використовуйте map(object({...})).
4. Як мігрувати, не пересоздаючи машини
Адреси змінюються (worker[0] → worker["worker-1"]), тож без допомоги Terraform вирішить «знищити всі й створити нові». Допоможуть moved-блоки з Челенджу #5:
moved {
from = aws_instance.worker[0]
to = aws_instance.worker["worker-1"]
}
moved {
from = aws_instance.worker[1]
to = aws_instance.worker["worker-2"]
}
moved {
from = aws_instance.worker[2]
to = aws_instance.worker["worker-3"]
}
Порядок критичний, і це найчастіша помилка:
Спершу лише міграція, у списку лишаються всі три. plan має показати три переміщення й нуль змін.
Тільки потім окремою зміною приберіть worker-2. Тепер plan знищує одну адресу, ту, яку ви і мали на увазі.
Якщо поєднати обидва кроки в одному PR, ви знову покладаєтесь на уважне читання plan там, де можна було не покладатись.
Коли count усе ж доречний
→ Увімкнути/вимкнути ресурс: count = var.enabled ? 1 : 0.
→ Однакові взаємозамінні екземпляри, яким не потрібні окремі імена й порядок не має значення.
Якщо елементи мають власну ідентичність (імена, ролі, зони), беріть for_each.
Головний урок
З count ідентичність це позиція. Видалення з середини списку зсуває номери, і «безневинний» plan зачіпає чужі ресурси. Це той самий принцип, що в Челенджі #5: адреса і є ідентичність, тому обирайте адреси, що не залежать від порядку.
Хто побачив, що знищується саме worker-3, ви читаєте plan уважніше за більшість 👊 У вівторок Урок 6: workspaces і environments.
Шостий челендж. Інженер хоче прибрати один зайвий воркер зі списку. Рядок видалено, plan виглядає майже безневинно. Знайди, що саме станеться. Розбір у понеділок.
Потрібно прибрати worker-2, тож зі списку видалили його:
default = ["worker-1", "worker-3"]
plan показує:
~ aws_instance.worker[1] (update in-place)
~ tags = { "Name" = "worker-2" -> "worker-3" }
- aws_instance.worker[2] will be destroyed
Plan: 0 to add, 1 to change, 1 to destroy.
Завдання
Яка саме фізична машина буде знищена і чому не та, яку мали на увазі?
Що станеться з машиною, яку хотіли прибрати?
Як переписати конфіг, щоб видалення зі списку зачіпало лише потрібний елемент?
Як перейти на нову схему, не пересоздаючи живі машини?
Куди дивитись:
За чим Terraform впізнає екземпляр у count, за іменем із списку чи за номером? (Челендж #5: що таке адреса?)
Що змінилось у номерах після видалення елемента із середини?
Який атрибут замість count дає стабільні ключі?
Найстрашніший сценарій: plan виглядає майже безневинно («1 to change, 1 to destroy»), але знищується робоча машина worker-3, яку ніхто не збирався чіпати, а непотрібна worker-2 продовжує працювати під чужим іменем.
Кидай свою відповідь у коментарі 👇 У понеділок розберемо.
🎓 Урок 5. Variables і outputs: інтерфейс вашого коду
#terraform_курс · урок 5 із 7
В Уроці 4 ми казали, що в модуля має бути «вузький чіткий інтерфейс». Сьогодні розбираємо, з чого він складається: змінні на вході, outputs на виході і трохи locals усередині.
Змінна: вхід
variable "env" {
type = string
description = "Назва середовища"
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "env має бути dev, staging або prod."
}
}
Що тут важливо:
→ type відсікає сміття ще до plan: string, number, bool, list(string), map(string), object({...}).
→ description це документація інтерфейсу. Пишіть її завжди, бо через рік ви першим не пам'ятатимете, що це.
→ validation перевіряє значення одразу й дає зрозумілу помилку замість дивної відмови від провайдера посеред apply.
→ default робить змінну необов'язковою. Без нього змінна обов'язкова.
Використання в коді: var.env.
Звідки беруться значення
Terraform збирає значення з кількох джерел. Від найслабшого до найсильнішого:
default у самій змінній
змінні оточення TF_VAR_env
terraform.tfvars
*.auto.tfvars
-var і -var-file у командному рядку (виграє останній указаний)
Тобто -var перекриває все. Це зручно для разових перевизначень і небезпечно, коли хтось забув, що перекрив значення в скрипті.
Практична схема для середовищ: окремі файли dev.tfvars, prod.tfvars і виклик terraform plan -var-file=prod.tfvars. Файл видно в PR, а різниця між середовищами зберігається як дані, а не як копії коду (продовження теми workspaces і environments в Уроці 6).
Секрети
→ sensitive = true ховає значення у виводі CLI, але в state воно лишається відкритим текстом (Урок 2).
→ Не кладіть секрети в *.tfvars, які потрапляють у git. Передавайте їх через TF_VAR_... із секретного сховища CI або читайте з менеджера секретів.
→ У CI додавайте -input=false, інакше обов'язкова змінна без значення зависне в очікуванні вводу.
locals не приймають значення ззовні. Це лише іменований вираз, щоб не повторювати його десять разів. Правило: змінна це те, що задає споживач, local це похідне від неї.
Як спроєктувати хороший інтерфейс модуля
→ Мало обов'язкових змінних, решта з розумними дефолтами.
→ Описи й типи на всьому, це читають і люди, і validate.
→ Віддавайте лише те, що справді потрібно споживачам. Кожен зайвий output стає контрактом, який потім складно змінити.
→ Не передавайте «весь об'єкт» без потреби. Модуль, що приймає десять окремих значень, краще, ніж той, що чекає гігантську структуру.
Одна обережність про terraform_remote_state
Через нього один state читає outputs іншого. Зручно, але це зв'язує state між собою: хто читає, отримує й доступ до значень, а зміна output у вихідному state може тихо зламати споживача. Використовуйте свідомо й віддавайте лише безпечні значення.
Головний висновок уроку
Змінні це вхід, outputs це вихід, locals це обчислення всередині. Разом вони й утворюють інтерфейс модуля.
Значення беруться з кількох джерел, а -var перемагає всіх.
Секрети не в tfvars, а sensitive ховає лише вивід, не state.
Outputs одних модулів стають змінними інших: так складається композиція.
Наступного уроку (вівторок): workspaces і environments, як мати dev/staging/prod без копіювання коду.
Питання для закріплення: змінна env задана і в terraform.tfvars, і через TF_VAR_env, і через -var. Яке значення переможе і чому? 👇