CI/CD 管道是软件工程师用来生产高质量软件的关键工具之一。它代表持续集成(CI)和持续交付(CD)。其理念是,与其在软件开发结束时一次性整合并测试所有更改,不如持续集成(测试)和发布(部署)软件,以便更快地发现错误。
像许多人一样,我将软件源代码存储在 GitHub 上。几年前,我在 GitHub 中设置了一个简单的 CI/CD 管道来构建、分析和测试我的 Web 应用程序/Web 服务。它运行良好,由于这是我第一次在 GitHub 中设置 CI/CD 管道,我保持了相当简单的步骤。
- 构建(和部署)
随着时间推移,我发现自己越来越不愿意对软件进行更改。作为一个有 ADHD 的开发者,我有时会发现当存在多个障碍时很难完成事情,我意识到导致问题的原因之一是我的 CI/CD 管道需要运行5分钟。每次我想做出更改时,我都必须编写代码,然后去喝杯咖啡等待管道测试和部署代码。我并不总是能回来。我经常会分心。
作为参考,我最初在5.5分钟内完成以下工作:
- 构建
- Purgecss
- Stylelint(CSS)
- HTML 验证
- YAML 语法检查
- SCA 漏洞扫描(go vuln)
- 2个 Go 代码检查器(staticcheck 和 golangci-lint)
- 打包应用程序,包括 Nginx 配置,到可部署的 zip
- 运行近200个单元和集成测试
我决定代码测试和部署的最长等待时间为1分钟。
以下是我优化 CICD 管道的方法:
- 将操作拆分为多个并行作业
- 使用 GitHub 缓存
- 优化代码检查
- 调整作业以适应彼此
尽管我的应用程序是 Golang 应用程序,但我认为这些技术应该适用于任何编程语言。
并行作业
我第一次尝试并行作业有点过度。我决定将 Makefile 中的每个步骤都分离到自己的作业中。检查 GitHub YAML?让它成为一个单独的作业。检查网站的 CSS?是的,让它成为一个单独的作业。等等。
这工作得很好,但我最终耗尽了 GitHub 可计费分钟数。一些作业只运行了9秒,但 GitHub 仍然按最接近的整分钟计费。我想做一些更合理的事情,所以我将许多短目标合并为更合理的内容。由于 GitHub 提供双核虚拟机,我的第一种方法是使用 make -j2 组合项目并并行运行。
这还可以。但是当作业失败时,调试变得有点困难,因为日志消息会交错。很难确定每个子组件运行了多长时间。
最终我选择了5个作业,如果它们全部成功,就启动 CD 管道以部署到开发服务器:
我觉得这是成本和性能之间相当不错的权衡。但是要达到5个作业,我首先必须做一些其他事情。
GitHub 缓存
我做的最重要的事情是启用缓存。每次运行构建时:
GitHub 会在 Docker 容器内启动构建,并且必须以串行方式下载所有 Go 包。由于这是串行发生的,仅下载项目的依赖项就需要一分多钟。
依赖项缓存
幸运的是,GitHub 有一个 很好的、文档齐全的缓存功能[1] 。
build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: | ~/.cache/go-build ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - name: Run make build run: | make build
通过在构建之前添加 actions/cache@v4 步骤,GitHub 会根据 go.sum 文件自动缓存依赖项。只要我不更改依赖项,它们就会自动缓存,如果我确实更改了依赖项,第一次运行时会稍慢一些以构建缓存。
缓存后速度非常快!在 6 秒内恢复 419MB:
不过,一个小技巧是,我还使用 go 进行代码检查和漏洞扫描,这些有不同的依赖项。所以我最终调整了缓存键,在运行 golangci-lint 时略有不同(例如):
lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: | ~/.cache/go-build ~/go/pkg/mod key: ${{ runner.os }}-golint-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-golint- - name: Run make ci-lint run: | make ci-lint
我的 Makefile 是这样的,它既运行任务又安装先决条件。我广泛使用 Makefile,因为它具有可移植性,可以让我为本地开发引导机器,然后在 CI 管道中运行相同的内容。
(我在 Mac 和 Windows 的 WSL 中开发。)
install-golang-ci: install-golang go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest ci-lint: install-golang-ci golangci-lint run
一个附带好处是我可以在本地进行大部分 CI 开发,这更快。然后我只需在 GitHub yaml 文件中添加一个经过良好测试的一行命令,这样就 不会花时间调试 yaml[2] 。
(我再次强调,根据我的经验,本地开发比在 GitHub runners 中测试更改快 4 倍。)
数据缓存
另一个减速是我的单元和集成测试通过从 YouTube 获取许多 GB 的数据来引导自身。我决定将数据库打包并通过每天午夜运行一个作业来构建第二天的缓存。
通过使用 cache/save@v4 操作将这些文件加载到 GitHub 缓存中,我减少了测试时间几分钟:
- name: Run make test run: | make test - name: Run make cache-archive run: | make cache-archive - uses: actions/cache/save@v4 with: path: | cache.tar.gz key: ${{ runner.os }}-cache-${{ hashFiles('cache.tar.gz') }} - uses: actions/cache/save@v4 with: path: | bleve.tar.gz key: ${{ runner.os }}-bleve-${{ hashFiles('bleve.tar.gz') }}
然后使用 cache/restore@v4 恢复它们
test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache/restore@v4 with: path: | ~/.cache/go-build ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - uses: actions/cache/restore@v4 with: path: | cache.tar.gz key: ${{ runner.os }}-cache-${{ hashFiles('cache.tar.gz') }} restore-keys: | ${{ runner.os }}-cache- - uses: actions/cache/restore@v4 with: path: | bleve.tar.gz key: ${{ runner.os }}-bleve-${{ hashFiles('bleve.tar.gz') }} restore-keys: | ${{ runner.os }}-bleve- - name: Run make cache-install run: | make cache-install - name: Run make test run: | make test
现在我的构建和单元测试在不到一分钟内运行。
代码检查
我还花了一些时间调优代码检查性能。再次强调,由于我的 Makefile 可以在本地运行所有内容,我可以快速调整作业以在我的笔记本电脑上提高性能。
标记语言代码检查
我最终得到了一个 markup-lint 作业,将类似的作业捆绑在一起:
- yamllint
- html-validate
- csspurge
- stylelint
从技术上讲,我可以使用 GitHub 缓存来缓存 html 和 css 的 NPM 包。但是...我不想深入研究理解 NPM。无疑,阅读这篇文章的人会说"但这很容易"。是的,它可能很容易。但我让上面的 4 个检查在 GitHub 上以大约 20 秒的速度运行,这对我来说已经足够好了。
并非所有事情都需要挤出最后一点性能。有时候"足够好"就是足够好。我确实做了一个改变以加快 NPM 安装速度:添加一些性能标志并一次性安装所有目标,这似乎比逐个安装软件包少了 20 秒:
install-weblint: install-npm npm --no-audit --progress=false i -g html-validate@latest purgecss@latest stylelint@latest stylelint-config-standard@latest
Golang 代码检查
在 Golang 方面,我一直在使用 golangci-lint,但从未花太多时间思考它的作用,所以我还单独运行了 staticcheck,因为我注意到独立的 staticcheck 中的规则比 golangci-lint 捆绑的规则更严格。所以我对 golangci-lint 的最初印象并不是很有利。
然而,golangci-lint 有一个很大的优势:它可以捆绑几乎所有内容以提高速度。
所以我最终添加了一个 .golangci.yml 文件,并删除了我之前使用的任何单独的代码检查工具:
--- linters: enable: - errcheck - gosimple - govet - ineffassign - staticcheck - unused - bodyclose - exhaustive - gocheckcompilerdirectives - godox - gofmt - goimports - gosec - whitespace - usestdlibvars linters-settings: staticcheck: checks: ["all"]
我对此很满意。它运行了我认为有意义的所有代码检查工具(比 golangci-lint 的默认值多得多),_并且_整个列表是可缓存的,缓存后在不到一分钟内运行。
题外话:为什么 golangci-lint 默认不启用 gosec[3] 对我来说没有意义。
markup-lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run make markup-lint run: | make -j2 markup-lint lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: | ~/.cache/go-build ~/go/pkg/mod key: ${{ runner.os }}-golint-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-golint- - name: Run make ci-lint run: | make ci-lint
提醒:我真正喜欢使用 Makefile 的原因之一是我的 Macbook M2 比 GitHub runners 快得多。
在我的笔记本电脑上运行 golangci-lint 只需不到 13 秒。
time make ci-lint scripts/checkGoVersion.sh go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest golangci-lint run make ci-lint 2.11s user 4.72s system 53% cpu 12.738 total
在 GitHub 上运行需要 4 倍时间(51 秒):
如果你正在阅读这篇文章,并且将逻辑放在GitHub yaml文件中,你将承诺在每次测试更改时等待大约4倍的时间。
我强烈建议GitHub yaml中的每个作业都是你本地已经测试过的单个命令。
调整作业
然而,看着markup-lint和扫描作业的运行时间,我意识到我实际上可以将这些作业重新合并到构建作业中。
如果我制作一个自定义的Makefile作业,打包并归档我的构建,并称之为"package",并将markup-lint和vuln作为先决条件,然后使用make -j2运行,以充分利用GitHub运行器中的两个CPU核心...
package: install-golang arm64 package-nginx vuln tidy markup-lint scripts/build_and_package.sh
我得到了一个简单的GitHub操作,包括构建/代码检查/测试,这些都不超过一分钟(以在构建作业中运行一些代码检查为轻微的认知成本)。
注意:我决定将GitHub作业称为"build",但Makefile目标称为"package"。这完全是出于我个人的美学原因。请随意对管道做出自己的美学决定。
结论
现在我有了一个包含构建/测试/代码检查作业的CI管道。
每个阶段都需要一个可计费的分钟
然后部署阶段即使只运行2秒,也需要一个分钟,在另一个仓库的后台,我有一个实际执行部署的部署作业:
deploy: runs-on: ubuntu-latest needs: [ lint, test, build, ] if: github.event_name == 'push' && github.ref_name == 'main' steps: - name: deploy run: | export GH_TOKEN=${{secrets.GH_DEPLOY_TOKEN}} gh workflow run github-actions-deploy.yml -f \ env=DEV -f version=${{github.sha}} \ --repo myrepo/deploy
因此每次构建和部署的总成本是5个可计费分钟。
在GitHub免费层每月2000个可计费分钟的情况下,这给我提供了400次部署,即每天13.3次部署。
然而,由于我的缓存作业每晚打包数据库和缓存文件,它每晚消耗4个可计费分钟,即每月120分钟。
这使得计算结果为(2000 - 120) / 5 = 360次部署/月,或每天12次。
对于我这样的独立开发者来说已经绰绰有余。
总的来说,这是一次有趣的体验。我觉得通过GitHub缓存和本地优先作业,大多数CICD管道都可以变得更快。至少几乎和本地开发一样快。
我成功将以下所有内容压缩到不到一分钟:
- 构建
- purgecss
- stylelint (css)
- html-validate
- yamllint
- SCA漏洞扫描 (go vuln)
- SAST漏洞扫描 (gosec) 已添加
- 14个其他golang代码检查工具 已添加7个
- 打包应用程序,包括nginx配置,到可部署的zip
- 运行近200个单元和集成测试
不幸的是,如果你有一个本地构建和安装需要很长时间的应用程序(回想起2005年左右的Java应用),你可能无法改善太多。我选择golang是因为它的快速编译时间,并且感觉这确实得到了很好的回报。
编辑:我将大部分脚本和配置文件上传到了 GitHub[4] 。请阅读README中的免责声明。不直接复制我上传的内容有很多原因。这些内容是为了帮助理解这篇文章,而不是盲目复制。
- 技术栈[5]
- cicd[6]
- 幕后[7]
参考链接
- 很好的、文档齐全的缓存功能: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows
- 不会花时间调试 yaml: https://grumpyolddev.com/post/cicd-stop-putting-code-in-yaml/
- gosec: https://github.com/securego/gosec
- GitHub: https://github.com/sethgecko13/golang_cicd_template
- 技术栈: https://mzfit.app/tags/tech-stack/
- cicd: https://mzfit.app/tags/cicd/
- 幕后: https://mzfit.app/tags/behind-the-scenes/