回溯到2016年,我写了篇《在Docker中构建节点应用程序的经验教训》,现已帮助十万多开发者对Node.js应用程序进行了Docker开发。 多年以来,生态系统和在Docker中使用Node的方式都发生了很多变化,所以需要进行一次大修。
在这篇更新的教程中,我们将使用Docker从零开始设置到最终生产就绪,完成socket.io聊天示例。特别将学习以下内容:
本教程假定开发者已经对Docker和Node有所了解。如果想先对Docker进行一些简要了解,建议浏览Docker的官方介绍。
我们将从头开始进行设置。最终代码可在github上找到,在此过程的每一步都有标签。这是第一步的代码,供参考。
如果没有Docker,我们需要开始就在主机上安装Node和其他需要的依赖项,然后运行npm init以创建新软件包。除了这样别无选择,但如果一开始就使用Docker,我们将方便很多。当然,使用Docker的全部意义在于,开发者不必在主机上安装任何东西。首先,我们创建一个安装了Node的“引导容器”,然后使用它来进行设置该应用程序的npm软件包。
引导容器和服务
我们需要先编写两个文件,一个Dockerfile文件和一个docker-compose.yml文件,稍后将添加更多文件。我们先从引导文件Dockerfile开始:
FROM node:10.16.3
USER node
WORKDIR /srv/chat
这是一个简短的文件,但也需要关注一些重点:
现在转到撰写引导文件docker-compose.yml:
version: '3.7'
services:
chat:
build: .
command: echo 'ready'
volumes:
- .:/srv/chat
此外,还有很多需要解压的东西:
现在,我们已经准备好构建和测试我们的引导容器了。当我们运行docker-compose build时,Docker将创建一个映像,其节点设置为Dockerfile。然后docker-compose up将使用该映像启动一个容器并运行echo命令,以便验证一切设置正确。
$ 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服务的容器中运行一个交互式外壳,并使用它来设置初始包文件:
$ 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
然后文件出现在主机上,我们可以对它们进行版本控制:
$ tree
.
├── Dockerfile
├── docker-compose.yml
├── package-lock.json
└── package.json
接下来,我们将安装应用程序的依赖项。我们希望通过Dockerfile将这些依赖项安装在容器内,这样容器就可以包含运行应用程序所需的所有内容了。这意味着我们需要将package.json和package-lock.json文件放入映像中并在npm install中运行Dockerfile。更改如下所示:
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
详细解释如下:
理论上这可以是最后一步了,但是当将应用程序文件夹绑定安装到主机/srv/chat上时,它会在开发中引起问题。因为该node_modules文件夹在主机上不存在,绑定有效地隐藏了我们在映像中安装的节点模块。最后,mkdir -p node_modules这一步和下一部分与如何处理此问题有关。
围绕节点模块隐藏问题有几种 方法 ,但是我认为最优雅的方法是在绑定时包含一个卷目录。为此,我们必须在docker compose文件中添加几行:
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。
虽然这很简单,但是要使它工作起来,幕后还有很多事情要做:
/srv/chat$ tree # in image
.
├── node_modules
│ ├── accepts
...
│ └── yeast
├── package-lock.json
└── package.json
/srv/chat$ tree # in container without node_modules volume
.
├── Dockerfile
├── docker-compose.yml
├── node_modules
├── package-lock.json
└── package.json
有一点不好就是node_modules镜像绑定时被隐藏了;在容器内,我们只能在主机上看到一个空node_modules文件夹。
/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抢占先机。一旦安装了一些软件包,就不再需要这样做了。
下面我们先重建映像,我们将准备安装软件包。
$ docker-compose build
... builds and runs npm install (with no packages yet)...
聊天应用程序需要express,因此我们在容器中先启动一个shell,运行npm install时增加–save参数以将依赖项保存到package.json并相应地对package-lock.json进行更新:
$ 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 ,我们可以在其中找到它们:
$ 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:
$ docker-compose run --rm chat npm install --save socket.io@1
# ...
然后,在docker compose文件中,删除虚拟echo ready命令,然后运行聊天示例服务器。最后,我们设置Docker Compose在主机上的容器端口为3000,之后我们可以在浏览器中对其进行访问:
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 命令运行:
$ docker-compose up
Recreating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | listening on *:3000
之后可以通过http://localhost:3000查看其运行状况。
现在,我们使用docker compose使应用程序在开发环境中运行。在生产中使用此容器之前,我们要解决一些问题,并需要做出一些改进:
幸运的是,Docker提供了一个功能强大的工具,可以帮助完成以上所有任务:多阶段构建。主要做法是我们可以在Dockerfile中使用多个FROM命令,每个阶段一个,每个阶段都可以复制先前阶段的文件。让我们看看如何进行设置:
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"]
此多阶段操作在第一阶段Dockerfile运行npm install,该阶段具有可用于构建的完整节点映像。然后,将生成的node_modules文件夹复制到第二阶段映像,该映像使用slim基础映像。该技术将生产图像的大小从909MB减少到152MB,这大约可以节省6倍的空间,而工作量相对较小(注6)。
现在我们已经设置了多阶段Dockerfile,我们需要告诉Docker Compose仅使用该development阶段,而不是完成整个阶段Dockerfile,我们可以使用以下target选项进行操作:
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文件:
.dockerignore
.git
docker-compose*.yml
Dockerfile
node_modules
完成所有这些设置后,我们可以运行生产构建以模拟CI系统如何构建最终映像,然后像协调器那样运行它:
$ docker build . -t chat:latest
# ... build output ...$ docker run --rm --detach --publish 3000:3000 chat:latest
dd1cf2bf9496edee58e1f5122756796999942fa4437e289de4bd67b595e95745
再次在浏览器中使用http://localhost:3000访问它。完成后,我们可以使用上方命令中的容器ID停止它。
$ docker stop dd1cf2bf9496edee58e1f5122756796999942fa4437e289de4bd67b595e95745
现在,我们已经拥有了独特的开发和生产映像,下面让我们看看如何通过在nodemon下运行应用程序以在更改源文件时在容器内自动重新加载,使开发映像对开发人员更加友好。
$ docker-compose run --rm chat npm install --save-dev nodemon
要安装nodemon,我们可以更新 compose文件来运行它:
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输出:
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的一部分在生产容器中运行测试。通常,它还可以改善开发人员与产品之间的均等性,就像一些智者曾经说过的那样:“边试边飞,边飞边试”。目前为止,虽然我们还没有任何测试,但是在需要的时候,我们很容易运行它们:
$ 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.
我们已经使用了一个应用程序,并使其完全在Docker内部进行开发和生产。跳过了一些希望具有启发性的步骤来引导Node环境,而不需要在主机上安装任何东西。我们还跳过了一些步骤,避免以root用户身份运行构建和进程,以非特权用户身份运行,这样可以提高安全性。
Node/npm将依赖项放在node_modules子文件夹中的方案比其他解决方案(如ruby的bundler)复杂得多,这些解决方案将依赖项安装在应用程序文件夹之外,但是我们能够使用 嵌套节点模块卷技巧轻松解决该问题。
最后,我们使用了Docker的多阶段构建功能创建Dockerfile来生产适合开发和生产的产品。这个简单而强大的功能在很多情况下都非常有用,我们将在以后的文章中再次看到它。
我将在本系列的下一篇文章中继续学习在Docker中测试node.js服务的内容。
当使用绑定方法安装Linux主机和容器之间的共享文件时,如果容器中用户的数字uid与主机上用户的数字uid不匹配,则可能会遇到权限问题。例如,在主机上创建的文件在容器中可能不可读或不可写,反之亦然。
我们可以解决此问题,但首先要注意的是,如果主机上的uid为1000,那么对于使用node的Dockerized开发来说,一切都还好。这是因为Docker的官方节点映像对节点用户使用uid 1000。您可以通过运行id命令在主机上检查您的uid,然后将其打印出来。例如,我的uid=1000(john) gid=1000(john) …。
一个uid 1000很常见,因为它是ubuntu安装过程分配的uid。如果您可以说服团队中的所有人将uid设置为1000,那么一切都会正常进行。如果不是这样,则有两种解决方法:
您可以(并且应该)仍然以生产中的非特权用户身份运行该流程。
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来设置参数,如下所示:
version: '3.7'
services:
chat:
build:
args:
UID: '500'
GID: '500'
在此示例中,容器中的uid和gid将设置为500。当然未来应该有一些更简单的方法。这些更改仅需要在开发阶段完成,而不是在生产阶段。
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”安装软件包,这是比较熟悉的操作了。
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文档。) ↩
如果您发现自己想要消除命名卷并从头开始的情况,则可以运行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移除卷本身,则可以将其移除。
process.on('SIGTERM', function() {
io.close();
Object.values(io.of('/').connected).forEach(s => s.disconnect(true));});
然后重建生产映像并尝试docker stop再次运行容器。它会断开所有客户端的连接,并在更改后立即停止。
领取专属 10元无门槛券
私享最新 技术干货