Health check завершается ошибкой, хотя приложение работает
Если health check завершается ошибкой при деплое, хотя локально приложение работает без проблем, обычно причина кроется в одном из пяти факторов. Проверьте binding, path, port, timing и dependencies в порядке, который быстрее всего помогает найти проблему.
Есть особый вид зависания, когда ошибка health check блокирует деплой каждого релиза, хотя приложение по всем доступным вам признакам полностью исправно. Оно работает локально. Оно работает в Docker локально. В логах видно, что приложение принимает подключения. А платформа сообщает об ошибке снова и снова — иногда двадцать раз подряд, — при этом в access log не появляется ни одного запроса.
Последняя деталь особенно важна: она сразу сужает круг поиска. Если приложение ни разу не записало запрос в лог, значит, проверка до него не дошла — и в коде приложения вы не найдёте объяснения этой проблемы.
Ниже перечислены пять причин в порядке, который помогает быстрее всего найти проблему.
1. Приложение привязано к localhost
Это самая распространённая причина, и именно она объясняет симптом «трафик вообще не приходит».
Внутри контейнера 127.0.0.1 означает loopback самого этого контейнера. Health check, который приходит извне контейнера, не может до него добраться. Процесс слушает порт, логи это подтверждают, но сокет недоступен откуда-либо, откуда действительно должен приходить запрос.
// Unreachable from outside the container
app.listen(3000, '127.0.0.1')
// Correct
app.listen(3000, '0.0.0.0')
У разных фреймворков разные значения по умолчанию, причём некоторые из них меняли default между major-версиями. Проверьте, к какому адресу на самом деле привязывается ваш фреймворк, а не тот, который вы помните.
# Confirm from inside the running container
dockup exec "ss -ltn || netstat -ltn" my-project/my-api
Если приложение слушает 127.0.0.1:3000, а не 0.0.0.0:3000, вы нашли причину, и остальные пункты из этого списка уже не имеют значения.
2. Порт, который проверяет платформа, отличается от порта приложения
Здесь задействованы два порта, и их легко перепутать: порт, который процесс слушает внутри контейнера, и порт, на который платформа направляет запросы. Если приложение читает PORT из окружения, а где-то в Dockerfile вы жёстко указали 3000, эти значения могут незаметно разойтись.
Надёжный подход — позволить платформе сообщить приложению нужный порт:
const port = process.env.PORT || 3000
app.listen(port, '0.0.0.0')
После этого укажите port сервиса один раз — на платформе — и не поддерживайте одно и то же число в двух местах.
3. Path возвращает неуспешный статус
Path health check сопоставляется точно, и неожиданно большое число сбоев связано с redirect. Если приложение перенаправляет /healthz на /healthz/ или принудительно включает HTTPS с помощью 301, checker, который считает успешными только ответы 2xx, будет каждый раз завершаться ошибкой. Браузер же перейдёт по redirect и покажет вам работающую страницу.
Три конкретные ловушки:
- Redirect из-за завершающего слеша.
/healthz→/healthz/— это 301. - Принудительный HTTPS. Внутренняя проверка обычно приходит по обычному HTTP через loopback. Безусловный redirect на HTTPS приводит к ошибке.
- Auth middleware. Глобальный authentication guard, выполняющийся до routing, также вернёт 401 для health path.
Явно исключите health path из auth и принудительного HTTPS. Это единственный route, который должен быть максимально простым.
4. Проверка выполняется быстрее, чем завершается cold start
Если проверка несколько раз завершается ошибкой, а затем проходит, либо падает во время деплоя, но проходит при повторной попытке, причина связана с timing, а не с configuration.
Нужно учитывать не длительность одной попытки, а общий budget: interval × retries. Приложению, которому требуется двенадцать секунд на подключение к базе данных и прогрев cache, нужен общий budget больше двенадцати секунд. Иначе каждый релиз будет завершаться ошибкой, а в итоге вы отключите gate, убрав единственную защиту между сломанной сборкой и пользователями.
dockup info my-project/my-api --json | grep -A6 healthCheck
Установите timeout выше длительности самой медленной допустимой отдельной попытки, а число retries — таким, чтобы interval × retries с запасом превышал длительность самого медленного допустимого запуска. Измерьте время запуска, а не гадайте: в логах есть timestamps.
5. Приложение действительно не готово
Последний случай — именно тот, для которого существует health check: приложение запустилось, не смогло подключиться к dependency и выполняет retry. Оно не упало, поэтому ничего не перезапускается. Оно не может обслуживать запросы, поэтому проверка завершается ошибкой. Система работает ровно так, как задумано, и сообщает, что этот релиз пока не должен получать трафик.
Отличить этот случай от остальных четырёх можно по тому, что приложение записало запрос в лог и ответило с неуспешным статусом. Если запрос есть в логах, причины с 1 по 3 исключены.
Порядок диагностики, который экономит время
# 1. Did the request reach the app at all?
dockup logs my-project/my-api --follow
# 2. What is the process actually bound to?
dockup exec "ss -ltn || netstat -ltn" my-project/my-api
# 3. Does the path answer from inside the container?
dockup exec "curl -si localhost:3000/healthz" my-project/my-api
# 4. What is the gate configured to expect?
dockup info my-project/my-api --json
Именно шаг 3 помогает решить большинство таких проблем. curl изнутри контейнера сразу исключает все сетевые переменные: если там он возвращает 200, а на платформе проверка по-прежнему завершается ошибкой, проблема в address или port, а не в приложении. Если он возвращает 301 или 401, причина найдена — и для этого не пришлось менять настройки платформы.
Почему gate стоит оставить включённым
После четвёртого неудачного деплоя возникает соблазн отключить health check и выпустить релиз. Но стоит помнить, что именно вы отключаете.
В Dockup health gate не даёт сломанному релизу попасть к пользователям. Новая версия собирается и запускается, пока текущая продолжает обслуживать запросы; трафик переключается только после того, как новая версия начинает отвечать. Отключив gate, вы снова разрешаете сценарий, при котором запустившийся, но неработающий контейнер заменяет исправный.
Проверка, которая четыре релиза подряд завершается ошибкой, раздражает. Но проверка, которая безусловно проходит всегда, не остановит тот деплой, который действительно важно остановить.
Часто задаваемые вопросы
Почему health check завершается ошибкой, когда приложение работает локально?
Почти всегда потому, что контейнер привязан к 127.0.0.1, а не к 0.0.0.0. Локально вы подключаетесь через тот же loopback, а из-за пределов контейнера этот адрес недоступен.
Должен ли health endpoint требовать authentication? Нет. Исключите его из глобального auth middleware, иначе checker получит 401, а деплой завершится ошибкой, хотя приложение работает.
Какой timeout использовать? Он должен быть больше длительности самой медленной допустимой отдельной попытки, а retries должны покрывать самый долгий допустимый cold start. Определите время запуска по логам, а не гадайте.
Безопасно ли отключить health check, чтобы разблокировать релиз? Это разблокирует релиз, но одновременно уберёт защиту, которая не позволяет сломанной версии принять трафик. Лучше исправить проверку: в большинстве случаев причина заключается в bind address или redirect, и устранение проблемы занимает несколько минут.
