На сервере работает Compose-файл, которого уже нет ни в одной ветке. Кто-то ночью поправил порт, добавил переменную окружения, поменял тег образа с одного на другой, а потом уснул с чувством выполненного долга. Утром в репозитории лежит одно, на сервере крутится другое, и вопрос «какая версия сейчас в продакшене?» превращается в археологию: открыть файл, вспомнить историю шелла, сверить с чатом.

Решение этой проблемы не требует кластера. Нужна дисциплина, в которой Git описывает желаемое состояние, а на сервер попадает только то, что прошло проверку. Ниже разберём, как собрать такой процесс вокруг обычного Docker Compose: что хранить в репозитории, а что нет, как довезти изменение до узла, почему нельзя выкатывать «что там сейчас на HEAD», как понять, что релиз действительно жив, и что делать, когда кто-то всё же залез на сервер руками.

Сразу уточним: GitOps здесь означает инженерный процесс, который вы строите сами. Это не встроенная функция Compose. Compose умеет читать конфигурацию и приводить контейнеры к ней, но сравнивать сервер с репозиторием, проверять релиз и откатывать его будут ваши скрипты и ваш конвейер. Поэтому важно с самого начала понимать, из каких деталей этот процесс состоит.

Что на самом деле описывает ваше приложение

Начнём с вопроса, который кажется скучным, но определяет всё остальное: какие файлы вместе задают работающее приложение? Если ответить на него честно, получится три группы.

  • Описание сервисов. Файл compose, список образов, сети, тома, порты, healthcheck, restart policy. Это и есть то, что должно жить в Git без исключений.
  • Различия между окружениями. То, что отличает разработку от продакшена: порты, переменные, подключённые тома. Эти различия тоже конфигурация, а значит, тоже файлы в репозитории.
  • Секреты. Пароли баз данных, токены, ключи. Они влияют на работу приложения, но в репозитории им не место.

Документация Compose прямо говорит, что одну и ту же конфигурацию можно использовать в CI, staging и production с отличающимися настройками, и перечисляет типичные отличия для продакшена: убрать bind mount с кодом, изменить порты и переменные окружения, добавить restart policy (Docker Compose: Production). Из этого следует практический вывод: базовый файл остаётся общим, а продакшен получает отдельный слой поверх него. Тогда разница между «как у разработчика» и «как на сервере» видна в одном небольшом файле, а не размазана по правкам на машине.

Структура репозитория может быть такой:

deploy/
  compose.yaml            # общее описание сервисов
  compose.prod.yaml       # отличия продакшена
  releases/
    images.lock.env       # зафиксированные версии образов
  scripts/
    release.sh            # единственная точка входа для выкатки

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

Где живут секреты и почему это граничит с безопасностью сборки

Секреты остаются на сервере вне репозитория: в файле с ограниченными правами доступа, который читает только пользователь, запускающий релиз, либо в хранилище секретов вашего CI. Репозиторий при этом должен знать только имена переменных, но не их значения. Тогда по коду видно, какие данные нужны сервису, а утечка репозитория не раскрывает пароли.

Есть и вторая сторона. Секреты нередко пытаются передать в образ при сборке. Документация по кешу сборки предупреждает, что передача секретов через COPY или ARG может их раскрыть, а для таких данных предусмотрен отдельный механизм build secrets (Docker: Cache storage backends). Для нашей схемы это значит простую вещь: если токен нужен только на этапе сборки, он не должен попасть в слои образа, иначе зафиксированный и опубликованный образ станет ещё и хранилищем вашего пароля.

Граница получается чёткой. В Git лежит то, что можно показать любому ревьюеру. Всё, что нельзя, приходит на узел отдельным путём и не пересекается с историей коммитов.

Путь изменения: от запроса на слияние до выбранного узла

Теперь представим, что нужно поменять Compose: например, добавить сервису лимит перезапусков или сменить публикуемый порт. В привычной ручной схеме человек открывает файл на сервере и правит. В схеме, о которой мы говорим, изменение проходит по цепочке, и каждое звено отсекает свой класс ошибок.

  1. Ветка и запрос на слияние. Изменение видят другие. Уже на этом шаге ловятся опечатки в именах сервисов и случайные правки, которые автор не хотел делать.
  2. Проверка конфигурации в CI. Конвейер рендерит итоговый Compose-файл из базового и продакшен-слоя и убеждается, что он собирается без ошибок. Для этого подойдёт команда docker compose -f compose.yaml -f compose.prod.yaml config: она разворачивает слои в единую конфигурацию и падает на синтаксических ошибках, а подстановки переменных видны прямо в выводе.
  3. Слияние в защищённую ветку. Только то, что оказалось в ней, считается кандидатом на выкатку.
  4. Доставка на узел. Это самое интересное звено, и о нём ниже.

Для запуска проверок нужен исполнитель, который доступен вам постоянно и принадлежит вам. Если своего агента ещё нет, про его развёртывание на VPS можно прочитать в материале GitLab Runner на VPS: свой CI/CD-агент. Но и сама логика релиза не привязана к конкретной системе: цепочка из проверки, фиксации версий и запуска одного скрипта работает в любом конвейере.

Как именно изменение попадает на сервер

Здесь легко соблазниться простотой: повесить на сервер задание, которое раз в минуту делает git pull и затем docker compose up -d. Выглядит как GitOps, а на деле это не оркестратор, а таймер с побочными эффектами. Такой цикл не знает, что именно он вытянул, не видит, прошла ли проверка этого коммита, не отличает чистую рабочую копию от испорченной ручной правкой и ничего не скажет, если релиз сломался.

Механизм доставки в нашей схеме конкретнее и скромнее. Конвейер после успешных проверок подключается к выбранному узлу и запускает на нём один заранее известный скрипт, передавая ему идентификатор коммита. Скрипт делает ровно то, что написано в репозитории, и отчитывается кодом возврата. Это именно push-доставка: инициатором выступает конвейер, а узел выполняет заданную последовательность. Подключение лучше ограничить отдельным пользователем с ключом, которому разрешён запуск только этого скрипта, а не полный shell.

Внутри скрипта порядок такой:

  1. Заблокировать запуск, чтобы два релиза не шли одновременно.
  2. Получить из репозитория именно указанный коммит, а не «последнее состояние ветки».
  3. Проверить, что рабочая копия на узле чистая и не содержит посторонних правок.
  4. Отрендерить конфигурацию и убедиться, что она корректна.
  5. Скачать образы нужных версий.
  6. Применить конфигурацию командой docker compose up -d.
  7. Дождаться подтверждения готовности и только после этого записать результат.

Блокировка нужна из-за гонок. Если конвейер дважды запустит релиз почти одновременно, например два слияния подряд, две выкатки начнут перетирать друг друга: одна уже скачала образы, другая меняет файлы. Простейшая защита — взаимоисключающая блокировка на узле, скажем через flock на файле блокировки: второй запуск либо ждёт, либо честно завершается с ошибкой «занято». Принципиально, что решение «ждать или отказать» принято заранее, а не случайно.

Проверка чистоты рабочей копии отвечает на вопрос из начала статьи. Если на узле лежит изменённый файл, команда git status --porcelain выдаст непустой вывод. Скрипт в этом случае не пытается молча затереть правки и не выкатывает поверх них, а останавливается и сообщает о расхождении. О том, что делать дальше, поговорим в последнем разделе.

Почему нельзя выкатывать случайный HEAD

Теперь о главной причине, по которой скрипт получает не имя ветки, а идентификатор коммита. Имя ветки — движущаяся цель. Между моментом, когда конвейер проверил состояние, и моментом, когда узел его скачает, в ветку может прилететь ещё один коммит. Тогда на сервер доедет код, который никто не проверял, а в журнале будет написано, что выкатили «main». Какая именно версия — уже не восстановить без раскопок.

Вторая часть проблемы — образы. Если в Compose указан тег вроде latest, то при каждом скачивании можно получить разное содержимое под одним и тем же именем. Тогда даже зафиксированный коммит с конфигурацией не определяет, что реально запустится. Конфигурация та же, а приложение другое.

Поэтому фиксировать нужно оба уровня:

  • Коммит с конфигурацией. Его идентификатор передаётся скрипту и записывается в журнал релиза.
  • Версии образов. Либо конкретный неизменяемый тег, привязанный к сборке, либо дайджест образа. Значения хранятся в файле вроде images.lock.env и подставляются в Compose через переменные.

Фрагмент продакшен-слоя может выглядеть так:

services:
  app:
    image: registry.example.com/team/app:${APP_TAG}
    restart: unless-stopped

А в файле версий лежит строка APP_TAG=2026-11-01-a1b2c3d, где тег построен из даты и короткого идентификатора коммита сборки. Это условный пример формата: сама схема именования — ваше решение, важно лишь, чтобы один тег никогда не указывал на два разных образа.

Теперь изменение версии приложения становится обычным коммитом: правка одной строки в файле версий. Она проходит ревью, видна в истории, и откатить её можно тем же способом, каким откатывается любое изменение в Git.

Где в этой цепочке сборка и кеш

Образ, который попадает в файл версий, должен быть собран и опубликован до релиза, а не на продакшен-узле. Документация напоминает, что обновление кода внутри образа требует пересборки образа и пересоздания контейнера (Docker Compose: Production). Правки кода в работающем контейнере или монтирование исходников с хоста в продакшене ломают соответствие между коммитом и тем, что запущено.

Чтобы пересборка не превращалась в долгое ожидание, помогает кеш. Buildx поддерживает inline, registry и local cache, причём доступность конкретного backend зависит от драйвера и image store. Registry cache хранится отдельно от итогового образа, и внешний кеш нужно явно импортировать и экспортировать (Docker: Cache storage backends). Для нашей схемы вывод практический: если сборка идёт на агенте CI, подумайте, где хранить кеш, и проверьте, что выбранный backend работает с вашим драйвером. Это ускоряет подготовку образа, но на корректность релиза не влияет: образ уже собран и зафиксирован тегом.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Релиз успешен не тогда, когда команда завершилась

Команда docker compose up -d возвращает управление, как только контейнеры запущены. Это не означает, что приложение готово обслуживать запросы. Процесс может стартовать и тут же упасть на миграции, не найти базу или подвиснуть на инициализации. Если считать успехом код возврата запуска, можно получить зелёный конвейер и мёртвый сервис.

Нужно отдельное подтверждение готовности. У него два слоя.

Healthcheck самого сервиса. В Compose для сервиса задаётся проверка здоровья: команда, которая изнутри контейнера отвечает, жив ли процесс в рабочем смысле, а не просто запущен. Для веб-приложения это обращение к эндпоинту, который проверяет и связь с зависимостями. Тогда состояние контейнера отражает не факт старта, а факт готовности.

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

Важно, чтобы ожидание было ограничено по времени. Скрипт опрашивает состояние с паузой между попытками и сдаётся по истечении заданного срока. Без лимита зависший релиз превратится в бесконечное ожидание, которое держит блокировку и мешает следующим запускам.

Считаем окно недоступности и откат

Здесь нужна честность. Restart policy не означает zero downtime: она говорит Docker, когда перезапускать упавший контейнер, а не как переключать трафик между версиями. При обновлении сервиса через пересоздание контейнера старый останавливается, новый стартует, и между ними есть промежуток. Документация отмечает, что отдельный сервис можно обновить без пересоздания его зависимостей (Docker Compose: Production), и это уменьшает масштаб побочных эффектов: база данных не перезапускается из-за правки в приложении. Но промежуток для самого сервиса остаётся.

Оценить его можно простой арифметикой. Возьмём условные цифры: остановка старого контейнера занимает 10 секунд, старт нового до прохождения healthcheck — 25 секунд, а внешняя проверка успеет ответить за 5 секунд. Тогда окно недоступности порядка 40 секунд. Если вы вынуждены откатываться, прибавьте время, нужное на возврат: остановка сломанной версии, повторный старт предыдущей и снова ожидание готовности. При тех же условных цифрах это ещё около 40 секунд, и худший случай — релиз, который не удался, и возврат — даёт примерно 80 секунд, плюс время, которое скрипт потратил, дожидаясь признания неудачи по тайм-ауту. Если тайм-аут healthcheck стоит минуту, реальное окно будет больше. Эти значения вы замерите на своём стенде, здесь они иллюстрируют структуру расчёта: окно складывается из остановки, старта, ожидания готовности и, в неудачном случае, возврата.

Из этой арифметики следует, что скорость отката зависит от того, что находится на узле заранее. Если предыдущий образ уже скачан, возврат не ждёт сети. Если его очистили, откат начнётся с загрузки. Поэтому политика очистки старых образов — часть планирования отката, а не отдельная гигиена.

Если нужно строго нулевое прерывание, одной restart policy и Compose это не решить: потребуются дополнительные механизмы вроде параллельного запуска двух версий и переключения перед ними. Для небольшого стека, где допустимо окно в десятки секунд, прозрачнее принять его осознанно и записать в описание процесса.

Запись о релизе: коммит и его результат вместе

Чтобы на вопрос «что сейчас работает?» отвечать без захода на сервер, мало знать коммит, который выкатывали. Нужно знать и то, чем закончилась выкатка. Коммит без результата говорит о намерении, а не о факте: возможно, он прошёл проверку и работает, а возможно, застрял на половине.

Поэтому скрипт после проверки готовности записывает итог. Минимальный набор полей записи:

Запись о выкатке
ПолеЧто хранитЗачем нужно
commitПолный идентификатор коммита конфигурацииОднозначно определяет, какой код и файлы применены
imagesТеги или дайджесты образов из файла версийФиксирует, что фактически запущено
started_at / finished_atВремя начала и конца выкаткиПозволяет оценить длительность окна
resultУспех, сбой готовности или откатОтделяет работающий релиз от неудачного
previous_commitКоммит последнего успешного релизаПодсказывает, куда возвращаться

Куда писать? Варианты разные, и у каждого свои последствия. Можно хранить запись файлом на узле, но тогда узнать состояние снова получится только зайдя на сервер. Можно отправлять её обратно в конвейер как статус развёртывания, так что результат виден рядом с коммитом. Можно ставить тег в репозитории вроде prod-ok, который переносится только на успешные релизы: тогда команда git log показывает, как далеко ветка ушла от последнего подтверждённого состояния. Самый надёжный вариант — сочетание: узел хранит локальную запись для скрипта, а конвейер публикует результат туда, где его видит команда.

Тег успешного релиза важен ещё по одной причине. Он задаёт «известную хорошую точку» для отката. Не просто предыдущий коммит, потому что тот мог быть неудачным, а именно последний, у которого результат записан как успешный.

Когда кто-то уже правил сервер руками

Вернёмся к сцене из начала. Срочная правка прошла мимо репозитория, и теперь есть расхождение. Что делать, когда скрипт при очередном релизе видит грязную рабочую копию или конфигурацию, которая отличается от записанной?

Первое правило: не затирать молча. Ручная правка могла быть вынужденной и полезной: возможно, она спасла сервис. Автоматическое стирание уничтожит единственное свидетельство того, что на самом деле потребовалось изменить. Скрипт должен остановиться, сохранить различия и сообщить о них. Вывод git diff и копия изменённых файлов, сохранённые рядом с записью о релизе, превращают тревогу в материал для разбора.

Дальше есть две дороги, и выбирать между ними надо осознанно.

  • Правка была верной. Тогда её переносят в репозиторий обычным коммитом через ревью и проверку. После этого состояние сервера и Git совпадают, а скрипту нечего затирать. Расхождение закрыто правильным путём, а не принудительно.
  • Правка была ошибочной или временной. Тогда рабочую копию возвращают к известному состоянию: сохранённые различия архивируются, а копия приводится к коммиту последнего успешного релиза. Для файлов под контролем Git это команды вроде git checkout нужного коммита и очистка изменений, после чего повторяется обычная выкатка.

Возврат к известной конфигурации устроен так же, как обычный релиз, только с другим коммитом. Скрипту передают идентификатор последнего успешного релиза, и он проходит тот же путь: блокировка, получение коммита, чистота копии, рендер конфигурации, образы, запуск, подтверждение готовности. Отдельного «режима отката» с особыми правилами заводить не нужно: чем меньше путей, тем меньше мест, где что-то может пойти не так. Откат — это выкатка старой точки, и проверка готовности нужна ей так же, как новой.

Есть тонкость с данными. Возврат конфигурации не возвращает содержимое томов. Если новая версия приложения успела выполнить миграцию базы, то старая версия может с ней не работать. Поэтому изменения схемы данных лучше планировать совместимыми в обе стороны на время перехода: новая версия умеет работать со старой схемой, а старая не ломается о новую. Это правило проектирования миграций, а не возможность Compose, но без него откат конфигурации окажется красивым жестом без пользы.

Какие ресурсы нужны этой схеме

Процесс, о котором мы говорим, нетребователен сам по себе: скрипт, Git и Docker. Нагрузку создаёт приложение, которое вы запускаете, а также моменты выкатки, когда одновременно существуют старый и новый контейнеры и скачиваются образы. Для промежуточного пика нужен запас по памяти и диску: на узле временно лежат две версии образов, а при пересоздании сервис может кратковременно занимать больше ресурсов.

Если вы выбираете место для такого узла, тарифы VPS MAATRIX задают ориентиры по ресурсам: Ba — 1 vCPU, 2 ГБ RAM, 40 ГБ NVMe; Uab — 2 vCPU, 4 ГБ, 80 ГБ; Heka — 4 vCPU, 8 ГБ, 160 ГБ; Maat — 8 vCPU, 16 ГБ, 240 ГБ; Djed — 16 vCPU, 32 ГБ, 320 ГБ. Эти цифры описывают конфигурацию, а не обещание ёмкости, поэтому подбирайте тариф по замеру своего стека: сколько занимают контейнеры в покое, сколько на выкатке и сколько места съедают образы с учётом вашей политики очистки. Сервер даёт NVMe, процессоры AMD EPYC и root-доступ, а он как раз нужен для установки Docker, настройки ограниченного пользователя для релизов и блокировок. Список локаций и состав доступных образов уточняйте в форме заказа на странице аренды VPS.

Заметьте, что один узел не даёт отказоустойчивости: если он недоступен, не работает и приложение. Схема из статьи делает релизы воспроизводимыми и проверяемыми, но не заменяет резервирование.

Ответ на вопрос о вчерашнем вечере

Теперь можно вернуться к тому, с чего всё началось: можно ли узнать работающую версию, не заходя на сервер и не вспоминая вчерашний вечер? Да, если выполнены четыре условия. Конфигурация целиком лежит в Git, а секреты нет. Узел получает не ветку, а конкретный коммит с зафиксированными версиями образов. Релиз признаётся успешным только после проверки готовности, и результат записан рядом с коммитом. Любое расхождение рабочей копии обнаруживается, сохраняется и не затирается молча.

Тогда на вопрос «что сейчас в продакшене?» отвечает тег последнего успешного релиза и запись о нём: коммит, образы, время, результат. Если запись есть, а чистая копия на узле совпадает с коммитом, больше ничего вспоминать не требуется. А если что-то разошлось, скрипт скажет об этом при следующем запуске, пока расхождение не накопило ещё несколько ночных правок.