在Node.js生产环境下使用Docker

发布于1/19/2020 来自:「前端知否」微信公众号

1.选择正确的基本映像

必须为您的Node.js应用程序选择正确的基础Docker映像。您应该始终尝试使用正式的Docker映像-因为它们具有出色的文档,使用最佳实践并且是针对大多数常见用例设计的。

如果您查看官方的Node.js Docker映像,仍然有很多映像可供选择。我总是选择可以运行Node.js应用程序的最小尺寸的图像。

当前,如果要在64位Linux上运行Node.js应用程序,则可以使用以下选项:

  • node:stretch (latest) → 344.36 MB
  • node:stretch-slim → 65.29 MB
  • node:lts-buster-slim → 57.57 MB
  • node:lts-buster → 321.14 MB
  • node:13.5.0-alpine3.11 → 37.35 MB

负责维护Node.js镜像的Node.js Docker团队基于Debian上的stretch和buster镜像。 Docker团队将Alpine镜像基于Alpine Linux发行版,Alpine Linux发行版是具有加固内核的最小Linux发行版。我的建议是,如果您的应用程序在Alpine上运行,则应使用Alpine镜像作为基本镜像,因为这可能会导致镜像尺寸最小。

2.使用非root容器用户

默认情况下,您的应用以root身份在容器内运行。 但是,容器中的root与主机上的root不同。 Docker容器中的用户仍然受到限制。 但是,为了进一步减少安全攻击面,希望您尽可能以非特权用户身份运行容器。

大多数官方映像已经在其Docker映像中创建了非root用户。 例如,参见Alpine Dockerfile,该文件已经创建了一个名为node的组和一个名为node的用户供您使用。 如果您的Docker文件基于Alpine,则可以使用该node用户以非root用户身份运行您的应用程序。 有关使用node用户的示例,请参见下面的Dockerfile。

FROM node:12-alpine 

RUN mkdir /home/node/app/ && chown -R node:node /home/node/app

WORKDIR /home/node/app

COPY --chown=node:node package*.json ./

USER node

RUN npm install && npm cache clean --force --loglevel=error

COPY --chown=node:node index.js .
COPY --chown=node:node lib ./lib/

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

在第一行中,我使用node:12-alpine图像作为基础图像。 第3行中的语句为我的应用创建目录。 由于我仍在运行root用户,因此我将该新目录的所有者显式设置为node用户。 请注意,WORKDIR指令也会创建文件夹,但是无法设置目录的所有者。 WORKDIR指令设置工作目录。

我将用户设置为第9行上的node用户。运行镜像时,USER指令设置用户名(或UID)。 在Dockerfile设置用户之后,我执行NPM install来安装我的应用程序的依赖项。 该命令将以node用户身份运行。 在第13和14行上,我将应用程序的源代码复制到工作文件夹中。 在最后一行,应用程序开始使用CMD指令。 CMD指令提供了执行容器的默认值。

3.启动和停止您的Node.js应用

以正确的方式启动和停止Docker容器中的Node.js应用至关重要。 在前面显示的Dockerfile中,我使用CMD指令启动Node.js应用程序。 使用CMD指令启动Node.js时,请确保在应用程序内部可以接收来自操作系统的信号(例如SIGINTSIGTERM),并对其进行处理以正常关闭应用程序。

如果您忽略了这些信号并停止了容器,则Docker将等待10秒(默认超时)以使您的应用程序响应。 如果那时您的应用程序没有应答,它将终止您的节点进程并停止容器。 因此,您要做的第一件事是响应SIGINT和SIGTERM命令并正常关闭您的应用程序。

const process = require('process');

var app = {};

process.on('SIGINT', function onSigint() {
app.shutdown();
});

process.on('SIGTERM', function onSigterm() {
app.shutdown();
});

app.shutdown = function () {
// 清理资源,然后退出
process.exit();
};

module.exports = app;

上面的代码示例显示了如何实现对SIGINTSIGTERM信号的响应。 在此示例中,我通过调用process.exit()退出该过程,但这也是停止服务器并清理未完成的连接的地方。

如果您无权访问源代码或不想更改应用程序的源代码,则可以使用两种不同的方法来关闭应用程序。 第一种是使用--init; 此标志向Docker指示初始化进程应该用作容器中的PID 1。 启动容器时,您可以像这样使用它:

docker run --init -d yournodeappimage

然后,您的容器将直接对Ctrl-C或Docker停止命令做出反应。

另一个更稳妥的选择是将tini添加到您的Dockerfile并将其包含在映像中。 这也是Docker在使用--init标志时在后台执行的操作。 您必须在镜像中安装tini,然后使用ENTRYPOINT启动和包装CMD —请参见下面的示例。

ENV TINI_VERSION v0.18.0
ADD https://github.com/foo/todo/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

# 在Tini下运行程序
CMD ["node", "index.js"]

优雅地关闭HTTP连接

要正常停止Node.js应用程序要添加的最后一件事是处理未完成的HTTP请求,并停止对新HTTP请求的响应。 当使用容器编排器(例如Docker Swarm或Kubernetes)时,这将允许更新而不会停机。

有一些库可以代替您自己开发,而不是自己开发。 我最常使用的那个是Stoppable-该库停止接受新连接并关闭现有的空闲连接(包括保持活动状态),而不会杀死正在运行的请求。

您必须使用stoppable构造函数包装创建HTTP服务器,如下所示:

const server = stoppable(http.createServer(handler))

最后,通过Stoppable的server.close关闭服务器,可以扩展关闭功能,如前所示。

app.shutdown = function () {
server.close(function onServerClosed(err) {
if (err) {
log.error('An error occurred while closing the server: ' + err);
process.exitCode = 1;
}
});

process.exit();
};

4.健康检查

Dockerfile中的HEALTHCHECK指令告诉Docker如何验证容器仍在工作。 这样可以检测到诸如服务器陷入无限循环并且无法处理新连接的情况,即使服务器进程仍在运行。

您必须自己实现功能以执行运行状况检查,这很明显,因为Docker不知道您的应用何时正常运行。 我通常向服务器添加其他路由,专门用于处理运行状况请求。

我添加了一个单独的小型Node.js应用程序,该应用程序对运行状况端点执行GET请求。 HEALTHCHECK指令使用了这个小应用程序。

const http = require('http');
const config = require('./config');
const log = require('./log');
const constants = require('./constants');

const options = {
host: 'localhost',
port: config.httpPort,
timeout: 2000,
method: 'GET',
path: '/api/health/',
};

const request = http.request(options, (result) => {
log.info(`Performed health check, result ${result.statusCode}`);
if (result.statusCode === constants.HTTP_STATUS_OK) {
process.exit(0);
} else {
process.exit(1);
}
});

request.on('error', (err) => {
log.error(`An error occurred while performing health check, error: ${err}`);
process.exit(1);
});

request.end();

在Dockerfile中,我使用此小型应用程序执行运行状况检查。

HEALTHCHECK --interval=21s --timeout=3 --start-period=10s CMD node healthcheck.js

5.为Node.js应用程序记录日志

在Docker容器中运行时,从Node.js应用程序进行日志记录很简单:根据情况记录日志到stdoutstderr。 其背后的原理是让其他人来处理日志。 让其他人来负责日志记录是有意义的,因为Docker容器主要用于微服务架构中,其中职责分布在多个服务之间。

我不建议直接从Node.js应用程序中使用console.log或console.err。 取而代之的是,我将编写包装器或使用低开销的日志记录框架,例如Pino。 Pino使用JSON记录到stdout和stderr,提供结构化记录。

6.使用环境变量进行配置

如果您已经使用了特定的配置对象,该对象可以为应用程序创建和集中所有配置选项,例如:

/*
* 创建和导出配置变量
*
*/
const constants = require('./constants');

// 全部环境变量
const environments = {};

environments.production = {
httpPort: 3000,
host: process.env.HOST || '0.0.0.0',
envName: 'production',
log: {
level: constants.LOG_LEVELS.DEBUG,
},

database: {
url: 'mongodb://localhost:27017/workflow-db',
name: 'workflow-db',
connectRetry: 5, // seconds
},
workflow: {
pollingInterval: 10, // Seconds
},
};


const currentEnvironment = typeof process.env.NODE_ENV === 'string' ? process.env.NODE_ENV.toLowerCase() : '';

// 检查当前环境变量,是上边配置中的哪一个
// 默认不是 prodution
const environmentToExport = typeof environments[currentEnvironment] === 'object' ? environments[currentEnvironment] : environments.production;

// 导出模块
module.exports = environmentToExport;

这样的对象巧妙地定义和集中了应用程序的所有配置选项。 在Docker容器中运行时这种方法的问题在于,所有配置都希望通过环境变量来完成。

我通常通过让环境变量覆盖配置对象的httpPort的默认值来解决此问题:process.env.HTTP_PORT || 3000。例如:

const environments = {};

environments.production = {
httpPort: process.env.HTTP_PORT || 3000,
host: process.env.HOST || '0.0.0.0',