- Add modules: yc-s3, yc-postgresql, k8s-namespace, k8s-secrets - Add live configuration for stage environment - Add GitLab CI with downstream pipelines - Implement best practices from theory.md
63 KiB
Архитектурные паттерны IaC на основе источников
1.1. Декларативный подход и идемпотентность
Terraform использует декларативный язык HCL, где инженер описывает целевое конечное состояние («что должно существовать»), в то время как инструмент самостоятельно вычисляет граф зависимостей и шаги для его достижения [22], [4]. Это обеспечивает идемпотентность: многократный запуск одного и того же кода приводит к идентичному результату, исключая риск дублирования ресурсов [21]. В отличие от процедурных инструментов (Ansible, Bash), где повторный запуск может потребовать дополнительных проверок, Terraform сравнивает текущее состояние (state) со свежим кодом и вносит только необходимые изменения [21], [16]. Начиная с версии 0.12 (HCL 2.0), синтаксис был упрощен: переменные больше не требуют обязательной интерполяции ${} везде, где они используются, что сделало код более читаемым для человека [1], [4]. Декларативный подход позволяет уйти от ручного управления («ClickOps») и превратить инфраструктуру в «живую» документацию [1], [5], [29].
1.2. Минимизация «радиуса поражения» (Blast Radius)
Монолитная инфраструктура, где все ресурсы описаны в одном файле или управляются единым файлом состояния, признана критическим антипаттерном [2]. Научное исследование SMELLS-SUS показывает, что «монолитный запах» (Monolithic Infrastructure smell) является наиболее распространенной проблемой и встречается в 9,67% проанализированных сценариев [2]. Ошибка в коде одного компонента (например, опечатка в Security Group) в монолитном стейте может привести к каскадному удалению или повреждению критически важных ресурсов, таких как базы данных всего ландшафта [15]. Эксперты рекомендуют разделять инфраструктуру на мелкие независимые стейты — «переборки» [16]. Опыт компании Selectel подтверждает, что распилка монолита на гранулярные компоненты позволяет ускорить деплой инфраструктуры в 20 раз за счет уменьшения времени опроса API облачного провайдера [5].
1.3 Слоистая архитектура (Layered Architecture)
Рекомендуется разделение ресурсов на независимые логические уровни (слои), каждый из которых имеет свой жизненный цикл и границы ответственности [17], [4609. «Владимир Дроздецкий — Эффективное управление инфрой»], [29]. Типичная иерархия включает следующие слои: Global (IAM, DNS), Network (VPC, подсети), Platform (K8s, базы данных) и Application (микросервисы) [17], [20]. Слой сети является фундаментальным, так как без него невозможна работа большинства других компонентов [29]. Связь между слоями осуществляется через передачу выходных данных (outputs), что в Terragrunt реализуется через блоки dependency, обеспечивающие автоматическую оркестрацию и соблюдение порядка развертывания [16], [18], [29]. Использование команды run-all позволяет развернуть всю иерархию модулей в правильной последовательности одним действием [1], [18], [27].
-
Организация и структура репо
-
Стратегия разделения: Modules и Live Общепринятым корпоративным стандартом является разделение реализации инфраструктуры от ее непосредственного развертывания по разным репозиториям [20], [30]:
Repository «Modules»: Библиотека универсальных, версионируемых «чертежей» (blueprints). Они не содержат специфических данных окружения (IP-адресов, имен) и предназначены для многократного использования [20], [7].
Repository «Live»: Описание реальных «зданий», построенных по чертежам из модулей. Здесь фиксируются конкретные параметры для каждого ландшафта (dev, stage, prod) [7], [20].
- Иерархическая структура каталогов (Live-репозиторий) Для обеспечения изоляции и прозрачности рекомендуется древовидная структура, отражающая логику облака [20], [30]: Account (Учетная запись) → Region (Регион) → Environment (Окружение) → Category (Категория) → Component (Компонент)
Account: На верхнем уровне разделяются облачные аккаунты (например, prod-account, stage-account), что обеспечивает максимальную изоляцию в рамках безопасности [7], [20].
Region: Внутри аккаунта создаются папки регионов (например, us-east-1, eu-central-1) [20]. Папка _global на этом уровне используется для ресурсов, доступных во всех регионах аккаунта (IAM, DNS) [7].
Environment: Разделение на dev, qa, stage и prod [20], [6]. Каждое окружение обычно соответствует отдельному VPC [7].
Category: Группировка ресурсов по назначению: networking (VPC, подсети), services (приложения, K8s), data-stores (БД, кэши) [20], [29].
Component: Конечная директория с файлом terragrunt.hcl или main.tf, управляющая конкретным ресурсом (например, mysql или vpc) [20].
- Организация файлов внутри компонента Для поддержания чистоты кода (принцип DRY) и удобства навигации рекомендуется разделение на функциональные файлы [6], [23]:
main.tf: Вызов модулей и описание основных ресурсов [6].
variables.tf: Объявление входных переменных с обязательным описанием (description) и типами [6].
outputs.tf: Описание выходных данных для передачи их между слоями или модулями [6].
providers.tf: Конфигурация провайдеров (AWS, Azure, Yandex) и их версий [23].
backend.tf: Настройка удаленного хранения стейта (S3, GCS) [6].
versions.tf: Фиксация версий Terraform и провайдеров для стабильности сборок [6].
- Иерархическое управление переменными в Terragrunt Terragrunt позволяет избежать дублирования за счет автоматического наследования конфигураций по иерархии папок [27], [30]:
root.hcl: Находится в корне и содержит общую конфигурацию remote_state и провайдеров для всех ландшафтов [27].
env.hcl / environment.yaml: Хранит переменные, общие для всего окружения (например, env = "prod") [18], [30].
region.hcl / region.yaml: Переменные конкретного региона (например, maintenance_window) [27], [30]. Для автоматического поиска этих файлов используется функция find_in_parent_folders() [30].
-
Изоляция состояний (State Isolation) Иерархическая структура напрямую влияет на управление состоянием. Gruntwork и эксперты рекомендуют управлять стейтом на уровне каждого отдельного юнита (директории) [20], [30]. Использование Workspaces для Production-сред не рекомендуется, так как они используют один бэкенд и скрывают структуру в CLI [27]. Разделение по папкам гарантирует, что каждый компонент имеет свой изолированный файл состояния, что критически снижает «радиус поражения» (blast radius) в случае ошибки [16], [24].
-
Нововведения в структуре (Stacks) В 2025 году появилась концепция Terragrunt Stacks, позволяющая упаковывать коллекции связанных юнитов в переиспользуемые стеки [27], [19]. Это переводит переиспользование с уровня отдельных модулей на уровень целых инфраструктурных паттернов (например, «App + DB + Monitoring»), полностью устраняя копипаст конфигураций между ландшафтами [27].
-
Модульность и стратегии переиспользования
Согласно исследованиям и best-practices, модульность является краеугольным камнем масштабируемой и поддерживаемой инфраструктуры. Она позволяет инкапсулировать ее сложность, обеспечивать переиспольщование и минимизировать дублирование кода, что напрямую снижает риск возникновения «монолитного запаха» (Monolithic Infrastructure smell) в проектах [2, 6].
3.1. Понятие и структура модуля
Как описывается в руководствах, в Terraform модулем может считаеться любая директория, содержащая конфигурационные файлы .tf [6]. Модули работают по принципу функций в программировании: они инкапсулируют логику создания определенного набора ресурсов (например, виртуальной машины или кластера БД), принимают входные параметры через переменные и возвращают результаты через выходные значения [21]. Это позволяет абстрагироваться от деталей реализации. Как показано в примерах, для создания переиспользуемого компонента его необходимо выделить в отдельную директорию. Например:
modules/object-storage/bucket/main.tf:
resource "yandex_storage_bucket" "this" { bucket = var.bucket_name acl = var.acl
dynamic "server_side_encryption_configuration" { for_each = var.kms_key_id != null ? [1] : [] content { rule { apply_server_side_encryption_by_default { kms_master_key_id = var.kms_key_id sse_algorithm = "aws:kms" } } } }
lifecycle_rule { enabled = true expiration { days = var.object_expiration_days } } }
Дабы вызвать данный модуль и корневого каталога, можно сделать следующее:
module "static_assets_bucket" { source = "./modules/object-storage/bucket" bucket_name = "myapp-static-assets-prod" acl = "private" kms_key_id = yandex_kms_symmetric_key.bucket_key.id }
Источники подчеркивают, что такой подход превращает инфраструктуру в набор переиспользуемых «кирпичиков лего» [6, 21].
3.2. Использование публичных и готовых модулей
Для ускорения разработки и следования лучшим практикам настоятельно рекомендуется использовать готовые модули. Хотя публичный реестр Terraform для Yandex Cloud менее обширен, чем для AWS, существуют как официальные модули от Yandex, так и проверенные сообществом, которые можно найти на GitHub. Использование таких модулей, как отмечается в источниках, позволяет избежать написания сотен строк стандартного кода для настройки, например, виртуальных частных облаков (VPC) или групп безопасности [6, 23]. Тем самым нам не нужно в большинстве случаев изобретать вилосипед.
Адаптированный пример того, как можно использовать структурированный модуль для быстрого развертывания сети:
module "vpc" { source = "terraform-yc-modules/vpc/yc" version = "~> 1.0"
name = "production-network" description = "VPC for production environment"
Определение подсетей в разных зонах доступности
subnets = { "ru-central1-a" = "10.0.1.0/24", "ru-central1-b" = "10.0.2.0/24", "ru-central1-c" = "10.0.3.0/24" }
Включение NAT для выхода в интернет из приватных подсетей
enable_nat = true }
Использование версионированных модулей из проверенных источников является всеобщей стандартной практикой [6, 23].
3.3. Стратегии проектирования
Сложные инфраструктурные решения строятся по принципу композиции («Lego bricks»). Как описывается в литературе, выходные данные одного модуля (например, идентификаторы подсетей) должны передаваться на вход другому (например, модулю создания виртуальной машины), формируя явные и управляемые зависимости [6, 30]. Это обеспечивает слабую, но хоть какую-то связанность компонентов.
1. Модуль создания кластера Managed PostgreSQL
module "prod_postgresql" { source = "../../../modules/data-stores/postgresql"
cluster_name = "app-db-prod" network_id = data.yandex_vpc_network.default.id subnet_ids = [yandex_vpc_subnet.private_a.id] host_class = "s2.micro" }
2. Модуль развертывания приложения, использующий вывод модуля БД
module "backend_app" { source = "../../../modules/services/backend-app"
name = "backend-api" subnet_id = yandex_vpc_subnet.public_a.id image_id = "blahblahblah" db_host = module.prod_postgresql.cluster_hosts_fqdn[0] db_name = module.prod_postgresql.database_name }
Такой подход исключает хардкод значений и делает инфраструктурный код декларативным и прозрачным [30].
3.4. Динамическое создание ресурсов (итерирование)
Для массового создания однотипных ресурсов без копирования кода используются count и for_each. В руководствах отмечается, что for_each предпочтительнее для работы с коллекциями (map, set), так как он создает ресурсы с уникальными идентификаторами в стейте, что делает их безопасными для последующих изменений [6, 10].
variables.tf
variable "vm_instances" { description = "Конфигурация создаваемых виртуальных машин" type = map(object({ cores = number memory = number boot_disk_gb = number image_family = string user_data = string # Cloud-init })) }
main.tf
resource "yandex_compute_instance" "vm" { for_each = var.vm_instances
name = each.key platform_id = "standard-v3" zone = "ru-central1-a"
resources { cores = each.value.cores memory = each.value.memory }
boot_disk { initialize_params { size = each.value.boot_disk_gb image_id = data.yandex_compute_image.this[each.key].id } }
network_interface { subnet_id = yandex_vpc_subnet.app.id nat = true # Для ВМ, которым нужен внешний IP }
metadata = { user-data = each.value.user_data } }
Использование data source для поиска актуального образа по семейству
data "yandex_compute_image" "this" { for_each = var.vm_instances family = each.value.image_family }
Это позволяет позволяет гибко управлять множеством ресурсов через изменение одной переменной [10]. Как это работает?
Допустим, в файле terraform.tfvars или в Terragrunt inputs мы передадим
vm_instances = { "web-server-01" = { cores = 2 memory = 4 boot_disk_gb = 30 image_family = "ubuntu-2204-lts" user_data = "#cloud-config\npackage_update: true" }, "database-01" = { cores = 4 memory = 8 boot_disk_gb = 100 image_family = "centos-7" user_data = "#cloud-config\ndisable_root: false" }, "monitoring-01" = { cores = 2 memory = 2 boot_disk_gb = 20 image_family = "debian-11" user_data = "#cloud-config\npackages: ['prometheus']" } }
Terraform возьмет каждый элемент этой мапы и создаст отдельный ресурс yandex_compute_instance:
Итерация 1:
Создается ВМ с именем web-server-01, 2 ядрами, 4 ГБ памяти
Итерация 2:
Создается ВМ с именем database-01, 4 ядрами, 8 ГБ памяти
Итерация 3:
Создается ВМ с именем monitoring-01 и т.д
3.5. Версионирование как гарантия стабильности
Для надежности production-сред в особненности, источники категорически настаивают на фиксировании версии как внешних провайдеров, так и собственных модулей. Это защищает от непреднамеренных изменений, которые могут быть внесены в апстрим репозитории [6, 21]. Семантическое версионирование (SemVer) позволяет контролировать процесс обновлений.
module "network" { source = "git::https://gitlab.sarex.io/terraform/yc-network-module.git?ref=v2.5.1"
vpc_name = "production" cidr = "10.100.0.0/16" }
3.6. Оркестрация и соблюдение принципа DRY через Terragrunt
Для управления сложными, многоуровневыми деплойментами в облаках и соблюдения принципа DRY рекомендуется использовать инструменты оркестрации, такие как Terragrunt [27, 30]. Он позволяет устранить дублирование в конфигурациях бэкенда и провайдеров, а также явно управлять зависимостями между компонентами.
Автоматическое включение конфигураций из родительских папок
include "root" { path = find_in_parent_folders("root.hcl") }
Объявление зависимости от модуля сети
dependency "vpc" { config_path = "../network"
Mock-выходы для команд plan без доступа к реальному state
mock_outputs = { subnet_ids = ["mock-subnet-id-a", "mock-subnet-id-b"] network_id = "mock-network-id" } }
Локальные переменные для удобства
locals { common_vars = read_terragrunt_config(find_in_parent_folders("common.hcl")).locals }
Входные переменные для Terraform-модуля
inputs = { environment = local.common_vars.environment region = local.common_vars.region subnet_id = dependency.vpc.outputs.subnet_ids[0]
... другие переменные
}
Файл root.hcl:
hcl
Настройка удаленного бэкенда в Yandex Object Storage с блокировкой через YDB
remote_state { backend = "s3" config = { endpoint = "storage.yandexcloud.net" bucket = "company-terraform-state-prod" key = "${path_relative_to_include()}/terraform.tfstate" region = "ru-central1" skip_region_validation = true skip_credentials_validation = true skip_requesting_account_id = true skip_metadata_api_check = true force_path_style = true
# Настройка блокировок состояний через Yandex Database (YDB)
dynamodb_endpoint = "https://docapi.serverless.yandexcloud.net/ru-central1/b1gxxxxxxxx/etn03j3j45678"
dynamodb_table = "terraform_locks"
} }
Генерация общего провайдера для Yandex Cloud
generate "provider" { path = "provider.tf" if_exists = "overwrite_terragrunt" contents = <<EOF provider "yandex" { folder_id = "${local.folder_id}" zone = "ru-central1-a" } EOF }
В источниках отмечается, что такой подход, реализуемый Terragrunt, позволяет централизованно управлять настройками для сотен компонентов, обеспечивая согласованность и безопасность [27, 30].
- Управление состоянием (State Management)
Файл состояния (state) является критически важным компонентом Terraform, который выполняет роль «памяти» для IaC. Этот JSON-файл содержит точное сопоставление декларативных определений в файлах .tf и реальными физическими ресурсами в облачной среде [6, 16, 24]. Без корректного файла состояния Terraform не может определить, какие ресурсы уже развернуты, какие требуют обновления, а какие должны быть удалены, что ведет к катастрофическим последствиям для инфраструктуры [6, 16, 21].
4.1. Состав и структура файла состояния
В источниках подробно описывается, что файл состояния (обычно terraform.tfstate) содержит не только перечень ресурсов, но и метаданные, необходимые для отслеживания зависимостей между ними и поддержания целостности инфраструктурного графа [6, 16, 21].
{ "version": 4, "terraform_version": "1.6.0", "serial": 5, "lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "outputs": { "vpc_id": { "value": "enp0s9d2v1abc", "type": "string" } }, "resources": [ { "mode": "managed", "type": "yandex_compute_instance", "name": "app_server", "provider": "provider["registry.terraform.io/yandex-cloud/yandex"]", "instances": [ { "attributes": { "id": "fhm0b28lgm4qabcde", "name": "app-prod-01", "platform_id": "standard-v3", "resources": { "cores": 2, "memory": 4 }, "boot_disk": [ { "disk_id": "fhm0b28lgm4qabcdefg" } ] }, "private": "bnR5cGU6IHN0cmluZwp2YWx1ZTogaGFzaGljb3JwL2xpbnV4LWFtaQ==" } ] } ] }
Как разъясняется в источниках, ключевыми элементами структуры являются:
version: версия формата данных состояния, которая меняется при обновлении Terraform [21].
serial: возрастающий номер, увеличивающийся при каждом успешном применении изменений (terraform apply), что позволяет отслеживать историю изменений [6, 21].
lineage: уникальный идентификатор UUID, генерируемый при инициализации бэкенда и предотвращающий случайное смешивание разных состояний проекта [6, 16].
resources: детализированный список всех управляемых ресурсов с их полными атрибутами, включая чувствительные данные, которые могут храниться в открытом виде [6, 16, 24].
4.2. Remote Backends
Хранение файла состояния локально на ПК допустимо только в тестовых целях. В командной работе это неизбежно ведет к потере данных, конфликтам версий [3, 6, 16]. Рекомендуемой практикой является использование удаленных бэкендов (remote backends), которые обеспечивают безопасное хранение, шифрование данных, версионирование и совместный доступ к состоянию [6, 16, 23]. Как показано в официальной документации Yandex Cloud и практических примерах, для надежного хранения состояния необходимо использовать Yandex Object Storage в сочетании с Yandex Database (YDB) для локов [10, 28].
terraform { backend "s3" { endpoint = "storage.yandexcloud.net" bucket = "company-name-terraform-state-prod" key = "global/network/terraform.tfstate" region = "ru-central1"
# Настройки для работы с Yandex Cloud S3-совместимым хранилищем
skip_region_validation = true
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
force_path_style = true
# Блокировка состояния через Yandex Database (YDB)
dynamodb_endpoint = "https://serverless.yandexcloud.net/ru-central1/b1g8..."
dynamodb_table = "terraform-state-locks"
encrypt = true
} }
В источниках отмечается, что использование YDB для блокировок является обязательным для production-сред, так как предотвращает одновременное изменение состояния несколькими пользователями [10, 28].
4.3. Блокировка состояния (State Locking)
Механизм блокировки состояния предотвращает состояние race condition, когда несколько инженеров или CI/CD-пайплайнов одновременно пытаются изменить одну и ту же инфраструктуру [3, 16, 28]. Если процесс выполнения terraform apply прервется аварийно (например, из-за сетевого сбоя), блокировка может остаться активной. Для ее снятия вручную, согласно документации, используется команда:
terraform force-unlock <LOCK_ID>
В руководства=х предостерегают от частого использования этой команды и рекомендуют сначала убедиться, что ни один другой процесс не использует файл стейта [16, 21].
4.4. Изоляция окружений (Isolation)
Для обеспечения надежного разделения сред разработки (dev), тестирования (stage) и production (prod) в источниках рассматриваются два основных подхода:
Workspaces: Позволяют использовать одну и ту же конфигурацию кода для нескольких окружений, но хранят состояния всех окружений в одном бэкенде. Этот подход может подходить для небольших проектов, но не рекомендуется для production из-за риска случайного воздействия на несколько сред и отсутствия полной изоляции [6, 23].
Структура каталогов (Рекомендуемый подход): Обеспечивает полную изоляцию за счет использования разных директорий и, соответственно, разных файлов состояния для каждого окружения. В сочетании с Terragrunt этот подход автоматизируется с помощью функции path_relative_to_include() для динамического формирования ключей (key) в Object Storage [7, 20, 27].
Пример структуры каталогов с Terragrunt:
live/ ├── prod/ │ ├── ru-central1/ │ │ ├── vpc/ │ │ │ └── terragrunt.hcl # key = "prod/ru-central1/vpc/terraform.tfstate" │ │ └── k8s/ │ │ └── terragrunt.hcl # key = "prod/ru-central1/k8s/terraform.tfstate" │ └── _global/ │ └── iam/ │ └── terragrunt.hcl # key = "prod/_global/iam/terraform.tfstate" └── dev/ └── ru-central1/ └── vpc/ └── terragrunt.hcl # key = "dev/ru-central1/vpc/terraform.tfstate"
Как подчеркивается в источниках Gruntwork, такая структура минимизирует «радиус поражения» (blast radius) и обеспечивает максимальную безопасность [7, 20].
4.5. Работа с данными из других стейтов
Для создания зависимостей между изолированными компонентами инфраструктуры (например, когда модулю приложений нужен ID VPC из модуля сети) в источниках описывается два подхода:
Нативный подход Terraform: Использование data source terraform_remote_state для чтения выходных значений из состояния другого модуля.
data "terraform_remote_state" "network" { backend = "s3" config = { endpoint = "storage.yandexcloud.net" bucket = "company-name-terraform-state-prod" key = "prod/ru-central1/vpc/terraform.tfstate" region = "ru-central1" skip_region_validation = true skip_credentials_validation = true } }
resource "yandex_compute_instance" "app" { name = "app-server" platform_id = "standard-v3" zone = "ru-central1-a"
network_interface { subnet_id = data.terraform_remote_state.network.outputs.subnet_ids[0] } }
Однако этот подход требует жесткого указания путей к бэкенду и может создать скрытые зависимости [6, 16].
Подход с использованием Terragrunt: Использование блока dependency, который автоматически управляет зависимостями и порядком развертывания.
terragrunt.hcl для модуля приложения
dependency "vpc" { config_path = "../vpc" }
inputs = { subnet_id = dependency.vpc.outputs.subnet_ids[0] }
4.6. Манипуляции со стейтом через CLI и код
Никогда не следует редактировать файл состояния вручную с помощью текстового редактора, так как это почти гарантированно приведет к повреждению данных и неработоспособности инфры [3, 6, 16]. Для безопасного управления состоянием предназначены CLI-команды Terraform:
Импорт существующих ресурсов: Привязка ресурсов, созданных вручную или другим способом, под управление Terraform.
terraform import yandex_compute_instance.app fhm0b28lgm4qabcde
Как описывается в источниках, эта команда добавляет ресурс в состояние, но не создает код, который нужно написать отдельно [6, 21].
Безопасный рефакторинг с moved: Современный способ переименования ресурсов или изменения их адреса без физического пересоздания.
В конфигурации Terraform
moved { from = yandex_compute_instance.old_name to = yandex_compute_instance.new_name }
Этот блок в книге Robert Hafner'а указывает Terraform на необходимость перенести состояние, а не удалять и создавать заново [21].
Удаление ресурса из управления Terraform: Команда state rm удаляет ресурс из состояния, но не удаляет его физически в облаке.
bash
terraform state rm yandex_compute_instance.temporary
Эта операция должна выполняться с крайней осторожностью!! [16, 21].
Downstream Pipelines и автоматизация в CI/CD
При управлении крупной инфраструктурой, состоящей из сотен компонентов, классический подход с единым CI/CD-пайплайном становится неприемлемым. Его выполнение превращается в многочасовую процедуру, а ошибка в одном компоненте приводит к остановке всего процесса и сложностям в локализации проблемы. Как показывают практические кейсы, Downstream (дочерние) Pipelines решают эту проблему, превращая монолитный деплой в набор независимых, параллельных задач, что сокращает время развертывания с часов до минут и кардинально упрощает операционную деятельность [5, 29].
5.1. Динамическая генерация пайплайна: принцип работы и практическая реализация
Суть динамической генерации заключается в том, что структура пайплайна не задается жестко в YAML-файле, а создается скриптом на лету на основе актуальной файловой структуры репозитория live/, как было уже указано выше. Это обеспечивает полное соответствие между инфраструктурным кодом и процессом его доставки.
Контекст и исходная структура: Предположим, инфраструктура организована по принципу, описанному в материале Gruntworks - Folder Structure, и структурно выглядит как Окружение/Регион/Компонент:
live/ ├── prod/ │ ├── ru-central1-a/ │ │ ├── network/ # Компонент A: VPC, subnets │ │ │ └── terragrunt.hcl │ │ └── k8s-cluster/ # Компонент B: k8s кластер │ │ └── terragrunt.hcl │ └── _global/ │ └── object-storage/ # Компонент C: s3 buckets │ └── terragrunt.hcl └── dev/... # Аналогичная структура для других каталогов
Шаг 1: Создание скрипта-генератора Скрипт generate-pipeline.sh выполняет ключевую роль. Он:
Сканирует всю иерархию live/ в поисках файлов terragrunt.hcl, игнорируя служебные директории (например, .terragrunt-cache/).
Для каждого найденного компонента извлекает из пути его метаданные: окружение (prod, dev), регион (ru-central1-a, _global) и имя компонента (network).
Генерирует три стандартные job GitLab CI для каждого компонента: validate, plan и apply.
Автоматически назначает переменные окружения (например, TG_ROOT) и, что критически важно, настраивает правило when: manual для стадии apply в production-окружениях. Это обязательная мера безопасности, чтобы production-изменения не катились автоматически.
#!/bin/bash echo "stages: [validate, plan, apply]" > .gitlab-ci.generated.yml
find live -name "terragrunt.hcl" -not -path "/.terragrunt-cache/" | while read CONFIG_FILE; do COMPONENT_DIR=$(dirname "$CONFIG_FILE") ENV=$(echo "$COMPONENT_DIR" | cut -d'/' -f2) REGION=$(echo "$COMPONENT_DIR" | cut -d'/' -f3) COMPONENT_NAME=$(basename "$COMPONENT_DIR")
for STAGE in validate plan apply; do
JOB_NAME="${STAGE}-${ENV}-${REGION}-${COMPONENT_NAME}"
cat >> .gitlab-ci.generated.yml << EOF
${JOB_NAME}: stage: ${STAGE} variables: TG_ROOT: "${COMPONENT_DIR}" ENVIRONMENT: "${ENV}" script: - cd ${TG_ROOT} - terragrunt init -reconfigure -input=false - terragrunt ${CI_JOB_STAGE} -input=false --terragrunt-non-interactive EOF # Ключевое правило безопасности для production if ; then echo " when: manual" >> .gitlab-ci.generated.yml fi echo "" >> .gitlab-ci.generated.yml done done
Шаг 2: Оркестрация в основном пайплайне Основной файл .gitlab-ci.yml становится минимальным и выполняет лишь две функции — запуск генератора и триггер downstream пайплайна.
yaml
stages:
- generate
- trigger
generate-config: stage: generate script: ./generate-pipeline.sh artifacts: paths: - .gitlab-ci.generated.yml expire_in: 1 hour
trigger-downstream: stage: trigger trigger: include: - artifact: .gitlab-ci.generated.yml job: generate-config strategy: depend
Следовательно, при каждом пуше в репозиторий сначала выполняется джоба generate-config, которая создает актуальный файл .gitlab-ci.generated.yml и сохраняет его как артефакт. Затем job trigger-downstream запускает новый пайплайн, используя сгенерированный файл в качестве .gitlab-ci.yml.
Шаг 3: Что происходит в сгенерированном downstream пайплайне В результате для структуры из трех компонентов будет создано 9 независимых job, организованных в трех стейджах:
-=ВСЕ ДЖОБЫ ВЫПОЛНЯЮТСЯ ПАРАЛЛЕЛЬНО validate: • validate-prod-ru-central1-a-network • validate-prod-ru-central1-a-k8s-cluster • validate-prod-global-object-storage
plan (если validate завершился успешно): • plan-prod-ru-central1-a-network • plan-prod-ru-central1-a-k8s-cluster • plan-prod-global-object-storage
Стадия 'apply' (manual для prod или manual для всех apply): • apply-prod-ru-central1-a-network (manual) • apply-prod-ru-central1-a-k8s-cluster (manual) • apply-prod-global-object-storage (manual)
А вот так будет выглядеть сгенерированный gitlab-ci для компонента network:
validate-prod-ru-central1-a-network: stage: validate variables: TG_ROOT: "live/prod/ru-central1-a/network" ENVIRONMENT: "prod" YC_FOLDER_ID: "${YC_PROD_FOLDER_ID}" before_script: - cd ${TG_ROOT} script: - terragrunt init -reconfigure --terragrunt-non-interactive - terragrunt validate -input=false --terragrunt-non-interactive rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' tags: - yc interruptible: true
plan-prod-ru-central1-a-network: stage: plan variables: TG_ROOT: "live/prod/ru-central1-a/network" ENVIRONMENT: "prod" YC_FOLDER_ID: "${YC_PROD_FOLDER_ID}" before_script: - cd ${TG_ROOT} script: - terragrunt init -reconfigure --terragrunt-non-interactive - terragrunt plan -input=false --terragrunt-non-interactive -out=tfplan rules: - if: '$CI_COMMIT_BRANCH == "master"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' tags: - yc interruptible: true artifacts: paths: - ${TG_ROOT}/*.plan when: always expire_in: 1 week
apply-prod-ru-central1-a-network: stage: apply variables: TG_ROOT: "live/prod/ru-central1-a/network" ENVIRONMENT: "prod" YC_FOLDER_ID: "${YC_PROD_FOLDER_ID}" before_script: - cd ${TG_ROOT} script: - terragrunt init -reconfigure --terragrunt-non-interactive - terragrunt apply -input=false --terragrunt-non-interactive tfplan rules: - if: '$CI_COMMIT_BRANCH == "master"' when: manual tags: - yc interruptible: true
Такой подход обеспечивает максимальный параллелизм. Например, пока выполняется plan для network, одновременно может идти validate для object-storage. Стадия apply для всего production-окружения блокируется и требует ручного нажатия кнопки деплоя для каждого компонента в отдельности, что дает полный контроль над разверткой.
5.2. Ключевые преимущества и операционные выгоды подхода
Экспоненциальный рост скорости: Вместо последовательного выполнения команд для всех компонентов, они обрабатываются параллельно. Это преобразует время деплоя из линейной зависимости O(n) в константную O(1). Яркий пример — кейс компании Selectel, где внедрение Downstream Pipelines сократило время полного развертывания инфраструктуры с 8 часов до 24 минут [5].
Минимизация «радиуса поражения» (Blast Radius): При использовании единого пайплайна с командой terragrunt run-all ошибка в любом модуле приводит к остановке всего процесса, а откат изменений может быть проблематичным занятием. При гранулярном подходе падение джобы типа apply-prod-ru-central1-a-k8s-cluster никак не повлияет на уже завершенное или еще не начатое развертывание компонентов network или object-storage. Можно точечно исправить ошибку в одном компоненте и перезапустить только его, не затрагивая остальные стабильные части системы [5, 16].
Прозрачность и простой траблшутинг: В интерфейсе GitLab CI/CD каждый компонент представлен отдельной джобой с собственным логом. Это кардинально отличается от гаргантюанского вывода run-all, где логи десятков модулей идут единым потоком. При возникновении проблемы инженер мгновенно видит, в каком именно компоненте и на каком стейдже произошел сбой, и вследствие может посмотреть его лог предметно. [5, 29].
<езопасность и контроль: Правило when: manual для apply в production является не просто рекомендацией, а обязательным guardrail, согласно истоичникам. Оно исключает возможность автоматического применения потенциально разрушительных изменений из-за ошибки в коде или сбоя в CI-системе. Каждое изменение подтвержадется руками, что соответствует принципам GitOps и compliance-требованиям для сред, критичных к аптайму.
Управление зависимостями и порядком выполнения: Важным аспектом, который решается уже на уровне конфигурации Terragrunt, а не самого пайплайна, являются зависимости между компонентами. Например, k8s-cluster зависит от выходных данных network (ID подсетей). В файле terragrunt.hcl компонента k8s-cluster это описывается через блок dependency. Во время выполнения terragrunt apply в сгенерированной джобе Terragrunt автоматически прочитает состояние network из remote стейта, получит необходимые выходные переменные и передаст их в модуль. Таким образом, оркестрация зависимостей и параллелизм не конфликтуют: пайплайн может запустить джобы параллельно, а Terragrunt внутри джобы будет ждать, пока состояние зависимого компонента (network) не станет актуальным [18, 27].
5.3. Статический анализ и валидация
Динамическую генерацию можно расширить для создания предварительных стадий проверки. Например, можно создать отдельную стадию lint, которая для каждого компонента запускает:
terragrunt validate — проверка синтаксиса Terraform.
terragrunt hclfmt --terragrunt-check — проверка соответствия формату.
checkov -d . — статический анализ безопасности на наличие уязвимых конфигураций (например, открытых security groups, незашифрованных дисков) [5, 23].
Эти проверки выполняются за секунды и позволяют «отсеять» очевидные проблемы до запуска длительных операций plan и apply, экономя время и ресурсы.
- Рефакторинг инфры
Рефакторинг в Terraform долгое время был доовльно рискованной операцией. Изменение идентификатора ресурса в коде (например, с aws_instance.web на aws_instance.server) воспринималось TF как удаление старого объекта и создание нового, что приводило к простоям и потере данных у ресурсов с состоянием (БД, S3-бакеты) [21, 24]. С выходом версии 1.1 Terraform представил блоки moved — декларативный способ управления состоянием прямо в коде, который пришел на замену ручным и опасным командам в CLI [6, 21].
6.1. Деструктивный рефакторинг
Terraform связывает идентификатор ресурса в файлах .tf с уникальным ID в облаке через стейт [16, 21]. Если мы просто переименуем ресурс в коде, Terraform интерпретирует это как удаление ресурса и создание другого. Это приводит к печальным последствиям — удалению работающего инстанса виртуальной машины, базы данных или бакета [16, 21, 24].
6.2. Блок moved
Блок moved сообщает Terraform, что ресурс сменил свое расположение или имя внутри кода. Это позволяет перепривязать существующую запись в стейте к новому имени без негативных последствий на реальную инфраструктуру [6, 21]. Синтаксис блока прост:
hcl
moved { from = <СТАРЫЙ_АДРЕС_РЕСУРСА> to = <НОВЫЙ_АДРЕС_РЕСУРСА> }
6.3. Основные сценарии использования
В руководствах выделяют несколько ключевых сценариев, где блок moved становится незаменимым.
Сценарий 1: Простое переименование ресурса Наиболее частый случай — приведение имен ресурсов к единому стандарту.
Было: resource "yandex_compute_instance" "vm"
Стало:
resource "yandex_compute_instance" "app_server" { name = "production-app-01" platform_id = "standard-v3"
... остальная конфигурация
}
moved { from = yandex_compute_instance.vm to = yandex_compute_instance.app_server }
После применения terraform apply в стейте Terraform запись для ВМ будет обновлена, а сама ВМ в облаке останется нетронутой.
Сценарий 2: Перемещение ресурса внутрь модуля В процессе развития проекта монолитный код разбивается на модули. Блок moved позволяет безопасно инкапсулировать ресурс.
В корневом main.tf (старая структура)
resource "yandex_vpc_network" "this" { name = "my-network" }
В новом модуле modules/network/main.tf
resource "yandex_vpc_network" "this" { name = var.network_name }
В корневом main.tf (новая структура)
module "network" { source = "./modules/network" network_name = "my-network" }
moved { from = yandex_vpc_network.this to = module.network.yandex_vpc_network.this }
Сценарий 3: Рефакторинг вызова модулей Можно переименовывать не только ресурсы, но и вызовы самих модулей для улучшения читаемости кода.
moved { from = module.obscure_name_xyz to = module.kubernetes_cluster }
6.4. Разница между moved и terraform state mv?
До появления блоков moved использовали команду terraform state mv. Однако у декларативного подхода есть большие преимущества:
Автоматизация для всей команды: Блок moved является частью кода. Когда разработчик обновляет репозиторий и запускает terraform apply, миграция состояния происходит автоматически. Не нужно запоминать и выполнять последовательность команд вручную [6, 21].
Безопасность и проверка через plan: Изменения стейта отображаются в выводе terraform plan. Вы можете заранее убедиться, что Terraform планирует именно перемещение (в логе будет указано # resource has moved), а не удаление и создание (# resource will be destroyed, # resource will be created) [6, 21].
История изменений в Git: Факт рефакторинга фиксируется в системе контроля версий. Любой член команды, просматривая историю коммитов, увидит, когда и почему ресурс был переименован, что делает эволюцию инфраструктуры полностью прозрачной [21].
6.5. Интеграция рефакторинга в рабочий процесс с Terragrunt
Terragrunt, выступая оркестратором, накладывает строгие ограничения на структуру проекта, что делает рефакторинг более предсказуемым и безопасным [27, 30].
- Изоляция состояния и минимизация Blast Radius Как уже было сказано, Terragrunt стимулирует дробление инфраструктуры на мелкие, независимые компоненты, каждый со своим файлом состояния [7, 20, 27].
Преимущество: Опять же, при ошибке в рефакторинге (например, ошибка в блоке moved) затронет состояние только одного компонента. Это предотвращает каскадные сбои во всей инфраструктуре [16, 24, 30].
Пример: При переименовании ресурса в модуле k8s-cluster состояние модулей network и database останутся полностью нетронутыми, что обеспечивает спокойный сон запускающего Terragrunt.
-
Согласованный рефакторинг в нескольких окружениях При использовании иерархической структуры live/ один и тот же модуль может использоваться в dev, stage и prod. Блок moved, добавленный в исходный код модуля, будет автоматически применен во всех окружениях при их следующем обновлении. Terragrunt позволяет выполнить массовую проверку с помощью terragrunt run-all plan и после аппрува terragrunt run-all apply, обеспечивая консистентность изменений во всех средах [7, 20].
-
Управление зависимостями во время рефакторинга Если рефакторинг изменяет outputs модуля (например, переименовывает выходную переменную vpc_id в network_id), зависимые модули могут временно сломаться. В Terragrunt можно решить эту проблему через mock_outputs [29].
В конфигурации модуля приложения, зависящего от network
dependency "network" { config_path = "../network"
mock_outputs = { # Предполагаем, что после рефакторинга vpc_id стал называться network_id network_id = "mock-temp-id" subnet_ids = ["mock-subnet-a", "mock-subnet-b"] } mock_outputs_allowed_terraform_commands = ["plan", "validate"] }
inputs = {
Используем новое имя переменной
vpc_id = dependency.network.outputs.network_id }
Это позволяет выполнить terragrunt plan в модуле приложения до того, как рефакторинг в модуле сети будет применен, и проверить, корректно ли обновлены ссылки на выходные данные.
6.6. Рекомендации по использованию moved
Не удалять блоки moved сразу: В отличие от блоков import, блоки moved рекомендуется оставлять в коде на длительный срок, особенно в публичных модулях. Это позволяет пользователям, которые обновляются нерегулярно, бесшовно мигрировать со старых версий [21].
Использовать модули публичные: Включение блоков moved в публичные и внутренние модули — это золотой стандарт, который обеспечивает обратную совместимость и беспроблемное обновление для консьюмеров модуля [6, 21].
Всегда внимательно проверять план (plan): Перед применением рефакторинга с помощью terraform apply или terragrunt apply обязательно нужно запустить terraform plan. Убедиться, что в выводе присутствует только запись о перемещении ресурсов (# ... has moved to ...) и отсутствуют операции destroy [16, 21].
6.7. Практический пример: комплексный рефакторинг
Рассмотрим сценарий, где необходимо переименовать несколько ресурсов и перенести конфигурацию сети в отдельный модуль.
Исходная конфигурация:
main.tf (устаревший монолит)
resource "yandex_vpc_network" "main" { name = "prod-net" } resource "yandex_vpc_subnet" "private" { name = "private-subnet" v4_cidr_blocks = ["10.0.1.0/24"] } resource "yandex_compute_instance" "app" { name = "app-old-name"
... ссылка на subnet: yandex_vpc_subnet.private.id
}
Целевая конфигурация после рефакторинга:
Модуль: modules/network/main.tf
resource "yandex_vpc_network" "this" { name = var.name } resource "yandex_vpc_subnet" "private" { name = "${var.name}-private" v4_cidr_blocks = var.private_subnets }
Корневой main.tf
module "network" { source = "./modules/network" name = "prod-network" private_subnets = ["10.0.1.0/24"] }
resource "yandex_compute_instance" "application_server" { name = "prod-app-01"
... ссылка теперь на выход модуля: module.network.private_subnet_id
}
Блоки moved для безопасного перехода:
В корневом main.tf, рядом с вызовом модуля
moved { from = yandex_vpc_network.main to = module.network.yandex_vpc_network.this } moved { from = yandex_vpc_subnet.private to = module.network.yandex_vpc_subnet.private } moved { from = yandex_compute_instance.app to = yandex_compute_instance.application_server }
После выполнения terraform apply все существующие ресурсы будут корректно перенесены в стейт без их пересоздания в облаке.