Ansible и Docker Compose: раскатываем сервисы без ручной магии

Как использовать Ansible для управления Docker Compose стеками: inventory, group_vars для нескольких окружений, шаблоны compose и .env, секреты через ansible-vault и модуль community.docker.docker_compose_v2 — с рабочим примером из digital-cookbook

Если сервисы разворачиваются через docker compose, очень легко скатиться в ручной режим: зайти по SSH, поправить .env, заменить compose-файл, перезапустить контейнеры. Сначала это кажется быстрым, но потом превращается в плохо воспроизводимый процесс: непонятно, что именно сейчас задеплоено, конфиги на серверах начинают расходиться, а воспроизвести состояние с нуля — это история на полчаса по памяти.

Ansible закрывает эту проблему без перехода на Kubernetes: шаблоны compose-файлов и .env хранятся в репозитории и рендерятся под нужное окружение; деплой — одна команда; результат воспроизводим и идемпотентен.

Ниже — практическое устройство такого пайплайна: inventory, group_vars для dev/stage/prod, шаблоны через Jinja2, секреты и модуль community.docker.docker_compose_v2.

Управляющий узел Ansible раздаёт одинаковые плейбуки на идентичные серверы

В статье

Когда 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

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
Поток деплоя Docker Compose стека через Ansible

Управляющий узел — любая машина с 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.stderr

changed_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 зависимостей тоже нет.

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

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

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

Комментарии