MAATRIX / Блог / Тег latest подтянул новую версию ночью, и API начал отдавать 500

Тег latest подтянул новую версию ночью, и API начал отдавать 500

MAATRIX

В 03:04 ночи на дежурного посыпались алерты: доля ошибок 500 на API выросла с обычных долей процента до почти сотни. Никто ничего не деплоил — последний осознанный релиз был два дня назад, и все, кто мог что-то менять руками, спали. Первая мысль дежурного была логичной: «сервер упал сам». Через сорок минут выяснилось, что сервер работал прекрасно, просто ночью на него сам приехал новый образ контейнера, о существовании которого никто не знал — потому что в docker-compose.yml был прописан тег latest, а рядом крутился Watchtower, который добросовестно следил за реестром и обновлял контейнер при первой возможности.

Что сломалось: симптомы без единого деплоя

Первый сигнал пришёл из Grafana: график http_requests_total{status="500"} для эндпоинтов POST /orders и POST /payments подскочил почти до 100% буквально за одну минуту, а GET-эндпоинты продолжали отвечать нормально. Это сразу разделило проблему на две гипотезы: либо упала конкретная часть приложения, либо что-то сломалось именно в обработке записи.

Дежурный проверил базовые вещи в первую очередь:

docker ps
docker stats --no-stream

Контейнер api был жив, не в состоянии Restarting, не подъедал память сверх обычного, CPU был в норме. Это исключило самый простой вариант — падение по OOM или crashloop. Приложение отвечало, просто отвечало ошибкой.

Посмотрели логи самого контейнера:

docker logs api --since 20m | grep -i error

В логе повторялась одна и та же трассировка на каждый POST-запрос с телом, где было поле created_at в формате без миллисекунд — "2026-08-29T03:00:00Z" вместо ожидаемого "2026-08-29T03:00:00.000Z". Ошибка валидации схемы, а не исключение уровня инфраструктуры. Само по себе это уже подсказка, но в 3 часа ночи с одним открытым терминалом сходу не очевидно, откуда в проверенной месяцами схеме вдруг взялось новое требование к формату даты.

Что показали логи и метрики глубже

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

docker events --since '2026-08-29T02:50:00' --until '2026-08-29T03:10:00'

В выводе нашлось ровно то, что нужно:

2026-08-29T03:04:02 container stop api (image=registry.example.com/api:latest)
2026-08-29T03:04:03 container destroy api
2026-08-29T03:04:05 container create api (image=registry.example.com/api:latest)
2026-08-29T03:04:06 container start api

Контейнер был пересоздан — не упал, а именно пересоздан, с тем же именем и тем же тегом образа. Тег один и тот же, latest, но это не значит, что образ тот же самый: под одним и тем же тегом в реестре мог лежать уже другой манифест. Проверили digest у текущего контейнера и сравнили с тем, что было записано в CI-логе прошлого осознанного деплоя:

docker inspect api --format '{{.Image}}'
# sha256:9f2a1c4e7b3d...

# в логе CI-пайплайна двухдневной давности:
# Successfully pushed registry.example.com/api:latest (digest: sha256:5e8b0a91cd2f...)

Digest не совпадал. Значит, за последние двое суток кто-то (или что-то) отправил в реестр новый образ под тем же тегом latest, а в 03:04 этот новый образ приехал на прод и заменил собой старый рабочий контейнер. Ни один человек в эту ночь деплой не запускал — оставалось понять, кто запускал его вместо человека.

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

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

Арендовать сервер

Гипотезы, которые отбросили

Версия 1: сработал автоскейлинг, и новая нода подняла контейнер из устаревшего образа в локальном кэше. Автоскейлинга на этом окружении вообще не было — одна выделенная машина без кластера, версия отпала сразу.

Версия 2: кто-то из команды тихо задеплоил ночью, не предупредив. История CI/CD не запускала ни одного джоба деплоя в это окно, только сборка по расписанию для другой ветки. Ручных docker compose up -d через SSH тоже не было — по last на сервере ночью никто не заходил.

Версия 3: контейнер упал от нехватки памяти и сам перезапустился политикой restart: unless-stopped. Тогда docker events показал бы die с ненулевым кодом выхода перед start, а не stopdestroycreatestart. Такая последовательность характерна именно для внешнего пересоздания контейнера, а не для краша и рестарта Docker-демоном.

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

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

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

Настоящая причина: Watchtower, плавающий тег и незамеченная сборка

Картина сложилась из трёх независимых решений, каждое из которых по отдельности было разумным, а вместе дали ровно такой инцидент.

Первое. В docker-compose.yml на проде образ был указан как registry.example.com/api:latest, а не как конкретная версия. Так сделали ещё на старте проекта, чтобы не редактировать compose-файл при каждом релизе — типичное упрощение, которое работает, пока не начинает работать против вас.

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

watchtower:
  image: containrrr/watchtower
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock
  environment:
    - WATCHTOWER_POLL_INTERVAL=3600

Без явного списка контейнеров или лейбла com.centurylinklabs.watchtower.enable=true Watchtower по умолчанию следит за всеми контейнерами на хосте, у которых образ можно сопоставить с записью в реестре. Про эту деталь конфигурации есть отдельный разбор частых ошибок в статье про автообновление Watchtower на сервере. Контейнер api под это описание тоже подошёл, хотя ставили Watchtower совершенно для другой цели.

Третье. В ту ночь сборочный пайплайн для другой фичи (по расписанию, а не по мержу) пересобрал образ из ветки main и запушил его в реестр с тегом latest — так была настроена сборка изначально: каждый ночной прогон обновляет плавающий тег, чтобы «всегда были под рукой последние изменения» для внутреннего стенда. В main в тот день также замержили рефакторинг схемы валидации входящих запросов, где неявно обновилась транзитивная зависимость валидатора JSON-схем с диапазоном версий ^8.0.0 до 8.4.0 — минорное обновление по семверу, формально совместимое. В новой версии валидатора по умолчанию строже проверялся формат date-time, требуя миллисекунды там, где раньше это было опционально. CI это не поймал, потому что тестовые фикстуры генерировались с миллисекундами по привычке, а часть реальных клиентов API (старая версия мобильного приложения) миллисекунды не отправляла — и раньше это было нормально.

Watchtower ночью увидел новый digest под тегом latest, решил, что вышла новая версия, остановил старый контейнер и поднял новый — прямо на проде, потому что prod и «внутренний стенд» физически оказались одним и тем же хостом с одним и тем же Docker-демоном. Разграничения по окружениям на уровне инфраструктуры не было — было джентльменское соглашение «этот тег для теста, не трогайте прод», которое никак не проверялось технически.

Как остановили кровотечение

Откатывать через docker compose pull было нельзя — latest уже указывал на новый, сломанный образ, обычный pull притянул бы его же снова. Нужен был конкретный старый digest, который сохранился в логе прошлого CI-деплоя:

docker pull registry.example.com/api@sha256:5e8b0a91cd2f...
docker tag registry.example.com/api@sha256:5e8b0a91cd2f... registry.example.com/api:latest
docker compose up -d api

Это вернуло работоспособность за пару минут — критичная часть инцидента на этом закончилась. Вторым шагом сразу же остановили Watchtower для контейнера api, не дожидаясь полного разбора:

docker stop watchtower

Это была временная грубая мера — на время расследования, чтобы не получить повторное самопроизвольное обновление, пока разбираемся в деталях.

Что изменили, чтобы не повторилось

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

Ушли от плавающего тега в проде. docker-compose.yml для боевого окружения теперь ссылается на образ по конкретному тегу-версии, который меняется только явным коммитом в репозиторий конфигурации, а не автоматически:

services:
  api:
    image: registry.example.com/api:v2026.08.24-3f2a1

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

Развели ночную сборку для стенда и релизы для прода на уровне тегов реестра. Ночной пайплайн теперь пушит в отдельный namespace registry.example.com/internal/api:nightly, а не в тот же путь, что и боевой образ. Human-readable разница в пути — это не идеальная защита (человек всё ещё может перепутать), но она физически ломает совпадение по тегу latest между двумя окружениями. Если у вас ещё не поднят собственный реестр с разделением по путям и авторизацией — есть пошаговая статья как установить и настроить приватный Docker Registry на VPS.

Ограничили область действия Watchtower явными лейблами. Теперь автообновление касается только тех контейнеров, у которых явно прописан лейбл:

services:
  internal-dashboard:
    image: registry.example.com/internal-dashboard:latest
    labels:
      - com.centurylinklabs.watchtower.enable=true

watchtower:
  image: containrrr/watchtower
  command: --label-enable
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock
  environment:
    - WATCHTOWER_POLL_INTERVAL=3600

Флаг --label-enable переворачивает логику: без явного лейбла контейнер Watchtower не трогает вообще, даже если видит новый digest под тем же тегом. Для боевого API такой лейбл теперь не ставится в принципе.

Добавили постепенную выкатку вместо мгновенной замены всего трафика. Даже с пиновкой версий механика «раз — и весь прод обновился одним движением» остаётся рискованной: баг в новой версии обнаруживается сразу по всем запросам, а не по части. Для следующих релизов начали переводить часть трафика на новую версию через веса в upstream nginx, прежде чем переключать остальной — подход и конфигурация разобраны в статье «Canary deploy: основы». Для одного сервера это не полноценный canary с отдельным кластером, но и два upstream с разными весами внутри одного nginx уже снижают цену ошибки при выкатке.

Зафиксировали версии зависимостей и добавили проверку lock-файла в CI. Диапазон ^8.0.0 в package.json заменили на точную версию, а сборку научили падать, если package-lock.json разошёлся с package.json:

npm ci --ignore-scripts

Команда npm ci (в отличие от npm install) требует точного совпадения lock-файла и завершается ошибкой, если он не синхронизирован — это не даёт транзитивным минорным обновлениям тихо просочиться в сборку между релизами.

Добавили алерт на изменение digest у боевых контейнеров вне окна деплоя. Cron-скрипт раз в минуту сверяет текущий docker inspect --format '{{.Image}}' для критичных контейнеров со значением, записанным при последнем осознанном деплое, и шлёт алерт при расхождении, если оно не помечено как ожидаемое. Дешёвая, но эффективная страховка против обновления, о котором никто не просил.

Как проверить у себя, что вы не в такой же ситуации

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

# 1. Есть ли где-то плавающий тег в проде
grep -r "image:.*:latest" docker-compose*.yml

# 2. Стоит ли Watchtower и на что он подписан
docker ps --filter "ancestor=containrrr/watchtower"
docker inspect watchtower --format '{{.Config.Cmd}}'

# 3. Сохраняете ли вы digest образа при каждом деплое
docker inspect <container> --format '{{.Image}}'
Что проверяемПлохоХорошо
Тег образа в проде:latest или без тега вовсеконкретная версия/хеш коммита
Автообновлениебез фильтра по лейблам, на весь хост--label-enable, точечно на нужные сервисы
Namespace реестрастенд и прод пушат в один и тот же путьразные пути/репозитории на реестре
Обновление зависимостейдиапазоны ^/~ без lock-файла в CIточные версии, npm ci / аналог в сборке
Откатнет сохранённого digest прошлого релизаdigest фиксируется в логе каждого деплоя

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

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

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

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли вообще не использовать Watchtower, а обновлять контейнеры только руками?

Можно, и для критичных боевых сервисов это часто разумнее. Автообновление уместно там, где допустима задержка и ошибка не критична — внутренние дашборды, тестовые стенды. Для прода лучше явный шаг деплоя, пусть даже автоматизированный через CI, но без «магии» полностью независимого от релизного процесса обновления.

Почему CI не поймал проблему с валидацией дат до продакшена?

Тестовые фикстуры генерировались с миллисекундами по умолчанию, а часть реальных клиентов их не отправляла. Тесты проверяли то, что сами же и создавали, а не реальное разнообразие входных данных — стоит время от времени прогонять тесты на выборке реальных, а не синтетических запросов.

Что делать, если digest старого рабочего образа не сохранился нигде?

Тогда откат возможен только через пересборку из старого коммита в git — дольше, но рабочий вариант, если история и Dockerfile не потеряны. Поэтому стоит логировать digest каждого успешного деплоя отдельной строкой в CI.

Разве imagePullPolicy в Kubernetes не решает эту проблему сама?

Политика IfNotPresent не перетянет образ повторно на существующей ноде, но при создании нового пода с тем же тегом latest всё равно подтянет то, что сейчас лежит в реестре под этим тегом — защита та же самая: дело не в оркестраторе, а в том, ссылаетесь вы на изменяемый тег или на digest/версию.

Стоит ли вообще пушить что-либо под тегом latest?

Можно оставить его для ознакомительных целей — чтобы docker pull без тега показывал «что-то последнее» разработчику, изучающему проект. Но такой образ не должен фигурировать ни в одном compose-файле боевых или полу-боевых окружений — граница должна быть на уровне процесса, а не устной договорённости.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →