Композиция: вложенные модули и среды

Сила модулей раскрывается в композиции: высокоуровневые модули собираются из низкоуровневых, а выход одного становится входом другого. Так из кирпичей строят целые окружения.

Хорошая архитектура Terraform похожа на слоёный пирог: сеть → данные → приложение. Каждый слой — модуль, который берёт выходы нижнего и отдаёт выходы верхнему. Циклы между слоями запрещены.

Реальные системы строятся слоями. Модуль network создаёт VPC и подсети. Модуль database берёт subnet_id из сети и поднимает БД. Модуль app берёт и сеть, и эндпоинт БД. Это композиция: связь модулей через передачу выходов входами.

Слоистая сборка

module "network" {
  source   = "./modules/network"
  vpc_cidr = "10.0.0.0/16"
}

module "database" {
  source    = "./modules/database"
  subnet_id = module.network.subnet_id   # выход сети -> вход БД
}

module "app" {
  source       = "./modules/app"
  subnet_id    = module.network.subnet_id
  db_endpoint  = module.database.endpoint  # выход БД -> вход app
}

Эти связи задают тот же граф зависимостей, что и между ресурсами: Terraform создаст сеть, затем БД, затем приложение. Циклы запрещены: если app отдаёт выход в network, а network зависит от app — ошибка Cycle.

  СЛОЙ 1: network   (vpc, subnets)
              |  subnet_id
              v
  СЛОЙ 2: database  (rds в subnet)
              |  endpoint
              v
  СЛОЙ 3: app       (ec2 + lb, знает БД)

  поток выходов СВЕРХУ ВНИЗ, циклов нет = DAG

Как работает под капотом

Композиция модулей — это тот же топологический порядок, что и для ресурсов, только узлы крупнее. Смоделируем сборку окружения с проверкой на циклы:

modules = {
    "network":  [],
    "database": ["network"],
    "app":      ["network", "database"],
}

def build_order(modules):
    order, temp, done = [], set(), set()
    def visit(m):
        if m in done:
            return
        if m in temp:
            raise ValueError(f"ЦИКЛ через модуль {m}!")
        temp.add(m)
        for dep in modules[m]:
            visit(dep)
        temp.discard(m); done.add(m); order.append(m)
    for m in modules:
        visit(m)
    return order

print("Порядок применения модулей:")
for i, m in enumerate(build_order(modules), 1):
    print(f"  {i}. module.{m}")

«Попробуй сам ▶» — добавьте "network": ["app"] и увидите, как детектор поймает цикл. Это ровно та проверка, что делает Terraform.

Частые ошибки

  • Циклы между модулями. Взаимная передача выходов ломает граф — Terraform падает с Cycle.
  • Слишком глубокая вложенность. Модуль в модуле в модуле трудно отлаживать; держите 2-3 уровня максимум.
  • Дублирование root на окружения. Копипаста root-конфигов для dev/prod расходится со временем; параметризуйте через tfvars.

Best practices

  • Стройте чёткие слои: сеть → данные → приложение, поток выходов в одну сторону.
  • Один root на окружение с разными tfvars, общие child-модули переиспользуются.
  • Избегайте «модуля ради модуля»: оборачивайте только то, что реально повторяется.

Разбор глубже

В сообществе сложилось важное различие между модулями-ресурсами и корневыми модулями. Модуль-ресурс — это переиспользуемый кирпич (VPC, БД), который сам по себе не разворачивается, а только подключается. Корневой модуль — это конкретная сборка под конкретное окружение, которая эти кирпичи компонует и запускается напрямую. Хорошая практика — держать переиспользуемые модули как можно более «глупыми» и параметризуемыми, а всю специфику окружения (имена, размеры, какие модули вообще подключать) выносить в корневые модули. Так один набор кирпичей обслуживает все окружения.

Отдельная тема — инструменты-обёртки вроде Terragrunt, появившиеся именно из боли с композицией на масштабе. Когда корневых модулей на каждое окружение становится много, в них накапливается дублирование: один и тот же блок backend, одни и те же провайдеры, повторяющиеся вызовы. Terragrunt позволяет вынести это в общие шаблоны и держать конфигурацию по-настоящему DRY, а также управлять зависимостями между отдельными state. Для старта он не нужен — но знать о его существовании полезно: когда вы упрётесь в копипасту между десятками окружений, это и будет сигналом, что пора смотреть в сторону таких инструментов.

Итог: композиция собирает окружения из модулей-слоёв через передачу выходов; граф остаётся ацикличным. Один root на окружение, общие child переиспользуются. Дальше — выходим в реальный мир: провайдеры и облако.

Проверьте себя
1. Что произойдёт при взаимной передаче выходов между двумя модулями (A→B и B→A)?
AУскорится apply
BTerraform упадёт с ошибкой Cycle — граф перестанет быть ацикличным
CМодули сольются в один
DНичего, это нормально
2. Как обычно организуют конфигурацию на несколько окружений (dev/prod)?
AПолная копипаста root-конфига для каждого
BОдин набор child-модулей + отдельный root/tfvars на окружение
CОдин общий state на все
DЧерез count в каждом ресурсе