logo

鱼肚的博客

Don't Repeat Yourself

前端CI中的性能优化

前言

前端CI中的一个很难绕过的问题,是node_modules的处理。现在的前端生态建立在npm之上,而npm有黑洞之称,相信这个图大家都不陌生。

npm 黑洞

当然除了node_modules以外,还会有一些其它的性能瓶颈,这些问题都会逐渐地吞噬掉CI的时间,使的一次pipeline的时间越来越久,阻塞开发部署等流程。

常见的性能问题

在实际工作中,我遇到过一些影响性能的问题,这里和大家分享一下。

  1. node_modules的安装问题
  2. docker镜像拉取速度问题
  3. gitlab cache加载和保存缓慢问题
  4. runner性能跑满,服务器压力过高,导致整体变慢问题
  5. eslint等执行过慢问题
  6. 磁盘读写过慢问题
  7. 磁盘占用过多问题

常见的解决方案

一句话总结:缓存解万愁(当然缓存也带来新的烦恼,此处暂且不提)。

具体的策略包括但不限于如下范围:

  1. Gitlab CI中的Cache机制
  2. 镜像加速
  3. Gitlab Runner中docker executor的缓存
  4. Docker cache
  5. Yarn、npm 等cache
  6. ESLint Cache(针对eslint过慢的问题)
  7. BCache(混合SSD加速)
  8. 定期清理磁盘空间

实例分享与原理剖析

node_modules的优化

node_modules的体积比较大,涉及到的磁盘I/O和网络I/O比较多,再加上国内访问npm的速度不佳,很容易成为性能瓶颈。

对它的优化主要是如下几种思路:

  1. 最彻底的,能不安装就不安装。可使用基础镜像全局安装、Docker Cache等手段
  2. 次之的,在不得不安装node_modules的时候,缓存上一次的node_modules,或使用npm cache、yarn cache等。这种方式一般会用到 Gitlab CI中的cache机制
  3. 最后,切换镜像到一个国内镜像,也有助于加速。

先说尽量不安装node_modules的方法

全局安装

假定有一个工程,CI任务中只需要处理npm发包。那有可能node_modules中只有 typescript之类的工具。

如果是这种场景的话,可以考虑全局安装typescript等工具,代替项目中安装。

从原来的

1FROM node:12-alpine
2WORKDIR /workspace
3
4ADD . /workspace
5RUN npm i && npm run build && publish

转换成:

1FROM node:12-alpine
2WORKDIR /workspace
3RUN npm i -g typescript
4
5ADD . /workspace
6RUN npm run build && publish

这样就省去了 npm i 这个过程,同时因为RUN npm i -g typescript这个步骤比较容易命中Docker cache,所以大多数时候并不会重复安装依赖。

全局安装的方法,很多时候还是避免不了安装node_modules,因为有可能用到一些更复杂的,必须随项目安装的依赖,比如一个需要webpack构建之后,部署构建产物的工程,也就是常见的前端业务工程,基本都实现不了全部用全局包。

这个时候就用到了依赖前置。

依赖前置

依赖前置是为了利用Docker Cache而对Dockerfile进行的一个改进。

修改之后的Dockerfile是这种样子:

1FROM node:12-alpine
2WORKDIR /workspace
3RUN mkdir -p /workspace
4
5COPY ./package.json ./package-lock.json /workspace
6RUN npm i
7
8ADD . /workspace
9RUN npm run build && publish

它和上面的Dockerfile相比,主要的改动是把 npm install 放在了 ADD . /workspace的前面。

按照Docker cache的原理,上面的layer如果没变,下面的RUN命令就会直接使用缓存。如果package.jsonpackage-lock.json文件没有变化,就会命中缓存,如果这两个文件中任何一个有变化,则无法命中缓存,会重新执行安装。

在大多数场景中,发生变化的是代码,而非package.json等工程配置文件。因此ADD . /workspace之后基本上无法命中缓存,而COPY ./package.json ./package-lock.json /workspace之后则大概率能命中缓存。

以上两种都是避免安装node_modules实现加速,下面介绍下其它的方案:

Gitlab CI Cache

在有yarn cache或npm cache时,安装node_modules会比全新安装快不少,因为有不少的资源可以从本地获取了。

Gitlab CI中提供了一个Cache机制,可以指定要缓存的路径,在下次执行时先加载缓存,后执行任务。

比如说一个常见的CI配置:

1cache: &global_cache
2  key: ${CI_COMMIT_REF_SLUG}
3  paths:
4    - node_modules/
5    - public/
6    - vendor/
7  policy: pull-push
8
9job:
10  cache:
11    # inherit all global cache settings
12    <<: *global_cache
13    # override the policy
14    policy: pull

具体情况,可直接查阅官方文档https://docs.gitlab.com/ee/ci/caching/

在使用Gitlab CI Cache的时候需要注意,对于I/O压力比较高的服务器来说,CI Cache可能也会消耗较长的时间。

切换国内镜像

这一部分资源很多,此处不再赘述。可考虑使用nrm切换到taoabao等镜像源。

ESLint的优化

之前写过一篇博客专门讲这个:传送门

Gitlab Runner优化

Gitlab Runner中可以配置一些优化,我尝试过的主要是如下的方面:

  1. 通过限制资源和并发数,避免CI服务器限入激烈的资源竞争。
  2. 优先使用本地docker镜像,避免因为docker hub服务慢拖慢Runner

限制并发数

Gitlab Runner中可以设置并发数的上限,既有服务级别的,也有Job级别的。

示例配置:

1concurrent = 7
2check_interval = 0
3
4[session_server]
5  session_timeout = 1800
6
7[[runners]]
8  name = "hello"
9  limit = 2
10  url = "http://git.mycompany.com/"
11  token = "mytoken"
12  executor = "docker"
13  [runners.custom_build_dir]
14  [runners.docker]
15    tls_verify = false
16    image = "docker"
17    privileged = false
18    disable_entrypoint_overwrite = false
19    oom_kill_disable = false
20    disable_cache = false
21    volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
22    pull_policy = "if-not-present"
23    shm_size = 0
24

如上的示例代码中,concurrent = 7代表此Runner服务最多同时跑7个Job。而limit = 2则代表hello这个job最多同时跑两个。

限制资源

限制资源是通过Docker提供的限制资源的方式,会需要较新的Docker服务版本。可以用来限制CPU、GPU、内存等资源。

如限制Docker run过程中可用的cpu资源:

1docker run --cpus="1" xxx

详细使用方法参考官方文档:Runtime options with Memory, CPUs, and GPUs

优先使用本地镜像

Runner服务器的 /etc/gitlab-runner/config.toml文件中,有关于docker executor的配置,在[[runners.docker]]中,默认是始终拉最新的,可以改成优先使用本地的:

1  [runners.docker]
2    pull_policy = "if-not-present"

配置成if-not-present之后,如果本地有此镜像,就不会再重新查询docker hub。

磁盘的优化

CI服务器需要的磁盘空间一般都比较大,尤其是在需要npm install的时候。所以有可能服务器中使用的是机械硬盘,而非固态。而读写速度对CI的影响会比较大。

在有些时候,有可能能有一些比较小的固态硬盘,CI存储全存在上面不太现实,但可以用来做混合SSD,实现I/O方面的优化。

Linux中可以使用BCache工具制作混合SSD,参考我之前写的博文:Linux中使用小容量SSD制作混合SSD硬盘

定期清理磁盘空间

磁盘过满的时候,也会影响性能,当磁盘空间不足时,甚至会使CI服务直接停止工作。所以需要加一些清理脚本。

在使用Docker executor的时候,磁盘的占用主要是已经不再使用的Docker image,以及一些Docker volume。

要定期清理它们,可以使用下面的命令:

1#!/bin/bash
2
3# 清理镜像,保留一天
4docker system prune -f --filter "until=$((1*24))h"
5
6# 清理volume,保留三天
7docker volume prune -f --filter "until=$((3*24))h"

把这个脚本随便起个名字,加上可执行权限,然后复制到/etc/cron.daily/etc/cron.hourly等目录中,实现每天清理一次或每小时清理一次的效果。

以上。