Terraform для Docker-хостов и сетей: где проходит граница пользы

Где Terraform реально помогает в Docker-инфраструктуре — хост, firewall, генерация Ansible inventory, — а где только добавляет слой абстракции: lifecycle контейнеров и docker-сети. С рабочим примером стыка Terraform → Ansible из digital-cookbook

Terraform тащат во всё, что хоть как-то связано с инфраструктурой. И в Docker-мире этот рефлекс срабатывает очень быстро: есть VM, есть контейнеры, есть сети — значит, давайте опишем всё в .tf-файлах. На практике это быстро приводит к вопросу: что именно описывать Terraform, а что уже лучше отдать другому инструменту?

Статья — спокойный разбор этой границы. Создание VM и механика cloud-init подробно разобраны в статье Proxmox + Terraform. Деплой Compose-стека на готовый хост — в Ansible и Docker Compose. Здесь — про то, как эти слои стыкуются, что стоит держать в Terraform, а что нет, и как передать управление от одного инструмента к другому без потери источника истины.

Три слоя инфраструктуры — хост, firewall-периметр и контейнеры, — связанные единой линией передачи

В статье

Три слоя ответственности

Хост, сеть и приложение живут на разных уровнях и меняются с разной частотой. Именно это определяет, какой инструмент для чего подходит.

Слой Что держит Частота изменений Инструмент
Инфраструктура VM-хост, диск, firewall, DNS, LB редко Terraform
Хост/ОС пакеты, Docker Engine, пользователи изредка Ansible
Приложение контейнеры, docker-сети, тома, compose-стек часто Docker Compose

Проблемы начинаются, когда инструмент используют не на своём слое: Terraform на уровне контейнеров, Ansible вместо Terraform для сетей, Compose для управления пакетами. Каждый из них оптимизирован под свою частоту изменений и семантику операций.

flowchart TB subgraph TF["Terraform — инфраструктура"] H["VM docker-хост"] FW["Firewall / security group"] INV["Ansible inventory (генерируется)"] end subgraph AN["Ansible — хост/ОС"] DCK["Docker Engine, пакеты, пользователи"] end subgraph CMP["Docker Compose — приложение"] C["Контейнеры, docker-сети, тома"] end H --> DCK FW --> H INV -->|"inventory"| DCK DCK --> C style TF fill:#f9f3e3,stroke:#8b7355 style AN fill:#e8dcc8,stroke:#8b7355 style CMP fill:#c9e4c5,stroke:#5b8a5e

flowchart TB
  subgraph TF["Terraform — инфраструктура"]
    H["VM docker-хост"]
    FW["Firewall / security group"]
    INV["Ansible inventory (генерируется)"]
  end
  subgraph AN["Ansible — хост/ОС"]
    DCK["Docker Engine, пакеты, пользователи"]
  end
  subgraph CMP["Docker Compose — приложение"]
    C["Контейнеры, docker-сети, тома"]
  end
  H --> DCK
  FW --> H
  INV -->|"inventory"| DCK
  DCK --> C

  style TF fill:#f9f3e3,stroke:#8b7355
  style AN fill:#e8dcc8,stroke:#8b7355
  style CMP fill:#c9e4c5,stroke:#5b8a5e
Три слоя ответственности и стык между ними

Что Terraform делает хорошо

Terraform силён там, где изменения редкие, требования декларативные, а воспроизводимость критична: VM и хосты, firewall-периметр, DNS-записи, floating IP, load balancer, managed-сервисы. Это «фундамент»: создаётся один раз, описывается один раз, воспроизводится точно.

Firewall — классический пример такой сущности. Это сетевой периметр вокруг хоста: он меняется редко, должен быть согласован на уровне кластера и быть явно задокументирован. Именно здесь Terraform даёт наибольшую пользу.

В примере из digital-cookbook → terraform/docker-host-handoff firewall описан тремя ресурсами: переиспользуемая security group на уровне кластера, её привязка к каждому хосту и включение firewall с политикой DROP по умолчанию.

# Переиспользуемая security group на уровне кластера:
# периметр docker-хоста (SSH + опубликованный сервис). Источник истины для сети — Terraform.
resource "proxmox_virtual_environment_cluster_firewall_security_group" "docker_host" {
  name    = "docker-host"
  comment = "Managed by Terraform — периметр docker-хоста"

  rule {
    type    = "in"
    action  = "ACCEPT"
    comment = "SSH (Ansible)"
    dport   = "22"
    proto   = "tcp"
    log     = "info"
  }

  rule {
    type    = "in"
    action  = "ACCEPT"
    comment = "HTTP (опубликованный сервис)"
    dport   = "80"
    proto   = "tcp"
    log     = "info"
  }

  rule {
    type    = "in"
    action  = "ACCEPT"
    comment = "HTTPS (опубликованный сервис)"
    dport   = "443"
    proto   = "tcp"
    log     = "info"
  }
}

# Привязка security group к каждому docker-хосту.
resource "proxmox_virtual_environment_firewall_rules" "docker_host" {
  for_each = proxmox_virtual_environment_vm.docker_host

  node_name = each.value.node_name
  vm_id     = each.value.vm_id

  rule {
    security_group = proxmox_virtual_environment_cluster_firewall_security_group.docker_host.name
    comment        = "Периметр docker-хоста"
  }
}

# Включаем firewall на интерфейсе VM.
resource "proxmox_virtual_environment_firewall_options" "docker_host" {
  for_each = proxmox_virtual_environment_vm.docker_host

  node_name = each.value.node_name
  vm_id     = each.value.vm_id

  enabled       = true
  input_policy  = "DROP"
  output_policy = "ACCEPT"
}

input_policy = "DROP" + явные ACCEPT-правила означает, что весь не описанный трафик отбрасывается по умолчанию. Это безопаснее и читаемее, чем открытый периметр с правилами-исключениями.

Деталь, которая может быть неочевидной: при for_each по ресурсу VM (proxmox_virtual_environment_vm.docker_host) each.value.node_name и each.value.vm_id — это атрибуты самого VM-ресурса из Terraform state, а не поля входной переменной var.docker_hosts (там только cores, memory, disk, ip). Поэтому firewall привязывается к хосту по фактическим node_name/vm_id, которые Proxmox присвоил при создании.

Что не стоит тащить в Terraform

Когда Docker-хост создан и firewall настроен, возникает соблазн описать там же и сами контейнеры. Для этого существует провайдер kreuzwerker/docker. Технически он работает, но в большинстве сценариев это лишний слой.

# kreuzwerker/docker — технически возможно, но обычно лишнее
resource "docker_container" "app" {
  name  = "whoami"
  image = "traefik/whoami:v1.10"
  ports {
    internal = 80
    external = 8080
  }
}

Почему это проблема:

  • Частота изменений не совпадает. Контейнеры обновляются при каждой выкладке приложения. terraform apply на каждый релиз — это медленно, рискованно (можно случайно задеть инфраструктурный ресурс рядом) и концептуально неправильно.
  • State становится точкой отказа. При рестартах контейнеров, healthcheck-пересозданиях и ручных docker stop Terraform state расходится с реальностью. Следующий plan показывает drift там, где его нет.
  • Нет встроенной логики выкладки. Rolling update, ожидание healthcheck, откат — всё это есть в Compose и оркестраторах. В Terraform для контейнеров это нужно эмулировать вручную через lifecycle и null_resource.
  • Docker-сети bridge/overlay органично описываются в compose-файле рядом с сервисами, которые их используют. Перенос сетей в Terraform разрывает эту связность без выигрыша.

Вывод: lifecycle контейнеров и docker-сети — зона Docker Compose, не Terraform. Terraform отвечает за среду, в которой работает Compose.

Стык слоёв: Terraform → Ansible

Самое интересное место — граница между Terraform и Ansible. Типичная проблема: Terraform создаёт VM, а IP адреса хостов нужно потом вручную прописать в Ansible inventory. Это и есть начало рассинхронизации.

Решение элегантное: Terraform сам генерирует Ansible inventory из своих выходных данных. Желаемые адреса объявляются один раз — в карте docker_hosts в terraform.tfvars, — но в inventory попадают не напрямую оттуда, а из атрибута VM в state (...ip_config[0].ipv4[0].address). Это тонкость, но важная: Ansible получает именно тот адрес, который реально назначен хосту после apply, а не тот, что вы предполагали во входной переменной. После apply inventory всегда актуален.

Сначала output — контракт Terraform-слоя для всего, что downstream:

# Карта host → IP (без CIDR-маски). Это контракт для Ansible-слоя.
output "docker_hosts" {
  description = "Созданные docker-хосты: имя → IP"
  value = {
    for name, vm in proxmox_virtual_environment_vm.docker_host :
    name => split("/", vm.initialization[0].ip_config[0].ipv4[0].address)[0]
  }
}

split("/", ...)[0] снимает CIDR-маску (например из 10.10.10.111/24 оставляет 10.10.10.111). Это важно: output отдаёт чистый IP, не CIDR.

Затем inventory.tf — ресурс local_file, который рендерит inventory из шаблона:

# Главный приём handoff: Terraform рендерит Ansible inventory из своих же данных.
# После apply Ansible получает готовый, всегда актуальный inventory.
resource "local_file" "ansible_inventory" {
  filename = "${path.module}/../ansible/inventory/hosts.ini"

  content = templatefile("${path.module}/templates/hosts.ini.tmpl", {
    hosts = {
      for name, vm in proxmox_virtual_environment_vm.docker_host :
      name => split("/", vm.initialization[0].ip_config[0].ipv4[0].address)[0]
    }
    user = var.default_user
  })

  file_permission = "0640"
}

Шаблон inventory (templatefile из встроенной функции Terraform):

# ВНИМАНИЕ: файл сгенерирован Terraform (inventory.tf). Не редактировать вручную.
# Источник истины — карта docker_hosts в terraform.tfvars.

[docker_hosts]
%{ for name, ip in hosts ~}
${name} ansible_host=${ip} ansible_user=${user}
%{ endfor ~}

[docker_hosts:vars]
ansible_python_interpreter=/usr/bin/python3

%{ for ... ~} — синтаксис templatefile, не Bash. Тильда ~ убирает лишние переносы строк. Файл создаётся по пути ../ansible/inventory/hosts.ini относительно Terraform-модуля — прямо туда, откуда Ansible его подберёт.

Порядок применения:

cd terraform
terraform apply          # создаёт хост, firewall, рендерит ../ansible/inventory/hosts.ini

cd ../ansible
ansible-playbook -i inventory/hosts.ini site.yml

После apply Ansible сразу получает актуальный inventory. Добавить новый хост = одна запись в docker_hosts map в terraform.tfvars + повторный apply. Удалить хост = убрать запись + apply. Никакой синхронизации вручную.

Полный рабочий стенд: digital-cookbook → terraform/docker-host-handoff. За механикой деплоя compose-стека поверх готового хоста — статья Ansible и Docker Compose.

Один источник истины для каждой сущности

Три слоя хаоса возникают не из-за количества инструментов, а из-за дублирования: IP прописан и в Terraform, и в Ansible inventory руками; firewall описан и в Terraform, и через iptables на хосте; версия образа задана и в group_vars, и в Compose-файле напрямую.

Правило простое: у каждой сущности один владелец, и только через него она меняется.

Сущность Владелец
VM-хост, диск, сеть VM Terraform
Firewall-периметр (security group) Terraform
Ansible inventory Terraform (генерируется)
Docker Engine на хосте Ansible
Контейнеры, docker-сети, compose-стек Docker Compose (через Ansible)

Пример нарушения: вручную добавить хост в hosts.ini, а потом запустить terraform apply — Terraform перегенерирует inventory и потеряет ручную запись. Или поправить firewall через Proxmox UI — следующий terraform plan покажет drift, следующий apply вернёт конфиг к декларированному состоянию.

Стыки между слоями должны быть явными и односторонними: Terraform генерирует inventory → Ansible его подбирает. Не наоборот, не в обе стороны.

Типичные ошибки

Несколько вещей, которые стоит знать заранее:

  • State с чувствительными данными. terraform.tfstate содержит IP, vm_id, иногда токены. Добавить в .gitignore с самого начала. Для команды — remote state (Terraform Cloud, S3-backend с блокировкой). terraform.tfvars с секретами — тоже в .gitignore.

  • Drift при ручных правках. Поправили firewall в Proxmox UI — terraform plan покажет расхождение, следующий apply вернёт конфиг к тому, что в коде. Это правильное поведение, но неожиданное, если не предупредить команду заранее. Строка comment = "Managed by Terraform" в ресурсах — не украшение, а напоминание.

  • Порядок apply → playbook. inventory генерируется только после terraform apply. Запускать Ansible до первого apply нечего — файл hosts.ini не существует. В CI это две последовательные стадии: сначала Terraform, потом Ansible.

  • Соблазн apply на каждую выкладку. Если контейнеры оказались в Terraform — каждый релиз приложения становится terraform apply с риском задеть инфраструктурный ресурс рядом. Держите прикладной слой в Compose.

  • Слишком широкий firewall. input_policy = "DROP" + явные ACCEPT-правила безопаснее, чем открытый периметр. Не оставляйте 22/tcp открытым без ограничения источника там, где это возможно.

Официальные источники

Обсуждение в Telegram

Присоединиться →

Комментарии