Это тема про взрослую эксплуатацию инфраструктуры как кода. Когда ты один - можно жить на ветках и копипасте. Когда вас десять и пять окружений - без версий и без DRY-подхода всё превращается в болото. Поехали.
Семвер и git-теги: контракт между тобой и потребителями модуля
Модуль terraform - это публичный контракт. Кто-то на него подписался строкой source. Значит, у изменений должны быть правила, по которым потребитель понимает, чего ждать. Эти правила - семантическое версионирование, semver. Версия выглядит как MAJOR.MINOR.PATCH, например 2.4.1.
- PATCH (2.4.0 -> 2.4.1) - багфиксы, ничего не сломалось в интерфейсе. Обновляешься смело.
- MINOR (2.4.1 -> 2.5.0) - новая функциональность, обратная совместимость сохранена. Добавил опциональную переменную с дефолтом - это minor.
- MAJOR (2.5.0 -> 3.0.0) - ломающее изменение. Переименовал переменную, убрал ресурс, поменял дефолт так, что у людей пересоздастся инстанс - это major. Тут потребитель обязан прочитать changelog, а не просто нажать apply.
Код: Выделить всё
# поправили модуль, обновили CHANGELOG.md
git add .
git commit -m "feat: add optional dns_record variable"
git tag v1.3.0
git push origin main --tags

Как потребитель пристёгивается к версии
Самая частая ошибка новичка - ссылаться на модуль без версии или на ветку. Так делать нельзя в команде, это та самая мина из вступления. Правильно - фиксировать версию через constraint:
Код: Выделить всё
module "network" {
source = "registry.terraform.io/my-org/network/yandex"
version = "~> 1.3"
network_name = "prod-vpc"
zones = ["ru-central1-a", "ru-central1-b", "ru-central1-d"]
}
- - ровно эта версия, ни шагом влево.
Код: Выделить всё
= 1.3.0 - - эта и любая новее. Опасно: подтянет и major 3.0.0.
Код: Выделить всё
>= 1.3.0 - - "pessimistic constraint". Это >= 1.3, но строго меньше 2.0. То есть берёт любые minor и patch внутри ветки 1.x, но не пустит ломающий major. Золотая середина для команд.
Код: Выделить всё
~> 1.3 - - тот же оператор, но зафиксировал minor: пустит только патчи 1.3.x.
Код: Выделить всё
~> 1.3.0
Код: Выделить всё
module "vpc" {
source = "git::https://gitlab.example.ru/infra/tf-yc-network.git//modules/vpc?ref=v1.3.0"
}
Версии terraform и провайдеров: блок required и lock-файл
Версионируется не только модуль, но и сам инструмент с провайдерами. Если на ноутбуке у тебя terraform 1.9, у коллеги OpenTofu 1.11, а в CI вообще 1.6 - вы получите три разных результата и долгие споры в чате. Лекарство - блок terraform с ограничениями:
Код: Выделить всё
terraform {
required_version = ">= 1.7"
required_providers {
yandex = {
source = "yandex-cloud/yandex"
version = "~> 0.120"
}
}
}
Точные версии провайдеров фиксирует файл .terraform.lock.hcl. Его обязательно коммитят в git. В нём прибиты не только номера версий, но и хеши артефактов под каждую платформу - чтобы в CI на Linux и у тебя на маке встал бит-в-бит один и тот же провайдер. Обновляешь осознанно:
Код: Выделить всё
terraform init -upgrade
# пересчитает lock под новые constraint, коммитишь обновлённый .terraform.lock.hcl
Голый terraform отлично описывает один кусок инфраструктуры. Проблемы начинаются, когда окружений много. У тебя dev, staging, prod, в каждом по десять компонентов, и в каждом - почти одинаковый блок backend для хранения terraform state в Object Storage. Копипаста плодится, в ней заводятся опечатки. Это и решает Terragrunt - тонкая обёртка над terraform/OpenTofu. В 2026 он наконец дорос до стабильной ветки 1.0, так что на него можно опираться всерьёз.
Что даёт Terragrunt:
- DRY backend. Описываешь конфигурацию state один раз в корневом terragrunt.hcl, а во всех дочерних компонентах просто наследуешь её. Никакой копипасты бакета и ключа.
- Оркестрация зависимостей. Через блок dependency один компонент читает output другого. Terragrunt строит граф и применяет компоненты в правильном порядке: сначала сеть, потом инстансы, которые в этой сети живут.
- before/after hooks. Хуки, которые выполняются до или после команды terraform - например, прогнать линтер перед plan или отправить уведомление после apply.
Код: Выделить всё
remote_state {
backend = "s3"
config = {
endpoint = "https://storage.yandexcloud.net"
bucket = "tfstate-prod"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "ru-central1"
# Object Storage Yandex Cloud S3-совместим, поэтому отключаем AWS-специфику
skip_region_validation = true
skip_credentials_validation = true
}
}
Код: Выделить всё
include "root" {
path = find_in_parent_folders()
}
dependency "network" {
config_path = "../network"
}
inputs = {
subnet_id = dependency.network.outputs.subnet_id
}
2026: декларативный жизненный цикл через import и removed
Раньше управление жизненным циклом ресурса делалось императивными командами CLI: terraform import гонял по аргументам, а terraform state rm выкидывал ресурс из стейта руками. Проблема - это не отражается в коде, не проходит ревью, легко забыть и невозможно воспроизвести в CI. Современный подход - описывать это декларативно прямо в конфиге.
Блок import (появился в terraform 1.5) затягивает уже существующий ресурс под управление кодом. Ты добавляешь ресурс в конфиг и рядом пишешь:
Код: Выделить всё
import {
to = yandex_compute_instance.web
id = "fhm8abcd1234example"
}
resource "yandex_compute_instance" "web" {
name = "web-1"
# ... остальная конфигурация под существующий инстанс
}
Блок removed (terraform 1.7) - зеркальный сценарий. Ты хочешь убрать ресурс из управления terraform, но НЕ удалять его в облаке. Удаляешь блок resource из кода и вместо него ставишь:
Код: Выделить всё
removed {
from = yandex_compute_instance.legacy
lifecycle {
destroy = false
}
}
Типичные грабли
- source без version. Классика. Ссылаешься на модуль, забыл version - и любой апдейт модуля прилетает тебе неконтролируемо. Всегда фиксируй constraint.
- Ветка вместо тега. ?ref=main - это бомба замедленного действия. Только ?ref=vX.Y.Z.
- .terraform.lock.hcl в .gitignore. Lock-файл обязан быть в репозитории, иначе CI и локалка разъедутся по версиям провайдеров.
- Major-бамп без changelog. Сломал интерфейс - подними MAJOR и опиши, что именно сломалось. Иначе потребители узнают об этом в проде.
- Terragrunt без нужды. Если у тебя один компонент и одно окружение - terragrunt только добавит слой абстракции и боли. Он окупается на масштабе.
- Путаница import и moved. import - затащить существующий ресурс из облака. moved - переименовать адрес ресурса внутри стейта. Это разные блоки под разные задачи.
- Возьми любой свой модуль, заведи в нём CHANGELOG.md, поставь git-тег v0.1.0 и запушь с --tags.
- Подключи этот модуль из другого проекта через source + version = "~> 0.1" и убедись, что terraform init его тянет.
- Внеси в модуль обратно-совместимую правку, тегни v0.2.0, и посмотри, как ~> 0.1 подхватит её, а ~> 0.1.0 - нет.
- Возьми существующий ресурс в Yandex Cloud, опиши его в конфиге и затащи блоком import. Затем убери из управления через removed с destroy = false и проверь planом, что облако не тронуто.
- Что означает оператор ~> 1.3 в constraint версии модуля и почему он удобен для команды?
- Зачем коммитить .terraform.lock.hcl в git и что в нём хранится кроме номеров версий?
- Чем блок removed с lifecycle { destroy = false } отличается от terraform destroy и от terraform state rm?
- Какие три проблемы решает Terragrunt и почему его не стоит тащить в маленький проект?
Версия модуля - это git-тег по semver, а major-бамп - это обещание потребителю "читай changelog, тут сломалось". Потребитель всегда пристёгивается к версии через version constraint, а ~> закрывает большинство командных сценариев. Версии terraform/OpenTofu и провайдеров фиксируются блоком required и lock-файлом, который живёт в git. Когда окружений становится много - приходит Terragrunt со своим DRY-backend, графом зависимостей и хуками. А управление жизненным циклом ресурсов в 2026 году делается декларативно: import затаскивает существующее под код, removed аккуратно отпускает его, не трогая облако. Меньше CLI-магии руками - больше воспроизводимости через git.