Итак, хорошая новость заключается в том, что у вас есть приложение Node, которое работает и обслуживает трафик. Поздравляю! Плохая новость заключается в том, что в какой-то момент ваше приложение будет делать то, чего не должно. Возможно, соединение с базой данных устарело, узлу не хватает памяти или развертывание заставляет ваш сервер перезагружаться. Вам нужно задать вопрос: что происходит с любыми запросами, находящимися в процессе обработки? Скорее всего, когда ваше приложение неожиданно завершит работу, так же поступайте и с любыми запросами на лету.
Изящное завершение - это способ справиться с этими ситуациями, позволяя приложению завершить ответ на запросы и выключиться до того, как процесс фактически завершится. Хотя добавить плавное завершение работы к приложению Node относительно легко, то, как Docker и npm
порождают дочерние процессы и обрабатывают сигналы, приводит к некоторым неожиданным различиям между локальными и Dockerized реализациями.
Беспощадный выход
Чтобы проверить это, давайте создадим очень простое приложение узла.
// package.json { "name": "simple_node_app", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.13.3" } } // server.js 'use strict'; const express = require('express'); const PORT = process.env.port || 8080; const app = express(); app.get('/', function (req, res) { res.send('Hello world\n'); }); app.get('/wait', function (req, res) { const timeout = 5; console.log(`received request, waiting ${timeout} seconds`); const delayedResponse = () => { res.send('Hello belated world\n'); }; setTimeout(delayedResponse, timeout * 1000); }); app.listen(PORT);
Как и ожидалось, когда мы запускаем наше приложение локально, оно не завершается корректно.
# Start the application $ npm install && npm start > start simple_node_app > node server.js
Сделать запрос в другом терминале
$ curl http://localhost:8080/wait
Отправить сигнал SIGTERM в `npm` до того, как запрос будет завершен
# find the PID of the npm process $ ps -falx | grep npm | grep -v grep UID PID PPID CMD 502 68044 31496 npm # send a SIGTERM (-15) signal to that process $ kill -15 68044
Посмотрите, что npm
завершился, и запрос прерывается на полпути
$ npm start > node server.js Running on http://localhost:8080 received request, waiting 5 seconds Terminated: 15 $ curl http://localhost:8080/wait curl: (52) Empty reply from server
Обрабатывать все сигналы
Чтобы исправить это, нам нужно добавить явную обработку сигналов в наш server.js
файл (обработка сигналов позаимствована из этого замечательного поста Григория Чуднова)
const server = app.listen(PORT); // The signals we want to handle // NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled var signals = { 'SIGHUP': 1, 'SIGINT': 2, 'SIGTERM': 15 }; // Do any necessary shutdown logic for our application here const shutdown = (signal, value) => { console.log("shutdown!"); server.close(() => { console.log(`server stopped by ${signal} with value ${value}`); process.exit(128 + value); }); }; // Create a listener for each of the signals that we want to handle Object.keys(signals).forEach((signal) => { process.on(signal, () => { console.log(`process received a ${signal} signal`); shutdown(signal, signals[signal]); }); });
Теперь, когда мы повторно запускаем наш предыдущий эксперимент, мы видим, что сервер завершает обработку запроса и закрывается перед выходом.
$ npm start > node server.js Running on http://localhost:8080 received request, waiting 5 seconds process received a SIGTERM signal shutdown! sending response! server stopped by SIGTERM with value 15
и запрос завершается
$ curl http://localhost:8080/wait Hello belated world
Примечание. npm
на самом деле жалуется здесь, потому что не ожидает node
выхода. Однако, поскольку узел делает то, что должен делать, эта ошибка на самом деле не имеет смысла.
npm ERR! [email protected] start: `node server.js` npm ERR! Exit status 143
Dockerize все вещи
Docker - это инструмент для контейнеризации программного обеспечения, который предлагает возможность эффективно упаковывать, развертывать и управлять вашим приложением. Я не буду вдаваться в подробности того, почему Docker классный, так что просто поверьте мне (и Интернету) на слово.
Докеризация нашего приложения проста: мы просто добавляем Dockerfile
, а затем создаем и запускаем наш новый контейнер.
# Dockerfile FROM node:boron
# Create app directory RUN mkdir -p /usr/src/app WORKDIR /usr/src/app
# Install app dependencies COPY package.json /usr/src/app/ RUN npm install --production --quiet
# Bundle app source COPY . /usr/src/app
EXPOSE 8080
CMD ["npm", "start"]
Затем мы можем собрать и запустить наше приложение Docker.
$ docker build -q -t grace . && docker run -p 1234:8080 --rm --name=grace grace > node server.js
Теперь мы хотим повторить наш предыдущий эксперимент, отправив запрос докеризованному приложению и выключив его до завершения запроса. Мы делаем это, выполняя запрос к нашей новой конечной точке (Docker внутренне отображает порт 8080 на внешний порт 1234) и вызывая docker stop grace
(который отправляет сигнал SIGTERM в контейнер докера с именем grace
).
$ curl http://localhost:1234/wait
curl: (52) Empty reply from server
Что случилось? Почему мы видим прерванный запрос, если тот же код корректно вел себя, когда мы запускали его на нашей машине?
Кишки НПМ
Чтобы понять это, давайте подробнее рассмотрим, как работает npm start
.
Когда мы запускаем npm start
локально, он порождает процесс узла как дочерний. Мы знаем это, потому что идентификатор родительского процесса (PPID) процесса node
является идентификатором процесса (PID) процесса npm
.
$ ps -falx | grep "node\|npm" | grep -v grep UID PID PPID CMD 502 65378 31800 npm 502 65379 65378 node server.js
Мы можем дважды проверить, что npm
порождает только один дочерний процесс, выполнив поиск всех процессов в этом идентификаторе группы процессов (PGID).
$ ps xao uid,pid,ppid,pgid,comm | grep 65378
UID PID PPID PGID CMD
502 65378 31800 65378 npm
502 65379 65378 65378 node
Однако мы видим кое-что иное, когда проверяем граф процесса в нашем контейнере докеров.
$ ps falx UID PID PPID COMMAND 0 1 0 npm 0 16 1 sh -c node server.js 0 17 16 \_ node server.js
В Docker процесс npm
заменяет процесс shell
, который затем порождает процесс node
. Это означает, что npm
не порождает node
процесс как прямой потомок.
Давайте выясним, связано ли это расхождение с тем, как Docker выполняет CMD ["npm", "start"]
, или с более общей причудой npm
, выполняемой в Docker. Для этого мы подключим ssh к работающему контейнеру Docker и запустим npm start
вручную, чтобы увидеть, как он порождает дочерние процессы.
# Add an extra port mapping to our container so that we can run two node servers $ docker run -p 1234:8080 -p 5678:5000 --rm --name=grace grace # SSH into the container in another terminal and check the currently-running processes $ docker exec -it grace /bin/sh $ ps falx UID PID PPID COMMAND 0 1 0 npm 0 15 1 sh -c node server.js 0 16 15 \_ node server.js # Start up a second node server on a different port $ port=5000 npm start > node server.js Running on http://localhost:5000
Теперь мы подключаемся по ssh к контейнеру в другом терминале и проверяем, как выглядит граф процесса.
$ docker exec -it grace /bin/sh $ ps falx UID PID PPID COMMAND 0 22 0 /bin/sh 0 46 22 \_ npm 0 56 46 \_ sh -c node server.js 0 57 56 \_ node server.js 0 1 0 npm 0 15 1 sh -c node server.js 0 16 15 \_ node server.js
Здесь мы видим, что независимо от того, как вызывается npm start
, он всегда порождает процесс shell
, который затем порождает процесс node
. Это отличается от того, как npm
ведет себя локально, где непосредственно порождает node
процесс.
Передача отличного сигнала
Я не уверен, почему npm
ведет себя по-разному в этих двух сценариях, но это порождающее несоответствие является моим лучшим предположением относительно того, почему один и тот же код корректно завершается локально, но терпит неудачу и умирает в Docker. npm
знает, как передавать сигналы своему дочернему процессу, но процесс оболочки не знает, как передавать сигналы своему дочернему процессу node
.
Примечание: было написано несколько замечательных сообщений о том, как основной процесс докера передает (или не проходит) сигналы, когда он выполняется как PID 1, в том числе этот от Григория Чуднова, Это Брайан ДеХамер, а это Yelp. Многие люди написали решения этой проблемы, включая библиотеку dumb-init от Yelp, библиотеку tini и флаг docker run --init
. Однако важно отметить, что проблема, которую мы наблюдаем, связана с тем, как npm
передает сигналы, независимо от того, запущен он как PID 1 или нет.
Решение этой проблемы с передачей сигналов разочаровывающе простое: запускать node server.js
прямо из файла Docker вместо npm start
.
# Dockerfile EXPOSE 8080
CMD ["node", "server.js"]
Это прискорбно, поскольку npm start
предлагает единую точку входа для запуска вашего приложения. Это особенно полезно, если вы передаете несколько флагов или параметров в node
, но я думаю, что возможность изящного выхода превосходит тонкости возможности вызвать одну команду start
.
С этим изменением мы видим, что вызов docker stop
передает сигнал процессу узла, чтобы он мог ответить и корректно завершить работу перед завершением.
$ docker build -q --no-cache -t grace . && docker run -p 1234:8080 --rm --name=grace grace Running on http://localhost:8080 received request, waiting 5 seconds process received a SIGTERM signal shutdown! sending response! server stopped by SIGTERM with value 15
Наш запрос завершается, как ожидалось.
$ curl http://localhost:1234/wait Hello belated world
Действительно очень длинные запросы
Вы можете заметить что-то странное, если у вас очень долгий запрос.
app.get('/wait', function (req, res) { // increase the timeout const timeout = 15; console.log(`received request, waiting ${timeout} seconds`); const delayedResponse = () => { console.log("sending response!"); res.send('Hello belated world\n'); }; setTimeout(delayedResponse, timeout * 1000); });
Когда мы повторяем нашу обычную настройку запуска контейнера докеров, выполнения запроса и последующей остановки контейнера, мы возвращаемся к безграмотности.
$ docker build -q --no-cache -t grace . && docker run -p 1234:8080 --rm --name=grace --init grace Running on http://localhost:8080 received request, waiting 15 seconds process received a SIGTERM signal shutdown! $ curl http://localhost:1234/wait curl: (52) Empty reply from server
Запрос прерывается, потому что docker stop
имеет тайм-аут по умолчанию в 10 секунд, прежде чем он откажется и отправит сигнал уничтожения. Напомним, что сигналы SIGKILL не могут быть пойманы или проигнорированы, что означает, что мы не можем корректно завершить работу после того, как один был отправлен. Однако у docker stop
есть флаг --time, -t
, который можно использовать для увеличения времени, по истечении которого контейнер будет уничтожен. Это полезно, если вы ожидаете, что один запрос займет более 10 секунд.
Изящный вывод
Чрезвычайно важно, чтобы веб-приложение могло корректно завершить работу, чтобы оно могло выполнить любую работу по очистке и завершить обслуживание текущего запроса. Это легко сделать в приложениях Node, добавив явную обработку сигналов к процессу node
; однако этого может быть недостаточно для Dockerized приложений из-за способа, которым процессы порождают дочерние элементы и передают сигналы.
Любой посредник, который используется для запуска node
, например сценарий оболочки или npm
, может быть не в состоянии передавать сигналы фактическому процессу node
. Из-за этого лучше запускать node
прямо из файла Docker, чтобы он мог правильно получать сигналы. Кроме того, поскольку докер отправляет сигнал KILL после тайм-аута, следующего за docker stop
, приложениям с длительными запросами может потребоваться увеличить этот тайм-аут, чтобы разрешить выполнение запросов до завершения работы приложения.