大家使用 CI 的一个初衷就是用来构建编译产物,很多编程语言都有自己的包管理系统,可以借助社区的力量快速搭建自己的业务代码。但是由于依赖包安装过程太过缓慢,会严重影响 CI 运行的时间,所以我们在使用 CI 时一般倾向于将初次安装后的依赖包缓存下来,来加快后续或者下次的 CI 构建流程。本篇文章将会拿 Node.js 为例来讲解如何在 gitlab CI 中使用缓存。
1. 实现方式 1.1 直接缓存安装目录 Node.js 的包默认会安装在项目中的 node_modules 文件夹下,所以首先想到的就是直接将这个文件缓存起来备用。带着这个目标,我们写出如下 gitlab-ci.yml 文件:
image : node: latest
variables :
CI : 1
NPM_INSTALL_CMD : 'npm i --no-audit --no-fund --verbose'
.node_modules-cache : &node_modules-cache
key :
files :
- package- lock.json
paths :
- node_modules
policy : pull
.check_node_modules :
script : &check-node-modules
- |
set -v
echo "check cache..."
if [ -d node_modules ] ; then
echo "show 10 deps:" && (ls node_modules/ | head) && echo "cache exist"
else
eval $NPM_INSTALL_CMD
fi
before_script :
- git - - version
- node - v
- npm - v
stages :
- prepare
- build
- image
.when-to-run : &when_to_run
rules :
- if : $CI_COMMIT_MESSAGE !~ /^\d+.\d+.\d+/
- if : $CI_COMMIT_TAG =~ /^v\d+.\d+.\d+\S*$/
job:prepare :
stage : prepare
script :
- eval $NPM_INSTALL_CMD
cache :
- << : *node_modules-cache
policy : pull- push
allow_failure : false
<< : *when_to_run
job:build:eslint :
stage : build
script :
- *check-node-modules
- npm run eslint
allow_failure : false
cache :
- << : *node_modules-cache
<< : *when_to_run
job:build:build :
stage : build
artifacts :
expire_in : 10min
paths :
- dist/
script :
- *check-node-modules
- npm run build
cache :
- << : *node_modules-cache
allow_failure : false
<< : *when_to_run
代码 1.1.1 首先需要指出的是 gitlab 中的缓存是不可靠性,生成的缓存可以手动清除掉,清除的方法可以手动去 runner 机器上删除缓存所在目录,或者在 Pipelines 页面上手动点击 Clear runner caches
按钮均可清除缓存。所以我们在 CI 文件中增加了缓存是否判断的判断,如果不存在就重新安装一遍,这也就是 check_node_modules
代码块的作用。 为了更加精确的控制缓存版本,这里 package-lock.json 作为缓存的 key,在 gitlab 运行时会对该文件做 md5 计算,以计算得到的 md5 值为 key,查询 gitlab 中是否存在对应的缓存。这样做的好处是,一旦有包的增删 package-lock.json 就会产生变化,这就代表着之前的缓存失效,需要重新安装。 Node.js 中自带的包管理器 npm,很多情况下性能比较低下,一旦当前 package-lock.json 和 node_modules 中有差异的时候,其在安装过程中会进行差分计算,算的比较慢。所以这里直接用 pcakge-lock.json 作为缓存 key,就是想让其尽量节省安装时间。不过 package-lock.json 有一个副作用,它内部会冗余一个项目的 version 字段,假设你运行 npm version 命令来手动打一个 git tag 的时候,这个命令会自动修改 package-lock.json 中的 version 字段,这会直接导致我们使用 package-lock.json 作为 key 的缓存失效。
1.2 使用 Node.js 自带的缓存命令 Node.js 的 npm 命令可以支持在安装的时候,手动指定缓存文件夹,这样可以做到优先使用缓存文件夹中的数据,如果缓存文件夹中没有找到所需要的包,才会从网上去下载。下面是一个使用 npm cache 参数的 CI 代码:
image : node: latest
variables :
CI : 1
.node_modules-cache : &node_modules-cache
key : for- all
paths :
- .npm
policy : pull
before_script :
- git - - version
- node - v
- npm - v
- ls .npm - lh | head | | true
- npm i - - cache .npm - - prefer- offline - - no- audit - - no- fund - - verbose
stages :
- prepare
- build
- image
.when-to-run : &when_to_run
rules :
- if : $CI_COMMIT_MESSAGE !~ /^\d+.\d+.\d+/
- if : $CI_COMMIT_TAG =~ /^v\d+.\d+.\d+\S*$/
job:prepare :
stage : prepare
script :
- echo prepare
cache :
- << : *node_modules-cache
policy : pull- push
allow_failure : false
<< : *when_to_run
job:build:eslint :
stage : build
script :
- npm run eslint
allow_failure : false
cache :
- << : *node_modules-cache
<< : *when_to_run
job:build:build :
stage : build
artifacts :
expire_in : 10min
paths :
- dist/
script :
- npm run build
cache :
- << : *node_modules-cache
allow_failure : false
<< : *when_to_run
代码 1.2.1 首先我们通过 npm 的 –cache 参数将缓存写入项目根目录下的 .npm 文件夹。如果我们不使用缓存指令的话,它在每次 job 执行完成之后,这个文件夹也随之消逝了,所以我们通过 job 的 cache 指令,将 .npm 文件夹缓存起来。注意我们在配置缓存的 key 的时候直接将其名字写为 for-all
,虽然这个名字是随便起的,但是会导致缓存将会在所有代码分支、tag 中可用。 同时我们在 npm 命令中使用 –prefer-offline 参数,它将可以保证首先使用本地缓存的安装包,本地缓存没有找到可用包时才从网上下载。 我们这里使用的代码结构也跟之前不一样,在 代码 1.1.1 中,只在 prepare 阶段才显式的安装依赖,但是在 代码 1.2.1 中,在所有阶段都运行了安装命令。这是由于我们缓存的文件夹是 .npm 而不是 node_modules ,所以需要每次通过安装命令来生成 node_modules 目录。 不论像 代码 1.1.1 那样缓存 node_modules ,还是像 代码 1.2.1 那样缓存 .npm 目录,两者都各有利弊。对于前者来说缓存一旦生成,下次可以直接使用缓存从而跳过安装步骤,但是缓存 key 选择 package-lock.json 时容易因为修改 packge 的 version 属性导致缓存失效。对于 后者,能够使用全局缓存来保证缓存生命周期一直有效,但是每次执行安装过程还是会耗费一定时间,缓存命中时 job 的执行时间比前者要慢。
2. 其他解释 2.1 为何不用 npm ci 命令 网上的很多教程在 CI 中安装 Node 依赖的时候都是使用 npm ci 命令,那么它和 npm install 的区别是啥呢,首先 npm ci 在安装的时候会删除 node_modules 文件夹,但是我们的 CI 运行在 docker 中,node_modules 初始化的时候就是空的。其次,npm ci 只使用 package-lock.json 来安装依赖包,但是一旦有人不按照规范来安装依赖包,就会导致安装完的包不能用。如果为了约束安装行为可以使用 npm ci,如果为了更好的兼容性可以使用 npm install 。
2.2 为何使用 docker 模式的时候缓存生成后不能读取 使用 docker 模式时,会通过挂载宿主机目录的方式来加载缓存,但是默认会随机挂载一个宿主机目录。这样上一个 docker job 生成的缓存文件,在下一个 job 中将会失效。直接将 gitlab runner 中的挂载的 /cache 目录,映射到一个固定宿主目录即可。例如下面这个配置,volumes
属性默认为 ["/cache"]
,gitlab runner 关联的 docker 启动后将会随机映射宿主机目录,这里将其关联 /tmp
目录后,将会直接关联宿主机 /tmp
目录,保证缓存能够复用成功。
[ [ runners ] ]
name = "My Docker Runner"
url = "https://gitlab.com"
id = 1234567
token = "你的注册token"
token_obtained_at = 2023-12-16T12:54:50Z
token_expires_at = 0001-01-01T00:00:00Z
executor = "docker"
[ runners.cache ]
MaxUploadedArchiveSize = 0
[ runners.docker ]
tls_verify = false
image = "docker:20.10.16"
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = [ "/var/run/docker.sock:/var/run/docker.sock" , "/tmp:/cache" ]
shm_size = 0
network_mtu = 0
代码 2.2.1
2.3 既然 npm 使用缓存如此拉跨,有没有替代方案 npm 的 package-lock.json 冗余 version 字段确实给我们使用缓存带来的很多不变,但是如果我们切换为其他包管理工具,例如 yarn 或者 pnpm 却不会有这么烦人的问题,它们的 lock 文件比较纯粹,只有依赖包的信息,使用类似 1.1 小节的解决方案是完全可以的。 下面给出一个使用 yarn 作为包管理工具的 CI 示例文件:
image : node: latest
variables :
CI : 1
.node_modules-cache : &node_modules-cache
key :
files :
- yarn.lock
paths :
- node_modules
policy : pull
.check_node_modules :
script : &check-node-modules
- |
set -v
echo "check cache..."
if [ -d node_modules ] ; then
echo "show 10 deps:" && (ls node_modules/ | head) && echo "cache exist"
else
yarn install
fi
stages :
- prepare
- build
- image
.when-to-run : &when_to_run
rules :
- if : $CI_COMMIT_MESSAGE !~ /^\d+.\d+.\d+/
- if : $CI_COMMIT_TAG =~ /^v\d+.\d+.\d+\S*$/
job:prepare :
stage : prepare
script :
- yarn install
- npm run eslint
cache :
- << : *node_modules-cache
policy : pull- push
allow_failure : false
<< : *when_to_run
job:build:build :
stage : build
artifacts :
expire_in : 10min
paths :
- dist/
before_script :
- *check-node-modules
script :
- npm run build
cache :
- << : *node_modules-cache
allow_failure : false
<< : *when_to_run
job:build:test :
stage : build
coverage : '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
variables :
NODE_ENV : test
before_script :
- *check-node-modules
script :
- npm run test: ci
artifacts :
when : always
reports :
junit : junit.xml
coverage_report :
coverage_format : cobertura
path : coverage/cobertura- coverage.xml
cache :
- << : *node_modules-cache
allow_failure : false
<< : *when_to_run
dependencies : [ ]
代码 2.3.1
此外还有一个终极解决方案,不过这个方案必须得使用自建的 gitlab runner,不能使用线上 sass 版的 gitlab 共享 runner,因为我们需要修改 runner 部署机器上的配置文件。其次,我们需要使用 pnpm,借助其高效的本地磁盘缓存机制可以快速安装依赖包。示例代码如下:
.install_node_modules :
script : &install-node-modules
- |
pnpm config set store-dir /pnpm/store
pnpm install
stages :
- prepare
- test
- image
.when-to-run : &when_to_run
rules :
- if : $CI_COMMIT_MESSAGE !~ /^\d+.\d+.\d+/
- if : $CI_COMMIT_TAG =~ /^v\d+.\d+.\d+\S*$/
job:prepare :
stage : prepare
before_script :
- *install-node-modules
script :
- npm run lint
- npm run build
artifacts :
expire_in : 25min
paths :
- dist/
allow_failure : false
<< : *when_to_run
job:test :
stage : test
coverage : '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
script :
- *install-node-modules
- ls node_modules - lh
- npm run test
artifacts :
when : always
reports :
junit : junit.xml
coverage_report :
coverage_format : cobertura
path : coverage/cobertura- coverage.xml
allow_failure : false
<< : *when_to_run
dependencies : [ ]
代码 2.3.2
在代码的开头,我们使用了 pnpm config set store-dir /pnpm/store
强制让 pnpm 使用目录 /pnpm/store 目录来存储磁盘缓存文件。如果你是使用 shell 模式的话,这么配置完可以直接用,只要保证在 runner 执行的机器上 /pnpm/store
目录存在即可。如果使用 docker 模式,我们需要修改 runner 的 /etc/gitlab-runner/config.toml
文件,找到注册 runner 的配置,修改 runners.docker
下的 volumes
配置:
[ [ runners ] ]
name = "My Docker Runner"
url = "https://xxx.gitlab.com"
id = xxxx
token = "yyyy"
token_obtained_at = 2023-12-16T12:54:50Z
token_expires_at = 0001-01-01T00:00:00Z
executor = "docker"
[ runners.cache ]
MaxUploadedArchiveSize = 0
[ runners.docker ]
tls_verify = false
image = "docker:20.10.16"
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = [ "/var/run/docker.sock:/var/run/docker.sock" , "/cache" , "/opt/pnpm/store:/pnpm/store" ]
shm_size = 0
network_mtu = 0
代码 2.3.3
上述配置中,我们将宿主机中的 /opt/pnpm/store
目录映射到容器中的 /pnpm/store
,读者可以根据实际情况进行映射。
这么设置完之后,不光当前项目会收益于 pnpm 的缓存,任何和此项目配置相同 pnpm 缓存路径的项目也能受益。