В истории релизов записано: «выкатили 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. Практический порядок такой:
- CI собирает образ и присваивает ему тег — например, по короткому хешу коммита.
- CI отправляет образ в registry.
- Из результата отправки или из registry CI получает digest этого манифеста и сохраняет его как артефакт пайплайна.
- Проверки (тесты, сканирование, ручная приёмка на стенде) запускаются против образа, указанного именно по digest.
- В продакшн уходит тот же 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. Если записи ведутся, процедура коротка:
- Открыть запись релиза 40 и прочитать digest, версию конфигурации и номер миграции.
- Проверить в записи, допускает ли текущая схема базы запуск образа 40. Если нет — остановиться и решать вопрос с данными отдельно, а не запускать образ «на удачу».
- Вернуть конфигурацию к версии релиза 40.
- Подставить в
APP_IMAGEпрежний digest. - Пересоздать контейнер приложения, не трогая сервисы-зависимости.
Пятый шаг в командной строке выглядит примерно так:
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. Различие кешей остаётся различием скорости (где-то загрузка нужна, где-то нет), но не различием результата.
Как проверить это на практике, не доверяя рассуждениям:
- На каждом сервере выполните загрузку по ссылке с digest.
- Запросите
RepoDigestsлокального образа и сравните значения между машинами и с записью релиза. - Убедитесь, что запущенные контейнеры созданы из этого образа, а не из старого с похожим именем.
Если у вас серверы в разных локациях, у каждого свой маршрут до 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. Для какой архитектуры — платформа. Почему образ не удалили — защищённый тег. С какими настройками — коммит конфигурации. С какими секретами — ссылка на запись. На какую схему рассчитан — номер миграции. И, наконец, последняя строка, ради которой всё и затевалось: когда этот образ вернуть можно, а когда нельзя.