MAATRIX / Блог / Контейнер перезапускается по кругу: читаем причину правильно

Контейнер перезапускается по кругу: читаем причину правильно

MAATRIX

docker ps показывает контейнер со статусом Restarting (1) 3 seconds ago, и так уже полчаса. docker logs выдаёт три строчки, из которых непонятно вообще ничего — то ли приложение упало само, то ли его убили, то ли оно просто не успело подняться. Restart-политика делает своё дело: перезапускает контейнер снова и снова, а вы тем временем теряете время, глядя не в те логи и проверяя не те гипотезы. Ниже — порядок диагностики, который реально приводит к причине, а не к очередному циклу перезапуска.

Почему docker logs показывает не то, что нужно

По умолчанию docker logs печатает вывод текущего процесса контейнера. Когда контейнер перезапускается, движок Docker создаёт новый процесс с тем же ID контейнера, а stdout/stderr предыдущей попытки уже закрыт. С драйвером логов json-file (он стоит по умолчанию почти everywhere) Docker обычно хранит только текущий лог-файл, если явно не настроена ротация с сохранением нескольких файлов — так что после N-го падения вы видите хвост именно этого падения, а не всей истории.

Отсюда две частые ошибки:

  • Смотреть docker logs <container> без флагов и делать вывод по последним трём строчкам — но именно эти строчки могут быть от совершенно другой причины, чем та, что убивала контейнер пять минут назад (например, база уже поднялась, а раньше падало именно из-за неё).
  • Не проверять docker logs --previous — этот флаг существует именно для того, чтобы достать stdout завершившегося (уже перезапущенного) контейнера, но работает только для одного предыдущего запуска, не для всей истории.

Первый шаг — зафиксировать окно времени и достать логи именно за него:

docker logs --since 10m --timestamps my-service > /tmp/my-service.log

--timestamps критичен: без него вы не сможете сопоставить строки лога с моментами перезапуска из docker events (см. ниже). Если нужен лог именно последнего упавшего процесса, а не текущего:

docker logs --previous my-service

Если контейнер настолько быстро падает, что даже --previous не успевает захватить нужный контекст (приложение падает до того, как что-то важное успевает попасть в stdout), стоит на время диагностики переключить логирование на драйвер с внешним хранением — например, journald или отправку в отдельный агрегатор — чтобы история не терялась при каждом рестарте. Если у вас уже настроен centralized logging, посмотрите, что писала Grafana Loki — там история сохраняется независимо от жизненного цикла контейнера.

docker inspect: что на самом деле означает exit code

Самый информативный источник для crash loop — не логи, а секция State в выводе docker inspect:

docker inspect my-service --format \
  'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}'

Ключевое правило чтения exit code:

  • Код 0 — процесс завершился успешно. Для сервиса, который должен работать постоянно, это означает, что главный процесс просто вышел (например, entrypoint-скрипт отработал и закончился, не запустив foreground-процесс), а не что случилась ошибка.
  • Код от 1 до 127 — приложение само вернуло этот код. Смотрите документацию/исходники приложения: обычно это штатная ошибка (не удалось прочитать конфиг, не открылся порт, не прошла валидация переменных окружения).
  • Код 128 + номер сигнала — процесс был убит сигналом, а не завершился сам. Например, 137 = 128 + 9 (SIGKILL), 143 = 128 + 15 (SIGTERM), 139 = 128 + 11 (SIGSEGV). Это принципиально другая категория причины: контейнер не "решил" завершиться, его прибили снаружи (или упал на уровне рантайма — SIGSEGV).

Отдельно стоит проверять поле OOMKilled. Exit code 137 сам по себе может означать и SIGKILL от docker stop -t 0, и убийство OOM killer'ом ядра — но если OOMKilled: true, сомнений нет: контейнеру не хватило памяти. Это отдельная, очень частая причина crash loop, разбираем её детально в статье про OOM killer, который убил базу — там же описано, как ядро вообще выбирает жертву при нехватке памяти, это пригодится, если непонятно, почему упал именно этот контейнер, а не соседний.

Таблица для быстрой ориентации:

Exit codeЧто произошлоКуда смотреть
0Процесс завершился штатно (но контейнер должен жить — проверьте entrypoint)CMD/ENTRYPOINT, foreground-процесс
1Общая ошибка приложениялоги приложения, конфиг
137SIGKILL — OOM killer или принудительная остановкаOOMKilled в docker inspect, лимиты памяти
139SIGSEGV — сегфолт в процессеверсия бинарника/образа, архитектура (arm/x86)
143SIGTERM — штатный сигнал остановки, не обработанный вовремяgraceful shutdown, stop_grace_period

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

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

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

docker events: восстанавливаем историю перезапусков

docker inspect даёт срез на текущий момент, но не историю. Чтобы понять, с какой периодичностью и когда именно контейнер падал (это важно — регулярные падения раз в 30 секунд и падения раз в час указывают на разные причины), используйте docker events:

docker events --since 1h --filter container=my-service \
  --format '{{.Time}} {{.Action}}'

Вывод покажет последовательность start, die, destroy/restart с таймстампами. Разница между соседними die — это фактическое время жизни контейнера между падениями. Если оно почти постоянное (например, всегда около 15 секунд) — вероятнее всего, дело в чём-то детерминированном при старте: health check, зависимость, конфиг. Если время жизни плавает и растёт от попытки к попытке — это больше похоже на постепенную деградацию (утечка памяти, накопление соединений).

Полезно сразу сопоставить это с логами по времени — ради этого мы и добавляли --timestamps в docker logs на первом шаге. В связке docker events + docker logs --since <точное время> вы получаете полную картину конкретной итерации падения, а не усреднённый шум.

Если работаете через Docker Compose, аналог для всего стека:

docker compose events --since 1h

Типичная причина №1: слишком агрессивный health check

Если в docker inspect в поле State.Health.Status стоит unhealthy, а restart-политика или orchestrator (Swarm, Kubernetes) реагирует на это перезапуском — контейнер может быть абсолютно рабочим, но не успевать пройти проверку в отведённое время.

Типичная ошибка конфигурации health check в Compose:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
  interval: 5s
  timeout: 2s
  retries: 2
  start_period: 0s

Проблема тут в start_period: 0s при медленном старте приложения (миграции БД при запуске, прогрев кэша, JVM warm-up) — первые же неудачные проверки после старта уже считаются провалом, retries: 2 исчерпывается за 10 секунд, и оркестратор решает, что контейнер нужно перезапустить, хотя приложению просто нужно было ещё 20-30 секунд. Смотреть историю самих проверок:

docker inspect my-service --format '{{json .State.Health.Log}}' | jq

Это покажет последние N попыток с их выводом и кодом возврата — часто там прямо видно Connection refused, потому что порт ещё не слушается. Решение — увеличить start_period до реального времени старта приложения (с запасом), а не гонять retries. Подробный разбор параметров и типичных промахов — в статье про настройку docker healthcheck.

Типичная причина №2: зависимость ещё не готова

depends_on в Compose (без condition: service_healthy) гарантирует только порядок запуска контейнеров, а не готовность сервиса внутри них. Если у вас:

services:
  app:
    depends_on:
      - postgres
  postgres:
    image: postgres:16

то app стартует, как только процесс postgres внутри контейнера начал запускаться — а не когда PostgreSQL реально готов принимать соединения (инициализация БД при первом запуске может занимать десятки секунд). Приложение без retry-логики на подключение к БД падает с ошибкой соединения, restart-политика поднимает его заново, а база всё ещё не готова — получаем classic crash loop именно в первые минуты после docker compose up или после перезапуска хоста.

Правильная связка — явная проверка готовности зависимости:

services:
  app:
    depends_on:
      postgres:
        condition: service_healthy
  postgres:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10

Это решает проблему только на уровне первого старта. Если зависимость может стать недоступной уже после успешного запуска (сеть моргнула, БД перезагрузилась на обновление), спасает только retry-логика внутри самого приложения — health check зависимостей тут не поможет, потому что зависимость на момент старта app была вполне доступна.

Отдельная причина: конфиг из переменных окружения

Частый источник crash loop, который не виден ни в exit code, ни в health check — неверно подхваченная переменная окружения. Приложение стартует, пытается прочитать, скажем, DATABASE_URL или REDIS_HOST, получает пустое значение или значение с опечаткой и падает с exit code 1 на самой ранней стадии инициализации.

Проверить, что контейнер реально видит:

docker exec my-service env | sort

Если контейнер уже не запускается (падает раньше, чем можно зайти внутрь exec), временно переопределите entrypoint, чтобы вместо приложения контейнер просто «повис» и дал возможность зайти и проверить окружение вручную:

docker run --rm -it --entrypoint sh my-image
# внутри:
env | sort
cat /app/config.yaml   # если конфиг генерируется из переменных при старте

Отдельно стоит проверить, не переопределяет ли что-то переменные — например, одновременное использование .env файла, environment: в Compose и --env-file в ручном docker run может привести к тому, что реально применяется не та версия значения, которую вы редактировали последней. Порядок приоритета в Compose: environment: в конкретном сервисе переопределяет .env в корне проекта, а env_file: — это отдельный источник, который применяется до environment:.

Как отличить падение при старте от падения под нагрузкой

Это разделение задаёт разную стратегию диагностики, и его стоит сделать в первую очередь, ещё до чтения логов.

Падает почти сразу после старта (секунды — первая минута). Причина почти всегда одна из: неверный конфиг/переменные окружения, недоступная на старте зависимость, ошибка в entrypoint-скрипте, несовместимость версии образа с архитектурой хоста. Диагностика: docker logs --previous, docker inspect на exit code, ручной запуск с переопределённым entrypoint (см. выше). docker events в этом случае покажет почти одинаковый и короткий интервал между start и die на каждой итерации.

Падает через минуты или часы работы, часто под нагрузкой. Тут набор причин другой: утечка памяти и последующий OOMKilled, исчерпание файловых дескрипторов или соединений с БД, накопление состояния, которое со временем ломает конкретный обработчик запроса, деградация зависимого сервиса под той же нагрузкой. Здесь важно смотреть не разовый docker inspect, а динамику потребления ресурсов до падения:

docker stats my-service --no-stream

Лучше снимать это в цикле и писать в файл, чтобы увидеть тренд, а не одну точку:

while true; do
  docker stats my-service --no-stream --format \
    '{{.Time}} {{.MemUsage}} {{.CPUPerc}}' >> /tmp/stats.log 2>/dev/null || break
  sleep 5
done

Если график памяти уверенно растёт до падения — это утечка, и дальше нужно смотреть уже не Docker-инструментами, а профилировщиком самого приложения (heap dump, GC-логи, что применимо к стеку). Если память стабильна, а падение совпадает по времени со скачком нагрузки (пиковый трафик, крон-задача, бэкап) — ищите проблему в конкурентном доступе к ресурсу: соединения с БД, лимиты воркеров, таймауты вышестоящих сервисов. Про ограничения ресурсов контейнера, из-за которых он может упираться в потолок задолго до того, как это станет очевидным, — в статье про лимиты CPU и памяти в Docker.

Полезный побочный инструмент здесь — dmesg на хосте: если ядро действительно убило процесс через OOM killer, а не сам Docker, в dmesg останется соответствующая запись с указанием, какой именно процесс и cgroup пострадали:

dmesg -T | grep -i -E 'killed process|out of memory'

Общий системный подход к чтению логов и локализации сбоя (не только Docker-специфичный) разобран в статье как читать логи и находить причину сбоя — пригодится, если проблема не ограничивается одним контейнером, а затрагивает несколько сервисов сразу.

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

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

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

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

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

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

Restart-политика always и unless-stopped — в чём разница для диагностики crash loop?

always перезапустит контейнер даже после явной ручной остановки хоста (после перезагрузки Docker daemon поднимет его снова), unless-stopped — нет, если вы сами его остановили командой docker stop. Для диагностики это важно: если нужно "заморозить" упавший контейнер и спокойно его исследовать, с политикой unless-stopped достаточно docker stop, он не поднимется заново сам, и вы получите время на docker logs --previous, docker inspect, ручной docker run с тем же образом.

Можно ли временно отключить restart, чтобы контейнер просто остался в упавшем состоянии?

Да: docker update --restart=no my-service, затем при следующем падении контейнер останется в статусе Exited, и вы спокойно снимете docker inspect и docker logs --previous без гонки со следующим циклом перезапуска. После диагностики верните политику обратно тем же docker update --restart=always.

docker inspect показывает OOMKilled: false, но памяти похоже не хватало — как так?

OOM killer ядра может убить не сам процесс контейнера, а обвязку (например, дочерний процесс) до того, как cgroup-статистика зафиксирует это как OOM самого контейнера, либо контейнер упал по внутренней логике приложения (оно само поймало нехватку памяти и корректно вышло с ненулевым кодом) раньше, чем вмешалось ядро. В этом случае dmesg на хосте — более надёжный источник, чем поле OOMKilled.

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

Как временная мера на проде — да, это остановит crash loop и даст время на нормальную диагностику. Но если рост потребления памяти вызван утечкой, увеличенный лимит просто отодвинет момент падения, а не устранит причину — контейнер всё равно упадёт, только позже и, возможно, в более неудобный момент.

Помогает ли docker system events (а не docker events) с указанием формата JSON?

Да, если нужна программная обработка: docker events --since 1h --format '{{json .}}' отдаёт построчный JSON, который удобно прогнать через jq для фильтрации по конкретному контейнеру, действию или времени — полезно, когда падений много и вручную читать вывод неудобно.

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

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

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