最近 NodeJS 后端工程的 Docker 打包优化工作总算告一段落了。其实去年 12 月份就开始试点改造,期间遇到了很难复现的间歇性 socket hang up
问题,不得不延后。上周终于抽出时间全力排查了下,发现是升级 NodeJS 到 6.15.0 后,其有一个 HTTP Keep-alive 连接超时的 Bug。不得不感慨:这小版本升级也要格外小心啊。
回到正题。在确认没有其他附带问题后,在试点的基础上,又增加了一些新的目标。总的目标大概如下:
- 支持优雅停机,要求 Node 进程能够接收到 SIGTERM 软终止信号
- 提升打包速度,充分利用 Docker Layer 缓存机制,降低 yarn install、node_modules 拷贝等高 IO 动作的运行频率
- 保证源代码安全,不要将源代码打包到镜像里
- 尽可能降低最终镜像大小,不要包含不必要的文件(如 node_modules 中的 devDependencies)
下面从各个目标一一介绍下我们的优化实践之路。
基础镜像设置
由于之前的基础镜像使用的是 FROM node:6
,只有 major version,没有指定 minor version、patch version。当该基础镜像 minor 或 patch 版本更新后,如果本地的镜像缓存也被清除了,那么打包就会使用新版本的基础镜像。这也是上面不经意升级到 node 6.15.0 的原因。所以这里我们限定了基础镜像的全版本:FROM node:6.16.0
。
我们的产品主要在国内使用,运维人员也都是在国内。为了更方便查看日志中的时间、方便程序中的日期计算,把时区调整为北京时区(即东八区):RUN rm /etc/localtime && echo "Asia/Shanghai" > /etc/timezone && dpkg-reconfigure -f noninteractive tzdata
。注意,Debian Stretch 版本后需要 rm /etc/localtime
,否则时区修改可能无法生效(被替换回原值)。
最后设置镜像的工作目录:WORKDIR /app
。这样,我们新的基础镜像就完成了。
支持优雅停机
优雅停机(Gracefully Shutdown),就是当应用(进程)要被关闭时,首先会被发送一个软终止信号。应用在收到这个信号后,执行清理工作,然后自行退出。如果在指定的时间内没有自行退出,则会被强制关闭——这自然就不优雅了。这个软终止信号一般就是指 SIGTERM。NodeJS 进程默认会对 SIGTERM 信号进行响应,执行进程退出。但是默认的监听程序并不会执行清理工作。我们需要显式监听该信号,并在清理完毕后执行 process.exit(0)
以退出进程。
然而,在 Docker 容器里实现优雅停机会有一些新的问题需要面对。当使用 docker stop
停止一个容器时,docker 会首先发送一个 SIGTERM 信号给容器内的 PID=1 进程,也就是常说的 init 进程。如果 PID=1 进程没有在规定时间(一般 10 秒)内退出,则 docker 会发送 SIGKILL 信号强制退出容器内的所有进程。PID=1 进程比较特殊,在 linux 下,它会忽略所有默认的信号监听程序,也就是说收到 SIGTERM 默认不会退出。所以,我们的 PID=1 进程要求能显式监听 SIGTERM 并执行后续动作。
然而,当我们使用 shell form 的 ENTRYPOINT 或 CMD 指令时——如 CMD npm run start
,Docker 容器会默认启用一个 Shell 来运行后面的指令。此时 PID=1 进程是 /bin/sh
,完整的运行命令是 /bin/sh -c 'npm run start'
。当 sh 收到 SIGTERM 信号时,它自身并不会退出。因为 sh 并没有显式监听 SIGTERM,默认的信号处理器被忽略了。自然 sh 内部也不会把信号转发给子进程。最后只会超时,继而被 SIGKILL 强制关闭。
Docker 推荐我们用 exec form 的 ENTRYPOINT 或 CMD 指令,如 CMD ["npm", "run", "start"]
。这样 PID=1 进程就是 npm 了,不再有 sh 进程了。但继续用 npm scripts 会不会还有问题?这就依赖 Host 环境了。我们来看一下 npm scripts 的运行原理。以 npm run start
为例,在运行时,首先会起一个 npm 进程。npm 进程会 spawn()
一个 /bin/sh
进程(/bin/sh -c
),执行 start
script 的内容(通常就是 node xxx.js
)。这样就形成了三个进程构成的进程树,分别是 npm、sh、node。当 npm 进程收到 SIGTERM 信号时,它内部已经监听 SIGTERM,其逻辑就是转发给子进程,也就是 sh 进程。sh 进程收到信号后退出,接着 npm 也退出了。但是,剩下的 node 进程并没有收到信号,它被忽略了,继而被 Docker 直接 SIGKILL。看起来完全不行嘛,那为什么说依赖 Host 环境呢?因为中间这个 sh 进程在 bash 里(/bin/sh
指向 bash),是有可能不存在的。是不是很神奇?当使用 -c
运行命令时,bash 会判断是否需要 fork()
当前进程以产生一个新的进程来执行该命令。当 -c
命令不包含复杂的结构,如多个命令连接(&&
、||
)、重定向(>
)等情况时,bash 不会 fork()
出新的子进程,而是直接使用 exec()
替换当前进程。而 node:6
Docker 镜像所用的 Debian Stretch 操作系统,/bin/sh
默认指向的是 dash,而不是 bash。所以在这里,我们最好也不要用 npm scripts。
那我们就只剩一个选项了:直接将 node 作为 PID=1 的进程,如 CMD ["node", "dist/server.js"]
。虽然说 PID=1 的进程还要处理僵尸进程(Zombie Process),但我们这里基本上不会有,也就可以不考虑了。
yarn install 优化
这方面最基础的一个优化就是利用 Docker Layer 缓存特性,降低 yarn install 的发生次数。
# 在 package.json、yarn.lock 没有变化的情况下,后面的 yarn install 会直接复用上次打包的缓存结果
COPY package.json yarn.lock
RUN yarn install --frozen-lockfile
复制代码
要注意的一个问题是,yarn 会在其他位置建立依赖缓存(cache)。可以用 yarn cache clean
来移除缓存。不过我们这里并没有用,因为后面的改造方式让我们不需要它了。
我们的工程依赖里有私有 Git 仓库,如 "js-util": "git+ssh://git@gitlab.xxx.com:yyy/library/js-util.git#v2"
。我们原先的 CI 过程,是在宿主机上先安装依赖,然后把整个 node_modules 拷贝到 Docker Server 端中进行打包。宿主机有 SSH Key(一般就是 Gitlab Deploy Key,注意不要加密码,否则无法在 non-interactive shell 下使用),下载私有 Git 仓库不会有权限问题,但是就无法利用上述的缓存优化了。鱼和熊掌不可兼得,那就选中间。如果我们把 SSH Key 也打包到镜像里呢?那就太不安全了。那把它从镜像里又删除呢?可惜还是有安全隐患——Docker 的 Union FS 机制会导致这些文件还存在于原来的 Layer 里。
解决这个问题没有特别完美的方法。可以尝试提供一个内网的 SSH Key 在线下载地址,使用一个 RUN 指令完成 wget、ssh-add、yarn install、rm 等一系列操作,保证没有任何一个 Layer 会留存 SSH Key。而我们这里采用的是 Multi Stage Build——多阶段打包机制。在阶段一,复制 SSH Key,获取 Gitlab 服务器的公钥,并执行 yarn install。在阶段二,把阶段一打包出来的内容复制过来,注意这里不要复制 SSH Key。
# 构建时需要执行的指令
FROM node:6.16.0 as build
WORKDIR /app
COPY .ssh /root/.ssh/
RUN chmod 600 /root/.ssh/id_rsa && ssh-keyscan gitlab.xxx.com > /root/.ssh/known_hosts
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# 运行时需要执行的指令
FROM node:6.16.0 as runtime
WORKDIR /app
COPY --from=build /app/node_modules /app/node_modules/
复制代码
这样,阶段二打包出来的最终镜像,就没有 SSH Key 了。至于阶段一的 .ssh 目录,可以在调用 docker build
之前,从 $HOME/.ssh/id_rsa
上复制到当前目录,可千万别上传到 Git 仓库哦。
打包速度优化
在充分利用 Docker Layer 缓存机制的基础上,我们需要把那些不容易产生变更的指令放到上面、把不容易产生变更的部分剥离出来。像 WORKDIR、CMD、ENV、还有一些环境配置指令,都可以放到前面。把文件复制过程中,不容易产生变更的文件单独抽离出来,形成一个新的 COPY 指令,尽量避免 COPY . /p/a/t/h/
这样的复制方式。说到 COPY,还要注意其跟 Linux cp
命令有一些不一样的地方。当复制一个目录时,COPY 是将这个目录下的所有文件复制到目标文件夹下,而不是把这个目录自身复制到目标文件夹中。
源代码安全
在最终的镜像里,最好不要包括源代码,而只有 Transpile、Uglify 甚至是 Minify 后的代码。我们使用 npm run build
来做这些转换工作,它会把 src 源代码目录,转换到 dist 目录。使用上面的多阶段打包,只要在第二阶段 COPY dist 目录即可。
镜像大小优化
最终打包出来的镜像大小,除了基础镜像 node:6.16.0
占用大部分空间外,剩下的主要就是 node_modules
目录了——大概有 200-300MB。我们可以考虑把 devDependencies
从 node_modules 中删除来减少大小。再增加一条指令:RUN yarn install --production
即可。然而我们并没有这样做,主要有这两个原因:
- 我们在注册了
postinstall
npm scripts,它依赖一些devDpendencies
。 - 由于还有
npm run build
,它所依赖的 babel 都是devDpendencies
。由于它必须在 COPY 源代码之后运行,意味着只要源代码有变化,npm run build
就会被执行。那还在它后面的yarn install --production
自然也会被再次执行,可能就会影响打包效率了。
上下文目录优化
docker build -t xxx .
,最后的那个 .
就表示上下文目录位置(.
就是当前目录)。docker build 是在 go 语言写的一个本地服务端上运行。所以一开始需要把上下文目录打包发送到服务端,然后在服务端内解压,再运行各个指令,生成最终的镜像。这样我们的上下文目录就不能太大,不然 IO 吃不消。我们可以用 .dockerignore 文件来限制上下文目录只包含哪些文件。为了得到一个比较通用的 .dockerignore 文件,我们主要使用排除法规则。排除那些容器运行时不需要的文件;排除那些不会在多阶段打包过程中使用的中间文件,如 node_modules、dist。示例 .dockerignore 文件如下:
*
!package.json
!yarn.lock
!src
!bin
!test
!gulpfile.js
!.babel*
!.eslint*
!.nycrc
!.ssh
复制代码
最终的 Dockerfile
把上面各个改造结合在一起,我们的 Dockerfile 就出炉啦!还有一些小细节,期待你自己的发现哦。
############################################
# 构建阶段
############################################
FROM node:6.16.0 as build
WORKDIR /app
# 运行 docker build 前需要把 SSH Keys 复制到当前目录下的 .ssh 中,并在 build 完后删除
COPY .ssh /root/.ssh/
RUN chmod 600 /root/.ssh/id_rsa && ssh-keyscan gitlab.xxx.com > /root/.ssh/known_hosts
# 在 package.json、yarn.lock 没有变化的情况下,yarn install 会复用上次的缓存结果
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# 注意使用 .dockerignore 来屏蔽掉不必要的文件
COPY . ./
RUN npm run lint && npm run build && npm run test
############################################
# 运行时,也即最终的 Image 内容
############################################
FROM node:6.16.0 as runtime
WORKDIR /app
# 第一行,设置时区为北京时区(东八区)
# 第二行,解决 npm log 日志中掺杂命令行控制符导致日志解析、匹配困难的问题
RUN rm /etc/localtime && echo "Asia/Shanghai" > /etc/timezone && dpkg-reconfigure -f noninteractive tzdata \
&& npm config set color false
ENV NODE_ENV="production"
# 不要使用 npm,也不要用 shell form,避免 node 进程无法收到 SIGTERM 信号。
ENTRYPOINT ["node"]
CMD ["dist/server.js"]
# 运行时需要的文件
COPY --from=build /app/package.json /app/yarn.lock ./
COPY --from=build /app/node_modules /app/node_modules/
COPY --from=build /app/dist /app/dist/
复制代码