Terraform тащат во всё, что хоть как-то связано с инфраструктурой. И в Docker-мире этот рефлекс срабатывает очень быстро: есть VM, есть контейнеры, есть сети — значит, давайте опишем всё в .tf-файлах. На практике это быстро приводит к вопросу: что именно описывать Terraform, а что уже лучше отдать другому инструменту?
Статья — спокойный разбор этой границы. Создание VM и механика cloud-init подробно разобраны в статье Proxmox + Terraform. Деплой Compose-стека на готовый хост — в Ansible и Docker Compose. Здесь — про то, как эти слои стыкуются, что стоит держать в Terraform, а что нет, и как передать управление от одного инструмента к другому без потери источника истины.
В статье
- Три слоя ответственности
- Что Terraform делает хорошо
- Что не стоит тащить в Terraform
- Стык слоёв: Terraform → Ansible
- Один источник истины для каждой сущности
- Типичные ошибки
- Официальные источники
Три слоя ответственности
Хост, сеть и приложение живут на разных уровнях и меняются с разной частотой. Именно это определяет, какой инструмент для чего подходит.
| Слой | Что держит | Частота изменений | Инструмент |
|---|---|---|---|
| Инфраструктура | 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
Что 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 stopTerraform 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открытым без ограничения источника там, где это возможно.
Комментарии