MAATRIX / Блог / PID 1 не пересылал сигналы, и каждый деплой ронял запросы клиентов

PID 1 не пересылал сигналы, и каждый деплой ронял запросы клиентов

MAATRIX

Каждый деплой на проде — как маленькая рулетка: код прошёл тесты, CI зелёный, а через десять секунд после docker compose up -d в Grafana появляется всплеск 502 и в саппорт летят жалобы «не работает оформление заказа». Самое неприятное — всплеск не случайный, а стабильный: он повторяется при каждом релизе, длится примерно одно и то же время и не зависит от того, что именно поменяли в коде. Это разбор конкретного инцидента, где причина оказалась не в приложении и не в nginx, а в том, кто в контейнере отвечает на сигнал SIGTERM — и что происходит, когда отвечать некому.

Что видели в момент деплоя

Стек был обычным для среднего проекта: Node.js API за nginx как реверс-прокси, PostgreSQL, всё поднято через docker compose, деплой — пересборка образа и docker compose up -d --no-deps --build api из CI-раннера несколько раз в день. Проблему заметили не сразу — сначала были единичные жалобы от клиентов, что после «технических работ» (а по факту — обычного релиза) оформление заказа иногда падает с ошибкой. Дежурный смотрел мониторинг после жалобы — там уже всё было зелёным, инцидент как будто закрывался сам.

Переломный момент — когда подключили алерт на всплеск 5xx с привязкой к меткам деплоя в Prometheus. Оказалось, что при каждом без исключения релизе на графике error rate возникает узкий пик: 8–12 секунд повышенного числа 502 и 504, синхронно с меткой деплоя. Вне этого окна error rate почти нулевой. В логах nginx в этот момент стабильно повторялась одна и та же строка:

2026/08/19 14:03:41 [error] 812#812: *55231 upstream prematurely closed connection while reading response header from upstream, client: 91.x.x.x, server: shop.example, request: "POST /api/checkout HTTP/1.1", upstream: "http://172.19.0.4:3000/api/checkout"

«Upstream prematurely closed connection» — это не таймаут и не 500 от приложения, это разрыв TCP-соединения со стороны бэкенда посреди ответа. То есть в момент деплоя старый контейнер не отвечал с ошибкой — он просто обрывался, пока держал открытое соединение с реальным запросом клиента. Также обратили внимание на docker events: между событием container stop для старого контейнера и container die стабильно проходило почти ровно 10 секунд — слишком ровное число, чтобы быть совпадением.

Гипотеза первая: таймауты nginx

Первая мысль была самой стандартной: раз upstream рвётся, значит, дело в таймаутах прокси — proxy_read_timeout, proxy_send_timeout или keepalive-соединения к апстриму, которые не успевают переустановиться при пересоздании контейнера. Подняли proxy_read_timeout с 60 до 90 секунд, добавили proxy_next_upstream error timeout для повторной попытки на другой апстрим. Ситуация не изменилась ни на секунду — пик 502 при деплое остался ровно того же размера и той же длительности. Это логично: proxy_next_upstream помогает, когда есть второй живой апстрим, а здесь на момент разрыва был только один контейнер приложения (второй ещё не поднялся), и увеличение таймаута ничего не решает, если соединение обрывается принудительно, а не зависает. Гипотезу отбросили — механизм разрыва был не про ожидание ответа, а про физическое закрытие сокета.

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

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

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

Гипотеза вторая: пул соединений к базе

Вторая версия — что при остановке контейнера пул соединений к PostgreSQL (pg-pool, 10 коннектов) обрывается некорректно, приложение виснет на попытке доработать запрос к БД, и в итоге не успевает ответить клиенту до принудительного убийства. В приложение добавили явное закрытие пула на выходе:

process.on('SIGTERM', async () => {
  await pool.end();
  process.exit(0);
});

Задеплоили, проверили — пик 502 никуда не делся. Это было ожидаемо задним числом: если бы обработчик SIGTERM реально вызывался, closing pool не создал бы обрыва TCP-соединений с клиентами, он лишь корректно закрыл бы коннекты к базе. Но раз пик не изменился совсем, логичный следующий вопрос — а вызывается ли этот обработчик вообще. Добавили console.log('SIGTERM received') первой строкой в обработчик и передеплоили ещё раз, специально глядя в docker logs в момент остановки старого контейнера. Строка не появилась ни разу. Обработчик просто не срабатывал — сигнал до него не долетал.

Гипотеза третья: гонка в healthcheck

Третья версия была про порядок: может быть, новый контейнер объявляется healthy и начинает принимать трафик через nginx upstream раньше, чем реально готов (не прогрет кеш, не установлены соединения к базе), и часть 502 — это ошибки нового контейнера, а не разрыв старого. Проверили healthcheck в docker-compose.yml — там был обычный HTTP-пинг /health, который отвечал сразу после старта процесса, до полной инициализации. Похожая история подробно разобрана в статье про healthcheck, который проверял не то, что нужно — та же ловушка «отвечает на HTTP» вместо «готов работать». Добавили start_period и более честную проверку готовности с реальным SQL-запросом к базе — это чуть сгладило пик на графике, но узкое окно повышенных 502 при остановке старого контейнера осталось практически неизменным. Стало понятно, что это две разные проблемы: healthcheck нового контейнера действительно был неточным, но основной источник ошибок был не в старте нового, а в убийстве старого контейнера.

Что оказалось на самом деле

Разгадка нашлась при простой проверке: зашли в работающий контейнер и посмотрели, кто там процесс с PID 1.

$ docker exec -it shop_api_1 ps aux
PID   USER     TIME  COMMAND
1     node       0:00 sh -c node server.js
7     node       0:12 node server.js

PID 1 — это не node, а sh, оболочка, через которую Dockerfile запускал команду. В Dockerfile была вполне обычная на вид строка:

CMD npm start

npm start в свою очередь запускал node server.js из package.json. Docker при таком написании CMD без массива автоматически оборачивает команду в /bin/sh -c "npm start", и именно эта оболочка становится процессом с PID 1 внутри контейнера, а npm, а затем и node — её дочерними процессами (PID 7 и выше).

Вот в чём проблема. Когда docker compose stopdocker compose up -d с пересозданием контейнера сначала останавливает старый) шлёт сигнал в контейнер, он отправляет SIGTERM только процессу с PID 1 внутри пространства имён контейнера. У обычной, неинтерактивной оболочки нет обработчика для SIGTERM по умолчанию, который бы пересылал сигнал дочерним процессам — она просто не настроена так себя вести. sh не транслирует SIGTERM дочернему node, а сама завершается по нему не сразу, если вообще завершается штатно. Node внутри как ни в чём не бывало продолжает держать открытые соединения и обрабатывать текущие запросы, полностью не подозревая, что его просят остановиться.

Docker ждёт stop_grace_period (по умолчанию 10 секунд — то самое «слишком ровное число» из docker events), и если контейнер за это время не завершился сам, посылает SIGKILL. SIGKILL нельзя перехватить и нельзя проигнорировать — ядро принудительно убивает процесс. В этот момент node, который так и не узнал о том, что его просят завершиться, обрывается мгновенно вместе со всеми открытыми TCP-соединениями — в том числе с теми, где клиент как раз ждал ответ на POST /api/checkout. Отсюда и «upstream prematurely closed connection»: не таймаут, не гонка, а буквальное убийство процесса посреди активного запроса, потому что штатный сигнал на graceful-остановку никогда до него не доходил.

Ключевой урок этого случая: docker exec ps aux и вопрос «а кто у меня в контейнере PID 1» стоит задавать себе для любого сервиса ещё до инцидента, а не после. Формы CMD без массива (CMD npm start, CMD node server.js без кавычек-массива, любой CMD "команда с пробелами") почти всегда означают лишний слой оболочки между Docker и реальным процессом.

Как исправили

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

1. Убрали лишнюю оболочку. CMD переписали в exec-форме — массивом строк, без участия /bin/sh:

CMD ["node", "server.js"]

В этом случае Docker запускает node напрямую как PID 1, без промежуточной оболочки. Если в проекте всё же нужен entrypoint-скрипт (например, для миграций перед стартом), важно не забыть exec в конце самого скрипта — иначе PID 1 достанется уже bash-скрипту:

#!/bin/sh
set -e
node ./migrate.js
exec node server.js

Без exec перед последней командой node server.js снова запустился бы как дочерний процесс скрипта, и грабля вернулась бы в том же виде.

2. Добавили init-процесс. Даже когда PID 1 — это сам node, у него по умолчанию нет встроенной логики пересылки сигналов подпроцессам и «пожинания» зомби (актуально, если приложение само порождает дочерние процессы — воркеры, дочерние скрипты обработки файлов и т. п.). Чтобы не полагаться на то, что рантайм языка сам всё сделает правильно, включили tini как обёртку — либо через флаг Docker, либо через compose:

services:
  api:
    image: shop-api:latest
    init: true

Флаг init: true в docker-compose.yml (эквивалент docker run --init) подставляет tini как PID 1 автоматически, без правки Dockerfile. tini — маленький init-процесс, единственная задача которого — корректно пересылать полученные сигналы основному дочернему процессу и подбирать за ним зомби-процессы. Это стандартная практика для контейнеров, где приложение порождает субпроцессы, и дешёвая страховка даже там, где не порождает.

3. Научили приложение graceful shutdown. Раньше правки одного PID 1 недостаточно — если сигнал долетит до node, но приложение не умеет на него реагировать, оно просто упадёт мгновенно вместо аккуратного завершения активных запросов. Добавили нормальный обработчик:

let server;
const activeConnections = new Set();

server = app.listen(3000);
server.on('connection', (socket) => {
  activeConnections.add(socket);
  socket.on('close', () => activeConnections.delete(socket));
});

process.on('SIGTERM', () => {
  console.log('SIGTERM получен, прекращаю принимать новые соединения');
  server.close(async () => {
    await pool.end();
    process.exit(0);
  });
  // Не ждём вечно: если за 8 секунд не закрылись сами — добиваем
  setTimeout(() => process.exit(1), 8000).unref();
});

server.close() перестаёт принимать новые соединения, но дожидается завершения уже начатых запросов — именно то поведение, которого не хватало во время инцидента. stop_grace_period в compose подняли до 15 секунд, чтобы у приложения было больше времени на честное завершение долгих запросов, прежде чем Docker пришлёт SIGKILL:

services:
  api:
    stop_grace_period: 15s

После всех трёх правок пик 502 на графике деплоя исчез полностью — error rate во время релиза стал неотличим от фонового. Отдельно проверили руками: во время docker compose stop держали открытым долгий запрос (искусственно добавленная задержка в тестовом эндпоинте) и убедились, что ответ клиенту приходит полностью, а не обрывается. Заодно пересмотрели саму стратегию выкатки — для эндпоинтов с чувствительными к разрывам операциями (тот самый checkout) имеет смысл разносить деплой во времени и трафике, а не выключать сразу весь контейнер; основы такого подхода разобраны в статье про canary-деплой.

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

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

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

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

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

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

Как быстро проверить, кто в моём контейнере PID 1, не дожидаясь инцидента?

Выполните docker exec -it <container> ps aux (или ps -ef) и посмотрите на первую строку с PID 1. Если это sh, bash или entrypoint-скрипт вместо самого приложения — вероятно, сигналы не пересылаются, стоит проверить CMD/ENTRYPOINT в Dockerfile.

Обязательно ли использовать tini, если я уже переписал CMD в exec-форме?

Нет, но это дешёвая страховка. Если приложение никогда не порождает дочерние процессы и само корректно обрабатывает SIGTERM, tini не обязателен. Как только появляются субпроцессы (воркеры, вызовы внешних утилит, cron внутри контейнера) — риск зомби-процессов и непересланных сигналов возрастает, и init: true снимает эту заботу за одну строку в compose.

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

Потому что дело было не в нехватке времени на завершение, а в том, что сигнал вообще не доходил до процесса, который мог бы этим временем воспользоваться. Дать больше времени оболочке, которая не пересылает SIGTERM, бессмысленно — она всё равно не разбудит дочерний процесс. Увеличивать stop_grace_period имеет смысл только после того, как сигнал реально начал доходить до приложения.

Актуально ли это только для docker compose или для Kubernetes тоже?

Механизм тот же самый на уровне контейнерного рантайма: kubelet при остановке пода тоже шлёт SIGTERM процессу с PID 1 внутри контейнера и ждёт terminationGracePeriodSeconds перед SIGKILL. Если в образе PID 1 — оболочка без пересылки сигналов, ровно та же картина с обрывом запросов будет повторяться при каждом rolling update в кластере, только вместо docker events это будет видно в событиях пода.

Как отличить эту проблему от обычного 502 из-за медленного бэкенда?

По привязке ко времени и по стабильности: если 502 стабильно совпадает с меткой деплоя, длится примерно одинаковое время при каждом релизе (часто около grace-периода) и в логах nginx именно «prematurely closed connection», а не таймаут ожидания ответа — это почти всегда про сигналы и остановку контейнера, а не про производительность приложения. Разбор похожего, но другого по механике случая с 502 — в статье про nginx, который отдаёт 502 через раз.

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

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

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