Skip to content

Node.js Docker Cheat Sheet / Node.js Docker速查表

The following cheatsheet provides production-grade guidelines for building optimized and secure Node.js Docker. You’ll find it helpful regardless of the Node.js application you aim to build. This article will be helpful for you if:

以下速查表提供了生产级指南,用于构建优化和安全Node.js Docker。无论您要构建哪种Node.js应用程序,它都会很有帮助。如果您:

  • your aim is to build a frontend application using server-side rendering (SSR) Node.js capabilities for React.
    中文:目标是使用服务器端渲染(SSR)的Node.js功能构建前端应用程序,使用React。
  • you’re looking for advice on how to properly build a Node.js Docker image for your microservices, running Fastify, NestJS or other application frameworks.
    中文:正在寻找如何正确构建Node.js Docker镜像以运行微服务的建议,包括Fastify、NestJS或其他应用程序框架。

1) Use explicit and deterministic Docker base image tags / 1) 使用显式和确定的Docker基础镜像标签

It may seem to be an obvious choice to build your image based on the node Docker image, but what are you actually pulling in when you build the image? Docker images are always referenced by tags, and when you don’t specify a tag the default, :latest tag is used.

基于node Docker镜像构建镜像可能看起来是一个明显的选择,但您在构建镜像时实际上在拉取什么?Docker镜像总是通过标签引用,并且当您没有指定标签时,默认使用:latest标签。

So, in fact, by specifying the following in your Dockerfile, you always build the latest version of the Docker image that has been built by the Node.js Docker working group:

因此,实际上,在您的Dockerfile中指定以下内容,您始终构建的是由Node.js Docker工作组构建的最新版本的Docker镜像:

FROM node

The shortcomings of building based on the default node image are as follows:

基于默认 node 镜像构建的不足如下:

  1. Docker image builds are inconsistent. Just like we’re using lockfiles to get a deterministic npm ci behavior every time we install npm packages, we’d also like to get deterministic docker image builds. If we build the image from node—which effectively means the node:latest tag—then every build will pull a newly built Docker image of node. We don’t want to introduce this sort of non-deterministic behavior.
    中文:Docker 镜像构建不一致。就像我们使用 lockfiles 来确保每次安装 npm 软件包时 npm ci 行为的一致性一样,我们也希望得到确定性的 Docker 镜像构建。如果我们从 node 构建——实际上意味着使用 node:latest 标签——那么每次构建都会拉取一个新的 node Docker 镜像。我们不希望引入这种非确定性行为。
  2. The node Docker image is based on a full-fledged operating system, full of libraries and tools that you may or may not need to run your Node.js web application. This has two downsides. Firstly a bigger image means a bigger download size which, besides increasing the storage requirement, means more time to download and re-build the image. Secondly, it means you’re potentially introducing security vulnerabilities, that may exist in all of these libraries and tools, into the image.
    中文:Node Docker 镜像是基于完整的操作系统构建的,其中包含了大量的库和工具,这些库和工具可能是您运行 Node.js 网络应用程序所需的,也可能不是。这有两个缺点。首先,更大的镜像意味着更大的下载大小,这不仅增加了存储需求,还意味着下载和重建镜像需要更多的时间。其次,这意味着您可能将所有这些库和工具中可能存在的安全漏洞引入到镜像中。

In fact, the node Docker image is quite big and includes hundreds of security vulnerabilities of different types and severities. If you’re using it, then by default your starting point is going to be a baseline of 642 security vulnerabilities, and hundreds of megabytes of image data that is downloaded on every pull and build.

事实上,node Docker 镜像相当大,包含了数百种不同类型和严重程度的安全漏洞。如果您正在使用它,那么默认情况下,您的起点将是一个包含 642 个安全漏洞的基线,以及每次拉取和构建时都会下载的数百兆字节的镜像数据。

The recommendations for building better Docker images are:

构建更好的 Docker 镜像的建议如下:

  1. Use small Docker images—this will translate to a smaller software footprint on the Docker image reducing the potential vulnerability vectors, and a smaller size, which will speed up the image build process
    中文:使用小的 Docker 镜像——这将转化为 Docker 镜像上更小的软件占用,减少潜在的安全漏洞,并且尺寸更小,这将加快镜像构建过程。
  2. Use the Docker image digest, which is the static SHA256 hash of the image. This ensures that you are getting deterministic Docker image builds from the base image.
    中文:使用 Docker 镜像摘要,即镜像的静态 SHA256 哈希。这确保您从基础镜像中获得确定性的 Docker 镜像构建。

Based on this, let’s ensure that we use the Long Term Support (LTS) version of Node.js, and the minimal alpine image type to have the smallest size and software footprint on the image:

基于此,让我们确保使用 Node.js 的长期支持(LTS)版本和最小的 alpine 镜像类型,以在镜像上拥有最小的尺寸和软件占用:

FROM node:lts-alpine

Nonetheless, this base image directive will still pull new builds of that tag. We can find the SHA256 hash for it in the Docker Hub for this Node.js tag, or by running the following command once we pulled this image locally, and locate the Digest field in the output:

尽管如此,此基础镜像指令仍然会拉取该标签的新构建。我们可以在Docker Hub上的此Node.js标签中找到它的SHA256哈希值,或者在我们本地拉取此镜像后运行以下命令,并在输出中找到Digest字段:

$ docker pull node:lts-alpine
lts-alpine: Pulling from library/node
0a6724ff3fcd: Already exists
9383f33fa9f3: Already exists
b6ae88d676fe: Already exists
565e01e00588: Already exists
Digest: sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
Status: Downloaded newer image for node:lts-alpine
docker.io/library/node:lts-alpine

$ docker pull node:lts-alpine lts-alpine: 正在从library/node拉取 0a6724ff3fcd: 已存在 9383f33fa9f3: 已存在 b6ae88d676fe: 已存在 565e01e00588: 已存在 Digest: sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a 状态:下载了较新的镜像为node:lts-alpine docker.io/library/node:lts-alpine

Another way to find the SHA256 hash is by running the following command:

另一种找到SHA256哈希值的方法是运行以下命令:

$ docker images --digests
REPOSITORY                     TAG              DIGEST                                                                    IMAGE ID       CREATED             SIZE
node                           lts-alpine       sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a   51d926a5599d   2 weeks ago         116MB

Now we can update the Dockerfile for this Node.js Docker image as follows:

现在我们可以更新此Node.js Docker镜像的Dockerfile如下:

FROM node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci
CMD "npm" "start"

However, the Dockerfile above, only specifies the Node.js Docker image name without an image tag which creates ambiguity for which exact image tag is being used—it’s not readable, hard to maintain and doesn’t create a good developer experience.

然而,上面的Dockerfile仅指定了Node.js Docker镜像名称,没有指定镜像标签,这会导致对正在使用哪个确切镜像标签产生歧义——它不可读,难以维护,并且不会为开发者提供良好的体验。

Let’s fix it by updating the Dockerfile, providing the full base image tag for the Node.js version that corresponds to that SHA256 hash:

让我们通过更新Dockerfile来修复这个问题,提供与该SHA256哈希值对应的Node.js版本的完整基础镜像标签:

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci
CMD "npm" "start"

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a 工作目录 /usr/src/app 复制 . /usr/src/app 运行 npm ci 命令 "npm" "start"

2) Install only production dependencies in the Node.js Docker image / 2) 仅在 Node.js Docker 镜像中安装生产依赖

The following Dockerfile directive installs all dependencies in the container, including devDependencies, which aren’t needed for a functional application to work. It adds an unneeded security risk from packages used as development dependencies, as well as inflating the image size unnecessarily.

以下 Dockerfile 指令在容器中安装所有依赖,包括 devDependencies,这些依赖对于应用功能运行不是必需的。这会引入不必要的从开发依赖中使用的包的安全风险,以及无谓地膨胀镜像大小。

RUN npm ci

Enforce deterministic builds with npm ci. This prevents surprises in a continuous integration (CI) flow because it halts if any deviations from the lockfile are made.

使用 npm ci 强制执行确定性的构建。这可以防止在持续集成 (CI) 流程中出现意外,因为如果出现与锁文件不符的情况,它将停止执行。

In the case of building a Docker image for production we want to ensure that we only install production dependencies in a deterministic way, and this brings us to the following recommendation for the best practice for installing npm dependencies in a container image:

在构建用于生产的 Docker 镜像的情况下,我们希望确保以确定性的方式仅安装生产依赖,这引出了以下关于在容器镜像中安装 npm 依赖的最佳实践建议:

RUN npm ci --omit=dev

The updated Dockerfile contents in this stage are as follows:

此阶段的更新 Dockerfile 内容如下:

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --omit=dev
CMD "npm" "start"

3) Optimize Node.js tooling for production

When you build your Node.js Docker image for production, you want to ensure that all frameworks and libraries are using the optimal settings for performance and security.

This brings us to add the following Dockerfile directive:

ENV NODE_ENV production

At first glance, this looks redundant, since we already specified only production dependencies in the npm ci phase—so why is this necessary?

Developers mostly associate the NODE_ENV=production environment variable setting with the installation of production-related dependencies, however, this setting also has other effects which we need to be aware of.

Some frameworks and libraries may only turn on the optimized configuration that is suited to production if that NODE_ENV environment variable is set to production. Putting aside our opinion on whether this is a good or bad practice for frameworks to take, it is important to know this.

As an example, the Express documentation outlines the importance of setting this environment variable for enabling performance and security related optimizations:

例如,Express 文档概述了设置此环境变量以启用性能和安全相关优化的重要性:

The performance impact of the NODE_ENV variable could be very significant.

NODE_ENV 变量的性能影响可能非常显著。

Many of the other libraries that you are relying on may also expect this variable to be set, so we should set this in our Dockerfile.

您所依赖的其他许多库也可能期望设置此变量,因此我们应该在 Dockerfile 中设置它。

The updated Dockerfile should now read as follows with the NODE_ENV environment variable setting baked in:

更新后的 Dockerfile 应该如下所示,其中已内置 NODE_ENV 环境变量设置:

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --omit=dev
CMD "npm" "start"

4) Don’t run containers as root / 4) 不要以 root 用户运行容器

The principle of least privilege is a long-time security control from the early days of Unix and we should always follow this when we’re running our containerized Node.js web applications.

最小权限原则是 Unix 早期就有的长期安全控制,我们在运行容器化的 Node.js 网络应用程序时应该始终遵循这一原则。

The threat assessment is pretty straight-forward—if an attacker is able to compromise the web application in a way that allows for command injection or directory path traversal, then these will be invoked with the user who owns the application process. If that process happens to be root then they can do virtually everything within the container, including [attempting a container escape or privilege escalation. Why would we want to risk it? You’re right, we don’t.

威胁评估相当直接——如果攻击者能够以允许 命令注入 或 目录路径遍历 的方式破坏网络应用程序,那么这些操作将以拥有应用程序进程的用户身份执行。如果这个过程恰好是 root 用户,那么他们可以在容器内几乎做任何事情,包括 [尝试容器逃逸或权限提升。我们为什么要冒这个风险呢?您说得对,我们不应该冒险。

Repeat after me: “friends don’t let friends run containers as root!”

请跟我重复:“朋友不会让朋友以root身份运行容器!”

The official node Docker image, as well as its variants like alpine, include a least-privileged user of the same name: node. However, it’s not enough to just run the process as node. For example, the following might not be ideal for an application to function well:

官方的 node Docker镜像,以及其变体如 alpine,都包含了一个同名的最小权限用户:node。然而,仅仅以node用户运行进程是不够的。例如,以下方式可能不适合应用程序良好运行:

USER node
CMD "npm" "start"

The reason for that is the USER Dockerfile directive only ensures that the process is owned by the node user. What about all the files we copied earlier with the COPY instruction? They are owned by root. That’s how Docker works by default.

之所以如此,是因为USER Dockerfile指令仅确保进程由node用户拥有。那么我们之前使用COPY指令复制的所有文件怎么办?它们属于root用户。这就是Docker默认的工作方式。

The complete and proper way of dropping privileges is as follows, also showing our up to date Dockerfile practices up to this point:

正确降低权限的完整方法如下,同时也展示了我们到目前为止的Dockerfile实践:

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --omit=dev
USER node
CMD "npm" "start"

5) Properly handle events to safely terminate a Node.js Docker web application / 5) 正确处理事件以安全终止Node.js Docker网络应用程序

One of the most common mistakes I see with blogs and articles about containerizing Node.js applications when running in Docker containers is the way that they invoke the process. All of the following and their variants are bad patterns you should avoid:

我在关于容器化Node.js应用程序并在Docker容器中运行的博客和文章中,最常见的错误之一是它们启动进程的方式。以下所有方式和它们的变体都是你应该避免的坏模式:

  • CMD “npm” “start”
  • CMD [“yarn”, “start”]
  • CMD “node” “server.js”
  • CMD “start-app.sh”

Let’s dig in! I’ll walk you through the differences between them and why they’re all patterns to avoid.

让我们深入探讨!我将向您介绍它们之间的区别以及为什么都应该避免这些模式。

The following concerns are key to understanding the context for properly running and terminating Node.js Docker applications:

以下问题对于理解正确运行和终止 Node.js Docker 应用程序的环境至关重要:

  1. An orchestration engine, such as Docker Swarm, Kubernetes, or even just Docker engine itself, needs a way to send signals to the process in the container. Mostly, these are signals to terminate an application, such as SIGTERM and SIGKILL.
    中文:需要一个编排引擎,如 Docker Swarm、Kubernetes 或甚至是 Docker 引擎本身,来向容器中的进程发送信号。通常,这些信号是用于终止应用程序的,例如 SIGTERM 和 SIGKILL。
  2. The process may run indirectly, and if that happens then it’s not always guaranteed that it will receive these signals.
    中文:进程可能间接运行,如果发生这种情况,则不能保证它一定会接收到这些信号。
  3. The Linux kernel treats processes that run as process ID 1 (PID) differently than any other process ID.
    中文:Linux 内核对以进程 ID 1 (PID) 运行的进程的处理方式与其他任何进程 ID 都不同。

Equipped with that knowledge, let’s begin investigating the ways of invoking the process for a container, starting off with the example from the Dockerfile we’re building:

有了这个知识,让我们开始研究调用容器中进程的方法,从我们正在构建的 Dockerfile 中的示例开始:

CMD "npm" "start"

The caveat here is two fold. Firstly, we’re indirectly running the node application by directly invoking the npm client. Who’s to say that the npm CLI forwards all events to the node runtime? It actually doesn’t, and we can easily test that.

这里有两个需要注意的地方。首先,我们通过直接调用 npm 客户端间接运行了 node 应用程序。谁能保证 npm CLI 会将所有事件转发到 node 运行时呢?实际上它不会,我们可以轻松地测试这一点。

Make sure that in your Node.js application you set an event handler for the SIGHUP signal which logs to the console every time you’re sending an event. A simple code example should look as follows:

确保在您的 Node.js 应用程序中设置一个事件处理器来处理 SIGHUP 信号,每次发送事件时都会在控制台记录。一个简单的代码示例如下:

function handle(signal) {
   console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)

function handle(signal) { console.log(*^!@4=> 收到事件: ${signal}) } process.on('SIGHUP', handle)

Then run the container, and once it’s up specifically send it the SIGHUP signal using the docker CLI and the special --signal command-line flag:

然后运行容器,一旦它启动,就使用 docker CLI 和特殊的 --signal 命令行标志专门向它发送 SIGHUP 信号:

$ docker kill --signal=SIGHUP elastic_archimedes

Nothing happened, right? That’s because the npm client doesn’t forward any signals to the node process that it spawned.

The other caveat has to do with the different ways in which way you can specify the CMD directive in the Dockerfile. There are two ways, and they are not the same:

  1. the shellform notation, in which the container spawns a shell interpreter that wraps the process. In such cases, the shell may not properly forward signals to your process.
  2. the execform notation, which directly spawns a process without wrapping it in a shell. It is specified using the JSON array notation, such as: CMD [“npm”, “start”]. Any signals sent to the container are directly sent to the process.

Based on that knowledge, we want to improve our Dockerfile process execution directive as follows:

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

We are now invoking the node process directly, ensuring that it receives all of the signals sent to it, without it being wrapped in a shell interpreter.

However, this introduces another pitfall.

When processes run as PID 1 they effectively take on some of the responsibilities of an init system, which is typically responsible for initializing an operating system and processes. The kernel treats PID 1 in a different way than it treats other process identifiers. This special treatment from the kernel means that the handling of a SIGTERM signal to a running process won’t invoke a default fallback behavior of killing the process if the process doesn’t already set a handler for it.

当进程以 PID 1 运行时,它们实际上承担了 init 系统的一些责任,init 系统通常负责初始化操作系统和进程。内核以不同于其他进程标识符的方式对待 PID 1。这种内核的特殊处理意味着,如果运行中的进程尚未为 SIGTERM 信号设置处理程序,则处理该信号的默认回退行为不会杀死进程。

To quote the Node.js Docker working group recommendation on this:  “Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker. For example, a Node.js process running as PID 1 will not respond to SIGINT (CTRL-C) and similar signals”.

The way to go about it then is to use a tool that will act like an init process, in that it is invoked with PID 1, then spawns our Node.js application as another process while ensuring that all signals are proxied to that Node.js process. If possible, we’d like a small as possible tooling footprint for doing so to not risk having security vulnerabilities added to our container image.

因此,我们可以使用一个工具来模拟 init 进程的行为,即以 PID 1 调用它,然后作为另一个进程启动我们的 Node.js 应用程序,同时确保所有信号都代理到该 Node.js 进程。如果可能的话,我们希望工具的占用空间尽可能小,以避免将安全漏洞添加到我们的容器镜像中。

One such tool is dumb-init which is statically linked and has a small footprint. Here’s how we’ll set it up:

其中一个这样的工具是 dumb-init,它静态链接且占用空间小。以下是我们的设置方法:

RUN apk add dumb-init
CMD ["dumb-init", "node", "server.js"]

This brings us to the following up to date Dockerfile. You’ll notice that we placed the dumb-init package install right after the image declaration, so we can take advantage of Docker’s caching of layers:

这带我们来到了以下最新的 Dockerfile。您会注意到,我们在镜像声明之后放置了 dumb-init 包的安装,这样我们就可以利用 Docker 对层的缓存:

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
CMD ["dumb-init", "node", "server.js"]

Good to know: docker kill and docker stop commands only send signals to the container process with PID 1. If you’re running a shell script that runs your Node.js application, then take note that a shell instance—such as /bin/sh, for example—doesn’t forward signals to child processes, which means your app will never get a SIGTERM.

值得了解:docker kill 和 docker stop 命令只向 PID 为 1 的容器进程发送信号。如果您正在运行一个运行 Node.js 应用的 shell 脚本,请注意,shell 实例(例如 /bin/sh)不会将信号转发给子进程,这意味着您的应用永远不会收到 SIGTERM 信号。

6) Graceful tear down for your Node.js web applications / 6) 为您的 Node.js 网络应用程序提供优雅的关闭

If we’re already discussing process signals that terminate applications, let’s make sure we’re shutting them down properly and gracefully without disrupting users.

如果我们已经在讨论终止应用程序的进程信号,那么让我们确保我们能够正确且优雅地关闭它们,而不会干扰用户。

When a Node.js application receives an interrupt signal, also known as SIGINT, or CTRL+C, it will cause an abrupt process kill, unless any event handlers were set of course to handle it in a different behavior. This means that connected clients to a web application will be immediately disconnected. Now, imagine hundreds of Node.js web containers orchestrated by Kubernetes, going up and down as needs arise to scale or manage errors. Not the greatest user experience.

当一个Node.js应用程序接收到中断信号,也就是SIGINT,或者CTRL+C,除非设置了任何事件处理器来处理不同的行为,否则它将导致进程突然终止。这意味着连接到Web应用程序的客户端将立即断开连接。现在,想象一下由Kubernetes编排的数百个Node.js Web容器,根据需要上下波动以进行扩展或管理错误。这不是最佳的用户体验。

You can easily simulate this problem. Here’s a stock Fastify web application example, with an inherent delayed response of 60 seconds for an endpoint:

你可以轻松地模拟这个问题。以下是一个Fastify Web应用程序的示例,该示例端点具有固有的60秒延迟响应:

fastify.get('/delayed', async (request, reply) => {
 const SECONDS_DELAY = 60000
 await new Promise(resolve => {
     setTimeout(() => resolve(), SECONDS_DELAY)
 })
 return { hello: 'delayed world' }
})

const start = async () => {
 try {
   await fastify.listen(PORT, HOST)
   console.log(`*^!@4=> Process id: ${process.pid}`)
 } catch (err) {
   fastify.log.error(err)
   process.exit(1)
 }
}

start()

Run this application and once it’s running send a simple HTTP request to this endpoint:

运行此应用程序,一旦运行,向此端点发送一个简单的HTTP请求:

$ time curl https://localhost:3000/delayed

Hit CTRL+C in the running Node.js console window and you’ll see that the curl request exited abruptly. This simulates the same experience your users would receive when containers tear down.

在运行的 Node.js 控制台窗口中按 CTRL+C,你会看到 curl 请求突然退出。这模拟了当容器拆除时用户会遇到的相同体验。

To provide a better experience, we can do the following:

为了提供更好的体验,我们可以做以下操作:

  1. Set an event handler for the various termination signals like SIGINT and SIGTERM.
    中文:为各种终止信号设置事件处理器,如 SIGINT 和 SIGTERM。
  2. The handler waits for clean up operations like database connections, ongoing HTTP requests and others.
    中文:处理器等待清理操作,如数据库连接、正在进行的 HTTP 请求等。
  3. The handler then terminates the Node.js process.
    中文:然后处理器终止 Node.js 进程。

Specifically with Fastify, we can have our handler call on fastify.close() which returns a promise that we will await, and Fastify will also take care to respond to every new connection with the HTTP status code 503 to signal that the application is unavailable.

特别地,使用 Fastify,我们可以让我们的处理器调用 fastify.close(),它返回一个我们将等待的承诺,并且 Fastify 也会注意对每个新的连接响应以 HTTP 状态码 503,表示应用程序不可用。

Let’s add our event handler:

让我们添加我们的事件处理器:

async function closeGracefully(signal) {
   console.log(`*^!@4=> Received signal to terminate: ${signal}`)

async function closeGracefully(signal) { console.log(*^!@4=> 收到终止信号:${signal})

   await fastify.close()
   // await db.close() if we have a db connection in this app
   // await other things we should cleanup nicely
   process.exit()
}
process.on('SIGINT', closeGracefully)
process.on('SIGTERM', closeGracefully)

await fastify.close() // await db.close() 如果我们在这个应用中有数据库连接 // await 其他我们应该优雅清理的事情 process.exit() } process.on('SIGINT', closeGracefully) process.on('SIGTERM', closeGracefully)

Admittedly, this is more of a generic web application concern than Dockerfile related, but is even more important in orchestrated environments.

诚然,这更多的是一个通用Web应用的问题,而不是Dockerfile相关的问题,但在编排环境中这更加重要。

7) Find and fix security vulnerabilities in your Node.js docker image / 7) 在你的Node.js Docker镜像中查找和修复安全漏洞

See Docker Security Cheat Sheet - Use static analysis tools

参见 Docker安全备忘录 - 使用静态分析工具

8) Use multi-stage builds / 8) 使用多阶段构建

Multi-stage builds are a great way to move from a simple, yet potentially erroneous Dockerfile, into separated steps of building a Docker image, so we can avoid leaking sensitive information. Not only that, but we can also use a bigger Docker base image to install our dependencies, compile any native npm packages if needed, and then copy all these artifacts into a small production base image, like our alpine example.

多阶段构建是一种将简单的、但可能存在错误性的Dockerfile转换为构建Docker镜像的独立步骤的绝佳方式,这样我们可以避免泄露敏感信息。不仅如此,我们还可以使用更大的Docker基础镜像来安装我们的依赖项,如果需要,编译任何原生npm包,然后将所有这些工件复制到一个小型生产基础镜像中,比如我们的alpine示例。

Prevent sensitive information leak / 防止敏感信息泄露

The use-case here to avoid sensitive information leakage is more common than you think.

这里避免敏感信息泄露的使用案例比你想象的更常见。

If you’re building Docker images for work, there’s a high chance that you also maintain private npm packages. If that’s the case, then you probably needed to find some way to make that secret NPM_TOKEN available to the npm install.

如果您在工作时构建 Docker 镜像,那么您很可能也在维护私有 npm 包。如果是这样,那么您可能需要找到一种方法来使这个秘密的 NPM_TOKEN 可用于 npm install。

Here’s an example for what I’m talking about:

以下是一个关于我所谈论内容的示例:

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
#RUN npm ci --omit=dev
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --omit=dev
USER node
CMD ["dumb-init", "node", "server.js"]

Doing this, however, leaves the .npmrc file with the secret npm token inside the Docker image. You could attempt to improve it by deleting it afterwards, like this:

然而,这样做会在 Docker 镜像中留下包含秘密 npm 令牌的 .npmrc 文件。您可以通过之后删除它来尝试改进,如下所示:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --omit=dev
RUN rm -rf .npmrc

However, now the .npmrc file is available in a different layer of the Docker image. If this Docker image is public, or someone is able to access it somehow, then your token is compromised. A better improvement would be as follows:

但是,现在 .npmrc 文件位于 Docker 镜像的不同层中。如果这个 Docker 镜像是公开的,或者有人能够以某种方式访问它,那么您的令牌就处于危险之中。更好的改进方法如下:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --omit=dev; \
   rm -rf .npmrc

The problem now is that the Dockerfile itself needs to be treated as a secret asset, because it contains the secret npm token inside it.

现在的问题是,Dockerfile 本身需要被视为一个机密资产,因为它包含其中的机密 npm 令牌。

Luckily, Docker supports a way to pass arguments into the build process:

幸运的是,Docker 支持在构建过程中传递参数的方法:

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --omit=dev; \
   rm -rf .npmrc

And then we build it as follows:

然后我们按照以下方式构建它:

$ docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234

I know you were thinking that we’re all done at this point but, sorry to disappoint 🙂

我知道你此时可能认为我们已经完成了,但很抱歉让你失望了 :-)

That’s how it is with security—sometimes the obvious things are yet just another pitfall.

这就是安全性的问题——有时候显而易见的事情却只是另一个陷阱。

What’s the problem now, you ponder? Build arguments passed like that to Docker are kept in the history log. Let’s see with our own eyes. Run this command:

现在问题是什么,你在想?以这种方式传递给 Docker 的构建参数会被保存在历史记录中。让我们亲自看看。运行以下命令:

$ docker history nodejs-tutorial

which prints the following:

打印以下内容:

IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
b4c2c78acaba   About a minute ago   CMD ["dumb-init" "node" "server.js"]            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   USER node                                       0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg…   5.71MB    buildkit.dockerfile.v0
<missing>      About a minute ago   ARG NPM_TOKEN                                   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY . . # buildkit                             15.3kB    buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /usr/src/app                            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ENV NODE_ENV=production                         0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN /bin/sh -c apk add dumb-init # buildkit     1.65MB    buildkit.dockerfile.v0

IMAGE CREATED CREATED BY SIZE COMMENT b4c2c78acaba 大约一分钟前 CMD ["dumb-init" "node" "server.js"] 0B buildkit.dockerfile.v0 大约一分钟前 USER node 0B buildkit.dockerfile.v0 大约一分钟前 RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg… 5.71MB buildkit.dockerfile.v0 大约一分钟前 ARG NPM_TOKEN 0B buildkit.dockerfile.v0 大约一分钟前 COPY . . # buildkit 15.3kB buildkit.dockerfile.v0 大约一分钟前 WORKDIR /usr/src/app 0B buildkit.dockerfile.v0 大约一分钟前 ENV NODE_ENV=production 0B buildkit.dockerfile.v0 大约一分钟前 RUN /bin/sh -c apk add dumb-init # buildkit 1.65MB buildkit.dockerfile.v0

Did you spot the secret npm token there? That’s what I mean.

你发现了那里的秘密 npm 令牌吗?这就是我的意思。

There’s a great way to manage secrets for the container image, but this is the time to introduce multi-stage builds as a mitigation for this issue, as well as showing how we can build minimal images.

有一种很好的方法来管理容器镜像的秘密,但现在正是介绍多阶段构建作为缓解此问题的时机,同时展示我们如何构建最小化镜像。

Introducing multi-stage builds for Node.js Docker images / 为 Node.js Docker 镜像引入多阶段构建

Just like that principle in software development of Separation of Concerns, we’ll apply the same ideas in order to build our Node.js Docker images. We’ll have one image that we use to build everything that we need for the Node.js application to run, which in a Node.js world, means installing npm packages, and compiling native npm modules if necessary. That will be our first stage.

就像软件开发中的关注点分离原则一样,我们将应用同样的思想来构建我们的 Node.js Docker 镜像。我们将有一个镜像,用于构建 Node.js 应用程序运行所需的一切,在 Node.js 世界中,这意味着安装 npm 包,如果需要,编译本机 npm 模块。这将是我们的第一阶段。

The second Docker image, representing the second stage of the Docker build, will be the production Docker image. This second and last stage is the image that we actually optimize for and publish to a registry, if we have one. That first image that we’ll refer to as the build image, gets discarded and is left as a dangling image in the Docker host that built it, until it gets cleaned.

第二个 Docker 镜像,代表 Docker 构建的第二个阶段,将是生产 Docker 镜像。这个第二和最后一个阶段是我们实际优化并发布到注册表(如果有)的镜像。我们将第一个镜像称为 build 镜像,它将被丢弃,并留在构建它的 Docker 主机上的悬空镜像中,直到它被清理。

Here is the update to our Dockerfile that represents our progress so far, but separated into two stages:

这里是我们Dockerfile的更新,展示了我们目前的进展,但分为两个阶段:

# --------------> The build image
FROM node:latest AS build
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --omit=dev && \
   rm -f .npmrc

--------------> 构建镜像 FROM node:latest AS build ARG NPM_TOKEN WORKDIR /usr/src/app COPY package*.json /usr/src/app/ RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \ npm ci --omit=dev && \ rm -f .npmrc

# --------------> The production image
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

--------------> 生产镜像 FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a RUN apk add dumb-init ENV NODE_ENV production USER node WORKDIR /usr/src/app COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules COPY --chown=node:node . /usr/src/app CMD ["dumb-init", "node", "server.js"]

As you can see, I chose a bigger image for the build stage because I might need tooling like gcc (the GNU Compiler Collection) to compile native npm packages, or for other needs.

正如你所见,我在build阶段选择了更大的镜像,因为我可能需要像gcc(GNU编译器集合)这样的工具来编译原生npm包,或者有其他需求。

In the second stage, there’s a special notation for the COPY directive that copies the node_modules/ folder from the build Docker image into this new production base image.

在第二个阶段,COPY指令有一个特殊的表示法,用于将构建Docker镜像中的node_modules/文件夹复制到这个新的生产基础镜像中。

Also, now, do you see that NPM_TOKEN passed as build argument to the build intermediary Docker image? It’s not visible anymore in the docker history nodejs-tutorial command output because it doesn’t exist in our production docker image.

此外,现在你注意到NPM_TOKEN作为构建参数传递给了build中间Docker镜像了吗?在docker history nodejs-tutorial命令的输出中不再可见,因为我们的生产Docker镜像中不存在它。

9) Keeping unnecessary files out of your Node.js Docker images / 9) 将不必要的文件排除在Node.js Docker镜像之外

You have a .gitignore file to avoid polluting the git repository with unnecessary files, and potentially sensitive files too, right? The same applies to Docker images.

你有一个.gitignore文件,用于避免将不必要的文件(以及可能敏感的文件)污染git仓库,对Docker镜像同样适用,对吧?

Docker has a .dockerignore which will ensure it skips sending any glob pattern matches inside it to the Docker daemon. Here is a list of files to give you an idea of what you might be putting into your Docker image that we’d ideally want to avoid:

Docker具有一个.dockerignore文件,可以确保它跳过将任何glob模式匹配项发送到Docker守护进程。以下是一个文件列表,供您了解您可能想要放入Docker镜像中的内容,我们理想情况下希望避免的内容:

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore

As you can see, the node_modules/ is actually quite important to skip because if we hadn’t ignored it, then the simplistic Dockerfile version that we started with would have caused the local node_modules/ folder to be copied over to the container as-is.

如您所见,node_modules/实际上非常重要,需要跳过,因为如果我们没有忽略它,那么我们最初开始的简单Dockerfile版本就会导致本地的node_modules/文件夹原样复制到容器中。

FROM node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci
CMD "npm" "start"

In fact, it’s even more important to have a .dockerignore file when you are practicing multi-stage Docker builds. To refresh your memory on how the 2nd stage Docker build looks like:

实际上,在练习多阶段Docker构建时,拥有一个.dockerignore文件更为重要。为了刷新您对第二个阶段Docker构建的印象:

# --------------> The production image
FROM node:lts-alpine
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

--------------> 生产镜像 FROM node:lts-alpine RUN apk add dumb-init ENV NODE_ENV production USER node WORKDIR /usr/src/app COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules COPY --chown=node:node . /usr/src/app CMD ["dumb-init", "node", "server.js"]

The importance of having a .dockerignore is that when we do a COPY . /usr/src/app from the 2nd Dockerfile stage, we’re also copying over any local node_modules/ to the Docker image. That’s a big no-no as we may be copying over modified source code inside node_modules/.

拥有.dockerignore的重要性在于,当我们从第二个Dockerfile阶段执行COPY . /usr/src/app时,我们也会将任何本地的node_modules/复制到Docker镜像中。这是大忌,因为我们可能会复制node_modules/中的修改过的源代码。

On top of that, since we’re using the wildcard COPY . we may also be copying into the Docker image sensitive files that include credentials or local configuration.

除此之外,由于我们使用了通配符COPY .,我们还可能将包含凭证或本地配置的敏感文件复制到Docker镜像中。

The take-away here for a .dockerignore file is:

对于.dockerignore文件,以下是我们得到的要点:

  • Skip potentially modified copies of node_modules/ in the Docker image.
    中文:跳过 Docker 镜像中可能被修改的 node_modules/ 复制。
  • Saves you from secrets exposure such as credentials in the contents of .env or aws.json files making their way into the Node.js Docker image.
  • It helps speed up Docker builds because it ignores files that would have otherwise caused a cache invalidation. For example, if a log file was modified, or a local environment configuration file, all would’ve caused the Docker image cache to invalidate at that layer of copying over the local directory.

10) Mounting secrets into the Docker build image

One thing to note about the .dockerignore file is that it is an all or nothing approach and can’t be turned on or off per build stages in a Docker multi-stage build.

Why is it important? Ideally, we would want to use the .npmrc file in the build stage, as we may need it because it includes a secret npm token to access private npm packages. Perhaps it also needs a specific proxy or registry configuration to pull packages from.

This means that it makes sense to have the .npmrc file available to the build stage—however, we don’t need it at all in the second stage for the production image, nor do we want it there as it may include sensitive information, like the secret npm token.

One way to mitigate this .dockerignore caveat is to mount a local file system that will be available for the build stage, but there’s a better way.

Docker supports a relatively new capability referred to as Docker secrets, and is a natural fit for the case we need with .npmrc. Here is how it works:

  • When we run the docker build command we will specify command-line arguments that define a new secret ID and reference a file as the source of the secret.
    中文:当我们运行 docker build 命令时,我们将指定命令行参数来定义一个新的密钥ID,并引用一个文件作为密钥的源。
  • In the Dockerfile, we will add flags to the RUN directive to install the production npm, which mounts the file referred by the secret ID into the target location—the local directory .npmrc file which is where we want it available.
    中文:在 Dockerfile 中,我们将向 RUN 指令添加标志来安装生产版本的 npm,这将把由密钥ID引用的文件挂载到目标位置——我们希望它可用的本地目录 .npmrc 文件。
  • The .npmrc file is mounted as a secret and is never copied into the Docker image.
    中文:.npmrc 文件作为密钥挂载,永远不会被复制到 Docker 镜像中。
  • Lastly, let’s not forget to add the .npmrc file to the contents of the .dockerignore file so it doesn’t make it into the image at all, for either the build nor production images.
    中文:最后,别忘了将 .npmrc 文件添加到 .dockerignore 文件的文件内容中,以确保它根本不会进入镜像,无论是构建镜像还是生产镜像。

Let’s see how all of it works together. First the updated .dockerignore file:

让我们看看这一切是如何协同工作的。首先,是更新后的 .dockerignore 文件:

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc

Then, the complete Dockerfile, with the updated RUN directive to install npm packages while specifying the .npmrc mount point:

然后,是完整的 Dockerfile,其中包含了更新后的 RUN 指令,用于安装 npm 包并指定 .npmrc 挂载点:

# --------------> The build image
FROM node:latest AS build
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --omit=dev

--------------> 构建镜像 FROM node:latest AS build WORKDIR /usr/src/app COPY package*.json /usr/src/app/ RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --omit=dev

# --------------> The production image
FROM node:lts-alpine
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

--------------> 生产镜像 FROM node:lts-alpine RUN apk add dumb-init ENV NODE_ENV production USER node WORKDIR /usr/src/app COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules COPY --chown=node:node . /usr/src/app CMD ["dumb-init", "node", "server.js"]

And finally, the command that builds the Node.js Docker image:

最后,构建 Node.js Docker 镜像的命令如下:

docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc

Note: Secrets are a new feature in Docker and if you’re using an older version, you might need to enable it Buildkit as follows:

注意: Docker 中的 Secrets 是一项新特性,如果您使用的是较旧版本,可能需要按照以下方式启用 Buildkit:

DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc

References / 参考