最简Dockerfile

我们以nest js为例讲一下怎么把一个node js应用容器化。nest js是一个node js的后端框架,之所以以它为例是因为它提供了一个脚手架可以让我们快速得到一个相对正式的项目结构。

使用下面命令创建nest应用。

npx nest new dockerize --package-manager npm

其中dockerize是我们的应用名。进入dockerize目录后npm start就可以启动服务。

把这个服务打包成docker镜像的过程叫做dockerize。我们先创建一个最简的Dockerfile达成这个目标,然后再分析它有什么问题,并逐步优化。

在项目的根目录下创建Dockerfile包含以下内容:

FROM node               
COPY . .
RUN npm install
CMD npm start

构建镜像并用dockerize作为它的tag:

docker build . --tag dockerize

docker build后面的.表示使用当前目录下的Dockerfile。

使用下面命令运行容器并暴露端口到宿主机:

docker run -it -p 3000:3000 --rm dockerize

可以使用浏览器访问localhost:3000验证服务是否启动成功。

Docker Ignore

我们可以看一下我们构建的镜像的大小:

 docker images dockerize
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
dockerize    latest    4b6a3067f6a1   9 seconds ago   1.24GB

可以看到即使我们只是用脚手架起一个hello world的服务,还没写任何的业务代码,我们的镜像已经有一个多G了。更大的镜像大小让我们拉取、推送镜像时耗费更长的时间。我们可以以减小镜像大小为目标,进行一系列优化。让我们从docker ignore开始。

我们Dockerfile的第二行COPY . .,将当前目录下的所有文件都拷贝进了镜像中。这不是一个很好的做法,我们应该只拷贝运行服务必要的文件进去。比如:

COPY src package*.json tsconfig*.json nest-cli.json .

这里使用COPY指定了多个源文件。它的用法是COPY <src>... <dest>,除了最后一个参数.是相对于WORKDIR的相对地址(我们没设置过,默认是/),前面的参数都是源文件。

另一个做法是使用docker ignore。它的写法和gitignore一样,用于在拷贝时忽略一些文件。我们在dockerfile同级目录下新建一个.dockerignore文件

# 任何你认为不需要被拷贝进docker镜像中的文件
.git
dist
node_modules
Dockerfile
README.md

重新打包镜像,并查看镜像大小:

 docker images dockerize       
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
dockerize    latest    fde824e7b99a   11 seconds ago   1.28GB

出乎意料的是,镜像大小还变大了。初步猜测是在docker下npm install的node_modules大小要比我的宿主机(MacOS)npm install的要大。在没有ignore node_modules时,在docker中npm install会使用从宿主机拷贝过来的缓存,跳过下载,所以最终构建出来的镜像大小会小些。

我们可以用docker history IMAGE查看不同层的大小。可以看到,确实在ignore之后npm install产生的大小要大于ignore之前COPY和npm install两层之和。

 docker history fde824e7b99a # ignore之后
IMAGE          CREATED       CREATED BY                                      SIZE
fde824e7b99a   4 days ago    CMD ["/bin/sh" "-c" "npm start"]                0B
<missing>      4 days ago    RUN /bin/sh -c npm install                      291MB
<missing>      4 days ago    COPY . .                                        595kB
... # 省略了base image的层
 docker history 4b6a3067f6a1 # ignore之前
IMAGE          CREATED       CREATED BY                                      SIZE
4b6a3067f6a1   4 days ago    CMD ["/bin/sh" "-c" "npm start"]                0B
<missing>      4 days ago    RUN /bin/sh -c npm install                      938kB
<missing>      4 days ago    COPY . .                                        247MB

即使从这里试验得到的镜像大小上来看,ignore是个“负优化”,但是我们仍然需要ignore node_modules。因为一些npm库会根据系统的类型去下载不同的二进制文件,在宿主机下载再拷贝进docker镜像的兼容性不好。

更换base image

另一个减少image size的方法是替换base image。Dockerfile的第一行FROM node指定了node作为我们的base image。一般上我们会使用特定的tag,在省略tag时,默认的tag为latest。我们可以在https://hub.docker.com/_/node?tab=description 查看所支持的tag,在https://hub.docker.com/_/node?tab=tags可以看到对应tag的image size。不同的tag可能有不同的node版本,不同的linux版本等。node镜像的系统有两大分支,debian和alpine。你在tag中看到的bullseye, buster, stretch等都是debian的版本代号。 latest一般是采用最新的node版本和最新的debian版本。如果追求最小化镜像体积,可以把linux版本替换成alpine,比如FROM node:17-alpine,相对于latest的300多M,alpine只有不到50M。

除了alpine,另一个选项是使用slim。slim是裁剪版的debian镜像,去除了大量非必要的组件。你可以在构建镜像时按需安装所需的组件。因为alpine底层使用musl而非glibc,一些依赖对musl的兼容性可能不好,所以有相关问题的情况下使用slim是个比较合适的选择。下图可以看到slim的镜像大小和alpine实际上差不多。

我的个人习惯是使用lts-slim或者lts-alpine作为base image。lts是指长期支持(Long Term Support)的node版本。目前的lts是node v16。大部分的npm库会积极维护和lts版本node的兼容性。在将base image替换为lts-slim后,我们的镜像大小为:

 docker images dockerize
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
dockerize    latest    a43eee64a5b0   18 minutes ago   465MB

注意这里的size是未被压缩的镜像大小,而docker hub上显示的是压缩过的镜像大小。所以即使在docker hub上看上去两个base image只有两百多M的差异,在实际替换后产生了显著的效果。

使用cached layer

我们跑docker build时,第一次运行会花费较长时间。如果你马上再跑一次docker build,会立刻完成构建。这是因为docker build在默认情况下会使用cached ayer。cached layer可以大大加速我们在本地多次构建镜像的速度。

在我们这个过于简单的例子中,我们需要从宿主机拷贝package.json, package-lock.json, 以及src/中的源码(当然还有其他一些必要的文件,比如tsconfig.json等,为了简化说明略过),以及需要安装依赖:npm install,和最终运行服务的步骤:npm run start

我们目前的构建步骤编排如下: 当我们更改src下的任意文件时,都会使COPY . .以及后续的步骤缓存命中失败。这使得我们在重复构建镜像时会跑没有必要的npm install,这个步骤在本地环境下通常要耗费几分钟之久。一个简单的优化是把npm install所必要的依赖单独拆出来,把剩余的依赖移到npm install之后。

FROM node:lts-slim
COPY package*.json .
RUN npm install
COPY . .
CMD npm start

在开发过程中,相对于src下的源码,package.json发生改变的概率更低。这个优化并不影响我们的镜像大小,但是极大优化了本地开发的体验。

拆分stage

对于大部分的服务,运行服务所需的依赖只是构建服务的一个子集。这也是我们区分开发依赖和运行依赖的原因。我们在运行时并不需要测试套件,不需要TypeScript编译器,不需要linter,而这些依赖通通被我们打包进了镜像。我们可以用将有不同依赖的层拆分成不同的stage,使用multi stage构建我们的镜像。

# deps stage安装生产环境所需的第三方依赖
FROM node:lts-slim as deps
COPY package*.json .
# 仅安装dependencies依赖
RUN npm install --production  
 
# 我们可以直接使用其他的stage作为base image
FROM deps as dev
# 补充安装devDependencies依赖
RUN npm install
# npm run build会需要我们src下的源码和tsconfig等文件
COPY . .
RUN npm run build
 
FROM deps as prod
# 从dev stage拷贝编译好的资源进prod stage
COPY --from=dev dist ./dist
CMD node dist/main.js

查看镜像大小,可以发现我们再次成功瘦身一半以上:

 docker images dockerize
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
dockerize    latest    93e92d2b36e4   3 minutes ago   189MB

根据实际项目需要,我们可以拆出更多的stage。在构建过程中,没有产生依赖关系的stage步骤可以并行运行。比如FROM deps as prod指定了deps stage作为基础镜像,那么prodstage需要等deps stage的所有步骤运行完成后才开始构建。再比如COPY --from=dev dist ./dist这一步骤依赖了dev stage,那么这一步骤会等待dev stage构建完成后才开始运行。如果此前有其他步骤,那么这些步骤是可以和构建dev stage步骤并行完成的。