最近 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>
那我们就只剩一个选项了:直接将 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://[email protected]: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>
- 由于还有 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/