首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

在Docker中构建Node APP的经验教训

回溯到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开始:

代码语言:javascript
复制
FROM node:10.16.3

USER node

WORKDIR /srv/chat

这是一个简短的文件,但也需要关注一些重点:

  1. 在撰写本文时,使用的是最新的会长期支持(LTS)节点的官方Docker映像。我更喜欢指定一个特定的版本,而不是像node:lts或node:latest这样的“浮动”标签,这样如果您或其他人在不同的机器上构建这个映像,他们将得到相同的版本,也就不再需要经历升级的痛苦。
  2. 这里USER告诉Docker以用户身份运行后续的构建步骤,然后再运行容器中的该过程,该node用户是非特权用户,已内置到Docker的所有正式节点映像中。没有这个设置,它们将以root身份运行,这违反了安全性最佳实践,尤其是最小特权原则。为了简单起见,许多Docker教程都跳过了这一步,但我们很有必要做一些额外的工作来避免以root身份运行,我认为这非常重要。
  3. ORKDIR这一步将在/srv/chat下创建所有后续构建步骤的工作目录,及以后根据映像创建的容器工作目录,这是我们放置应用程序文件的位置。该/srv文件夹应在遵循文件系统层次结构标准的任何系统上可用,该文件夹用于“由该系统提供服务的特定于站点的数据”,这听起来很适合节点应用程序。

现在转到撰写引导文件docker-compose.yml:

代码语言:javascript
复制
version: '3.7'
services:
chat:
build: .
command: echo 'ready'
volumes:
- .:/srv/chat

此外,还有很多需要解压的东西:

  1. version这一行告诉Docker Compose 我们正在使用哪个版本的文件格式。在撰写本文时,最新版本是3.7,所以我就用的这个版本,但是较早的3.x和2.x版本在这里也可以正常工作。实际上,根据您的用例(注2),甚至可能更适合2.x系列。
  2. 该文件定义了一个独立的服务chat,该服务依据当前目录下Dockerfile编译,.指代当前目录。该服务目前所做的只是回显ready并退出。
  3. volume这一行.:/srv/chat告诉Docker  绑定挂载当前目录,这是我们在Dockerfile上面设置的WORKDIR。这意味着我们将对主机上的源文件进行的更改将自动反映在容器内,反之亦然。这对于在开发中使测试-编辑-重新加载周期保持尽可能短非常重要。但是,这将在npm如何安装依赖项方面造成一些问题,我们稍后将再次讨论。

现在,我们已经准备好构建和测试我们的引导容器了。当我们运行docker-compose build时,Docker将创建一个映像,其节点设置为Dockerfile。然后docker-compose up将使用该映像启动一个容器并运行echo命令,以便验证一切设置正确。

代码语言:javascript
复制
$ docker-compose build
Building chat
Step 1/3 : FROM node:10.16.3
# ... more build output ...
Successfully built d22d841c07da
Successfully tagged docker-chat-demo_chat:latest
$ docker-compose up
Creating docker-chat-demo_chat_1 ... done
Attaching to docker-chat-demo_chat_1
chat_1 | ready
docker-chat-demo_chat_1 exited with code 0

此输出表明容器已成功运行,显示ready并退出。 初始化npm包

Linux 用户专用:为了使下一步顺利进行,node容器中的用户应与主机上的用户具有相同的uid(用户标识符)。这是因为容器中的用户需要具有通过绑定安装读取和写入主机上文件的权限,反之亦然。我提供了附录,其中包含有关如何处理此问题的建议。Mac用户的Docker不必担心这个问题,因为一些uid被重新映射了,但是Linux的Docker可以获得更好的性能,所以这个步骤也是值得的。

现在,我们在Docker中设置了一个节点环境,我们准备设置初始的npm软件包文件。为此,我们将在该chat服务的容器中运行一个交互式外壳,并使用它来设置初始包文件:

代码语言:javascript
复制
$ docker-compose run --rm chat bash
node@467aa1c96e71:/srv/chat$ npm init --yes# ... writes package.json ...
node@467aa1c96e71:/srv/chat$ npm install
# ... writes package-lock.json ...
node@467aa1c96e71:/srv/chat$ exit

然后文件出现在主机上,我们可以对它们进行版本控制:

代码语言:javascript
复制
$ tree
.
├── Dockerfile
├── docker-compose.yml
├── package-lock.json
└── package.json

这是github上的最终代码

安装依赖项

接下来,我们将安装应用程序的依赖项。我们希望通过Dockerfile将这些依赖项安装在容器内,这样容器就可以包含运行应用程序所需的所有内容了。这意味着我们需要将package.json和package-lock.json文件放入映像中并在npm install中运行Dockerfile。更改如下所示:

代码语言:javascript
复制
diff --git a/Dockerfile b/Dockerfile
index b18769e..d48e026 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,14 @@
FROM node:10.16.3
+RUN mkdir /srv/chat && chown node:node /srv/chat
+
USER node
WORKDIR /srv/chat
+
+COPY --chown=node:node package.json package-lock.json ./
+
+RUN npm install --quiet
+
+# TODO: Can remove once we have some dependencies in package.json.
+RUN mkdir -p node_modules

详细解释如下:

  1. RUN这一步使用mkdir和chown命令创建工作目录,并确保它是由节点用户所拥有,这是我们需要以root身份运行的唯一命令。
  2. 值得注意的是,在RUN中有两个链接在一起的shell命令。与将命令分成多个RUN步骤相比,将它们链接起来可以减少生成的镜像中的层数。虽然在这个例子中,并不是很重要,但我们也应该养成不使用过多图层的好习惯。如果你下载一个包,解压缩、构建、安装然后进行一步清理,则可以节省大量磁盘空间和下载时间,而不是将每一步的所有中间文件都保存起来。
  3. COPY到./这一步会将npm包文件复制到我们之前建立的WORKDIR目录。/告诉Docker目标是一个文件夹。仅复制打包文件而不是整个应用程序文件夹的原因是,Docker将缓存npm install以下步骤的结果并仅在打包文件发生更改时才重新运行。如果复制了所有源文件,即使所需的软件包没有更改,更改任何文件都会破坏高速缓存,从而导致npm install后续构建中不必要的浪费。
  4. 用于标记COPY的–chown=node:node可以确保文件由非特权的node用户拥有,而不是默认3的root用户。
  5. npm install将以node用户在工作目录中运行,以将依赖项安装在容器内部的/srv/chat/node_modules目录下。

理论上这可以是最后一步了,但是当将应用程序文件夹绑定安装到主机/srv/chat上时,它会在开发中引起问题。因为该node_modules文件夹在主机上不存在,绑定有效地隐藏了我们在映像中安装的节点模块。最后,mkdir -p node_modules这一步和下一部分与如何处理此问题有关。

node_modules伪卷

围绕节点模块隐藏问题有几种 方法 ,但是我认为最优雅的方法是在绑定时包含一个卷目录。为此,我们必须在docker compose文件中添加几行:

代码语言:javascript
复制
diff --git a/docker-compose.yml b/docker-compose.yml
index c9a2543..799e1f6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,3 +6,7 @@ services:
command: echo 'ready'
volumes:
- .:/srv/chat
+ - chat_node_modules:/srv/chat/node_modules
+
+volumes:
+ chat_node_modules:

chat_node_modules:/srv/chat/node_modules这一行设置了一个*卷(注4)*名为chat_node_modules的目录,将/srv/chat/node_modules目录包含在容器中。最后的顶层volumes:部分必须声明所有命名的卷,因此我们也要在其中添加chat_node_modules。

虽然这很简单,但是要使它工作起来,幕后还有很多事情要做:

  1. 在构建过程中,npm install将依赖项(我们将在下一部分中添加)安装到/srv/chat/node_modules映像中。以下是镜像中的文件:
代码语言:javascript
复制
/srv/chat$ tree # in image
.
├── node_modules
│   ├── accepts
...
│   └── yeast
├── package-lock.json
└── package.json
  1. 之后,当我们使用compose文件从该映像启动容器时,Docker首先将容器内主机的应用程序文件夹绑定到/srv/chat。以下是主机上的文件:
代码语言:javascript
复制
/srv/chat$ tree # in container without node_modules volume
.
├── Dockerfile
├── docker-compose.yml
├── node_modules
├── package-lock.json
└── package.json

有一点不好就是node_modules镜像绑定时被隐藏了;在容器内,我们只能在主机上看到一个空node_modules文件夹。

  1. 但是,我们还没有完成。Docker接下来创建一个包含映像副本的卷/srv/chat/node_modules,并将其安装在容器中。反过来,它将隐藏node_modules主机上的绑定:
代码语言:javascript
复制
/srv/chat$ tree # in container with node_modules volume
.
├── Dockerfile
├── docker-compose.yml
├── node_modules
│   ├── accepts
...
│   └── yeast
├── package-lock.json
└── package.json

这提供了我们想要的:主机上的源文件绑定在容器内,这允许快速更改,并且依赖项在容器内也可用,因此我们可以使用它们来运行应用程序。 现在,我们也可以解释上面Dockerfile引导程序的最后一步mkdir -p node_modules:我们尚未实际安装任何软件包,因此在构建过程npm install中不会创建该node_modules文件夹。Docker创建/srv/chat/node_modules卷时,它将自动为我们创建文件夹,但是它将归root拥有,这意味着节点用户将无法对其进行写入。我们可以在构建过程中通过创建node_modules抢占先机。一旦安装了一些软件包,就不再需要这样做了。

包安装

下面我们先重建映像,我们将准备安装软件包。

代码语言:javascript
复制
$ docker-compose build
... builds and runs npm install (with no packages yet)...

聊天应用程序需要express,因此我们在容器中先启动一个shell,运行npm install时增加–save参数以将依赖项保存到package.json并相应地对package-lock.json进行更新:

代码语言:javascript
复制
$ docker-compose run --rm chat bash
Creating volume "docker-chat-demo_chat_node_modules" with default driver
node@241554e6b96c:/srv/chat$ npm install --save express
# ...
node@241554e6b96c:/srv/chat$ exit

一般情况下,package-lock.json已替换了较旧的npm-shrinkwrap.json文件,package-lock.json文件对于确保Docker映像构建可重复进行非常重要。它记录了版本所有直接和间接关系依赖,并确保npm install在不同机器上构建的Docker中都将获得相同的依赖关系树。

最后,值得注意的是,我们安装的主机上不存在node_modules。主机上可能有一个空node_modules文件夹,这是绑定和我们之前创建的卷的原因,但实际文件位于该chat_node_modules卷中。如果我们在chat容器中运行另一个shell ,我们可以在其中找到它们:

代码语言:javascript
复制
$ ls node_modules
# nothing on the host$ docker-compose run --rm chat bash
node@54d981e169de:/srv/chat$ ls -l node_modules/
total 196
drwxr-xr-x 2 node node 4096 Aug 25 20:07 accepts
# ... many node modules in the container
drwxr-xr-x 2 node node 4096 Aug 25 20:07 vary

下次运行时docker-compose build,模块就会被安装到映像中。这是github上的最终代码

运行应用

我们终于可以安装该应用程序了,先复制其余的源文件,即index.js和index.html。

然后,我们安装socket.io软件包。在撰写本文时,聊天示例仅与socket.io版本1兼容,因此我们需要安装版本1:

代码语言:javascript
复制
$ docker-compose run --rm chat npm install --save socket.io@1
# ...

然后,在docker compose文件中,删除虚拟echo ready命令,然后运行聊天示例服务器。最后,我们设置Docker Compose在主机上的容器端口为3000,之后我们可以在浏览器中对其进行访问:

代码语言:javascript
复制
diff --git a/docker-compose.yml b/docker-compose.yml
index 799e1f6..ff92767 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,9 @@ version: '3.7'
services:
chat:
build: .
- command: echo 'ready'
+ command: node index.js
+ ports:
+ - '3000:3000'
volumes:
- .:/srv/chat
- chat_node_modules:/srv/chat/node_modules

然后,我们使用docker-compose up 命令运行:

代码语言:javascript
复制
$ docker-compose up
Recreating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | listening on *:3000

之后可以通过http://localhost:3000查看其运行状况。

这是github上的最终代码

适用于开发和生产的Docker

现在,我们使用docker compose使应用程序在开发环境中运行。在生产中使用此容器之前,我们要解决一些问题,并需要做出一些改进:

  • 最重要的是,目前我们正在构建的容器实际上并不包含应用程序的源代码-它仅包含npm打包文件和依赖项。容器的主要思想是它应该包含运行应用程序所需的所有内容,因此很明显,我们将要对此进行更改。
  • /srv/chat图像中的应用程序文件夹当前由node用户拥有并可以写。大多数应用程序不需要在运行时重写其源文件,因此再次应用最小特权原则,我们不应该允许这种权限存在生产环境中。
  • 该镜像相当大,根据dive镜像检查工具的数据,其重量为909MB 。对于镜像的大小不是我们最关心的,但是我们也不想不必要地浪费。大部分镜像来自默认的node基础镜像,该镜像包含完整的编译器工具链,使我们能够构建使用本机代码(与纯JavaScript相反)的节点模块。我们在运行时不需要该编译器工具链,因此从安全性和性能的角度来看,最好不要将其交付生产。

幸运的是,Docker提供了一个功能强大的工具,可以帮助完成以上所有任务:多阶段构建。主要做法是我们可以在Dockerfile中使用多个FROM命令,每个阶段一个,每个阶段都可以复制先前阶段的文件。让我们看看如何进行设置:

代码语言:javascript
复制
diff --git a/Dockerfile b/Dockerfile
index d48e026..6c8965d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:10.16.3
+FROM node:10.16.3 AS development
RUN mkdir /srv/chat && chown node:node /srv/chat
@@ -10,5 +10,14 @@ COPY --chown=node:node package.json package-lock.json ./
RUN npm install --quiet
-# TODO: Can remove once we have some dependencies in package.json.
-RUN mkdir -p node_modules
+FROM node:10.16.3-slim AS production
+
+USER node
+
+WORKDIR /srv/chat
+
+COPY --from=development --chown=root:root /srv/chat/node_modules ./node_modules
+
+COPY . .
+
+CMD ["node", "index.js"]
  1. 我们现有的Dockerfile将构成第一阶段步骤,现在我们将development通过在开头添加AS development到FROM行中来命名。我现在已经删除了mkdir -p node_modules引导过程中所需的临时步骤,因为我们现在已经安装了一些软件包。
  2. 新的第二阶段从第二步开始,该FROM提取slim相同节点版本的节点基础映像,并且为清晰起见production在该阶段调用。该slim映像还是Docker 的官方节点映像。顾名思义,它比默认node映像小,主要是因为它不包含编译器工具链;它不包含默认值。它仅包含运行节点应用程序所需的系统依赖关系,该数量远远少于构建一个节点应用程序所需的依赖关系。

此多阶段操作在第一阶段Dockerfile运行npm install,该阶段具有可用于构建的完整节点映像。然后,将生成的node_modules文件夹复制到第二阶段映像,该映像使用slim基础映像。该技术将生产图像的大小从909MB减少到152MB,这大约可以节省6倍的空间,而工作量相对较小(注6)。

  1. 再一次,使用USER node命令让Docker以非特权node用户而不是以root用户的身份运行构建应用程序。我们还必须重复创建WORKDIR,因为它不会自动保持到第二阶段。
  2. COPY --from=development --chown=root:root …行将前一development阶段安装的依赖项复制到生产阶段,并使其成为根用户,因此节点用户可以读取但不能写入它们。
  3. COPY . .该行将其余的应用程序文件从主机复制到容器中的工作目录,即/srv/chat。
  4. 最后,CMD步骤指定要运行的命令。在开发阶段,应用程序文件来自使用docker-compose设置的绑定挂载,因此在docker-compose.yml文件中指定命令而不是在Dockerfile中指定命令是有意义的。在这里,Dockerfile文件会被内置到容器中。

现在我们已经设置了多阶段Dockerfile,我们需要告诉Docker Compose仅使用该development阶段,而不是完成整个阶段Dockerfile,我们可以使用以下target选项进行操作:

代码语言:javascript
复制
diff --git a/docker-compose.yml b/docker-compose.yml
index ff92767..2ee0d9b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,9 @@ version: '3.7'
services:
chat:
- build: .
+ build:
+ context: .
+ target: development
command: node index.js
ports:
- '3000:3000'

这会保留我们在开发中添加多阶段构建之前的旧行为。

最后,为了使Dockerfile中COPY . .更加安全,我们应该添加一个.dockerignore文件。没有它,COPY . .可能会在生产映像中拾取不需要或不想要的其他东西,例如我们的.git文件夹,node_modules安装在Docker之外的主机上的任何文件,以及构建该应用程序所需的所有与Docker相关的文件、图片。减掉这些会使镜像更小,构建速度也更快,因为Docker守护程序不必为创建构建上下文的文件副本而消耗资源。这是.dockerignore文件:

代码语言:javascript
复制
.dockerignore
.git
docker-compose*.yml
Dockerfile
node_modules

完成所有这些设置后,我们可以运行生产构建以模拟CI系统如何构建最终映像,然后像协调器那样运行它:

代码语言:javascript
复制
$ docker build . -t chat:latest
# ... build output ...$ docker run --rm --detach --publish 3000:3000 chat:latest
dd1cf2bf9496edee58e1f5122756796999942fa4437e289de4bd67b595e95745

再次在浏览器中使用http://localhost:3000访问它。完成后,我们可以使用上方命令中的容器ID停止它。

代码语言:javascript
复制
$ docker stop dd1cf2bf9496edee58e1f5122756796999942fa4437e289de4bd67b595e95745

nodemon在开发中的设置

现在,我们已经拥有了独特的开发和生产映像,下面让我们看看如何通过在nodemon下运行应用程序以在更改源文件时在容器内自动重新加载,使开发映像对开发人员更加友好。

代码语言:javascript
复制
$ docker-compose run --rm chat npm install --save-dev nodemon

要安装nodemon,我们可以更新 compose文件来运行它:

代码语言:javascript
复制
diff --git a/docker-compose.yml b/docker-compose.yml
index 2ee0d9b..173a297 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,7 +5,7 @@ services:
build:
context: .
target: development
- command: node index.js
+ command: npx nodemon index.js
ports:
- '3000:3000'
volumes:

在这里,npx通过npm 运行nodemon 。当启动服务后,便可以看到熟悉的nodemon输出:

代码语言:javascript
复制
docker-compose up
Recreating docker-chat-demo_chat_1 ... done
Attaching to docker-chat-demo_chat_1
chat_1 | [nodemon] 1.19.2
chat_1 | [nodemon] to restart at any time, enter `rs`
chat_1 | [nodemon] watching dir(s): *.*
chat_1 | [nodemon] starting `node index.js`
chat_1 | listening on *:3000

最后,值得注意的是,Dockerfile上面的开发依赖项将包含在生产映像中。可以通过一些修改来避免这种情况,但是我认为包括它们不一定是一件坏事。确实,在生产中不太可能需要Nodemon,这是事实,但是开发人员依赖项通常包括测试实用程序,我们可以将其作为CI的一部分在生产容器中运行测试。通常,它还可以改善开发人员与产品之间的均等性,就像一些智者曾经说过的那样:“边试边飞,边飞边试”。目前为止,虽然我们还没有任何测试,但是在需要的时候,我们很容易运行它们:

代码语言:javascript
复制
$ docker-compose run --rm chat npm test
> chat@1.0.0 test /srv/chat
> echo "Error: no test specified" && exit 1
Error: no test specified
npm ERR! Test failed. See above for more details.

这是github上的最终代码

结论

我们已经使用了一个应用程序,并使其完全在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,那么一切都会正常进行。如果不是这样,则有两种解决方法:

  1. 只需省略USER nodeDockerfile的开发阶段(在Docker for Dev and Prod部分中介绍)的步骤,就可以在开发中作为根运行服务。这确保了容器(根)中的用户将能够在主机上读取和写入文件。如果容器中的用户创建任何文件,它们将在主机上以root用户身份拥有,但是您也可以通过sudo chown -R your-user:your-group .在主机上运行来修改权限。

您可以(并且应该)仍然以生产中的非特权用户身份运行该流程。

  1. 使用Dockerfile 构建参数以便在构建时配置节点用户的UID和GID。为此,我们可以在开发阶段增加几行Dockerfile:
代码语言:javascript
复制
FROM node:10.16.3 AS development
ARG UID=1000
ARG GID=1000
RUN \
usermod --uid ${UID} node && groupmod --gid ${GID} node &&\
mkdir /srv/chat && chown node:node /srv/chat
# ...

这引入了两个构建参数,UID和GID,如果没有设置参数值的话其默认为1000, 在以用户身份创建任何文件之前,更改节点用户和组以使用这些id。

每个具有非1000 uid / gid的开发人员都必须为Docker Compose 设置这些参数。一种方法是使用未签入版本控制(即.gitignore)的文件docker-compose.override.yml来设置参数,如下所示:

代码语言:javascript
复制
version: '3.7'
services:
chat:
build:
args:
UID: '500'
GID: '500'

在此示例中,容器中的uid和gid将设置为500。当然未来应该有一些更简单的方法。这些更改仅需要在开发阶段完成,而不是在生产阶段。

脚注

  1. 从根本上讲,文件在容器中的位置无关紧要。/opt也可以是一个非常合理的选择。另一个选择是将它们保留在/home/node,这简化了开发过程中的某些文件权限管理,但需要更多的键入操作,而在生产中则没有意义,在此我主张让root拥有应用程序文件作为保持它们只读的一种方式。无论如何,/srv都会做。
  2. Docker Compose文件格式的2.x和3.x版本仍在积极开发中。3.x系列的主要优点是,它在Docker Compose上运行的单节点应用程序与在Docker Swarm上运行的多节点应用程序之间具有交叉兼容性。为了兼容,版本3从版本2中删除了一些有用的功能。如果仅对Docker Compose感兴趣,则您可能更喜欢使用最新的2.x格式
  3. Dockerfile如果我们允许npm install构建步骤以root身份运行,则可以消除其中的一些讨巧方法。如果这样做,我们可以并且仍然应该在运行时使用非特权节点用户,这是大多数安全优势所在。Dockerfile以root用户身份运行构建并以节点用户身份运行容器的A 看起来更像这样:
代码语言:javascript
复制
FROM node:10.16.3
WORKDIR /srv/chat
COPY package.json package-lock.json ./
RUN npm install --quiet
USER node

这更简洁,而且在用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”安装软件包,这是比较熟悉的操作了。

  1. 我们可以改用匿名卷来包含模块,只需省略名称即可:
代码语言:javascript
复制
diff --git a/docker-compose.yml b/docker-compose.yml
index c9a2543..5a56364 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,3 +6,4 @@ services:
command: echo 'ready'
volumes:
- .:/srv/chat
+ - /srv/chat/node_modules

那会更短一些,但是很容易忘记清理匿名卷,这导致大量匿名模块,而没有指示它们来自哪个容器。您可以使用docker system prune进行清理,但这有点像“杀鸡用牛刀”。命名卷方法较为冗长,但也更加透明。

(补充说明:您可能想知道卷中的那些依赖文件真实存储位置在哪里。无论使用命名卷还是匿名卷,它们都位于主机上由Docker管理的单独目录中;有关卷的更多信息,请参阅Docker文档。)  

  1. 聪明的读者可能已经注意到,我们不必在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移除卷本身,则可以将其移除。

  1. Docker还提供了一个更小的官方映像变体alpine。但是,这些大小节省的部分原因是使用了libc与基于Debian的映像完全不同的软件包管理器。除非您要部署到空间有限的嵌入式系统上,否则由于这些差异而产生的复杂性可能不值得使用了,尤其是基于Debian的超薄映像会使你付出大量额外成本。
  2. 您可能会注意到,它大约需要10秒钟才能停止。这是因为socket.io聊天示例未正确处理SIGTERM信号,Docker在停止时会发送该信号以执行正常关闭。补充说明:将此代码添加到index.js的末尾:
代码语言:javascript
复制
process.on('SIGTERM', function() {
io.close();
Object.values(io.of('/').connected).forEach(s => s.disconnect(true));});

然后重建生产映像并尝试docker stop再次运行容器。它会断开所有客户端的连接,并在更改后立即停止。

  1. 有时不建议使用npm在容器中运行进程,但我认为也可以不遵从这个建议。较旧版本的npm确实在处理关闭进程所需的信号时遇到了问题,但这应该在最新版本中得到了解决。如果您的容器似乎总是需要10秒钟才能关闭,则很可能是他们没有在监听SIGTERM启动正常关闭的信号。见(注7)。通过流程运行npm确实会产生一些开销,即额外的节点流程,因此您可能希望在生产中避免使用它,但在开发中通常可以。
  2. 您可能会注意到rs可以重新启动nodemon。如果我们用docker-compose up来启动服务,那将无法正常工作,因为这样做时,我们的终端未连接到nodemon的标准输入。如果我们改为运行docker-compose run --rm chat,rs应该照常工作; 这在处理一个特定的服务时非常有用。

英文原文:Lessons from Building Node Apps in Docker (2019)

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/tNMyOeLBBBRHiZCw96IE
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券