December 19, 2019

记一次gitlab-ci的优化过程

最近在开发的过程中,发现部署的过程非常非常的慢,可能一个跑完完整的任务理想情况下都需要10分钟左右,如果同时任务多一点,甚至到20分钟,非常影响工作效率;尽管可以在部署任务的时候摸鱼,当然这个时候可以切换去做其他任~但是长此下去不行鸭。

分析

ci/cd中有很多job,得分析这些job的过程,卡在哪一步,才能针对过程进行优化。通常来说,一个job的“主任务”,也就是任务的核心过程,例如通过webpack构建网站资源,连接到服务器,发送文件等;这部分处理本身“主任务”之外,可能还跟对应服务器硬件资源较大关联,暂时这些就不列在这次的优化方向。

那么除了这些主任务之外,还涉及一些通用的,例如cacheartifacts,这些也是比较影响job的速度,所以这次处理方向也是这个。

优化结果

提前说一下优化的结果:

优化前,job数量2-3个的时候,大概10-20分钟;job数量在5-6的时候,需要25分钟左右;

优化后,job数量2-3个的时候,大概2-3分钟;job数量在5-6个的时候,大概5分钟左右;

可以对比,大概下降的达到70%到90%左右,极大提高开发效率。

优化手段

更改cache影响范围

这个其中一个原因是原有的cache策略设置不好,设置了全局的cache策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# .gitlab-ci.yml
cache:
  key: xx
  path:
    - node_modules

# job1    
job1:

# job2
job2:

# job3
job3:

因为设置了全局的cache,这个cache的内容是node_modules, 而且job之间并没有对策略(policy)进行更改,所以每个job执行的时候,开始都拉取cache(pull cache),然后提取node_modules;然后开始执行“主任务”,执行完任务之后,再把node_modules打包压缩为cache.zip,再进行上传:

1
2
3
4
5
download cache.zip
extract node_modules
// ...
tar node_modules
upload cache.zip

所以每个job都至少执行了“下载-上传”的过程;但是有些任务可能并不需要node_modules;所以整个node_modules连下载都没必要,通常下载是从docker对应的云盘进行下载;这些io也会很耗费时间,所以这个时候就需要看任务对cache的使用情况;

  1. 若大部分job都是需要node_modules缓存,只有少部分job不需要,则可以仍然设置全局cache,对部分job显式更改策略:
1
2
3
job1:
  # 不需要cache
  cache: {}
  1. 若只有一部分的job使用cache,那么则不设置全局cache,只对job进行设定cache
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
job1:
  cache:
    key: "node_modules"
    path:
      - node_modules/
job2:
  script:
    - echo "this is job2"
job3:
  script:
    - echo "this is job3"
job4:
  cache:
    key: "node_modules"
    path:
      - node_modules/

不同的job可以使用相同的缓存,通过key值来确定,例如上面的设置node_modulescache的id值,job1job4都会使用并且更新同一份cache

减少cache的上传次数

通过上面第一点的优化,可以把不需要cache的任务过滤掉;但是有些地方还是会有重复,例如上面例子的job1job4,两个job都对cache进行下载与更新,但是是否需要更新这么频繁呢?是否另外的任务是不需要对cahce进行更新,只需要获取就ok了?

对于这种情况,可以设置一个任务需要“下载-上传”cache,而其他需要到相同的cache,只进行下载,那么就可以省了“上传”的步骤;可以通过设置policy: pull配置该策略;policy的值有三个:

  • pull只下载
  • push只上传,不下载
  • pull-push下载-上传(不设置的默认值)

假设上面提到job1总是比job4要先,所以job4的策略可以做更改,删除job4的 npm install过程

1
2
3
4
5
6
job4:
  cache:
    key: "node_modules"
    path:
      - node_modules/
    policy: pull

而为了保证这种顺序,job1我们可以设置为一个独立的stage,在执行job4的之前,必须执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
stage:
  - first
  - second
job1:
  stage: first
  cache:
    key: "node_modules"
    path:
      - node_modules/
job2:

job3:

job4:
  cache:
    key: "node_modules"
    path:
      - node_modules/
    policy: pull

所以我们可以把stage: first的过程定义为获取node_modules,也就是npm install的过程,作为其他依赖node_modules最基础的一个job。这就完了?还有优化方向吗?

通常来说,node_modules的变化次数不会特别多,所以尽管把npm install的过程抽离出来,但是每次提交都执行npm install,还要把巨大的cache.zip上传一遍;耗费的时间也是不少。所以我们可以设置当npm包发生更改的时候再执行npm install的job;也就是package.json发生改变,所以job1可以更改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
job1:
  stage: first
  cache:
    key: "node_modules"
    path:
      - node_modules/
  rules:
    - changes:
      - package.json
      - yarn.lock # 或者 package-lock.json 等
      when: always
    - if: $YOUR_CUSTOME_VARIABLE
      when: always

上面的changes数组表示,如果package.json或者yarn.lock发生改变之后,再运行job1;细心的朋友可能发现rules的数组还有一个规则,if: $YOUR_CUSTOME_VARIABLE,这个规则作用是啥?

因为cache是不稳定的,有可能被删除;这个时候package.json并没有更新,job4就不能够获取cache,job4就不能跑起来(上面提到,job4去掉npm install过程);而这个if: $YOUR_CUSTOME_VARIABLE规则就是加入可以人为主动控制触发job1,用于下载npm install;而这个变量是存在于gitlab仓库的ci variable,或者主动触发pipeline的时候加入。这样子整个流程就能完整保证运行起来。

小结

实际上运用起来更改比较简单,但是效果确实非常明显,对于上面的方法,小结如下:

  1. 更改cache影响范围,不适用的选择跳过
  2. 减少cache的更新次数,通常node_modules不需要多个任务更新,独立到一个任务即可
  3. 按需触发cache的更新策略,并有人工干预手段进行处理,保证程序可用性

番外篇

实际上,之前旧的规则每次耗费这么多的时间,除了cache控制不好之外,也有另外一个非常重要的原因。从上面可以知道,在没优化cache策略之前,每个job都有对cache进行pull-push的过程;在push之前,ci会显示打包涉及的文件数量:node_modules/: found ${xxx} matching files,其中${xxx}为需要压缩为cache.zip的文件数量;通过观察发现,跑的pipeline越多,打包的文件数量越来越多;最终导致cache.zip体积也越来越大;甚至已经达到了30w+的文件数量,这是非常恐怖的数字。要知道,项目初始化安装完node_modules的时候,文件数量才3w+,为什么文件数量的数量级会直接加了一个数量级?

因为npm安装的文件也不会每次更改,已经有lock的相关文件,所以剔除涉及的npm包问题;那么node_modules除了npm包之外还有什么哪些文件?.bin.cache.bin的数量也是不变的;所以只能落到.cache文件夹变化了。

node_modules/.cache文件夹内容通常是cache-loader存储缓存文件的地方,通过统计开发机的该文件夹的文件数量,惊人的发现,居然也有将近20w的文件。其中.cache/vue-loader的最多,达到10几w,其次是.cache/babel-loader

那么为啥.cache文件夹的数量,因为我们在开发或者部署过程中,更改文件之后,就会生成新的一些cache,逐渐逐渐的,.cahce文件就越来越多。而优化前的cache规则,是全局的,而且每次都会push,所以就每次跑完pipeline都会把该次所更新的.cache增加文件,最后就导致文件非常庞大.....

测试篇

cache-loader的目标是为了加快构建速度,下面做了一个测试,大家可以观察一下:

# cache no-cache
first run dev-serve 40s 38s
second run dev-serve 20s 29s
third run dev-serve 20s 28s
add line 5s 5s
trigger eslint error 3s 3.1s
stop & restart dev-serve 21s 29s
stop & update element & restart dev-serve 25s 30s
first build 62s 60s
second build 23s 30s
update element & build 30s 33s

测试顺序从上到下,cache为使用cache-loaderno-cache没有使用,测试流程解析如下:

  1. first run dev-serve首次启动本地开发,通常是npm run serve
  2. second run dev-serve文件没有更改,再次启动本地开发
  3. third run dev-serve与上一步一致
  4. add line添加一行代码,添加的这行代码一致
  5. trigger eslint error,这里也是添加一行代码,但是这行代码会触发eslint的报错,例如执行一个不存在的函数
  6. stop && restart dev-serve,关掉本地开发的serve,然后再次启动
  7. stop && update element & restart dev-serve,关掉serve,然后更新部分元素,两次更新一致;然后再次启动serve
  8. first build,第一次执行build任务,生产环境构建
  9. second build,没有更改文件,再次执行build任务
  10. update element & build,更改部分元素,再次执行构建

从上面可以发现,cache-loader没有什么优势;只在两次构建文件没变化的时候,有一定的提升;但是这种情况比较少,所以是否利用也是值得商榷。当然也有可能测试不够严谨,大家也可以测试过程。

End.

参考文章: