当你有一个启动了并稳定运行, 而且能提供流量的 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 https://medium.com/@gchudnov/trapping-signals-in-docker-containers-7a57fdda7d86 ).
- 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 容器的方法 https://warjiang.github.io/devcat/2016/11/28/几种访问Docker容器的方法/ , 进入容器 https://yeasy.gitbooks.io/docker_practice/container/attach_exec.html ).
- # 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 写的这篇文章 https://medium.com/@gchudnov/trapping-signals-in-docker-containers-7a57fdda7d86 , Brian DeHamer 写的这篇文章 https://www.ctl.io/developers/blog/post/gracefully-stopping-docker-containers/ , 还有 Yelp 的这篇 https://engineeringblog.yelp.com/2016/01/dumb-init-an-init-for-docker.html . 也有了很多解决方案, 包括 Yelp 的 dumb-init 库 https://github.com/Yelp/dumb-init ,tini 库 https://github.com/krallin/tini 和 docker run --init https://docs.docker.com/engine/reference/commandline/run/ .
这个信号传递问题的解决方案非常简单: 直接在 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 秒的默认强制终止的选项 https://docs.docker.com/engine/reference/commandline/stop/#options . 再去发送 SIGKILL 信号不能被捕获或直接忽略 https://en.wikipedia.org/wiki/Unix_signal#SIGKILL , 这意味着一旦发送 SIGKILL 信号就不能平滑退出. 但是, docker stop 有一个 --time, -t 选项, 可以使用它来增加容器强制终止的时间. 如果某个请求确实会耗时 10 秒或以上, 可以考虑这个方案.
Graceful 结语(译者注: 原文是个双关语, A graceful conclusion)
web 应用程序能够优雅地退出以便它可以执行任何清理工作并完成服务中的请求是非常重要的. 通过向 Nodejs 进程添加显式的信号处理, 这在 Node 应用中很容易实现; 然而, 这对于 Docker 化应用程序来说可能不够, 因为进程会产生其他子进程并影响到信号传递.
最终结论如下:
任何用于启动 Nodejs 的中间服务, 例如 shell 或 npm , 都可能无法将信号传递给 Nodejs 进程. 因此, 最好在 Dockerfile 中通过 node 命令直接启动进程 , 以便使 Nodejs 进程可以正确接收信号.
另外, 由于 Docker 在 docker stop 后发生超时后会直接发送 KILL 信号, 因此耗时比较长的服务就需要需要在执行 docker stop 的时候配置超时选项, 以允许在应用关闭之前完成请求.
来源: https://juejin.im/entry/5b28d9aa51882574a36fa93f