最简Dockerfile
我们以nest js为例讲一下怎么把一个node js应用容器化。nest js是一个node js的后端框架,之所以以它为例是因为它提供了一个脚手架可以让我们快速得到一个相对正式的项目结构。
使用下面命令创建nest应用。
其中dockerize
是我们的应用名。进入dockerize目录后npm start
就可以启动服务。
把这个服务打包成docker镜像的过程叫做dockerize。我们先创建一个最简的Dockerfile达成这个目标,然后再分析它有什么问题,并逐步优化。
在项目的根目录下创建Dockerfile包含以下内容:
构建镜像并用dockerize
作为它的tag:
docker build
后面的.
表示使用当前目录下的Dockerfile。
使用下面命令运行容器并暴露端口到宿主机:
可以使用浏览器访问localhost:3000
验证服务是否启动成功。
Docker Ignore
我们可以看一下我们构建的镜像的大小:
可以看到即使我们只是用脚手架起一个hello world的服务,还没写任何的业务代码,我们的镜像已经有一个多G了。更大的镜像大小让我们拉取、推送镜像时耗费更长的时间。我们可以以减小镜像大小为目标,进行一系列优化。让我们从docker ignore开始。
我们Dockerfile的第二行COPY . .
,将当前目录下的所有文件都拷贝进了镜像中。这不是一个很好的做法,我们应该只拷贝运行服务必要的文件进去。比如:
这里使用COPY指定了多个源文件。它的用法是COPY <src>... <dest>
,除了最后一个参数.
是相对于WORKDIR
的相对地址(我们没设置过,默认是/
),前面的参数都是源文件。
另一个做法是使用docker ignore。它的写法和gitignore一样,用于在拷贝时忽略一些文件。我们在dockerfile同级目录下新建一个.dockerignore
文件
重新打包镜像,并查看镜像大小:
出乎意料的是,镜像大小还变大了。初步猜测是在docker下npm install
的node_modules大小要比我的宿主机(MacOS)npm install
的要大。在没有ignore node_modules时,在docker中npm install会使用从宿主机拷贝过来的缓存,跳过下载,所以最终构建出来的镜像大小会小些。
我们可以用docker history IMAGE
查看不同层的大小。可以看到,确实在ignore之后npm install
产生的大小要大于ignore之前COPY和npm install
两层之和。
即使从这里试验得到的镜像大小上来看,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
后,我们的镜像大小为:
注意这里的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
之后。
在开发过程中,相对于src
下的源码,package.json
发生改变的概率更低。这个优化并不影响我们的镜像大小,但是极大优化了本地开发的体验。
拆分stage
对于大部分的服务,运行服务所需的依赖只是构建服务的一个子集。这也是我们区分开发依赖和运行依赖的原因。我们在运行时并不需要测试套件,不需要TypeScript编译器,不需要linter,而这些依赖通通被我们打包进了镜像。我们可以用将有不同依赖的层拆分成不同的stage,使用multi stage构建我们的镜像。
查看镜像大小,可以发现我们再次成功瘦身一半以上:
根据实际项目需要,我们可以拆出更多的stage。在构建过程中,没有产生依赖关系的stage步骤可以并行运行。比如FROM deps as prod
指定了deps
stage作为基础镜像,那么prod
stage需要等deps
stage的所有步骤运行完成后才开始构建。再比如COPY --from=dev dist ./dist
这一步骤依赖了dev
stage,那么这一步骤会等待dev
stage构建完成后才开始运行。如果此前有其他步骤,那么这些步骤是可以和构建dev
stage步骤并行完成的。