Модули: переиспользование конфигурации

Модуль — это переиспользуемый набор ресурсов с входами (переменные) и выходами (output). По сути это функция инфраструктуры: вызвал с параметрами — получил готовый кусок облака.

Любая папка с .tf файлами — уже модуль. Вопрос лишь в том, вызываете вы её напрямую (root) или подключаете из другого места (child). Модули превращают копипасту в переиспользование.

Когда вы три раза копируете один и тот же набор «VPC + subnet + security group», пора делать модуль. Модуль инкапсулирует ресурсы за чётким интерфейсом: входные переменные — это его параметры, выходы — его результат, а внутренние ресурсы скрыты. Это та же абстракция, что функция в программировании.

Root и child модули

# root-модуль (где запускаете terraform) вызывает child:
module "network" {
  source = "./modules/network"   # путь к child-модулю

  vpc_cidr    = "10.0.0.0/16"    # вход
  environment = "prod"
}

# используем выход модуля как обычный атрибут
resource "aws_instance" "web" {
  subnet_id = module.network.subnet_id   # module.<имя>.<output>
}

Папка, где вы запускаете terraform, — это root-модуль. Всё, что он подключает через module, — child-модули. К выходам обращаются как module.имя.output. Child-модуль не видит переменные родителя — только то, что ему явно передали. Это и есть инкапсуляция.

        ROOT module (вы здесь)
        /          |          \
       v           v           v
  module.network  module.db  module.app
   (vpc,subnet)   (rds)      (ec2,lb)
       |                        ^
       +--- output subnet_id ---+
            (передаётся как вход)

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

Модуль — это namespace: ресурсы внутри получают префикс module.имя. в адресе. Родитель видит только объявленные выходы, дочерний — только переданные входы. Смоделируем эту инкапсуляцию:

def network_module(vpc_cidr, environment):
    # внутренние ресурсы -- скрыты от родителя
    vpc_id = f"vpc-{environment}"
    subnet_id = f"subnet-{environment}-a"
    _internal_log = "не виден снаружи"
    # наружу отдаём только объявленные outputs
    return {"vpc_id": vpc_id, "subnet_id": subnet_id}

mod = network_module(vpc_cidr="10.0.0.0/16", environment="prod")
print("Доступные выходы модуля:", list(mod.keys()))
print("subnet_id для инстанса:", mod["subnet_id"])

# попытка достать внутреннее -> провал, инкапсуляция работает
print("Виден ли _internal_log?", "_internal_log" in mod)

«Попробуй сам ▶» — наружу торчат только vpc_id и subnet_id; внутренние детали скрыты. Так модуль прячет сложность за интерфейсом.

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

  • Модуль-монолит. Один гигантский модуль «вся инфра» нельзя переиспользовать по частям. Дробите по назначению.
  • Слишком много входов. Модуль с 50 переменными неудобен. Группируйте через объекты, давайте разумные дефолты.
  • Прямой доступ к внутренним ресурсам. Обращаться к ресурсу внутри модуля в обход output нельзя — это нарушает инкапсуляцию (и невозможно).

Best practices

  • Модуль = одна понятная единица (сеть, БД, кластер). Маленькие модули — меньше blast radius.
  • Чёткий интерфейс: продуманные входы с типами/дефолтами и только нужные выходы.
  • Не передавайте регион, ARN, CIDR хардкодом внутрь модуля — только через входные переменные.

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

Важно понимать, что модуль создаёт собственное пространство имён в state. Ресурс aws_instance.web внутри модуля app в state адресуется как module.app.aws_instance.web. Это значит, что один и тот же модуль можно подключить несколько раз с разными именами, и их ресурсы не столкнутся — каждый вызов изолирован своим префиксом. Именно это делает модуль переиспользуемым: вызвали module "app_eu" и module "app_us" — получили две независимые копии одной и той же логики в разных регионах.

Хороший модуль проектируют по принципу «разумные умолчания, явные исключения». Большинство входных переменных должны иметь продуманные default, чтобы в простом случае модуль вызывался с минимумом параметров. И только то, что действительно обязано отличаться (имя, окружение, размер), делают обязательным. Модуль с пятьюдесятью обязательными переменными невозможно использовать; модуль с тремя обязательными и двадцатью опциональными — удобен и гибок. Это та же дисциплина, что и проектирование хорошего API функции: минимальная обязательная поверхность, богатая опциональная настройка.

Итог: модуль — это функция инфраструктуры с входами и выходами и скрытой реализацией. Root вызывает child, общение — только через интерфейс. Дальше — откуда модули брать и как версионировать.

Проверьте себя
1. Как root-модуль обращается к выходу child-модуля network?
Anetwork.subnet_id
Bmodule.network.subnet_id
Cvar.network.subnet_id
Ddata.network.subnet_id
2. Что обеспечивает инкапсуляция в модулях?
AУскорение apply
BChild видит только переданные входы, родитель — только объявленные выходы, внутренние ресурсы скрыты
CШифрование state
DПараллельное создание