docker中部署node服务的构建优化

node 服务全部切换 docker 部署后,构建阶段总有人反应很慢,原因是每次构建都要在 docker 中重新装包,就想怎么能优化一下,想了很多优化的路子,记录一下优化的思路,CI 使用 Jenkins。

利用npm缓存(不可行,已放弃)

最近构建过程中还有个 bug,node-sass 这个包一直装不上,原因是有个二进制文件要从GitHub下载,但是一直下载不下来:

image-20190430112823001

而我本地就安装的很快,本质上因为这个包我装过一次,所以直接从缓存取是很快的:

CA0A0064-2E7A-4D26-ADD3-C48055BF0647

从安装过程我们也可以看到,安装好后会保存一份缓存在本地的 .npm 文件夹中:

image-20190430113023678

所以我在想怎么在 docker 中装包时能使用到 npm 的缓存机制,我想到了两种方法:一种是在镜像外装好包后把 node_modules 拷贝进镜像中,这样就可以利用上构建服务器上.npm中的缓存,另一种是直接把.npm拷贝到镜像中,这样就可以用上缓存了。接下来看下实践。

拷贝node_modules

在镜像外的构建服务器上,我们会先安装一次node_modules(一般是用来编译我们的代码),在服务器上安装是可以利用服务器上的缓存的,而且我们一般不会删除之间安装的node_modules。这样的话如果我们能直接把外部安装好的node_modules拷贝进来,是不是可以大大缩短安装时间呢🤔?思考这个问题前,我们先要想一个问题:一台机器上装的node_modules是否可以用在别的机器上?我觉得应该是可以的,来试一下。

尝试在构建服务器提前装好包,同时把.dockerignore中的node_modules删除,这样 Dockerfile 中的COPY . /app/就可以把装好的包也复制进来,但是构建成功后,缺启动失败了,报错如下:

57FF6441-531A-4677-A0CE-152A893166EC

可以看到启动失败了,原因是有个 C++库(libstdc++.so.6)获取不到,所以我猜想,在有些包使用 C++ 编写的动态链接共享对象,即 NodeJs 插件的时候,包是不能在不同系统间共享的。

实际上我觉得这样简单共享 node_modules 时,肯定还有其他因系统不同导致的兼容问题存在,除非内外系统完全一致,而且这样简单共享还有一个问题就是,在外部编译还是需要安装devDependencies的,但其实运行时完全不需要,会造成一定的冗余。

所以拷贝node_modules的方法是行不通的,我们接着往下看。

拷贝.npm

直接拷贝 node_modules 不行,那么我们还有什么方法利用 npm 缓存呢,我想到了 .npm(可以使用npm config get cache查看位置),因为在本地安装的时候之所以快就是因为用到了 .npm 的缓存,那么我把构建服务器的 .npm 拷进容器不就可以用缓存了吗🤔?这样如果拷贝速度比下载速度快的话,也是可以加快构建速度的,再试一下:

1
2
3
4
5
# Shell
cp ~/.npm ./

# Dockerfile
RUN cp -r ./.npm /root/

首先要在构建服务器把缓存文件拷贝到当前目录(因为要在构建上下文才能复制到镜像中),然后更新 Dockerfile,构建 docker 时拷贝进镜像。结果一直卡在第一步的复制:

55587BA2-D844-4BEE-895D-2E3F6074FA97

过了很长时间,我停了看了一眼 .npm 的大小:

CBF7FED6-6CA6-4437-A843-BBD8FA3CC667

已经拷了 9.4G 了,还没拷完,我看了一下系统目录下的.npm大小:

57F0B362-129D-4940-8BCB-1535FDE5B7B4

足足有12G,拷贝到每个项目下根本不现实,所以这种方案也放弃了。

利用docker缓存机制

在利用 npm 缓存的方案都失败后,我想到了还有 docker 本身的缓存机制是不是可以用。

我们知道 Docker 构建是分层的,一条指令一层,在没有带--no-cache=true指令的情况下,如果某一层没有改动,Docker 就不会重新构建这一层而是使用缓存,先看 Docker 官方文档的描述

  • Starting with a parent image that is already in the cache, the next instruction is compared against all child images derived from that base image to see if one of them was built using the exact same instruction. If not, the cache is invalidated.
  • In most cases, simply comparing the instruction in the Dockerfile with one of the child images is sufficient. However, certain instructions require more examination and explanation.
  • For the ADD and COPY instructions, the contents of the file(s) in the image are examined and a checksum is calculated for each file. The last-modified and last-accessed times of the file(s) are not considered in these checksums. During the cache lookup, the checksum is compared against the checksum in the existing images. If anything has changed in the file(s), such as the contents and metadata, then the cache is invalidated.
  • Aside from the ADD and COPY commands, cache checking does not look at the files in the container to determine a cache match. For example, when processing a RUN apt-get -y update command the files updated in the container are not examined to determine if a cache hit exists. In that case just the command string itself is used to find a match.

Once the cache is invalidated, all subsequent Dockerfile commands generate new images and the cache is not used.

简单来说就是如果第n层有改动,则n层以后的缓存都会失效,大多数情况下判断有无改动的方法是判断这层的指令和缓存中的构建指令是否一致(如果不想在某一句指令上使用缓存,有个小技巧就是在这个指令上加个空格),但是对于 COPY 和 ADD 命令(ADD 只是多了个解压的功能,尽量用 COPY,语义清晰一点),则是比较要拷贝的文件是否有改动,然后判断本层是否有改动。我们可以利用这个特性,选择先复制package.json并装包,再把其他文件拷贝进来,这样就能实现只在package.json变动的时候重新安装,在没有变动的情况下使用缓存缩短构建时间。我们对 Dockerfile 进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
FROM xingshulin/node-with-pm2:10.13.0

WORKDIR /app

# 拷贝所有文件 每次都更新无法使用缓存
COPY . /app/

# 装包
RUN npm install --registry=http://npm.xingshulin.com/

# --no-daemon不是不守护 而是在前台守护 否则前台没有进程docker容器会自动退出
CMD pm2 start ./index.js --no-daemon >> /dev/null
1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM xingshulin/node-with-pm2:10.13.0

WORKDIR /app

# 修改为先拷贝package.json文件
COPY package.json /app/

# 装包
RUN npm install --registry=http://npm.xingshulin.com/

# 再拷贝剩余的文件
COPY . /app/

CMD pm2 start ./index.js --no-daemon >> /dev/null

我们修改除 package.json/Dockerfile 外其他文件,然后对比构建的日志:

全部复制 先复制package.json
image-20190510120142345 image-20190510120855766

可以看到全部复制从复制开始就无法使用缓存了,而先复制 package.json 则可以在装包阶段也使用缓存。

为了验证优化的有效性,同一个项目我使用两种形式分别进行了5次构建,从时间上来看,除了优化后第一次构建慢了一点外,之后有了缓存构建起来还是挺快的,优化效果还是肉眼可见的:

image-20190510145549393

image-20190510145557696

构建基础镜像

我们发现构建中还有很多东西是不需要经常变动的,比如 apt-get 安装的一些工具包,还有做进程守护的 pm2,这些最好把他们提前打包好构建一个基础镜像,以后从基础镜像开始构建,是最节省时间的(缓存有失效风险并且对比时间也省去了,虽然不知道对比时间有多少),我构建了一些基础镜像够日常开发使用,并推送到了Docker Hub

image-20190510143551785

我还写了一些shell脚本帮助更新基础镜像,这次先不具体介绍。

更多的想法

我还看到了一些其他的优化想法,一起贴在这里。

挂载 .npm 目录

容器中可以挂载主机目录,但是构建阶段是不可以的,所以还是行不通。

使用 CMD 启动后安装

CMD 命令用来描述容器启动后运行的程序(如果 Dockerfile 中有多个 CMD 指令,只有最后一个 CMD 有效),除了直接运行,我们也可以运行一个 shell:CMD ["sh", "./dockerShell.sh"]

我们可以把安装放在启动之后,这样就可以通过把 .npm 挂载到容器来使用缓存,装包之后再启动服务,但是这样的话服务的重启时间就比较长,我们还是希望服务能快速启动,对回滚也有好处,并且如果启动后直接找不到包,会一直报错,担心跑满服务器 CPU 影响其他服务。

使用上一次的镜像

每次以上一次构建的镜像为基础镜像进行构建也是不错的想法,按道理说之前的node_modules都在,也有缓存,装起来应该很快。

但是我试了下没有成功,package.json 改变后重新装包时没有走缓存,但是 .npm 是确实存在的,可能是对 docker 镜像构建或者 npm 缓存机制理解还不深,有谁知道可以帮忙解释下。

如果成功的话,仔细想一下这样优化的好处在于更新 package.json 后,只需要安装最新更新的包,并且有本地缓存,也有一些不完美的地方,比如第一次时没有上一次,要单独构建,并且要保留第一次构建的脚本,以防忘记一些第一次构建的细节,不知道对基础镜像做过什么了。

构建服务器

启动一个容器作为构建服务器

之前都是直接在 Jenkins 所在服务器上构建的前端代码,有些缺库少权限的问题还得找运维解决。设想一下如果我们自己起一个 docker 容器,专门用来构建,是不是自主权就多了很多,而且如果跟部署环境是一样的话,node_modules 应该是可以直接复制的?复制的话,就要在构建容器/服务器/服务容器之间来回复制,或者直接使用 docker 嵌套?但是我感觉嵌套的话太臃肿了,三者或者去掉最终服务容器的两者(构建容器和服务器,服务容器不使用挂载,保证最终服务容器的完整性)挂载一个目录我觉得也是可行的方法。

借助 docker-compose

再设想一下,把构建服务器和运行的服务器一起编排,是不是方便一些?

以上两种方法都还没试,不知道效果怎么样。

参考链接

利用构建缓存机制缩短Docker镜像构建时

How to cache the RUN npm install instruction when docker build a Dockerfile

npm install with cache in docker

缓存策略的更多知识可以看这里:

npm 模块安装机制简介

npm缓存现在是怎么做的?