原文:https://medium.com/@becintec/building-graceful-node-applications-in-docker-4d2cd4d5d392

当你有一个启动了并稳定运行,而且能提供流量的Node应用,你可能无法做到高枕无忧。比如有些时候你的应用会出现一些意外,比如,数据库链接超时、内存溢出、部署会迫使Nodejs服务需要重新启动。这个时候,你需要关注的是这个时候正在提供服务的进程会发生什么情况?不言而喻,随着进程终止,正在提供服务的请求也会终止服务。

**Graceful exiting(译者注:下文称“平滑退出”)**就是处理这类的问题的方法,它允许Nodejs应用完成对所有正常请求的响应之后然后再关闭进程。虽然Nodejs应用添加平滑退出机制相对比较容易,但Docker和npm启动子进程并处理信号的方式,会导致本地直接启动和Dockerized启动两种方式出现一些意想不到的差异。

请求生命周期与优雅退出(正在进行的请求仍将中止,但处理请求将完成)

平滑退出

为了测试平滑退出功能,我们来创建一个非常简单的Nodejs应用。

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);

正如所料,当我们在本地运行我们的应用程序时,它不会优雅地退出。

# 启动服务
$ npm install && npm start
> start simple_node_app
> node server.js

在另一个终端发起请求:

$ curl http://localhost:8080/wait

然后,在请求结束之前发送一个SIGTERMsignal(译者注:下文称“信号”)给npm

# 找到npm进程的PID
$ ps -falx | grep npm | grep -v grep
 UID PID PPID CMD
 502 68044 31496 npm
# 发送 SIGTERM (-15) 信号给这个进程
$ 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 文件中添加显式信号处理策略( 参考:this great post by Grigoriy Chudnov)。

const server = app.listen(PORT);

// 想要处理的信号集合
// 注意: SIGKILL信号(9)不能被截取和处理的
var signals = {
  'SIGHUP': 1,
  'SIGINT': 2,
  'SIGTERM': 15
};

// 在这里为我们的应用程序做必要的关闭逻辑
const shutdown = (signal, value) => {
  console.log("shutdown!");
  server.close(() => {
    console.log(`server stopped by ${signal} with value ${value}`);
    process.exit(128 + value);
  });
};
// 为我们想要处理的每个信号创建一个监听器
Object.keys(signals).forEach((signal) => {
  process.on(signal, () => {
    console.log(`process received a ${signal} signal`);
    shutdown(signal, signals[signal]);
  });
});

现在,现在重新走一遍刚刚的流程,我们可以看到Nodejs服务处理请求完成之后才会被关闭

$ 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在这里会抛错,因为它不期望Nodejs退出。但是,由于Nodejs正在做它应该做的事情,所以这个错误可以忽略。

npm ERR! simple_node_app@1.0.0 start: `node server.js`
npm ERR! Exit status 143

Docker化一切服务

Docker是一款服务容器化的工具, 可以高效地打包、部署和管理应用。 使用Docker容器化Nodejs服务很简单:只需添加一个Dockerfile ,然后build镜像,并运行容器即可。

 # 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"] 

然后,我们可以build并运行Docker应用。

$ docker build -q -t grace . && docker run -p 1234:8080 --rm --name=grace grace
> node server.js

现在重复我们之前的实验,想通过向docker中的应用发送请求,并在请求完成之前关闭进程。 我们通过向我们的新端口(Docker会内部将端口8080映射到外部端口1234)和调用docker stop grace(向名为grace的docker容器发送一个SIGTERM信号):

$ curl http://localhost:1234/wait
curl: (52) Empty reply from server

什么?为什么我们看到请求直接被终止了,同样的代码直接在宿主机上实验,明明是可以的优雅退出的啊?

NPM机制

为了理解原因,我们需要更深入地了解npm start的执行机制。

当我们在本地运行npm start,他会直接把Nodejs服务作为子进程启动。这是因为node进程的父进程ID(PPID)是npm进程的进程ID(PID)。

$ ps -falx | grep "node\|npm" | grep -v grep
  UID    PID    PPID    CMD
  502    65378  31800   npm
  502    65379  65378   node server.js

我们可以通过搜索该进程组ID(PGID)中的所有进程的方式来再次验证npm只会启动一个子进程。

$ ps xao uid,pid,ppid,pgid,comm | grep 65378
  UID    PID    PPID    PGID    CMD
  502    65378  31800   65378   npm
  502    65379  65378   65378   node

但是,当我们检查Docker容器上的进程​​时,我们发现有些不同。

$ 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进程,然后再启动Nodejs进程。 这意味着npm不会直接启动一个Nodejs进程。

接下来我们确认下,这种问题是因为Docker的RUN脚本启动Nodejs的机制,还是因为容器中本身npm的问题造成的。 为此,我们ssh进入正在运行的Docker容器并手动运行npm start以查看它是如何启动子进程的(译者注:参考几种访问Docker容器的方法进入容器)。

# 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

现在我们从另一个终端的进入容器,看看进程结构:

$ 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进程,然后再启动一个Nodejs进程。 这里与直接在宿主机上执行npm不同,在宿主机上会直接启动Node进程。

伟大的信号传递机制

我不确定为什么npm在这两种场景下会出现这种差异,但这个现象似乎能解释为什么相同的代码在宿主机上能够优雅地退出,但是在Docker中却会直接被关闭。

注意: 其实有很多不错文章论述了Docker主进程以PID 1启动时候的信号传递问题,比如Grigoriy Chudnov写的这篇文章, Brian DeHamer写的这篇文章,还有Yelp的这篇。 也有了很多解决方案,包括Yelp的dumb-init库tini库docker run --init

这个信号传递问题的解决方案非常简单:直接在Dockerfile中通过node server.js运行Nodejs服务,而不是npm start

# Dockerfile 
EXPOSE 8080 
CMD ["node", "server.js"] 

这个方案很无奈,因为npm start的初衷就是给你的Nodejs服务提供一个统一的入口。这个命令可以给你的Nodejs服务提供很多配置选项,但是在平滑重启功能面前便乏善可陈了。

通过改成node server.js运行之后,我们再来看,通过docker stop将信号传递给容器中的Nodejs服务,Nodejs服务就可以请求结束之后再关闭了。

$ 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存在一个10秒的默认强制终止的选项再去发送SIGKILL信号不能被捕获或直接忽略,这意味着一旦发送SIGKILL信号就不能平滑退出。但是, docker stop有一个--time, -t选项,可以使用它来增加容器强制终止的时间。 如果某个请求确实会耗时10秒或以上,可以考虑这个方案。

Graceful结语(译者注:原文是个双关语,A graceful conclusion)

Web应用程序能够优雅地退出以便它可以执行任何清理工作并完成服务中的请求是非常重要的。通过向Nodejs进程添加显式的信号处理,这在Node应用中很容易实现; 然而,这对于Docker化应用程序来说可能不够,因为进程会产生其他子进程并影响到信号传递。

最终结论如下:

任何用于启动Nodejs的中间服务,例如shellnpm ,都可能无法将信号传递给Nodejs进程。 因此,最好在Dockerfile中通过node命令直接启动进程 ,以便使Nodejs进程可以正确接收信号。
另外,由于Docker在docker stop后发生超时后会直接发送KILL信号,因此耗时比较长的服务就需要需要在执行docker stop的时候配置超时选项,以允许在应用关闭之前完成请求。

0 comments