Published on

How We Make Gitlab Golangci-lint Runs 50 Times Faster

Authors
  • avatar
    Name
    ttyS3
    Twitter

自从我们迁移到 k8s, 我们观察到 k8s 集群里的 gitlab CI runner 在跑 golangci-lint 的时候比本机跑慢很多.

本机一般几十秒就能搞定的事情, 在跑 CI 的时候, 基本上都要花上几分钟甚至几十分钟. (依项目代码量而异)

当然, 由于 CI 都是自动跑的, 大部分时间我们基本上不会花时间去刻意观察 CI 的执行. 因此慢一点其实也不会影响什么.

这个周末正好趁疫情居家哪也去不了, 研究了一下怎么想办法将速度优化.

起初我以为是因为普通云盘的 IO 性能低下导致的问题, 因为我们的节点的 CPU 和 内核配置都还算不错, 不至于跑个 CI 都会因为这个影响到速度.

但是经过研究, 我发现其实大部分时间花在重复地一次又一次地加载go mod 和 重复地进行代码静态分析.

没有优化之前的 CI lint stage 日志大概是这样的:

golangci-lint run  -v
level=info msg="[lintersdb] Active 33 linters: [bidichk bodyclose dogsled dupl durationcheck errorlint exhaustive exportloopref forbidigo gci gochecknoglobals gochecknoinits goconst gocritic gofmt gofumpt goimports gomodguard goprintffuncname gosimple importas ineffassign makezero misspell nilerr revive rowserrcheck sqlclosecheck staticcheck typecheck unconvert unused wastedassign]"
...
level=info msg="[loader] Go packages loading at mode 575 (compiled_files|exports_file|imports|types_sizes|deps|files|name) took 1m10.18730135s"
...
level=info msg="[runner] linters took 7m43.677383001s with stages: goanalysis_metalinter: 7m43.625988553s"
level=info msg="File cache stats: 148 entries of total size 521.9KiB"
level=info msg="Memory: 1015 samples, avg is 352.2MB, max is 1469.0MB"
level=info msg="Execution took 8m53.886795985s"

注意, goanalysis_metalinter 并不是一个具体的 linter 的名字, 而是指的最主要的 linter 执行阶段, 这里花了 7m43.625988553s. 另一个耗时比较小的stage 是 Go packages loading

所以, 其实最主要的原因还不是磁盘 io. 主要原因还是 golangci-lint 的每次分析结果都没有复用. 每次都要从0重新分析, 即使只修改了一行代码, 整个执行时间也无法减少.

于是查看 golangci-lint 文档, 看它有没有类似的缓存策略.

golangci-lint 缓存目录配置

文档对于 cache 的介绍, 只是一笔带过, 只有一句话描述, 放在配置页的最底部, 那么地不起眼, 以至于我多次看过这个文档而, 从来没引起过我的注意.

https://golangci-lint.run/usage/configuration/#cache

GolangCI-Lint stores its cache in the default user cache directory.

You can override the default cache directory with the environment variable GOLANGCI_LINT_CACHE; the path must be absolute.

这个文档确实比较含糊. 什么是用户默认缓存目录? 这里直接指向了 golang 标准库的文档:

On Unix systems, it returns $XDG_CACHE_HOME as specified by  https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html  if non-empty, else $HOME/.cache. On Darwin, it returns $HOME/Library/Caches. On Windows, it returns %LocalAppData%. On Plan 9, it returns $home/lib/cache.

我们的容器镜像都是 Linux 作为 base image 的, 因此自然是$HOME/.cache

但是实际上它的路径是: $HOME/.cache/golangci-lint

可以通过GOLANGCI_LINT_CACHE设置一个绝对路径覆盖这一默认行为. 文档特别强调要用绝对路径.

在 gitlab CI yaml 文件中, 我们可以这样设置:

variables:
  GOLANGCI_LINT_CACHE: /go/.cache/golangci-lint
  GOCACHE: /go/.cache/go-build
  GOMODCACHE: /go/pkg/mod
  GOPATH: /go

当然, 这里除了设置了 GOLANGCI_LINT_CACHE , 我们还设置了 GOCACHE, GOMODCACHEGOPATH 这三个对于 go 构建速度提升非常重要的环境变量 (它们对于 golangci-lint 速度提升几乎没有帮助, 但是对于后面的 go build 阶段非常有用).

Gitlab CI 缓存配置

我们用的是兼容s3协议的MinIO作为Gitlab CI 缓存后端. 它足够简单.

由于我们有很多个 Gitlab runner, 默认情况下, 缓存在各个runner 之间是不共享的, Gitlab 的实现非常简单, 即在缓存 key 前面自动加上了 runner id. 而这明显跟我们的需求不太符合. 我们不希望在一个runner上面跑好的缓存, 当下次 CI 调度到不同的 runner 的时候, 这个缓存不能利用上. Gitlab 当然也考虑到了这种需求, 只需要设置 CACHE_SHAREDtrue 即可.

具体可参考 https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnerscache-section

当然, Gitlab 启用了 cache 支持, 具体在 CI 的时候是否启用, 是通过 yaml 文件 cache 配置来实现的, demo 如下:

golangci-lint:
  stage: run-linter
  image: docker.io/golangci/golangci-lint:v1.45.0
  script:
    - golangci-lint version
    - golangci-lint run -v --color always --out-format colored-line-number
  cache:
    key: go-cache-${CI_PROJECT_PATH_SLUG}
    paths:
      - go

注意这里的 cache.key 配置, 为了避免不同的项目 CI 时缓存互相更新打架, 我们加上了项目路径 (CI_PROJECT_PATH_SLUG)后缀, 这样每个项目都拥有自己独立但多 runner 共享的 cache.

CI_PROJECT_PATH_SLUG 是专门做这种事情的, 它把 / 等替换成了 -, 方便用作 key name 或 file name.

回头看一下我们设置的环境变量, 其实这里涉及到一个缓存目录规划问题.

我们配置了一个顶级 cache 目录, 然后这个目录用来缓存 go mod (GOPATH: /goGOMODCACHE: /go/pkg/mod), go build cache (GOCACHE: /go/.cache/go-build) 和 golangci-lint cache (GOLANGCI_LINT_CACHE: /go/.cache/golangci-lint). 所以这些环境变量名和路径都不是随意选的, 而是有规划的.

关于cache.path, 有一个约束, 它只能是相对路径

An array of paths relative to the project directory ($CI_PROJECT_DIR). You can use wildcards that use glob patterns:

也许你发现问题了, 我们用的go mod 缓存目录是 GOMODCACHE: /go/pkg/mod

然后 Gitlab cache 只会把缓存内容释放到当前项目的CI目录. 那么我们为什么不用 $CI_PROJECT_DIR/pkg/mod ?

另外, 这个释放的缓存不在它应该在的位置, 我们怎么样利用上这个缓存?

答案就是: 因为 golangci-lint 不支持真正意义上的 skip-dirs

我们特意测试过, skip-dirs 并不像字面意思, skip-dirs 的工作方式是: 即使是在skip-dirs配置里的目录, 也同样进行静态分析, 但是如果有任何违反linter规则的问题, 不予以报告.

所以, 我们不能让 golangci-lint 去扫描这些 go mod, 我们就得想办法把 项目 CI 目录下的 缓存目录, 在执行 golangci-lint 之前移动到其它地方, 然后在 golangci-lint 执行完毕之后, 再移动回来 (因为这个时候Gitlab会将配置的cache.path目录全部打包push到MinIO, 如果不move回来, 就没机会push缓存了). 这个操作可以通过 Gitlab 的 before_script 和 after_script 来实现. 对于 build 阶段 ( go build 构建二进制以及容器镜像构建) 我们没有必要这样移动, 但是一定要记得将这个大于 1GB 的缓存目录(里面有 go mod 和 go build cache等, 体积基本上会超过1GB) 排除在 docker ignore, 要不然会拖慢 docker build. 最简单的办法就是, 我们不区分 lint 和 build 阶段, 统一做两次move (移走缓存是为了 golangci-lint, 移回来是为了Gitlab能push缓存).

.script_before: &go_with_cache_before |
  echo "---------------------------- begin before_script"

  if [[ -d go ]]; then
    echo "---------------------------- after cache get# move cwd go to ${GOPATH}"
    if [ "${DEBUG_GITLAB_CI_COMMAND}" = "true" ]; then
      du -sh go
    fi
    rm -rf "${GOPATH}" && mv go /
  else
    echo "no cache!!!"
  fi
  echo "---------------------------- end before_script"
  
.script_after: &go_with_cache_after |
  echo "---------------------------- begin after_script"

  if [[ -d "${GOPATH}" ]]; then
    echo "---------------------------- before cache put# move ${GOPATH} to cwd"
    mv "${GOPATH}" .
  else
    echo "no ${GOPATH} dir, this is OK if no cache defined"
  fi


  echo "---------------------------- end after_script"
  
before_script:
  - *init_git_credentials
  - *go_with_cache_before

after_script:
  - *go_with_cache_after

注意, 这里用了一个小技巧: yaml literal block + yaml anchors

literal block 可以使我们写多行shell脚本更加方便, Gitlab CI YAML 是支持 anchors 的, 这让我们写的多行shell脚本可以复用.

注意这里有一个 init_git_credentials anchor 引用, 我没有给出定义, 这个东西太简单, 就是 echo 然后 ssh-add 一下把 public key 加到 ssh-agent 而已. 并且跟当前我们要讨论的重点无关, 因此这里不写. 有兴趣的可以查看文档.

然后我们再对比看一下执行时间(同一个仓库, 同样的配置):

# before optimization
level=info msg="[runner] linters took 7m43.677383001s with stages: goanalysis_metalinter: 7m43.625988553s"
level=info msg="File cache stats: 148 entries of total size 521.9KiB"
level=info msg="Memory: 1015 samples, avg is 352.2MB, max is 1469.0MB"
level=info msg="Execution took 8m53.886795985s"


# after optimization
time="Mar 19 23:31:07.273" level=info msg="[runner] linters took 291.185017ms with stages: goanalysis_metalinter: 239.743075ms"
time="Mar 19 23:31:07.273" level=info msg="File cache stats: 0 entries of total size 0B"
time="Mar 19 23:31:07.273" level=info msg="Memory: 12 samples, avg is 58.9MB, max is 87.5MB"
time="Mar 19 23:31:07.273" level=info msg="Execution took 1.08783787s"

内存占用从平均 352M 下降到 60M 不到. 内存占用峰值从 1.5G 左右 下降到 90M 不到.

lint 执行时间从接近9分钟下降到只需要1秒左右.

当然, 这是代码变化特别小的情况. 按这个算的话, 是快了 500多倍.

对于日常提交, 一般可以将 lint 执行时间控制在10s 以内, 按这个来算, (480+53)/10 = 53.3 差不多是原来的50多倍吧:

time="Mar 19 21:33:04.218" level=info msg="[runner] linters took 6.151722993s with stages: goanalysis_metalinter: 6.099292807s"
time="Mar 19 21:33:04.218" level=info msg="File cache stats: 90 entries of total size 417.9KiB"
time="Mar 19 21:33:04.218" level=info msg="Memory: 88 samples, avg is 335.2MB, max is 621.5MB"
time="Mar 19 21:33:04.218" level=info msg="Execution took 8.665056149s"

Refs

https://docs.gitlab.com/ee/ci/variables/predefined_variables.html

https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnerscache-section

https://min.io/

https://docs.gitlab.com/ee/ci/caching/#how-archiving-and-extracting-works

https://docs.gitlab.com/ee/ci/yaml/script.html

https://stackoverflow.com/questions/42560083/multiline-yaml-string-for-gitlab-ci-gitlab-ci-yml/55347979#55347979

https://docs.gitlab.com/ee/ci/yaml/#cache

https://docs.gitlab.com/ee/ci/yaml/#cachepaths

https://docs.gitlab.com/ee/ci/yaml/yaml_optimization.html

https://docs.gitlab.com/ee/ci/yaml/yaml_optimization.html#anchors

https://docs.gitlab.com/ee/ci/ssh_keys/#ssh-keys-when-using-the-docker-executor