likes
comments
collection
share

基于 Docker 的 Node.js 应用容器化实践

作者站长头像
站长
· 阅读数 12

近年来云原生(Cloud Native)的概念大火,容器化作为云原生最佳实践的基础,是我们在进行云原生实践过程中必须了解的,那么什么是容器化?以及为什么我们要容器化呢?

试想我们在服务端应用部署的时候,如果每个应用都各自分别部署在宿主机上,那么对于服务压力不那么大的机器来说资源利用率肯定不高,且需要扩容的时候会比较麻烦。而多个应用部署到一个宿主机上,又会遇到对操作系统的依赖冲突、资源竞争,甚至可能发生高权限的进程攻击低权限进程的情况。那么我们即希望宿主机资源利用率最大化、水平扩展简便,并且应用之间可以相互隔离,容器化技术便可以解决这个问题。

那么什么是容器?容器是一个视图隔离、资源可限制、独立文件系统的进程集合,底层依托操作系统提供的隔离能力(比如 Linux namespace 技术、chroot 文件系统隔离、cgroups 资源管理)实现,在云原生最佳实践下,容器已经成为了测试与分发应用的最小单元。

接下来我们使用目前非常普遍的容器化方案 Docker 来进行 Node.js 应用的容器化:

什么是 Docker?

Docker is an open platform for developing, shipping, and running applications.

Docker 是目前最常见的应用容器化方案,它提供了容器镜像打包、容器分发、容器运行等完整的能力,帮助开发者实施应用的容器化。它本身是基于 C/S 架构的,后台运行一个守护进程 dockerd 来提供 docker 的核心能力,命令行工具 docker 通过 API 与守护进程通信实现开发者与 docker 的交互。Docker 通过镜像源(Registry)来提供容器分发的能力,Docker Hub 是 Docker 官方提供的公共镜像源,有条件的话也可以自己部署一个私有的镜像源。

容器与镜像

在进行应用容器化实践之前,有必要聊一聊 Docker 中镜像与容器的概念:

镜像(Images)是组合一系列容器构建指令的容器模板,镜像之间可以有继承关系,即一个镜像可以基于另一个基础镜像之上叠加额外的构建指令。你可以创建自己的镜像或者直接使用已经构建好的镜像,如果要使用 Docker 构建一个自己的镜像,需要通过 Dockerfile 来定义构建镜像的指令集,具体内容在下面实践的部分再展开。

容器(Containers)是一个镜像的实例,是一个进程级别的沙箱环境,且容器与容器之间和容器与宿主机之间都是相互隔离的。你可以配置容器的网络连接或者向它挂载额外的存储空间。容器启动时的状态由镜像定义,比如文件、环境变量等,当容器被移除,任何容器内状态的变化都会被整体移除。也正是基于这个特点,让每次基于容器的应用变更都可以以容器为单位进行替换部署而不需要担心有副作用。

镜像分层

Docker 的镜像是由一系列的层(Layer)组成的,一系列的 Layers 是一个栈结构,Dockerfile 中任何会导致修改文件系统的指令(包括对文件的写操作、新增、删除)都基于前一个 Layer 往上对应创建一个新的 Layer,这同时也意味着如果是删除文件的操作,虽然在新的 Layer 上文件是删除的状态,但文件依然会存在于前一个 Layer,删文件的操作对于最后镜像的尺寸不会有影响。

除了最后的一个 Layer 外,每一个 Layer 都是只读的。每一个新建的 Layer 只会保存文件系统修改的部分,并且是基于 Copy-On-Write 策略的,对于读文件,会直接从老的 Layer 中读取,如果有修改,就 Copy 老的内容到新 Layer 中再进行修改,这些都是为了镜像文件的空间效率最大化。 当基于一个镜像实例化一个容器时,会在 Layers 的栈顶再创建一个新的 Layer,这个 Layer 被称为 Container Layer 用于提供给容器运行时记录文件系统的变更。

所以我们发现,容器与镜像的区别就体现在最上层的 Container Layer 上,容器的创建、运行、销毁最后都反映在 Container Layer 上,不会对下层属于镜像的 Layer 产生影响,所谓了基于镜像实例化一个容器的本质,就是创建一个新的 Container Layer。

Docker 通过 storage drivers 来进行 Images Layers 和 Container Layer 的具体实现,虽然不同的 drivers 实现细节不同,但基本都使用了栈结构与 CoW 策略。

Node.js 应用容器化

基础环境准备

现在我们开始真正的容器化实战了,我们的应用可以基于容器快速部署到宿主机上的第一步是在容器内准备好应用基本的运行环境,我们是 Node.js 应用,那么至少要在容器中安装 Node.js 环境。

我们首先来创建我们的 Dockerfile:

FROM centos:7

# devtoolset
RUN yum install -y centos-release-scl
RUN yum install -y devtoolset-8
# git
RUN yum install -y git
# node
RUN (curl -sL https://rpm.nodesource.com/setup_12.x | bash -) && yum install -y nodejs

我的基础镜像是基于 centos7,接下来通过 yum 安装了 c++ 的支持和 git,现在要安装 node 了,这里我选择了 NodeSource 的 Node.js 分发方案,它为各个 Linux 操作系统提供了基于对应包管理能力的统一 Node.js 分发,可以手动指定版本,这里我暂时选择了 Node.js 12.x 版本。如果你希望使用 LTS 版本,也可以替换:

curl -fsSL rpm.nodesource.com/setup_lts.x | bash -

我们先试着第一次构建镜像,同时通过 tag 为我们的镜像取个名字,在当前目录下执行命令:

docker build --tag centos7_node12 .

命令正常执行完成后,镜像就被构建完成了,我们试着基于镜像启动一个容器:

docker run -it centos7_node12 bash

我们通过 docker run-i-t 开启了可交互模式,指定了我们要启动的镜像 centos7_node12,并在后面紧跟了我们要执行的命令 sh 来开启 shell 交互。这里要提一下,在 docker run 命令后面紧跟的命令相当于 Dockerfile 的 CMD 指令,提供一个容器运行的行为,如果在 Dockerfile 中有定义 CMD 指令,那么它就是运行容器时会默认执行的指令,如果在 docker run 的时候传入了其他指令,Dockerfile 中的 CMD 指令会被覆盖。具体可以参考 官方对 CMD 指令的说明

好了,我们顺利启动了我们的容器,并可以通过 shell 在容器中执行任何命令了,我们可以试试我们的 node.js 安装是否正常:

node --version
# 输出 v12.22.7
npm --version
# 输出 6.14.15

我们通过 exit 退出 shell 后,容器的执行也自动退出了,我们通过执行 docker ps -a 查看当前运行容器的状态,我们发现刚才运行的容器其实并没有被销毁,只是变成了 Exit 状态,你甚至可以基于上次容器退出的状态重新启动容器:

# 后面可以替换成你自己的 ContainerID
docker attach 82f41de4afce

如果你希望启动的容器在执行退出后自动销毁,可以通过增加 --rm 参数:

docker run -it --rm centos7_node12 bash

应用打包

有了基本的 Node.js 执行环境后,就开始将我们的应用也打包进容器中,这里为了便于说明,我们把应用简化为如下的实现(假设我们的应用实现在 app 目录下,入口文件为 app/server.js):

const ronin = require('ronin-server')
const mocks = require('ronin-mocks')

const server = ronin.server()

server.use('/', mocks.server(server.Router(), false, true))
server.start()

我们在打包镜像的过程中,将源码通过 COPY 的方式复制到镜像的文件系统中去:

WORKDIR /app
COPY app/*  ./

光是将源码打包进去是不够的,还需要安装 npm 依赖,那么就在追加一行(由于是服务部署,必然是生产模式,就在加上 --production):

RUN npm install --production

最后就是应用启动了:

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

这里没有使用 CMD 而是 ENTRYPOINT,按照官方文档描述的,使用 ENTRYPOINT 会把容器当做一个类似可执行文件来处理,所有不论是在 Dockerfile 中定的 CMD 还是通过 docker run 传入的命令,都会被认为是启动参数附加在 ENTRYPOINT 后面。 显然对于应用的部署,这种方式会更佳合适。

我们再次重新构建我们的镜像,并启动一个容器来执行我们的应用:

docker build --tag node12_app_demo .
docker run -it --rm node12_app_demo

我们的测试应用启动后会监听 8000 端口,我们试着访问一下:

curl --request POST \
  --url http://localhost:8000/test \
  --header 'content-type: application/json' \
  --data '{"msg": "testing" }'
  
# 输出
# curl: (7) Failed to connect to localhost port 8000: Connection refused

但似乎失败了,8000 端口看上去并没有被监听,这是怎么回事呢?

容器网络

Docker 是一个隔离的沙箱环境,其中也包括网络隔离。当 dockerd 启动时,一个默认的桥接网络会被加载,每一个新建的容器都会与它连接。在不指定网络模式的情况下,容器会应用默认的桥接网络,容器网络与宿主机是隔离的,我们需要配置端口映射:

docker run -it --rm -p 8000:8000 node12_demo

这样将宿主机的 8000 端口映射给容器网络,这样就可以访问我们的应用了,我们再试一下就可以访问成功了:

curl --request POST \
  --url http://localhost:8000/test \
  --header 'content-type: application/json' \
  --data '{"msg": "testing" }'

# 输出
# {"code":"success","payload":[{"msg":"testing","id":"5bdac299-3bf6-4c53-8a9c-d28fee58dae7","createDate":"2021-11-14T13:16:58.227Z"}]}

数据卷挂载

由于我们把容器作为最小的部署单元,而容器的部署是无状态的,即之前容器的变更在新的容器部署后,都不会被保留,这个在大部分情况下都没什么问题,但也会有希望被保留下来的状态,比如服务器日志即便在服务重新部署后依然可以被保留供问题排查。我们假设应用会将日志输出在 logs 目录下:

VOLUME /logs

这个部分就不做例子了,但建议每个应用在容器化时都考虑到这个部分。

容器发布

最后可以使用 docker push 明确发布你的容器,可以是公共源,但大部分情况是发布到自己的私有源去:

docker push node12_demo

最后

我们用一个非常简单的例子演示了基于 Docker 将一个 Node.js 应用容器化,这个过程中也简单介绍了 Docker 中镜像与容器的概念,其实这个也只是冰山一角,容器化乃至云原生已经成为现在涉及服务端开发的同学必知必会的内容了,后续也将会有更多云原生实践的内容输出。