用Docker对Node.js网络应用进行容器化的10个最佳实践

556 阅读25分钟

编者注

2022年9月14日:请看我们新近改进的关于用Docker对Node.js网络应用进行容器化的小抄!

你是否在寻找关于如何为你的网络应用构建Node.js Docker镜像的最佳实践?那么你就来对地方了!

下面的文章提供了构建优化和安全的Node.js Docker镜像的生产级指南。无论你的目标是建立什么样的Node.js应用程序,你都会发现它很有帮助。这篇文章对你有帮助,如果。

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

我们为什么要写这篇关于Node.js Docker网络应用容器化的指南?

这可能是另一篇关于如何为Node.js应用程序构建Docker镜像的文章,但我们在博客中看到的许多例子都非常简单,仅仅是为了指导你拥有一个运行应用程序的Node.js Docker镜像的基础知识,而没有周到地考虑安全性和构建Node.js Docker镜像的最佳做法。

我们将逐步学习如何将Node.js网络应用容器化,从一个简单的、可运行的Dockerfile开始,了解每一个Dockerfile指令的陷阱和不安全因素,然后再加以解决。

一个简单的Node.js Docker镜像

我们看到的大多数博客文章都是按照以下基本的Dockerfile指令开始和结束的,用于构建Node.js Docker镜像。

FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

将其复制到一个名为Dockerfile 的文件,然后构建并运行它。

$ docker build . -t nodejs-tutorial
$ docker run -p 3000:3000 nodejs-tutorial

这很简单,也很有效。

唯一的问题是什么?它充满了构建Node.js Docker镜像的错误和不良做法。无论如何都要避免上述情况。

让我们开始改进这个Dockerfile,以便我们能够用Docker构建优化的Node.js网络应用。

按照以下10个步骤,用Docker构建优化的Node.js网络应用。

  1. 使用明确和确定的Docker基础镜像标签
  2. 在Node.js Docker镜像中只安装生产依赖项
  3. 优化用于生产的Node.js工具
  4. 不要以root身份运行容器
  5. 安全地终止Node.js Docker网络应用
  6. 为你的Node.js网络应用程序提供优雅的关机服务
  7. 查找并修复Node.js docker镜像中的安全漏洞
  8. 使用多阶段构建
  9. 将不必要的文件保留在你的Node.js Docker镜像中
  10. 将秘密安装到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 标签--那么每次构建都会拉出一个新构建的Docker镜像node 。 我们不想引入这种非确定性的行为。
  2. node Docker镜像是基于一个成熟的操作系统,充满了库和工具,你可能需要也可能不需要它们来运行你的Node.js网络应用。这有两个坏处。首先,更大的镜像意味着更大的下载量,除了增加存储需求,还意味着更多的时间来下载和重新构建镜像。其次,这意味着你有可能将所有这些库和工具中可能存在的安全漏洞引入镜像。

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

Vulnerabilities in official container images

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

  1. 使用小的Docker镜像--这将转化为Docker镜像上较小的软件足迹,减少潜在的漏洞载体,而且尺寸较小,这将加快镜像构建过程。
  2. 使用Docker镜像摘要,也就是镜像的静态SHA256哈希值。这可以确保你从基础镜像中获得确定性的Docker镜像构建。

我们已经写了一篇关于如何选择最佳Node.js
Docker镜像的综合文章。文章详细介绍了为什么选择一个最新的Debian's slim发行版与长期支持的Node.js运行时版本是理想的选择的原因。

推荐使用的Node.js Docker映像应该是。

FROM node:16.17.0-bullseye-slim

这个Node.js Docker镜像标签使用了Node.js运行时的特定版本(`16.17.0`),映射到当前最新的长期支持。它使用`bullseye`镜像变体,这是当前稳定的Debian 11版本,有足够的终结日期。最后,它使用`slim`映像变量来指定一个较小的操作系统软件足迹,导致映像大小小于200MB,包括Node.js运行时间和工具。

也就是说,你会看到的一个常见的不知所云的做法是教程或指南引用了以下Docker指令作为基础镜像。

FROM node:alpine

这些文章引用了Node.js Alpine Docker镜像的使用,但这真的很理想吗?他们这样做主要是因为Node.js Alpine Docker镜像的软件占用空间较小,然而,它在其他特征上有很大不同,这使得它成为Node.js应用运行时的非最佳生产基础镜像。

什么是Node Alpine?

Node.js Alpine是一个非官方的Docker容器镜像构建,由Node.js Docker团队维护。Node.js镜像捆绑了Alpine操作系统,该系统由最小的busybox软件工具和muslC库实现提供支持。这两个Node.js Alpine图像的特点有助于Docker图像得到Node.js团队的非官方支持。此外,许多安全漏洞扫描器不能轻易检测到Node.js Alpine镜像上的软件工件或运行时,这对保护你的容器镜像的努力起到了反作用。

无论使用Node.js Alpine图像标签,使用单词别名形式的基础图像指令仍然可以拉动该标签的新构建,因为Docker图像标签是可变的。我们可以在这个Node.js标签的Docker Hub中找到它的SHA256 hash,或者在本地拉出这个图像后,运行下面的命令,在输出中找到Digest 字段。

$ docker pull node:16.17.0-bullseye-slim
5b1423465504: Already exists
2f232a362cd9: Already exists
aa653d801310: Already exists
25750f98abe8: Already exists
476cb0003ed3: Already exists
Digest: sha256:18ae6567b623f8c1caada3fefcc8746f8e84ad5c832abd909e129f6b13df25b4
Status: Downloaded newer image for node:16.17.0-bullseye-slim
docker.io/library/node:16.17.0-bullseye-slim

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

$ docker images --digests
REPOSITORY                     TAG              DIGEST                                                                    IMAGE ID       CREATED             SIZE
node                                         16.17.0-bullseye-slim   sha256:18ae6567b623f8c1caada3fefcc8746f8e84ad5c832abd909e129f6b13df25b4   f8e42f13e99d   6 days ago     183MB

现在我们可以为这个Node.js Docker镜像更新Dockerfile,如下。

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

然而,上面的Dockerfile只指定了Node.js Docker镜像的名称,而没有图像标签,这就造成了哪一个确切的图像标签被使用的模糊性--这是不可读的,难以维护,也不能创造一个良好的开发者体验。

让我们通过更新Dockerfile来解决这个问题,为Node.js版本提供与该SHA256 哈希值相对应的完整基础图像标签。

FROM node:16.17.0-bullseye-slim@sha256:18ae6567b623f8c1caada3fefcc8746f8e84ad5c832abd909e129f6b13df25b4
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

使用Docker镜像摘要可以确保一个确定的镜像,但对于一些可能不知道如何解释的镜像扫描工具来说,这可能会造成混乱或适得其反。出于这个原因,使用明确的Node.js运行时版本,如`16.17.0`是首选。即使理论上它是可变的,可以被覆盖,但在实践中,如果它需要接收安全或其他更新,它们会被推送到一个新的版本,如`16.17.1`,所以假设确定性的构建是足够安全的。

因此,在这个阶段,我们最终建议的Docker文件是如下。

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

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

下面的Dockerfile指令在容器中安装了所有的依赖项,包括devDependencies ,这些依赖项对于一个功能性的应用来说是不需要的。它从作为开发依赖的包中增加了不必要的安全风险,同时也不必要地扩大了镜像的大小。

RUN npm install

如果你遵循我之前关于10个npm安全最佳实践的指南,那么你就知道你想用npm ci ,强制执行确定性的构建。这可以防止在持续集成(CI)流程中出现意外,因为如果有任何偏离lockfile的情况,它就会停止。

在为生产构建Docker镜像的情况下,我们要确保只以确定的方式安装生产依赖,这就给我们带来了以下关于在容器镜像中安装npm依赖的最佳实践建议。

RUN npm ci --only=production

在这个阶段,更新的Dockerfile内容如下。

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

3.优化生产用的Node.js工具

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

这使我们要添加以下Dockerfile指令。

ENV NODE_ENV production

乍一看,这看起来是多余的,因为我们已经在npm install 阶段只指定了生产依赖--那么为什么要这样做呢?

开发人员大多将NODE_ENV=production 环境变量设置与安装生产相关的依赖关系联系起来,然而,这个设置也有其他影响,我们需要注意。

一些框架和库可能只有在NODE_ENV 环境变量被设置为production 的情况下才会开启适合生产的优化配置。抛开我们对框架采取这种做法是好是坏的看法,了解这一点很重要。

作为一个例子,Express文档概述了设置这个环境变量对于实现性能和安全相关优化的重要性。

Optimize Node.js tooling for production

NODE_ENV 这个变量对性能的影响可能非常大。

你所依赖的许多其他库也可能希望这个变量被设置,所以我们应该在Dockerfile中设置这个变量。

更新后的Docker文件现在应该如下所示,其中包含了NODE_ENV 环境变量的设置。

FROM node:16.17.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

4.不要以root身份运行容器

最小特权原则是Unix早期的一个安全原则,当我们运行容器化的Node.js网络应用程序时,我们应该始终遵循这一原则。

威胁评估是非常直接的,如果攻击者能够以允许命令注入目录路径遍历的方式破坏Web应用程序,那么这些将以拥有应用程序进程的用户来调用。如果该进程恰好是root,那么他们几乎可以在容器内做任何事情,包括尝试容器逃脱特权升级。我们为什么要冒这个险呢?你是对的,我们不愿意。

跟着我重复一遍。"朋友不要让朋友以root身份运行容器!"

官方的node Docker镜像,以及它的变体如alpine ,包括一个同名的最低权限用户:node 。然而,仅仅以node 的身份运行进程是不够的。例如,下面的应用功能就不太理想。

USER node
CMD "npm" "start"

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

完整而正确的放弃权限的方式如下,也显示了我们到目前为止最新的Dockerfile做法。

FROM node:16.17.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --only=production
USER node
CMD "npm" "start"

5.安全地终止Node.js Docker Web应用程序

在Docker容器中运行Node.js应用程序时,我在博客和文章中看到的最常见的错误之一是他们调用进程的方式。以下所有内容及其变体都是你应该避免的坏模式。

  • 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不同。

掌握了这些知识,让我们开始研究调用容器的进程的方法,从我们正在建立的Docker文件的例子开始。

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进程转发任何信号。

另一个注意事项与你可以在Docker文件中指定CMD 指令的不同方式有关。有两种方式,它们是不一样的。

  1. shellform符号,在这种情况下,容器会生成一个包裹进程的shell解释器。在这种情况下,shell可能不会正确地将信号转发给你的进程。
  2. execform符号,它直接生成一个进程,而不将其包裹在一个shell中。它是用JSON数组符号来指定的,比如说。CMD [“npm”, “start”].任何发送到容器的信号都会直接发送给进程。

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

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

我们现在直接调用节点进程,确保它收到所有发送给它的信号,而不需要把它包裹在一个shell解释器中。

然而,这引入了另一个隐患。

当进程作为PID 1运行时,它们实际上承担了init系统的一些职责,init系统通常负责初始化操作系统和进程。内核对待PID1的方式与对待其他进程标识符的方式不同。内核的这种特殊处理方式意味着,如果一个正在运行的进程没有设置处理程序,那么处理一个SIGTERM 信号不会调用默认的后退行为,即杀死该进程。

引用Node.js Docker工作组在这方面的建议。 "Node.js不是被设计成以PID 1的形式运行的,这导致了在Docker内部运行时的意外行为。例如,作为PID 1运行的Node.js进程不会响应SIGINT(CTRL-C)和类似的信号"。

那么要做的就是使用一个像init进程一样的工具,它以PID 1被调用,然后将我们的Node.js应用程序作为另一个进程生成,同时确保所有的信号都被代理给Node.js进程。如果可能的话,我们希望这样做的工具足迹尽可能小,以避免在我们的容器镜像中增加安全漏洞的风险。

我们在Snyk使用的这样一个工具是dumb-init,因为它是静态链接的,而且占用空间小。下面是我们将如何设置它。

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
CMD ["dumb-init", "node", "server.js"]

这给我们带来了以下最新的Dockerfile 。你会注意到,我们把dumb-init 包的安装放在了镜像声明之后,所以我们可以利用Docker的层缓存。

FROM node:16.17.0-bullseye-slim
RUN RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

当我们使用Docker的RUN 指令来添加软件时,就像我们使用RUN apt-get update && apt-get install 那样,那么我们会在Docker镜像上留下一些信息。为了在这个指令之后进行清理,我们可以按以下方式进行扩展,并保持一个更瘦小的Docker镜像。

FROM node:16.17.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommend dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

提示:在早期的构建阶段镜像中安装dumb-init 工具,然后将生成的/usr/bin/dumb-init 文件复制到最终的容器镜像中,以保持该镜像的清洁,这样做效果更好。我们将在本指南的后面学习更多关于多阶段Docker构建的知识。

要知道:docker killdocker stop 命令只向PID为1的容器进程发送信号。如果你正在运行一个shell脚本来运行你的Node.js应用程序,那么请注意,一个shell实例--例如/bin/sh ,不会将信号转发给子进程,这意味着你的应用程序永远不会收到SIGTERM

保护你的容器化Node.js Web应用程序

6.为你的Node.js网络应用程序提供优雅的关机服务

如果我们已经在讨论终止应用程序的进程信号,那么让我们确保在不影响用户的情况下正确、优雅地关闭它们。

当一个Node.js应用程序收到一个中断信号,也被称为SIGINT ,或CTRL+C ,它将导致一个突然的进程杀死,除非任何事件处理程序当然被设置为以不同的行为处理它。这意味着连接到网络应用的客户端将被立即断开连接。现在,想象一下由Kubernetes协调的数百个Node.js网络容器,在需要扩展或管理错误的情况下上下移动。这不是最好的用户体验。

你可以很容易地模拟这个问题。这里有一个库存的Fastify网络应用程序的例子,其中一个端点的固有延迟响应为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://localhost:3000/delayed

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

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

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

具体到Fastify,我们可以让我们的处理程序调用fastify.close(),它返回一个我们将等待的承诺,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.kill(process.pid, signal);
}
process.once('SIGINT', closeGracefully)
process.once('SIGTERM', closeGracefully)

诚然,这更像是一个普通的网络应用程序的问题,而不是与Dockerfile有关,但在协调的环境中更加重要。

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

还记得我们是如何讨论小型Docker基础镜像对我们的Node.js应用程序的重要性吗?让我们把这个测试付诸实践。

我将使用Snyk CLI来测试我们的Docker镜像。你可以在这里注册一个免费的Snyk账户。

$ npm install -g snyk
$ snyk auth
$ snyk container test node:16.17.0-bullseye-slim --file=Dockerfile

第一条命令是安装Snyk CLI,接着从命令行快速登录流程,获取API密钥,然后我们就可以测试容器是否有安全问题。下面是结果。

Organization:      lirantal
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:16.17.0-bullseye-slim
Platform:          linux/arm64
Base image:        node:lts-bullseye-slim
Licenses:          enabled
 
Tested 97 dependencies for known issues, found 44 issues.
 
According to our scan, you are currently using the most secure version of the selected base image

Snyk检测了97个操作系统依赖,包括我们的Node.js运行时可执行文件,并没有发现任何运行时的脆弱版本。然而,容器镜像中的一些软件确实存在44个安全漏洞。这些依赖关系中的43个是低严重性问题,1个是与zlib库有关的关键漏洞。

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:lts-bullseye-slim)
 
✗ Critical severity vulnerability found in zlib/zlib1g
  Description: Out-of-bounds Write
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-ZLIB-2976151
  Introduced through: meta-common-packages@meta
  From: meta-common-packages@meta > zlib/zlib1g@1:1.2.11.dfsg-2+deb11u1
  Image layer: Introduced by your base image (node:lts-bullseye-slim)
  Fixed in: 1:1.2.11.dfsg-2+deb11u2

如何修复Docker镜像的漏洞?

要跟上Docker镜像中的安全软件,一个有效而快速的方法是重建Docker镜像。你会依赖你使用的上游Docker基础镜像来为你获取这些更新。另一种方法是明确安装操作系统系统的更新包,包括安全修复。

对于官方的Node.js Docker镜像,团队对镜像更新的反应可能会比较慢,因此重建Node.js Docker镜像16.17.0-bullseye-slimlts-bullseye-slim ,不会有效果。另一个选择是用Debian的最新软件来管理你自己的基本镜像。在我们的Dockerfile ,我们可以这样做。

RUN apt-get update && apt-get upgrade -y

让我们在用那个新添加的RUN 指令构建Node.js Docker映像后,运行Snyk安全扫描。

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:16.17.0-bullseye-slim)
…
Tested 98 dependencies for known issues, found 43 issues.
According to our scan, you are currently using the most secure version of the selected base image

这导致多了一个操作系统依赖(98对之前的97),但现在所有影响这个Node.js Docker镜像的43个安全漏洞都是低度的,而且我们已经补救了关键的zlib安全漏洞。这对我们来说是一个巨大的胜利!

如果我们使用 FROM nodebase image指令会怎样?
更妙的是,让我们假设你使用了一个更具体的Node.js Docker基础镜像,比如说这个。

FROM node:14.2.0-slim


 High severity vulnerability found in node
  Description: Memory Corruption
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-570870
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.4.0

 High severity vulnerability found in node
  Description: Denial of Service (DoS)
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-674659
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.11.0


Organization:      snyk-demo-567
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:14.2.0-slim
Platform:          linux/amd64
Base image:        node:14.2.0-slim

Tested 78 dependencies for known issues, found 82 issues.

Base Image        Vulnerabilities  Severity
node:14.2.0-slim  82               23 high, 11 medium, 48 low

Recommendations for base image upgrade:

Minor upgrades
Base Image         Vulnerabilities  Severity
node:14.15.1-slim  71               17 high, 7 medium, 47 low

Major upgrades
Base Image        Vulnerabilities  Severity
node:15.4.0-slim  71               17 high, 7 medium, 47 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:14.15.1-buster-slim   55               12 high, 4 medium, 39 low
node:14.15.3-stretch-slim  71               17 high, 7 medium, 47 low

虽然看起来特定的Node.js运行时版本如FROM node:14.2.0-slim ,因为你指定了一个特定的版本(`14.2.0`),也使用了一个小的容器镜像(由于`slim`镜像标签),所以已经很好了,但Snyk能够从2个主要来源找到安全漏洞。

  1. Node.js运行时本身--你是否注意到上述报告中的两个主要安全漏洞?这些都是Node.js运行时间中公开的安全问题。对这些问题的直接修复是升级到较新的Node.js版本,Snyk会告诉你,也会告诉你哪个版本修复了它-14.11.0,你可以在输出中看到。
  2. 安装在这个debian基础镜像中的工具和库,如glibc、bzip2、gcc、perl、bash、tar、libcrypt和其他。虽然容器中的这些易受攻击的版本可能不会构成直接的威胁,但如果我们不使用它们,为什么还要有它们呢?

这个Snyk CLI报告的最好部分是什么?Snyk还推荐了其他可以切换到的基础镜像,所以你不必自己去想这个问题。寻找替代图像可能非常耗费时间,所以Snyk为你省去了这个麻烦。

我在这个阶段的建议如下。

  1. 如果你在注册表中管理你的Docker镜像,比如Docker Hub或Artifactory,你可以很容易地将它们导入Snyk,这样平台就会为你找到这些漏洞。这也会在Snyk UI中给你提供建议,以及持续监控你的Docker镜像是否有新发现的安全漏洞。
  2. 在你的CI自动化中使用Snyk CLI。CLI非常灵活,这正是我们创建它的原因--所以你可以将它应用于你的任何自定义工作流程。我们还有Snyk for GitHub Actions,如果你喜欢这些。

8.使用多阶段构建

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

防止敏感信息泄露

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

如果你为工作而构建Docker镜像,很有可能你也在维护私人的npm包。如果是这种情况,那么你可能需要找到一些方法来使npm安装的秘密NPM_TOKEN

这里有一个例子说明我在说什么。

FROM node:16.17.0-bullseye-slim

RUN apt-get update && apt-get install -y --no-install-recommend dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
#RUN npm ci --only=production
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

然而,这样做会使Docker镜像中带有秘密npm令牌的.npmrc 文件。你可以尝试通过事后删除它来改善它,就像这样。

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

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

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

现在的问题是,Docker文件本身需要被视为一种秘密资产,因为它里面包含了秘密的npm令牌。

幸运的是,Docker支持一种将参数传入构建过程的方法。

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   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

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

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

为Node.js Docker镜像引入多阶段构建

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

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

这里是我们的Docker文件的更新,代表了我们到目前为止的进展,但分为两个阶段。

# --------------> The build image
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommend dumb-init
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production && \
   rm -f .npmrc
 
# --------------> The production image
FROM node:16.17.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
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 指令有一个特殊的记号,它将构建的Docker镜像中的node_modules/ 文件夹复制到这个新的生产基础镜像中。

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

9.让不必要的文件远离你的Node.js Docker镜像

你有一个.gitignore ,以避免不必要的文件污染git仓库,也有可能是敏感文件,对吗?这同样适用于Docker镜像。

什么是Docker忽略文件?

Docker有一个.dockerignore,它将确保跳过向Docker守护进程发送任何里面的glob模式匹配。下面是一个文件列表,让你了解你可能在Docker镜像中放入了什么,我们最好避免这些文件:
-.dockerignore
-node_modules
-npm-debug.log
-Dockerfile
-.git
- 。.gitignore

正如你所看到的,node_modules/ 实际上是相当重要的,因为如果我们没有忽略它,那么我们开始使用的简单的Dockerfile版本就会导致本地的node_modules/ 文件夹被原样复制到容器上。

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

事实上,当你在练习多阶段Docker构建时,拥有一个.dockerignore 文件就更加重要了。让你回忆一下第二阶段的Docker构建是什么样子的。

# --------------> The production image
FROM node:16.17.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
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 的重要性在于,当我们从第2阶段的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的相对较新的能力,它很自然地适合我们在.npmrc 。 下面是它的工作原理。

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

让我们看看所有这些是如何一起工作的。首先是更新的.dockerignore 文件。

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

然后是完整的Docker文件,其中有更新的RUN指令,用于安装npm包,同时指定了.npmrc 安装点。

# --------------> The build image
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommend dumb-init
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 --only=production
 
# --------------> The production image
FROM node:16.17.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
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

注意:秘密是Docker的一项新功能,如果你使用的是旧版本,你可能需要启用它Buildkit如下。

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

总结

你一路走来,创建了一个优化的Node.js Docker基础镜像。干得好!

最后一步是对整个Node.js Docker网络应用容器化指南的总结,考虑到了性能和安全方面的优化,以确保我们构建的是生产级的Node.js Docker镜像!