Если сервисы разворачиваются через docker compose, очень легко скатиться в ручной режим: зайти по SSH, поправить .env, заменить compose-файл, перезапустить контейнеры. Сначала это кажется быстрым, но потом превращается в плохо воспроизводимый процесс: непонятно, что именно сейчас задеплоено, конфиги на серверах начинают расходиться, а воспроизвести состояние с нуля — это история на полчаса по памяти.
Ansible закрывает эту проблему без перехода на Kubernetes: шаблоны compose-файлов и .env хранятся в репозитории и рендерятся под нужное окружение; деплой — одна команда; результат воспроизводим и идемпотентен.
Ниже — практическое устройство такого пайплайна: inventory, group_vars для dev/stage/prod, шаблоны через Jinja2, секреты и модуль community.docker.docker_compose_v2.
В статье
- Когда Ansible + Compose оправдан, а когда нет
- Архитектура потока деплоя
- Структура проекта
- Inventory и переменные окружений
- Шаблоны docker-compose.yml и .env
- Секреты: ansible-vault и внешний Vault
- Playbook: модуль docker_compose_v2 и fallback
- Идемпотентность и проверка перед применением
- Типичные ошибки
- Официальные источники
Когда Ansible + Compose оправдан, а когда нет
Этот подход имеет смысл, когда:
- сервисы уже работают через
docker compose, и задача — убрать ручные операции, а не переписывать инфраструктуру; - несколько серверов или окружений (dev/stage/prod), между которыми конфиги различаются только значениями переменных;
- команда знает Ansible и не готова инвестировать в Kubernetes прямо сейчас;
- деплой нужно запускать воспроизводимо: из CI или вручную, но с одинаковым результатом.
И наоборот — связка избыточна, когда:
- один сервер, один человек, один раз:
scp + sshдешевле в обслуживании; - сервисы уже в Kubernetes — там свои механизмы управления конфигурацией;
- нужны canary-релизы, автоскейлинг, сложная маршрутизация: Compose плохо закрывает эти сценарии, и Ansible не поможет.
Честно говоря, этот подход — удобный промежуточный слой. Он хорошо живёт в ситуации «compose уже есть, Kubernetes — потом или никогда», и плохо масштабируется за пределы нескольких десятков хостов.
Архитектура потока деплоя
Идея одной картинкой: управляющий узел рассылает один и тот же набор конфигов на ряд одинаковых хостов — каждый получает идентичный стек. Ниже — тот же поток уже по шагам.
flowchart LR
subgraph control ["Управляющий узел"]
P["ansible-playbook deploy.yml"]
end
P -->|"SSH"| T
subgraph target ["Целевой хост"]
direction TB
D["Создать каталоги деплоя"]
R["Отрендерить docker-compose.yml из шаблона"]
E["Отрендерить .env из шаблона"]
U["docker compose up -d (docker_compose_v2)"]
D --> R --> E --> U
end
Управляющий узел — любая машина с Ansible: ноутбук разработчика или CI-раннер. Целевой хост — сервер с Docker. Никаких агентов на сервере: Ansible работает через SSH, Python-интерпретатор на хосте нужен только для модулей.
Структура проекта
compose-deploy/
├── ansible.cfg # пути, отключение host_key_checking
├── deploy.yml # точка входа — основной playbook
├── inventory/
│ └── hosts.ini # группы dev / stage / prod
├── group_vars/
│ ├── all.yml # общие переменные (stack_name, образы, порты)
│ ├── dev.yml # переопределения для dev
│ ├── stage.yml # переопределения для stage
│ ├── prod.yml # переопределения для prod
│ └── prod/
│ ├── secrets.example.yml # шаблон секретов (в репо)
│ └── secrets.yml # зашифрован vault (в .gitignore)
└── roles/
└── compose_stack/
├── defaults/main.yml # значения по умолчанию роли
├── tasks/main.yml # основная логика
└── templates/
├── docker-compose.yml.j2 # шаблон compose-файла
└── env.j2 # шаблон .envПолный стенд с рабочими файлами: github.com/khorost-tech/digital-cookbook / ansible/compose-deploy.
Inventory и переменные окружений
Inventory задаёт группы хостов, а group_vars — переменные для каждой группы. Это ключевой механизм: один playbook, разное поведение по окружениям.
# Inventory для стенда compose-deploy
# В реальном использовании замените localhost на IP/FQDN целевых серверов
[dev]
localhost ansible_connection=local
[stage]
# stage-server ansible_host=10.0.1.10 ansible_user=deploy
[prod]
# prod-server ansible_host=10.0.1.20 ansible_user=deploy
[all:vars]
ansible_python_interpreter=/usr/bin/python3Общие переменные (образы, порты, имя стека) живут в all.yml. Переменные окружений их переопределяют только там, где нужно расхождение:
# Имя стека (используется в путях и именах)
stack_name: compose-demo
# Базовый каталог для деплоя стеков
deploy_dir: /opt/stacks/{{ stack_name }}
# Имя проекта Docker Compose
compose_project: "{{ stack_name }}"
# Образы (версии пинуются; не используем latest)
app_image: "traefik/whoami:v1.10"
proxy_image: "caddy:2.8-alpine"
# Порт приложения внутри compose-сети
app_port: 8080
# Домен для виртуального хоста (Caddy)
app_domain: "localhost"
# Количество реплик приложения
app_replicas: 1
# Имя окружения (dev/stage/prod); переопределяется в каждом окружении
env_name: "dev"group_vars/prod.yml переопределяет только то, что отличается в production:
---
# Переменные окружения PROD
env_name: "prod"
app_domain: "app.example.com"
app_port: 8080
app_replicas: 2
# prod деплоит в /opt/stacks (из all.yml) — нужен root, поэтому become.
ansible_become: trueПо тому же принципу устроены dev.yml и stage.yml. Важная деталь — deploy_dir: в all.yml он указывает на /opt/stacks/… (системный каталог, требует root), и prod поэтому включает ansible_become: true. А dev.yml переопределяет deploy_dir на домашний каталог пользователя, чтобы стенд поднимался локально одной командой без sudo:
# В dev деплоим в домашний каталог — стенд поднимается без sudo/become.
deploy_dir: "{{ ansible_facts.user_dir }}/stacks/{{ stack_name }}"Ansible сам выбирает нужный набор переменных по тому, к какой группе принадлежит хост из -l.
Шаблоны docker-compose.yml и .env
Compose-файл хранится не как готовый YAML, а как Jinja2-шаблон. Ansible рендерит его на хосте перед запуском, подставляя переменные из group_vars. Это значит, что версии образов, реплики и порты в репозитории — единственный источник истины.
# Managed by Ansible — do not edit manually
# Stack: {{ stack_name }} | Env: {{ env_name }}
services:
app:
image: {{ app_image }}
restart: unless-stopped
networks:
- internal
environment:
- APP_ENV={{ env_name }}
deploy:
replicas: {{ app_replicas }}
proxy:
image: {{ proxy_image }}
restart: unless-stopped
ports:
- "{{ app_port }}:80"
networks:
- internal
environment:
- APP_DOMAIN={{ app_domain }}
- APP_BACKEND=app:80
command: >
caddy reverse-proxy
--from :80
--to app:80
depends_on:
- app
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 15s
timeout: 5s
retries: 3Здесь показаны оба сервиса; секции networks/volumes и комментарии опущены — полный шаблон в репозитории примера. Файл .env рендерится отдельным шаблоном. Это важно: он может содержать секреты, которые не стоит встраивать прямо в compose YAML.
# Managed by Ansible — do not edit manually
# Stack: {{ stack_name }} | Env: {{ env_name }}
STACK_ENV={{ env_name }}
APP_PORT={{ app_port }}
APP_DOMAIN={{ app_domain }}
APP_ADMIN_TOKEN={{ app_admin_token }}app_admin_token — единственная переменная здесь, которой нет в all.yml или prod.yml: в prod она переопределяется через зашифрованный prod/secrets.yml, а в dev/stage используется плейсхолдер REPLACE-ME из defaults/main.yml роли.
Секреты: ansible-vault и внешний Vault
Секреты не должны лежать в репозитории открытым текстом. Самый простой вариант для небольших стендов — ansible-vault: зашифрованный YAML-файл, который можно хранить в git, но читать только с паролем.
# Создать group_vars/prod/secrets.yml из шаблона и зашифровать:
cp group_vars/prod/secrets.example.yml group_vars/prod/secrets.yml
# Открыть редактор для заполнения значений:
ansible-vault edit group_vars/prod/secrets.yml
# Или зашифровать уже заполненный файл:
ansible-vault encrypt group_vars/prod/secrets.yml
# Запуск деплоя с запросом пароля vault:
ansible-playbook -i inventory/hosts.ini deploy.yml -l prod --ask-vault-pass
# Или через файл с паролем (удобно в CI):
ansible-playbook -i inventory/hosts.ini deploy.yml -l prod --vault-password-file ~/.vault_passВ репозитории хранится только secrets.example.yml с плейсхолдерами, secrets.yml добавлен в .gitignore. Ansible автоматически подхватывает зашифрованный файл из group_vars/prod/ при запуске для prod-хостов.
Альтернатива для команд с централизованным хранилищем секретов — lookup-плагин community.hashi_vault. В этом случае secrets.yml не нужен совсем: значение запрашивается из HashiCorp Vault (или Vault-совместимого бэкенда) прямо в шаблоне или переменной:
app_admin_token: "{{ lookup('community.hashi_vault.hashi_vault', 'secret=kv/prod/compose-demo:admin_token') }}"Vault дороже в эксплуатации, но даёт централизованное управление, ротацию секретов и аудит доступа — актуально, когда проект выходит за рамки одного-двух стендов.
Playbook: модуль docker_compose_v2 и fallback
Основной playbook — точка входа. Он выбирает нужные hosts по флагу -l и передаёт управление роли:
---
# Использование (inventory указываем флагом -i явно — надёжнее, чем
# полагаться на ansible.cfg, который Ansible игнорирует, напр. на
# world-writable путях):
# ansible-playbook -i inventory/hosts.ini deploy.yml -l dev
# ansible-playbook -i inventory/hosts.ini deploy.yml -l prod --ask-vault-pass
- name: Деплой Docker Compose стека
hosts: all
gather_facts: true
roles:
- role: compose_stackВнутри роли — задачи рендеринга шаблонов и запуска стека. Ключевая задача использует модуль community.docker.docker_compose_v2:
---
- name: Создать каталог деплоя
ansible.builtin.file:
path: "{{ deploy_dir }}"
state: directory
mode: "0750"
- name: Отрендерить docker-compose.yml из шаблона
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ deploy_dir }}/docker-compose.yml"
mode: "0640"
- name: Отрендерить .env из шаблона
ansible.builtin.template:
src: env.j2
dest: "{{ deploy_dir }}/.env"
mode: "0640"
# Запускается всегда, но идемпотентен: пересоздаёт контейнеры
# только при изменении docker-compose.yml/.env или образов.
- name: Поднять Docker Compose стек
community.docker.docker_compose_v2:
project_src: "{{ deploy_dir }}"
project_name: "{{ compose_project }}"
state: present
pull: missingЗдесь нет handler — и это сознательно. Соблазн повесить notify на шаблоны и держать «перезапуск стека» отдельным обработчиком есть, но он избыточен: задача docker_compose_v2 и так выполняется в каждом прогоне и сама идемпотентна. state: present сравнивает желаемое состояние с фактическим и пересоздаёт контейнеры только при реальном изменении (новый рендер compose-файла, .env или образа). Handler в конце привёл бы лишь к повторному вызову docker compose за тот же прогон. Идемпотентность здесь обеспечивает сам модуль, а не механизм handlers.
Fallback: без коллекции community.docker. Если на хосте нет коллекции или нужна максимальная совместимость, можно заменить docker_compose_v2 на шаблонизацию + вызов docker compose up:
- name: Поднять Docker Compose стек (fallback без community.docker)
ansible.builtin.command:
cmd: docker compose --project-name {{ compose_project }} up -d --remove-orphans
chdir: "{{ deploy_dir }}"
register: compose_result
changed_when: >
'Started' in compose_result.stderr or
'Recreated' in compose_result.stderr or
'Created' in compose_result.stderrchanged_when анализирует stderr docker compose up, где сообщения о реальных действиях (Started, Recreated, Created) отличаются от «ничего не изменилось». Без явного changed_when модуль ansible.builtin.command всегда помечал бы задачу как changed — по коду возврата он не может определить, было ли реально что-то сделано. Наше выражение помечает задачу changed только при реальных действиях; иначе сломался бы отчёт о drift (каждый прогон выглядел бы как изменение).
Идемпотентность и проверка перед применением
Ansible поддерживает dry-run через флаги --check и --diff. --check не делает реальных изменений, --diff показывает разницу в текстовых файлах (шаблонах). Вместе они дают предпросмотр деплоя:
# Dry-run с показом diff для dev
ansible-playbook -i inventory/hosts.ini deploy.yml --check --diff -l dev
# Проверка синтаксиса playbook (без подключения к хостам)
ansible-playbook -i inventory/hosts.ini deploy.yml --syntax-check
# Реальный деплой с подробным выводом
ansible-playbook -i inventory/hosts.ini deploy.yml -l prod --ask-vault-pass -v--diff особенно полезен при обновлении шаблонов: он показывает побайтовые изменения в compose-файле или .env ещё до применения. Для шаблона с секретами — осторожно: diff может вывести расшифрованные значения в терминал.
Важная оговорка: в --check корректно отрабатывают template-задачи (рендер файлов), а задача community.docker.docker_compose_v2 в check-режиме не даёт осмысленного предпросмотра — модуль обращается к Docker и не умеет симулировать запуск стека. Поэтому для предпросмотра именно изменений конфигов полагайтесь на --diff по шаблонам, а не на check-вывод шага compose.
После деплоя результат проверяется стандартными инструментами Docker прямо на сервере:
# Состояние контейнеров стека
docker compose -p compose-demo ps
# Логи с момента последнего деплоя
docker compose -p compose-demo logs --since=10m
# Все стеки на хосте
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"Идемпотентность обеспечивается на двух уровнях: ansible.builtin.template не записывает файл, если содержимое не изменилось; community.docker.docker_compose_v2 сверяет желаемое состояние с текущим — контейнеры пересоздаются только при реальных изменениях конфигурации или образа.
Типичные ошибки
Несколько вещей, которые стоили времени при реальных деплоях:
-
Drift между шаблоном и сервером. Если кто-то правит compose-файл руками прямо на сервере, следующий деплой перезапишет его шаблоном из репо. Это правильное поведение, но неожиданное, если об этом не предупредить. Строка
# Managed by Ansible — do not edit manuallyв шапке шаблона — не украшение, а напоминание. -
Динамика в шаблоне ломает идемпотентность. Соблазн добавить в шапку рендеримого файла
# Generated: {{ ansible_date_time.iso8601 }}велик, но тогдаtemplateбудет записывать новый файл в каждом прогоне (контент всегда отличается) — задача навсегдаchanged, а на втором запуске вы видитеchanged=2на пустом месте. В управляемых файлах держите только статические подстановки из переменных; время генерации, если нужно, выносите в лог или в отдельный не-сравниваемый артефакт. Заодноansible_date_time/ansible_user_dir— это auto-injected факты, на которые в свежих версиях Ansible идёт deprecation: предпочтительнее явныеansible_facts.*. -
Секреты в репо.
secrets.ymlобязан быть в.gitignoreдо первогоgit add. Проверить просто:git statusне должен его видеть. Восстановить секрет из истории git — неловко, менять его после утечки — обязательно. -
latestв образах. Ansible шаблонизирует образы из переменных, и соблазн написатьapp_image: "myapp:latest"высок. Проблема:pull: missingвdocker_compose_v2не потянет образ, если он уже есть локально — неважно, что в registry появился новыйlatest. Пиновать версию явно (v1.10,2.8-alpine) и менять её вall.ymlпри обновлении. -
restart_policyи pull.pull: missingозначает «тянуть образ только если его нет локально». Если нужно всегда проверять актуальность образа —pull: always. Но это замедляет деплой и может скрыть намеренный пин версии. Осознанный выбор. -
Отсутствие стратегии обновлений.
docker compose up -dзаменяет контейнер без downtime только если он stateless и реплика одна. Для сервисов с состоянием или требованием нулевого downtime нужен rolling update — в чистом Compose это не встроено. Ansible здесь не поможет: это ограничение самого Compose. -
Зависимости модуля. Вопреки распространённому ожиданию,
community.docker.docker_compose_v2не использует Docker SDK for Python — он вызывает Docker CLI. Поansible-docего требования: Docker CLI с плагином Compose 2.18.0+ на целевом хосте (где реально выполняетсяdocker compose), иPyYAML— только если стек задаётся инлайн через параметрdefinition, а не файлом. То естьpip install dockerздесь не нужен; нужен установленныйdocker compose. Для fallback-варианта черезansible.builtin.commandсо стороны Ansible зависимостей тоже нет.
Комментарии