跳到内容

Node.js Docker 备忘录

以下备忘录提供了构建优化且安全的 Node.js Docker 镜像的生产级指南。无论您要构建何种 Node.js 应用程序,它都将对您有所帮助。如果出现以下情况,本文将对您有所帮助:

  • 您旨在使用 Node.js 的服务器端渲染 (SSR) 功能为 React 构建前端应用程序。
  • 您正在寻找关于如何为运行 Fastify、NestJS 或其他应用程序框架的微服务正确构建 Node.js Docker 镜像的建议。

1) 使用明确且确定性的 Docker 基础镜像标签

将镜像基于 node Docker 镜像构建似乎是一个显而易见的选择,但是当您构建镜像时,您实际拉取了什么?Docker 镜像总是通过标签引用,当您不指定标签时,将使用默认的 :latest 标签。

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

FROM node

基于默认 node 镜像构建的缺点如下:

  1. Docker 镜像构建不一致。就像我们使用 lockfiles 来在每次安装 npm 包时获得确定性的 npm install 行为一样,我们也希望获得确定性的 docker 镜像构建。如果从 node 构建镜像——这实际上意味着 node:latest 标签——那么每次构建都会拉取一个新构建的 node Docker 镜像。我们不希望引入这种不确定性行为。
  2. node Docker 镜像基于一个功能齐全的操作系统,其中包含您运行 Node.js Web 应用程序可能需要或不需要的许多库和工具。这有两个缺点。首先,更大的镜像意味着更大的下载大小,除了增加存储需求外,还意味着更多的下载和重新构建镜像的时间。其次,这意味着您可能会将所有这些库和工具中可能存在的安全漏洞引入到镜像中。

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

构建更好的 Docker 镜像的建议是:

  1. 使用小型 Docker 镜像——这将转化为 Docker 镜像上更小的软件占用空间,从而减少潜在的漏洞向量,以及更小的体积,这将加快镜像构建过程。
  2. 使用 Docker 镜像摘要,即镜像的静态 SHA256 哈希值。这确保您从基础镜像获得确定性的 Docker 镜像构建。

基于此,我们应确保使用 Node.js 的长期支持 (LTS) 版本,以及最小的 alpine 镜像类型,以便在镜像上获得最小的体积和软件占用空间。

FROM node:lts-alpine

尽管如此,这个基础镜像指令仍然会拉取该标签的新构建。我们可以在此 Node.js 标签的 Docker Hub 中找到其 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

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

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

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

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

然而,上面的 Dockerfile 只指定了 Node.js Docker 镜像名称而没有镜像标签,这使得使用的确切镜像标签变得模糊不清——它不可读、难以维护,并且没有提供良好的开发体验。

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

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

2) 在 Node.js Docker 镜像中只安装生产环境依赖

以下 Dockerfile 指令会在容器中安装所有依赖项,包括 devDependencies,而这些对于功能性应用程序的运行并非必需。它增加了使用开发依赖包带来的不必要的安全风险,并无谓地增大了镜像大小。

RUN npm install

使用 npm ci 强制执行确定性构建。这可以防止持续集成 (CI) 流程中出现意外,因为它会在对 lockfile 进行任何更改时停止。

在为生产环境构建 Docker 镜像时,我们希望确保以确定性的方式只安装生产依赖项,这使我们得出了在容器镜像中安装 npm 依赖项的最佳实践建议如下:

RUN npm ci --omit=dev

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

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

3) 优化 Node.js 生产环境工具

当您为生产环境构建 Node.js Docker 镜像时,您希望确保所有框架和库都使用最佳的性能和安全设置。

这使我们添加了以下 Dockerfile 指令:

ENV NODE_ENV production

乍一看,这似乎是多余的,因为我们已经在 npm install 阶段只指定了生产依赖项——那么为什么这仍然是必要的呢?

开发者通常将 NODE_ENV=production 环境变量设置与生产相关依赖项的安装联系起来,但是,此设置还具有我们必须了解的其他影响。

某些框架和库可能只有在 NODE_ENV 环境变量设置为 production 时,才会开启适合生产环境的优化配置。暂且不论框架采取这种做法是好是坏,了解这一点很重要。

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

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

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

现在,包含 NODE_ENV 环境变量设置的更新后 Dockerfile 应如下所示:

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) 不要以 root 用户运行容器

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

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

跟我重复一遍:“朋友们,不要让朋友们以 root 用户运行容器!”

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

USER node
CMD "npm" "start"

原因是 USER Dockerfile 指令只确保进程由 node 用户拥有。那么我们之前使用 COPY 指令复制的所有文件呢?它们是由 root 用户拥有的。Docker 默认就是这样工作的。

完全且正确地放弃权限的方法如下,同时展示了我们截至目前最新的 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) 正确处理事件以安全终止 Node.js Docker Web 应用程序

我在关于将 Node.js 应用程序容器化并在 Docker 容器中运行的博客和文章中看到的最常见错误之一是它们调用进程的方式。以下所有及其变体都是您应该避免的糟糕模式:

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

让我们深入探讨!我将带您了解它们之间的区别以及为什么它们都是需要避免的模式。

以下考量是理解正确运行和终止 Node.js Docker 应用程序背景的关键:

  1. 编排引擎,例如 Docker Swarm、Kubernetes,甚至 Docker 引擎本身,都需要一种向容器中进程发送信号的方式。通常,这些是终止应用程序的信号,例如 SIGTERMSIGKILL
  2. 进程可能间接运行,如果发生这种情况,则不总是保证它会收到这些信号。
  3. Linux 内核对待作为进程 ID 1 (PID) 运行的进程与其他任何进程 ID 都不同。

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

CMD "npm" "start"

这里的注意事项是双重的。首先,我们通过直接调用 npm 客户端来间接运行 node 应用程序。谁能保证 npm CLI 会将所有事件转发给 node 运行时?它实际上不会,我们可以轻松测试这一点。

请确保在您的 Node.js 应用程序中为 SIGHUP 信号设置一个事件处理程序,该程序在每次发送事件时将日志输出到控制台。一个简单的代码示例如下所示:

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

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

$ docker kill --signal=SIGHUP elastic_archimedes

什么都没发生,对吧?那是因为 npm 客户端没有将任何信号转发到它生成的 node 进程。

另一个注意事项与在 Dockerfile 中指定 CMD 指令的不同方式有关。有两种方式,它们并不相同:

  1. shellform 形式,其中容器生成一个 shell 解释器来包装进程。在这种情况下,shell 可能无法正确地将信号转发到您的进程。
  2. execform 形式,它直接生成一个进程,而不用 shell 包装。它使用 JSON 数组表示法指定,例如:CMD [“npm”, “start”]。发送到容器的任何信号都直接发送到进程。

基于这些知识,我们希望如下改进我们的 Dockerfile 进程执行指令:

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

我们现在直接调用 node 进程,确保它接收到发送给它的所有信号,而无需将其包装在 shell 解释器中。

然而,这又引入了另一个陷阱。

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

引用Node.js Docker 工作组的建议:“Node.js 并非设计为作为 PID 1 运行,这导致在 Docker 内部运行时出现意外行为。例如,作为 PID 1 运行的 Node.js 进程将不会响应 SIGINT (CTRL-C) 和类似的信号。”

那么解决之道是使用一个充当 init 进程的工具,它以 PID 1 调用,然后将我们的 Node.js 应用程序作为另一个进程生成,同时确保所有信号都代理到该 Node.js 进程。如果可能,我们希望这样做所用的工具占用空间尽可能小,以避免将安全漏洞添加到我们的容器镜像中。

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

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

这使我们得到了以下最新的 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"]

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

6) 优雅地关闭您的 Node.js Web 应用程序

既然我们正在讨论终止应用程序的进程信号,那么让我们确保我们正在正确、优雅地关闭它们,而不会中断用户。

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

您可以轻松模拟这个问题。以下是一个 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()

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

$ time curl https://:3000/delayed

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

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

  1. 为各种终止信号(如 SIGINTSIGTERM)设置事件处理程序。
  2. 处理程序会等待清理操作,例如数据库连接、正在进行的 HTTP 请求等。
  3. 然后,处理程序终止 Node.js 进程。

特别是对于 Fastify,我们可以让处理程序调用 fastify.close(),它会返回一个 Promise,我们将等待它,Fastify 还会负责以 HTTP 状态码 503 响应每个新连接,以表示应用程序不可用。

让我们添加事件处理程序:

async function closeGracefully(signal) {
   console.log(`*^!@4=> Received signal to terminate: ${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)

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

7) 查找并修复 Node.js docker 镜像中的安全漏洞

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

8) 使用多阶段构建

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

防止敏感信息泄露

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

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

以下是我所说的示例:

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

然而,这样做会将包含秘密 npm 令牌的 .npmrc 文件留在 Docker 镜像内部。您可以尝试在之后删除它来改进,像这样:

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

然而,现在 .npmrc 文件在 Docker 镜像的不同层中可用。如果此 Docker 镜像公开,或者有人能够以某种方式访问它,那么您的令牌就会泄露。一个更好的改进方案如下:

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

现在的问题是 Dockerfile 本身需要被视为秘密资产,因为它内部包含了秘密 npm 令牌。

幸运的是,Docker 支持一种将参数传递到构建过程中的方式:

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

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

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

我知道您可能认为到此就结束了,但很抱歉让您失望了 🙂

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

您可能会想,现在问题出在哪里?像这样传递给 Docker 的构建参数会保留在历史日志中。让我们亲眼看看。运行此命令:

$ docker history nodejs-tutorial

它会打印以下内容:

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

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

有一种很好的方法来管理容器镜像的秘密,但现在是时候介绍多阶段构建来缓解这个问题,并展示我们如何构建最小镜像。

Node.js Docker 镜像的多阶段构建介绍

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

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

以下是我们 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

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

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

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

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

9) 将不必要的文件排除在 Node.js Docker 镜像之外

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

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

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

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

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

事实上,当您进行多阶段 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"]

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

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

关于 .dockerignore 文件的要点是:

  • 跳过 Docker 镜像中可能已修改的 node_modules/ 副本。
  • 防止秘密泄露,例如 .envaws.json 文件中的凭据进入 Node.js Docker 镜像。
  • 它有助于加快 Docker 构建速度,因为它会忽略那些原本会导致缓存失效的文件。例如,如果日志文件或本地环境配置文件被修改,都会导致 Docker 镜像缓存在复制本地目录那一层失效。

10) 将秘密挂载到 Docker 构建镜像中

关于 .dockerignore 文件需要注意的一点是,它是一种全有或全无的方法,不能在 Docker 多阶段构建中按每个构建阶段开启或关闭。

为什么它很重要?理想情况下,我们希望在构建阶段使用 .npmrc 文件,因为我们可能需要它,因为它包含一个秘密 npm 令牌来访问私有 npm 包。也许它还需要特定的代理或注册表配置来拉取包。

这意味着在 build 阶段提供 .npmrc 文件是合理的——然而,在生产镜像的第二阶段我们完全不需要它,也不希望它在那里,因为它可能包含敏感信息,例如秘密 npm 令牌。

缓解这个 .dockerignore 限制的一种方法是挂载一个本地文件系统,使其在构建阶段可用,但有更好的方法。

Docker 支持一项相对较新的功能,称为 Docker secrets(Docker 秘密),它非常适合我们处理 .npmrc 的情况。其工作原理如下:

  • 当我们运行 docker build 命令时,我们将指定命令行参数,这些参数定义一个新的秘密 ID 并引用一个文件作为秘密的来源。
  • 在 Dockerfile 中,我们将在 RUN 指令中添加标志以安装生产 npm,这将把秘密 ID 引用的文件挂载到目标位置——本地目录 .npmrc 文件,这是我们希望它可用的地方。
  • .npmrc 文件作为秘密挂载,并且永远不会复制到 Docker 镜像中。
  • 最后,不要忘记将 .npmrc 文件添加到 .dockerignore 文件的内容中,这样它就不会进入任何镜像,无论是构建镜像还是生产镜像。

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

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

然后,是完整的 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

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

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

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

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

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