最近项目上 k8s,用 jenkins 做 CI,跑得比较顺了,开始考虑优化。
主要有两个问题:
docker build耗时长。
我们把docker build单独放在一个stage, 所以很容易从 Jenkins Stage View 看出这个stage最耗时- 构建后的
image很大,超过 1G
接下来谈谈我们是怎么优化
构建速度
想要优化 docker build,我们就需要知道构建过程每一个步骤的耗时。经过 Google,很容易找到了方法:
计算 dockerfile 每一条 instruction 的耗时
# 1. 开启这个实验性 flag
export DOCKER_BUILDKIT=1
# 2. 添加参数 --progress plain 获得更加直观的输出结果
docker build -t YOUR_IMAGE_NAME:YOUR_TAG -f YOUR_DOCKERFILE --progress plain .
从输出结果我们发现最耗时的命令是 npm install,不出意外
有什么办法可以优化?我们知道,复用 node_modules 缓存可大大减少 npm install 时间。我们可以从两个方向做到这一点。
Solution 1:基于历史最新的业务镜像构建
比如你的镜像名叫 example,dockerfile 可能涨这样:
FROM node
我们可以已有的 example 镜像(因为它已经包含 node_modules)作为基础镜像:
FROM example
那第一次构建时,example 并不存在怎么办?我们需要一点点脚本来做判断:
YOUR_IMAGE_NAME_TAG=example
# 如果 demo 存在,用 demo 做基础镜像,否则用 node
base=$(DOCKER_CLI_EXPERIMENTAL=enabled docker manifest inspect $YOUR_IMAGE_NAME_TAG > /dev/null 2>&1 && echo $YOUR_IMAGE_NAME_TAG || echo "node")
# 作为参数传给 docker build
docker build --build-arg base=$base ...
对应的 Dockerfile:
# 给个默认值,坐下兼容
ARG base=node
FROM $base
这种方式可以适用于任何语言,不限于 Node.js。但是两个点需要注意:
- 需要考虑什么时候更新
:latesttag。
我们并不希望基础镜像是一个不稳定的版本 - 基础镜像本身不
clean。
其实我们复用了一切,包含构建过程中产生的文件,不仅仅是node_modules目录
Solution 2:利用 build cache
官方的 dockerfile 最佳实践将缓存和指令之间的相互作用讲得很清楚了,简单理解就是:
Dockerfile每一条指令会产生一个新的layer- 如果当前指令将要产生的
layer已经在 cache 里,那就直接复用缓存,而不是重复创建layer,指令本身也不会再次执行 - 如何判断是否在 cache 里?
特殊指令ADD和COPY,docker 会对文件系统发生的变更(增删改)做一次checksum,然后去缓存中匹配这个checksum
其余指令,就是简单的比较一下指令本身有没变化,如指令RUN your_command就是比较your_command部分有没有变化(就是这么简单) - 如果某个指令没有命中缓存,那么后续指令也不会读取缓存,每条指令均会创建新的
layer
根据这些规则,我们可以对 Dockerfile 进行优化,最大化利用 build cache。
比如我某个项目一开始的 Dockerfile 长这样:
FROM node:10.15.3
WORKDIR /data/server
COPY . .
# Configure apt and install packages
RUN apt-get update \
# install vim
&& apt-get install -y vim \
# clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
RUN npm install && npm run build
CMD ["./deploy/entrypoint.sh"]
因为 COPY . . 放在最前面,只要整个项目任意文件发生变更,后续的所有指令都会重新执行,几乎没有利用 build cache。怎么改呢?
注意看代码中的注释:
FROM node:10.15.3
# 1. 将全局安装的依赖,置于最前面
# Configure apt and install packages
RUN apt-get update \
# install vim
&& apt-get install -y vim \
# clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /data/server
# 2. 只有部分文件如 package.json 发生变更的时候,才重新执行 npm install
COPY package.json package-lock.json .
RUN npm install
# 复制剩余文件
COPY . .
RUN npm run build
CMD ["./deploy/entrypoint.sh"]
利用 build cache,我们完全跳过了 npm install,耗时接近 0!

这个方法我们可以用来优化任何耗时比较长的命令:
- 先
COPY该命令的依赖文件,而不是所有文件 - 执行该命令
镜像大小
我们注意到,一个简单的 Node.js 的镜像大小超过 1G,出乎意料
找出是哪些部分最占空间
我们可以用 du -sh 来找出每个文件夹占用的空间大小:
# 忽略一些 du 的错误
docker run -it YOUR_IMAGE du -sh /* 2>&1 | grep -v "cannot access"
我们可以看到最占空间的是(忽略体积较小的目录):
279M /data # 项目目录 /data/server,见前面的 dockerfile
920M /usr # OS目录
Step 1:寻找更轻量的 OS 镜像
很显然,OS 占了我们 75% 以上的空间,我们需要优先减少这一块。再一次 Google,发现 alpine 是目前被广泛采用、为容器定制的轻量 OS(也有缺陷,见 reddit 讨论)
因此,我们的基础镜像改为 node:lts-alpine
FROM node:lts-alpine
alpine不自带bash,需要手动安装RUN apk add --no-cache bash,不会增加多少空间
Step 2:减少 node_modules 大小
对于 /data,我们在执行一遍 du -sh /data/server/*,可以看出主要是 node_modules 吃硬盘:
16K ./__tests__
4.0K ./commitlint.config.js
8.0K ./jest.config.js
196K ./lib
277M ./node_modules
4.0K ./nodemon.json
336K ./package-lock.json
4.0K ./package.json
120K ./src
4.0K ./tsconfig.json
npm install 会安装 dependencies 和 devDependencies,但是运行时我们并不需要 devDependencies,因此,我们可以再执行一次 npm i --only=production 移除 devDependencies:
# 复制剩余文件
COPY . .
RUN npm run build
# cleanup devDependencies
RUN npm prune --production
经过上述两个优化,
117.7M /data/
111.7M /usr/
容器大小也从 1.2G 减少为 421M,镜像大小只有原来的 1/3!
以上。