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

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

Имя и содержимое: две разные вещи

Тег вроде myapp:1.8.2 или myapp:latest — это ярлык. Его можно перевесить на другой образ, и registry не возразит. Версионный тег создаёт ощущение неизменности, но неизменность в нём держится только на дисциплине команды: никто не пересобирает 1.8.2 «по-быстрому», никто не пушит поверх, ни один скрипт не делает это автоматически при повторном запуске пайплайна.

Digest устроен иначе. По документации Docker, это идентификатор образа, адресуемый по содержимому: если содержимое изменилось, digest будет другим. Теги, напротив, могут перемещаться, а digest фиксирует конкретное содержимое (Docker: Image digests). Выглядит он как строка вида sha256: и длинная шестнадцатеричная последовательность, а ссылка на образ записывается через знак @:

registry.example.com/team/myapp@sha256:<64 hex-символа>

Можно держать в ссылке и тег, и digest одновременно, например myapp:1.8.2@sha256:.... Тег тогда служит подсказкой для человека, а фактически выбирает образ digest. Это удобная привычка: в логах видно версию, а точность обеспечивает хеш.

Важно не приписывать digest лишнего. Он отвечает на один вопрос: «это то же самое содержимое?». Он не говорит, что образ собрал именно ваш CI, что в нём нет уязвимостей и что он дружит с вашими данными. Это идентификатор, а не печать качества. Но идентификатор — ровно то, чего не хватало в сценарии с latest.

Почему «просто версионировать теги» недостаточно

Типичные способы, которыми версионный тег перестаёт указывать на прежнее:

  • пайплайн перезапущен для того же коммита, и пересборка отправила новый образ под прежним тегом: базовый слой или зависимости могли измениться;
  • разработчик исправил «мелочь» и перезаписал тег вручную;
  • политика очистки registry удалила старый образ, а тег потом заняли заново;
  • тег stable или prod двигается намеренно, и его история нигде не хранится.

Ни один из этих случаев не требует злого умысла. Поэтому надёжнее опираться на то, что не двигается, чем на соглашение, которое может нарушиться.

Путь образа: сборка, registry, запуск

Идея проста: digest нужно узнать один раз, в момент публикации, и дальше передавать его по цепочке как значение, а не заново вычислять по тегу. Если на каждом шаге вы снова спрашиваете «а что сейчас под тегом?», вы вернулись к исходной проблеме.

Где рождается digest

Содержимое образа получает идентификатор, когда он отправляется в registry. Практический порядок такой:

  1. CI собирает образ и присваивает ему тег — например, по короткому хешу коммита.
  2. CI отправляет образ в registry.
  3. Из результата отправки или из registry CI получает digest этого манифеста и сохраняет его как артефакт пайплайна.
  4. Проверки (тесты, сканирование, ручная приёмка на стенде) запускаются против образа, указанного именно по digest.
  5. В продакшн уходит тот же digest, а не «последний тег».

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

Конкретные команды получения digest зависят от вашего CI и registry, и здесь важен принцип, а не синтаксис: значение берётся из ответа registry в момент публикации и записывается в артефакт сборки. Не восстанавливайте его позже по тегу.

Как digest попадает в Compose

Compose-конфигурацию можно использовать и в CI, и на staging, и в production, меняя отдельные настройки под среду (Docker Compose: Production). Это хорошо ложится на нашу схему: файл описывает сервисы, а образ подставляется переменной.

services:
  app:
    image: ${APP_IMAGE}
    restart: always
    env_file: .env

В файле окружения релиза лежит одна строка:

APP_IMAGE=registry.example.com/team/myapp@sha256:<digest>

Так образ перестаёт быть частью общего шаблона и становится параметром конкретного релиза. Смена версии превращается в смену одного значения, а откат — в возврат прежнего значения. Тут же срабатывает то, что в документации Compose сказано об обновлении: чтобы получить новый код из образа, нужно пересоздать контейнер, а отдельный сервис можно обновить, не трогая зависимости. Для приложения при живой базе это как раз то, что нужно: заменить контейнер app, не пересоздавая сервис базы данных.

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

Запись релиза: что ещё нужно сохранить кроме образа

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

Разложим релиз на независимые части и посмотрим, что про каждую можно сохранить и как.

Из чего состоит релиз и где это хранить
ЧастьЧто фиксируемЧто нельзя смешивать
ОбразСсылка по digest, платформа (например, linux/amd64)Тег как единственный идентификатор
КонфигурацияВерсия Compose-файла и несекретных переменных (коммит в репозитории)Секреты в том же файле
СекретыТолько имя или версия записи в хранилище секретовЗначения в записи релиза
Схема БДИдентификатор применённой миграцииИдея «образ откатился — база тоже»

Платформа тоже часть адреса

Если образ собирается под несколько архитектур, то под одним тегом может лежать набор вариантов, и «digest релиза» должен однозначно говорить, о чём идёт речь. Запишите, какой именно digest вы разворачиваете и под какую платформу он предназначен, чтобы позже не гадать, о каком из вариантов шла речь. Если вы собираете только под одну платформу, достаточно один раз зафиксировать это в записи.

Конфигурация и секреты — отдельные оси

Конфигурацию храните в системе контроля версий и привязывайте к релизу коммитом. Так, вернув прежний digest, вы одновременно можете вернуть и прежние несекретные настройки. Секреты же не должны попадать в запись релиза: достаточно сослаться на имя или версию записи в том месте, где вы их храните. Если значение секрета менялось между релизами, образ из прошлого может запуститься с новым ключом — и вести себя иначе, чем раньше. Это не ошибка digest; это следствие того, что секрет не входил в образ.

Compose-документация как раз подчёркивает, что в production часто меняют окружение, порты, убирают bind mount кода и добавляют политику перезапуска. Всё это — конфигурация, которая живёт рядом с образом и должна версионироваться отдельно, а не растворяться в нём.

Граница отката: схема базы данных

Вот место, где точный образ перестаёт помогать. Откат образа не откатывает данные и не откатывает схему. Допустим, релиз 41 выполнил миграцию, которая переименовала колонку full_name в name. Образ релиза 40 по-прежнему обращается к full_name. Вы вернули digest 40 — он стартует, а первый же запрос падает. Образ был точным, просто мир вокруг него изменился.

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

  • Расширение, затем сужение. Сначала релиз добавляет новую колонку и пишет в обе, потом, в следующем релизе, старая убирается. Пока обе существуют, старый образ работает со схемой.
  • Миграции, обратимые на практике. Для каждой миграции заранее понятно, что произойдёт при возврате, и это проверено не только на бумаге.
  • Явная пометка «точка невозврата». Если миграция разрушающая, релиз в записи отмечается как не откатываемый на образ предыдущей версии без восстановления данных.

Что именно вы выберете, зависит от приложения. Важно, чтобы в записи релиза стояло не только «образ — digest X», но и «схема — миграция N», а рядом решение: допускает ли миграция N запуск образа предыдущего релиза. Тогда в аварийную минуту никто не должен угадывать.

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

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

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

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

Повторное развёртывание прежнего digest

Теперь соберём всё в действие. Допустим, релиз 41 работает неправильно и нужно вернуться к релизу 40. Если записи ведутся, процедура коротка:

  1. Открыть запись релиза 40 и прочитать digest, версию конфигурации и номер миграции.
  2. Проверить в записи, допускает ли текущая схема базы запуск образа 40. Если нет — остановиться и решать вопрос с данными отдельно, а не запускать образ «на удачу».
  3. Вернуть конфигурацию к версии релиза 40.
  4. Подставить в APP_IMAGE прежний digest.
  5. Пересоздать контейнер приложения, не трогая сервисы-зависимости.

Пятый шаг в командной строке выглядит примерно так:

docker compose up -d --no-deps app

Флаг --no-deps говорит Compose не пересоздавать связанные сервисы. Это соответствует тому, что в документации сказано о возможности обновить один сервис отдельно; именно так вы заменяете приложение, не перезапуская базу.

Как убедиться, что работает именно он

Самое неприятное в откате — уверенность, что он состоялся, в то время как фактически продолжает работать прежний контейнер. Проверять нужно не то, что вы написали в файле, а то, что запущено. У запущенного контейнера можно спросить, с какого образа он создан, и сравнить результат с записью релиза:

docker compose ps --format json
docker inspect --format '{{.Image}}' <имя-контейнера>

Нужно помнить тонкость: поле Image у контейнера содержит идентификатор образа в локальном хранилище, а не обязательно digest манифеста из registry. Эти значения могут отличаться, поэтому сверять их напрямую нельзя без понимания, с чем именно сравниваешь. Надёжнее смотреть репозиторные digest у локального образа:

docker image inspect --format '{{json .RepoDigests}}' registry.example.com/team/myapp@sha256:<digest>

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

Два сервера, два кеша, одна ссылка

Теперь сценарий, который часто упускают. У вас два сервера, и на обоих выкатывается релиз. На первом образ уже есть в локальном хранилище, на втором — нет, потому что туда его ещё ни разу не загружали. Или наоборот: на одном сервере лежит старый локальный образ с тем же тегом, который когда-то подтянули и забыли.

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

Ссылка по digest убирает эту двусмысленность. Что бы ни лежало локально, требование «образ с таким содержимым» либо удовлетворяется найденным образом с тем же digest, либо приводит к загрузке из registry. Подсунуть другое содержимое под таким именем нельзя: изменённое содержимое получило бы иной digest. Различие кешей остаётся различием скорости (где-то загрузка нужна, где-то нет), но не различием результата.

Как проверить это на практике, не доверяя рассуждениям:

  1. На каждом сервере выполните загрузку по ссылке с digest.
  2. Запросите RepoDigests локального образа и сравните значения между машинами и с записью релиза.
  3. Убедитесь, что запущенные контейнеры созданы из этого образа, а не из старого с похожим именем.

Если у вас серверы в разных локациях, у каждого свой маршрут до registry и своя скорость загрузки, но digest-ссылка при этом одна. Для ресурсов тут важно скорее дисковое пространство: несколько версий образов в локальном хранилище занимают место, и на небольшом VPS с NVMe-диском (например, на тарифе Ba с 40 ГБ или Uab с 80 ГБ) приходится следить за накоплением старых слоёв. Это общее правило, а не оценка конкретной ёмкости: сколько версий вы сможете держать, зависит от размера ваших образов. Если образы разрослись, имеет смысл начать с их уменьшения — об этом есть отдельный материал: Docker-образ: как уменьшить размер.

Образ должен пережить очистку registry

Последняя, самая тихая ловушка. Откат по digest работает только тогда, когда образ с этим digest всё ещё существует. Registry же растёт, и рано или поздно кто-то включает автоматическую очистку: удалять образы старше N дней, оставлять последние K тегов, чистить «неиспользуемые».

Здесь проявляется ещё одна особенность. Многие политики очистки считают образ нужным, пока на него указывает тег. Образ, который вы закрепили в продакшне по digest, но чей тег давно перевесили или удалили, для такой политики выглядит бесхозным. Через месяц вы захотите откатиться, а digest окажется ссылкой в пустоту.

Отсюда несколько практических правил, которые вытекают из самой схемы, а не из какого-то конкретного продукта:

  • Каждому образу, ушедшему в продакшн, оставляйте тег, который политика очистки считает защищённым, — например, тег по номеру релиза. Он нужен не для запуска, а чтобы образ не сочли лишним.
  • Сроки хранения подбирайте по тому, как далеко назад вы готовы откатываться. Если откат на прошлый квартал возможен, образы квартальной давности должны существовать.
  • Прежде чем включать очистку, посмотрите, какие digest сейчас запущены на серверах и какие записаны в последних релизах: эти образы удалять нельзя.
  • Учтите, что локальная копия образа на сервере — не замена registry. Она может исчезнуть при очистке диска, пересоздании сервера или переезде.

Конкретные настройки зависят от используемого registry, и проверять их нужно по его собственной документации. Принцип же общий: срок хранения образа должен быть не меньше срока, на который вы обещаете себе возможность откатиться.

Где всё это запускать

Для описанной схемы не нужно ничего экзотического: сервер с Docker и Compose, доступ к registry и место на диске под несколько версий образов. На VPS MAATRIX с root-доступом, NVMe и процессорами AMD EPYC такая конфигурация размещается естественно; доступны локации UK, US, RU и FR, а наличие нужных ОС и образов уточняется в форме заказа. Для небольшого приложения с базой может хватить и младших тарифов, для нескольких сервисов с накопленными версиями образов вам пригодится запас по диску и памяти; ресурсы тарифа — это отправная точка, а не обещание ёмкости, и реальную потребность стоит измерять на своей нагрузке. Два сервера для сравнения кешей из предыдущего раздела — тоже обычный сценарий: отдельные серверы можно взять и в разных локациях, только не стоит считать, что это само по себе даёт отказоустойчивость приложению.

Запись релиза, которую можно прочитать через месяц

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

release: 40
date: <дата выката>
image: registry.example.com/team/myapp@sha256:<digest>
platform: linux/amd64
protected-tag: myapp:release-40
config: коммит <хеш> (compose + несекретные переменные)
secrets: запись app-prod, версия <N>
migration: 0087
rollback-to-this-image: допустим при миграции не выше 0088

Каждая строка отвечает на отдельный вопрос будущего вас. Что запускать — digest. Для какой архитектуры — платформа. Почему образ не удалили — защищённый тег. С какими настройками — коммит конфигурации. С какими секретами — ссылка на запись. На какую схему рассчитан — номер миграции. И, наконец, последняя строка, ради которой всё и затевалось: когда этот образ вернуть можно, а когда нельзя.