回溯到 2016 年,我写了篇《在Docker中构建节点应用程序的经验教训》,现已帮助十万多开发者对 Node.js 应用程序进行了Docker开发。 多年以来,生态系统和在 Docker 中使用 Node 的方式都发生了很多变化,所以需要进行一次大修。
在这篇更新的教程中,我们将使用 Docker 从零开始设置到最终生产就绪,完成socket.io聊天示例。特别将学习以下内容:
学会使用 Docker 引导节点应用程序。
如何不以 root 用户身份运行所有内容(用 root 不好!)。
如何使用绑定来缩短开发中的测试-编辑-重新加载周期。
如何在容器中对 node_modules 进行管理(这有一个窍门)。
如何使用package-lock.json确保构建的可重复性。
如何使用多阶段构建在开发和生产之间共享 Dockerfile。
本教程假定开发者已经对 Docker 和 Node 有所了解。如果想先对 Docker 进行一些简要了解,建议浏览Docker的官方介绍。
入门
我们将从头开始进行设置。最终代码可在github上找到,在此过程的每一步都有标签。这是第一步的代码,供参考。
如果没有 Docker,我们需要开始就在主机上安装 Node 和其他需要的依赖项,然后运行 npm init 以创建新软件包。除了这样别无选择,但如果一开始就使用 Docker,我们将方便很多。当然,使用 Docker 的全部意义在于,开发者不必在主机上安装任何东西。首先,我们创建一个安装了 Node 的“引导容器”,然后使用它来进行设置该应用程序的 npm 软件包。
引导容器和服务
我们需要先编写两个文件,一个 Dockerfile 文件和一个 docker-compose.yml 文件,稍后将添加更多文件。我们先从引导文件 Dockerfile 开始:
这是一个简短的文件,但也需要关注一些重点:
在撰写本文时,使用的是最新的会长期支持(LTS)节点的官方 Docker 映像。我更喜欢指定一个特定的版本,而不是像 node:lts 或 node:latest 这样的“浮动”标签,这样如果您或其他人在不同的机器上构建这个映像,他们将得到相同的版本,也就不再需要经历升级的痛苦。
这里 USER 告诉 Docker 以用户身份运行后续的构建步骤,然后再运行容器中的该过程,该 node 用户是非特权用户,已内置到 Docker 的所有正式节点映像中。没有这个设置,它们将以 root 身份运行,这违反了安全性最佳实践,尤其是最小特权原则。为了简单起见,许多 Docker 教程都跳过了这一步,但我们很有必要做一些额外的工作来避免以 root 身份运行,我认为这非常重要。
ORKDIR 这一步将在/srv/chat 下创建所有后续构建步骤的工作目录,及以后根据映像创建的容器工作目录,这是我们放置应用程序文件的位置。该/srv 文件夹应在遵循文件系统层次结构标准的任何系统上可用,该文件夹用于“由该系统提供服务的特定于站点的数据”,这听起来很适合节点应用程序。
现在转到撰写引导文件 docker-compose.yml:
此外,还有很多需要解压的东西:
version 这一行告诉 Docker Compose 我们正在使用哪个版本的文件格式。在撰写本文时,最新版本是 3.7,所以我就用的这个版本,但是较早的 3.x 和 2.x 版本在这里也可以正常工作。实际上,根据您的用例(注 2),甚至可能更适合 2.x 系列。
该文件定义了一个独立的服务 chat,该服务依据当前目录下 Dockerfile 编译,.指代当前目录。该服务目前所做的只是回显 ready 并退出。
volume 这一行.:/srv/chat 告诉 Docker 绑定挂载当前目录,这是我们在 Dockerfile 上面设置的 WORKDIR。这意味着我们将对主机上的源文件进行的更改将自动反映在容器内,反之亦然。这对于在开发中使测试-编辑-重新加载周期保持尽可能短非常重要。但是,这将在 npm 如何安装依赖项方面造成一些问题,我们稍后将再次讨论。
现在,我们已经准备好构建和测试我们的引导容器了。当我们运行 docker-compose build 时,Docker 将创建一个映像,其节点设置为 Dockerfile。然后 docker-compose up 将使用该映像启动一个容器并运行 echo 命令,以便验证一切设置正确。
此输出表明容器已成功运行,显示 ready 并退出。
初始化 npm 包
Linux 用户专用:为了使下一步顺利进行,node 容器中的用户应与主机上的用户具有相同的 uid(用户标识符)。这是因为容器中的用户需要具有通过绑定安装读取和写入主机上文件的权限,反之亦然。我提供了附录,其中包含有关如何处理此问题的建议。Mac 用户的 Docker 不必担心这个问题,因为一些 uid 被重新映射了,但是 Linux 的 Docker 可以获得更好的性能,所以这个步骤也是值得的。
现在,我们在 Docker 中设置了一个节点环境,我们准备设置初始的 npm 软件包文件。为此,我们将在该 chat 服务的容器中运行一个交互式外壳,并使用它来设置初始包文件:
然后文件出现在主机上,我们可以对它们进行版本控制:
安装依赖项
接下来,我们将安装应用程序的依赖项。我们希望通过 Dockerfile 将这些依赖项安装在容器内,这样容器就可以包含运行应用程序所需的所有内容了。这意味着我们需要将 package.json 和 package-lock.json 文件放入映像中并在 npm install 中运行 Dockerfile。更改如下所示:
详细解释如下:
RUN 这一步使用 mkdir 和 chown 命令创建工作目录,并确保它是由节点用户所拥有,这是我们需要以 root 身份运行的唯一命令。
值得注意的是,在 RUN 中有两个链接在一起的 shell 命令。与将命令分成多个 RUN 步骤相比,将它们链接起来可以减少生成的镜像中的层数。虽然在这个例子中,并不是很重要,但我们也应该养成不使用过多图层的好习惯。如果你下载一个包,解压缩、构建、安装然后进行一步清理,则可以节省大量磁盘空间和下载时间,而不是将每一步的所有中间文件都保存起来。
COPY 到./这一步会将 npm 包文件复制到我们之前建立的 WORKDIR 目录。/告诉 Docker 目标是一个文件夹。仅复制打包文件而不是整个应用程序文件夹的原因是,Docker 将缓存 npm install 以下步骤的结果并仅在打包文件发生更改时才重新运行。如果复制了所有源文件,即使所需的软件包没有更改,更改任何文件都会破坏高速缓存,从而导致 npm install 后续构建中不必要的浪费。
用于标记 COPY 的–chown=node:node 可以确保文件由非特权的 node 用户拥有,而不是默认3的 root 用户。
npm install 将以 node 用户在工作目录中运行,以将依赖项安装在容器内部的/srv/chat/node_modules 目录下。
理论上这可以是最后一步了,但是当将应用程序文件夹绑定安装到主机/srv/chat 上时,它会在开发中引起问题。因为该 node_modules 文件夹在主机上不存在,绑定有效地隐藏了我们在映像中安装的节点模块。最后,mkdir -p node_modules 这一步和下一部分与如何处理此问题有关。
node_modules 伪卷
围绕节点模块隐藏问题有几种 方法 ,但是我认为最优雅的方法是在绑定时包含一个卷目录。为此,我们必须在 docker compose 文件中添加几行:
chat_node_modules:/srv/chat/node_modules 这一行设置了一个*卷(注 4)*名为 chat_node_modules 的目录,将/srv/chat/node_modules 目录包含在容器中。最后的顶层 volumes:部分必须声明所有命名的卷,因此我们也要在其中添加 chat_node_modules。
虽然这很简单,但是要使它工作起来,幕后还有很多事情要做:
在构建过程中,npm install 将依赖项(我们将在下一部分中添加)安装到/srv/chat/node_modules 映像中。以下是镜像中的文件:
之后,当我们使用 compose 文件从该映像启动容器时,Docker 首先将容器内主机的应用程序文件夹绑定到/srv/chat。以下是主机上的文件:
有一点不好就是 node_modules 镜像绑定时被隐藏了;在容器内,我们只能在主机上看到一个空 node_modules 文件夹。
但是,我们还没有完成。Docker 接下来创建一个包含映像副本的卷/srv/chat/node_modules,并将其安装在容器中。反过来,它将隐藏 node_modules 主机上的绑定:
这提供了我们想要的:主机上的源文件绑定在容器内,这允许快速更改,并且依赖项在容器内也可用,因此我们可以使用它们来运行应用程序。
现在,我们也可以解释上面 Dockerfile 引导程序的最后一步 mkdir -p node_modules:我们尚未实际安装任何软件包,因此在构建过程 npm install 中不会创建该 node_modules 文件夹。Docker 创建/srv/chat/node_modules 卷时,它将自动为我们创建文件夹,但是它将归 root 拥有,这意味着节点用户将无法对其进行写入。我们可以在构建过程中通过创建 node_modules 抢占先机。一旦安装了一些软件包,就不再需要这样做了。
包安装
下面我们先重建映像,我们将准备安装软件包。
聊天应用程序需要 express,因此我们在容器中先启动一个 shell,运行 npm install 时增加–save 参数以将依赖项保存到 package.json 并相应地对 package-lock.json 进行更新:
一般情况下,package-lock.json 已替换了较旧的 npm-shrinkwrap.json 文件,package-lock.json 文件对于确保 Docker 映像构建可重复进行非常重要。它记录了版本所有直接和间接关系依赖,并确保 npm install 在不同机器上构建的 Docker 中都将获得相同的依赖关系树。
最后,值得注意的是,我们安装的主机上不存在 node_modules。主机上可能有一个空 node_modules 文件夹,这是绑定和我们之前创建的卷的原因,但实际文件位于该 chat_node_modules 卷中。如果我们在 chat 容器中运行另一个 shell ,我们可以在其中找到它们:
下次运行时 docker-compose build,模块就会被安装到映像中。这是github上的最终代码。
运行应用
我们终于可以安装该应用程序了,先复制其余的源文件,即 index.js 和 index.html。
然后,我们安装 socket.io 软件包。在撰写本文时,聊天示例仅与 socket.io 版本 1 兼容,因此我们需要安装版本 1:
然后,在 docker compose 文件中,删除虚拟 echo ready 命令,然后运行聊天示例服务器。最后,我们设置 Docker Compose 在主机上的容器端口为 3000,之后我们可以在浏览器中对其进行访问:
然后,我们使用 docker-compose up 命令运行:
之后可以通过http://localhost:3000查看其运行状况。
适用于开发和生产的 Docker
现在,我们使用 docker compose 使应用程序在开发环境中运行。在生产中使用此容器之前,我们要解决一些问题,并需要做出一些改进:
最重要的是,目前我们正在构建的容器实际上并不包含应用程序的源代码-它仅包含 npm 打包文件和依赖项。容器的主要思想是它应该包含运行应用程序所需的所有内容,因此很明显,我们将要对此进行更改。
/srv/chat 图像中的应用程序文件夹当前由 node 用户拥有并可以写。大多数应用程序不需要在运行时重写其源文件,因此再次应用最小特权原则,我们不应该允许这种权限存在生产环境中。
该镜像相当大,根据 dive 镜像检查工具的数据,其重量为 909MB 。对于镜像的大小不是我们最关心的,但是我们也不想不必要地浪费。大部分镜像来自默认的 node 基础镜像,该镜像包含完整的编译器工具链,使我们能够构建使用本机代码(与纯 JavaScript 相反)的节点模块。我们在运行时不需要该编译器工具链,因此从安全性和性能的角度来看,最好不要将其交付生产。
幸运的是,Docker 提供了一个功能强大的工具,可以帮助完成以上所有任务:多阶段构建。主要做法是我们可以在 Dockerfile 中使用多个 FROM 命令,每个阶段一个,每个阶段都可以复制先前阶段的文件。让我们看看如何进行设置:
我们现有的 Dockerfile 将构成第一阶段步骤,现在我们将 development 通过在开头添加 AS development 到 FROM 行中来命名。我现在已经删除了 mkdir -p node_modules 引导过程中所需的临时步骤,因为我们现在已经安装了一些软件包。
新的第二阶段从第二步开始,该 FROM 提取 slim 相同节点版本的节点基础映像,并且为清晰起见 production 在该阶段调用。该 slim 映像还是 Docker 的官方节点映像。顾名思义,它比默认 node 映像小,主要是因为它不包含编译器工具链;它不包含默认值。它仅包含运行节点应用程序所需的系统依赖关系,该数量远远少于构建一个节点应用程序所需的依赖关系。
此多阶段操作在第一阶段 Dockerfile 运行 npm install,该阶段具有可用于构建的完整节点映像。然后,将生成的 node_modules 文件夹复制到第二阶段映像,该映像使用 slim 基础映像。该技术将生产图像的大小从 909MB 减少到 152MB,这大约可以节省 6 倍的空间,而工作量相对较小(注 6)。
再一次,使用 USER node 命令让 Docker 以非特权 node 用户而不是以 root 用户的身份运行构建应用程序。我们还必须重复创建 WORKDIR,因为它不会自动保持到第二阶段。
COPY --from=development --chown=root:root …行将前一 development 阶段安装的依赖项复制到生产阶段,并使其成为根用户,因此节点用户可以读取但不能写入它们。
COPY . .该行将其余的应用程序文件从主机复制到容器中的工作目录,即/srv/chat。
最后,CMD 步骤指定要运行的命令。在开发阶段,应用程序文件来自使用 docker-compose 设置的绑定挂载,因此在 docker-compose.yml 文件中指定命令而不是在 Dockerfile 中指定命令是有意义的。在这里,Dockerfile 文件会被内置到容器中。
现在我们已经设置了多阶段 Dockerfile,我们需要告诉 Docker Compose 仅使用该 development 阶段,而不是完成整个阶段 Dockerfile,我们可以使用以下 target 选项进行操作:
这会保留我们在开发中添加多阶段构建之前的旧行为。
最后,为了使 Dockerfile 中 COPY . .更加安全,我们应该添加一个.dockerignore 文件。没有它,COPY . .可能会在生产映像中拾取不需要或不想要的其他东西,例如我们的.git 文件夹,node_modules 安装在 Docker 之外的主机上的任何文件,以及构建该应用程序所需的所有与 Docker 相关的文件、图片。减掉这些会使镜像更小,构建速度也更快,因为 Docker 守护程序不必为创建构建上下文的文件副本而消耗资源。这是.dockerignore 文件:
完成所有这些设置后,我们可以运行生产构建以模拟 CI 系统如何构建最终映像,然后像协调器那样运行它:
再次在浏览器中使用http://localhost:3000访问它。完成后,我们可以使用上方命令中的容器 ID 停止它。
nodemon 在开发中的设置
现在,我们已经拥有了独特的开发和生产映像,下面让我们看看如何通过在nodemon下运行应用程序以在更改源文件时在容器内自动重新加载,使开发映像对开发人员更加友好。
要安装 nodemon,我们可以更新 compose 文件来运行它:
在这里,npx通过 npm 运行 nodemon 。当启动服务后,便可以看到熟悉的 nodemon 输出:
最后,值得注意的是,Dockerfile 上面的开发依赖项将包含在生产映像中。可以通过一些修改来避免这种情况,但是我认为包括它们不一定是一件坏事。确实,在生产中不太可能需要 Nodemon,这是事实,但是开发人员依赖项通常包括测试实用程序,我们可以将其作为 CI 的一部分在生产容器中运行测试。通常,它还可以改善开发人员与产品之间的均等性,就像一些智者曾经说过的那样:“边试边飞,边飞边试”。目前为止,虽然我们还没有任何测试,但是在需要的时候,我们很容易运行它们:
结论
我们已经使用了一个应用程序,并使其完全在 Docker 内部进行开发和生产。跳过了一些希望具有启发性的步骤来引导 Node 环境,而不需要在主机上安装任何东西。我们还跳过了一些步骤,避免以 root 用户身份运行构建和进程,以非特权用户身份运行,这样可以提高安全性。
Node/npm 将依赖项放在 node_modules 子文件夹中的方案比其他解决方案(如 ruby 的 bundler)复杂得多,这些解决方案将依赖项安装在应用程序文件夹之外,但是我们能够使用 嵌套节点模块卷技巧轻松解决该问题。
最后,我们使用了 Docker 的多阶段构建功能创建 Dockerfile 来生产适合开发和生产的产品。这个简单而强大的功能在很多情况下都非常有用,我们将在以后的文章中再次看到它。
我将在本系列的下一篇文章中继续学习在 Docker 中测试 node.js 服务的内容。
附录:在 Linux 上处理 UID 不匹配问题
当使用绑定方法安装 Linux 主机和容器之间的共享文件时,如果容器中用户的数字 uid 与主机上用户的数字 uid 不匹配,则可能会遇到权限问题。例如,在主机上创建的文件在容器中可能不可读或不可写,反之亦然。
我们可以解决此问题,但首先要注意的是,如果主机上的 uid 为 1000,那么对于使用 node 的 Dockerized 开发来说,一切都还好。这是因为 Docker 的官方节点映像对节点用户使用uid 1000。您可以通过运行 id 命令在主机上检查您的 uid,然后将其打印出来。例如,我的 uid=1000(john) gid=1000(john) …。
一个 uid 1000 很常见,因为它是 ubuntu 安装过程分配的 uid。如果您可以说服团队中的所有人将 uid 设置为 1000,那么一切都会正常进行。如果不是这样,则有两种解决方法:
只需省略 USER nodeDockerfile 的开发阶段(在Docker for Dev and Prod部分中介绍)的步骤,就可以在开发中作为根运行服务。这确保了容器(根)中的用户将能够在主机上读取和写入文件。如果容器中的用户创建任何文件,它们将在主机上以 root 用户身份拥有,但是您也可以通过 sudo chown -R your-user:your-group .在主机上运行来修改权限。
您可以(并且应该)仍然以生产中的非特权用户身份运行该流程。
使用 Dockerfile 构建参数以便在构建时配置节点用户的 UID 和 GID。为此,我们可以在开发阶段增加几行 Dockerfile:
这引入了两个构建参数,UID 和 GID,如果没有设置参数值的话其默认为 1000, 在以用户身份创建任何文件之前,更改节点用户和组以使用这些 id。
每个具有非 1000 uid / gid 的开发人员都必须为 Docker Compose 设置这些参数。一种方法是使用未签入版本控制(即.gitignore)的文件 docker-compose.override.yml 来设置参数,如下所示:
在此示例中,容器中的 uid 和 gid 将设置为 500。当然未来应该有一些更简单的方法。这些更改仅需要在开发阶段完成,而不是在生产阶段。
脚注
从根本上讲,文件在容器中的位置无关紧要。/opt 也可以是一个非常合理的选择。另一个选择是将它们保留在/home/node,这简化了开发过程中的某些文件权限管理,但需要更多的键入操作,而在生产中则没有意义,在此我主张让 root 拥有应用程序文件作为保持它们只读的一种方式。无论如何,/srv 都会做。
Docker Compose 文件格式的 2.x 和 3.x 版本仍在积极开发中。3.x 系列的主要优点是,它在 Docker Compose 上运行的单节点应用程序与在 Docker Swarm 上运行的多节点应用程序之间具有交叉兼容性。为了兼容,版本 3 从版本 2 中删除了一些有用的功能。如果仅对 Docker Compose 感兴趣,则您可能更喜欢使用最新的2.x格式。
Dockerfile 如果我们允许 npm install 构建步骤以 root 身份运行,则可以消除其中的一些讨巧方法。如果这样做,我们可以并且仍然应该在运行时使用非特权节点用户,这是大多数安全优势所在。Dockerfile 以 root 用户身份运行构建并以节点用户身份运行容器的 A 看起来更像这样:
这更简洁,而且在用 npm install 构建时为 root 用户,不需要一些 mkdir 和 chown 消耗。总的来说,我认为避免在 root 用户下运行构建是值得的,但是您可能会更喜欢简洁的 Dockerfile。
如果您以 root 用户身份构建,则需要注意的一点是,当您以后要安装新的依赖项时,需要以 root 用户的身份而不是 node 用户的身份运行 shell,例如 in docker-compose run --rm --user root chat bash 和 then npm install --save express。这有点像“ sudoing”安装软件包,这是比较熟悉的操作了。
我们可以改用匿名卷来包含模块,只需省略名称即可:
那会更短一些,但是很容易忘记清理匿名卷,这导致大量匿名模块,而没有指示它们来自哪个容器。您可以使用 docker system prune 进行清理,但这有点像“杀鸡用牛刀”。命名卷方法较为冗长,但也更加透明。
(补充说明:您可能想知道卷中的那些依赖文件真实存储位置在哪里。无论使用命名卷还是匿名卷,它们都位于主机上由 Docker 管理的单独目录中;有关卷的更多信息,请参阅Docker文档。) ↩
聪明的读者可能已经注意到,我们不必在 docker-compose up 之前用 docker-compose build 事先安装依赖。这是因为它与 chat_node_modules 命名卷中的节点模块一起运行。下次进行构建时,npm 将从头开始将依赖项安装到映像中,但是对于日常安装软件包,我们可以 npm install 在容器中运行而无需重建。
如果您发现自己想要消除命名卷并从头开始的情况,则可以运行 docker volume list 以获取所有卷的列表。节点模块卷的全名将取决于您的 docker compose 项目。我的情况是,名称是 docker-chat-demo_chat_node_modules,如果我们先用 docker-compose rm -v chat 移除容器,然后用 docker volume rm docker-chat-demo_chat_node_modules 移除卷本身,则可以将其移除。
Docker 还提供了一个更小的官方映像变体 alpine。但是,这些大小节省的部分原因是使用了libc与基于 Debian 的映像完全不同的软件包管理器。除非您要部署到空间有限的嵌入式系统上,否则由于这些差异而产生的复杂性可能不值得使用了,尤其是基于 Debian 的超薄映像会使你付出大量额外成本。
您可能会注意到,它大约需要 10 秒钟才能停止。这是因为 socket.io 聊天示例未正确处理 SIGTERM 信号,Docker 在停止时会发送该信号以执行正常关闭。补充说明:将此代码添加到 index.js 的末尾:
然后重建生产映像并尝试 docker stop 再次运行容器。它会断开所有客户端的连接,并在更改后立即停止。
有时不建议使用 npm 在容器中运行进程,但我认为也可以不遵从这个建议。较旧版本的 npm 确实在处理关闭进程所需的信号时遇到了问题,但这应该在最新版本中得到了解决。如果您的容器似乎总是需要 10 秒钟才能关闭,则很可能是他们没有在监听 SIGTERM 启动正常关闭的信号。见(注 7)。通过流程运行 npm 确实会产生一些开销,即额外的节点流程,因此您可能希望在生产中避免使用它,但在开发中通常可以。
您可能会注意到 rs 可以重新启动 nodemon。如果我们用 docker-compose up 来启动服务,那将无法正常工作,因为这样做时,我们的终端未连接到 nodemon 的标准输入。如果我们改为运行 docker-compose run --rm chat,rs 应该照常工作; 这在处理一个特定的服务时非常有用。
英文原文:Lessons from Building Node Apps in Docker (2019)
评论