Итак, хорошая новость заключается в том, что у вас есть приложение 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, приложениям с длительными запросами может потребоваться увеличить этот тайм-аут, чтобы разрешить выполнение запросов до завершения работы приложения.