NodeJS Docker 打包优化

NodeJS Docker 打包优化


最近 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 即可。然而我们并没有这样做,主要有这两个原因:

  1. 我们在注册了 postinstall npm>
  2. 由于还有 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/


分享到:


相關文章: