CI/CD 流水线,就是一次改动从 git push 走到生产环境的自动化路径。持续集成(CI)在干净的机器上构建并测试每一次推送,让错误在犯下几分钟后就暴露出来;持续交付或持续部署(CD)则用一个可重复的步骤,把通过的东西发布出去:还要有人按一下按钮的叫交付(delivery),谁都不用按的叫部署(deployment)。在 GitHub Actions 上,流水线就是 .github/workflows/ 下的一个 YAML 文件,写明什么时候跑、跑什么。我的流水线是故意做薄的:检查只是把我在笔记本上敲的命令,对着我的 Docker Compose 文件启动的那套 Postgres 和 Valkey 镜像再跑一遍;每一种部署都是脚本或 CLI,离开 GitHub 也照样能跑。
2024 年我写过一篇用 Docker Compose 做 CI/CD,别把逻辑写进平台专属的 YAML,意思是构建逻辑放进 Compose,CI 工具只负责调用它。两年后,我把自己真正用来发布的仓库全部翻了一遍。有工作流的仓库一共四个,提交者都只有我一个人,而这些工作流里,已经没有一个在调用 docker compose 了。原则留下来了,做法没有。下面是取而代之的东西:删减过的示例文件、GitHub 记录下的每一步耗时,以及那些没有 CI/CD 示意图期望的那么自动化的部分。
我跑的所有流水线,一张表#
| 流水线 | 由谁触发 | CI 检查什么 | 发布到哪里 | 谁来发布 |
|---|---|---|---|---|
| 这个博客和一个客户的 Hugo 网站 | 每次推送,任何分支 | hugo --gc 能否构建 | Cloudflare Pages:main 是生产,其他分支得到预览 URL | 没人;推送就是发布 |
| 我那个集市应用的 API | 一个部署脚本,手动运行 | 每次推送到 main 时跑代码检查、类型检查和 Django 测试 | Fly.io,滚动部署 | 我 |
| 同一个应用的前端:Web、OTA 更新、商店构建 | 几个发布脚本和本地商店构建,手动 | 每次推送到 main 时跑 TypeScript 和 Jest | 对象存储、一个 OTA 更新服务,以及两家应用商店 | 我 |
| pyhtml2text,我的开源 Python 包 | 每次推送、Pull Request 和 tag | 15 个构建目标的 wheel,其中大部分会跑测试 | PyPI,打 tag 时 | tag |
用教科书的话说,两个 Hugo 网站是持续部署;而那个应用(我做的一个集市兼社区应用)是持续集成加上一条命令就能完成的发布,也就是持续交付:检查会告诉我哪里不对,但不会拦着我部署。这是有意为之。一次 OTA 更新会送到所有装着目标二进制版本的手机上,所以发布它应该是一个决定,而不是合并的副作用;而 API 是数据库迁移运行的地方。我另外两个 Django 应用,压根没有工作流。
CI 这一半:一个工作流,两个 job#
这个应用的 CI 只有一个文件,.github/workflows/checks.yml。10 月 11 日之前,连它都没有。下面是删减后的示例,看个大致样子:
# Lint, types and tests on every push and pull request to main.
# Nothing here deploys.
name: Checks
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: myapp
POSTGRES_DB: myapp
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U myapp"
--health-interval 5s --health-timeout 5s --health-retries 20
valkey:
image: valkey/valkey:8-alpine
ports: ["6379:6379"]
env:
DEBUG: "True"
DATABASE_URL: postgresql://myapp:myapp@localhost:5432/myapp
REDIS_URL: redis://localhost:6379
PYTHON_JIT: "0" # match production
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run mypy myapp/
- run: uv run python manage.py test --parallel 4 --noinput
app:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
# Expo writes this on `expo start` and it is gitignored,
# so a fresh checkout doesn't have it.
- run: printf '/// <reference types="expo/types" />\n' > expo-env.d.ts
- run: npm run typecheck
- run: npx jest --ci两个 job 并行运行。到目前为止成功的 35 次运行里,应用 job 的中位数大约七分钟,Python job 大约十二分半。
services: 这一块,就是 Compose 文件搬去的地方,下一节细说。环境变量只有寥寥几个,外加一个开关;其余的一切,都会回落到开发者 docker compose up 时拿到的开发默认值,所以测试不需要任何密钥。为了确认真是这样,我只用这套环境、别的什么都不带,跑了一部分测试。
CI 用的解释器和我的笔记本、生产镜像是同一个,也就是 uv 自己构建的 Python 3.15.0,因为 uv sync 会读 .python-version,并下载正是那个版本。(我真实的文件里还给 setup-uv 传了一个 python-version-file 输入,其实什么都没做:setup-uv v6 没有这个输入,每次运行都带着一条这样说的警告。写这篇文章时我才发现。)解释器一致的好处,在工作流上线八天后就显出来了。10 月 19 日我打开了 3.15 的 JIT 编译器,开启后的第一次运行就有一个测试失败:一句再普通不过的 yield from,在每次保存都会经过的代码路径上,抛出了 AttributeError: 'list' object has no attribute 'send'。同一个提交关掉 JIT 就能通过。我的 Mac 是 arm64,从没碰到过它;而 runner 是 x86_64,和生产机器一样。JIT 现在在 CI 和 Dockerfile 里都重新关掉了,这个 bug 从没到过生产环境。
其实第一次运行也失败了,原因更无聊。Expo 在 expo start 时会生成 expo-env.d.ts,而这个文件被 gitignore 了,所以我用过的每个工作副本里都有它,全新的检出里却没有。TypeScript 报了五个本地从没出现过的错误。补上生成这个文件的那一步,就修好了。
concurrency 会在同一分支来了更新的推送时,取消之前的运行;到目前为止被取消的十二次运行,全都是这么结束的。另外还有一个提交前钩子(pre-commit),会跑代码检查,暂存了 Python 或 TypeScript 文件时再跑类型检查,所以大部分错误根本走不到推送那一步。如果你要照抄这个文件,请把 action 换成更新的大版本:每次运行都会警告 actions/checkout@v4、actions/setup-node@v4 和 astral-sh/setup-uv@v6 面向的是已被弃用的 Node.js 20。
Docker Compose 文件去哪儿了#
下面是这个应用的 docker-compose.yml 里 CI 关心的那部分,同样是示例。真实文件里服务更多,环境变量也多得多。
services:
valkey:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
# Host port 55432, not 5432, so it can't collide with a local Postgres.
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=myapp
- POSTGRES_PASSWORD=myapp
- POSTGRES_DB=myapp
ports:
- "55432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 5s
retries: 10
api:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp
- REDIS_URL=redis://valkey:6379
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_started
volumes:
postgres_data:把它和 checks.yml 里的 services: 放在一起看,是同样的两个镜像:Postgres 17(这里是 Alpine 版,CI 里是 Debian 版)和 valkey/valkey:8-alpine。现在两者的关系就是这样:Compose 文件是开发者跑的东西;CI 把同样的镜像声明为服务容器,然后直接在 runner 上跑开发者跑的那些命令,uv run … 和 npm run …。
和 2024 年那篇对照,我最在意的部分留了下来:CI 工具依然很薄,检查和部署都是我自己就能跑的命令。没留下来的,是让 CI 去调用 Compose。这个应用的 CI 根本不构建 Docker 镜像;镜像在部署时由 Fly 的构建机构建一次,CI 只需要回答代码好不好,工具链有缓存的话,这只要六秒的安装。另外,Compose 文件会把工作副本 bind mount 进去、灌入种子数据,这些都不是一次测试运行该做的事。所以 CI 本来还得有一份自己的 Compose 文件,也就是同一组服务的第二份描述,靠手工保持同步。
还有,2024 年那个例子里有个我当时没发现的 bug。docker compose build 只构建镜像,从不运行服务的 command,所以那个文件里的 npm test 在 CI 里一次都没跑过,工作流不管测试过没过都是绿的。
那篇文章最担心的风险,也就是换 CI 供应商,始终没有发生。真正发生的迁移在另一头:这个博客和那个客户的网站换了托管,搬到了 Cloudflare Pages,博客这边只是新写了一个 45 行的工作流,因为构建本来就只是一条命令。至于我当年警告过的三百行工作流?我确实有一个,pyhtml2text 的。它之所以长,是因为 wheel 必须分别在 Linux、macOS 和 Windows 的 runner 上、为多种架构构建,也正因为如此,这是唯一一个平台专属 YAML 才是对的工具的活儿。
缓存:先看每一步的耗时#
GitHub 记录了每一步的耗时,用 gh api repos/<owner>/<repo>/actions/runs/<run-id>/jobs 就能拿到。下面是这个应用最近一次成功的运行,10 月 20 日:
| Job | 步骤 | 秒 |
|---|---|---|
| Python | 启动 Postgres 和 Valkey | 18 |
| Python | uv sync --locked,缓存已热 | 6 |
| Python | mypy | 27 |
| Python | Django 测试 | 761 |
| App | npm ci,缓存已热 | 60 |
| App | Jest | 333 |
两个依赖缓存都开着,而且都是便宜的那部分。GitHub 为这个仓库保存的 uv 缓存只有 200 KB 左右,因为 setup-uv 会清掉 uv 重新下载就行的东西;npm 的接近 400 MB。时间花在测试上,而测试已经四路并行了。想让运行更快的话,我会去改测试套件,而不是缓存。
博客的情况正好相反。Hugo 处理过的图片放在被 gitignore 的 resources/_gen 里,所以每台新的 runner 都要把封面、正文图片和占位图全部重新编码一遍。下文的工作流用 actions/cache 把它们恢复回来,键是图片本身的哈希,对不上时退回到最新的旧缓存,因为 Hugo 只处理缺的部分。加缓存之前的最后八次运行,构建这一步要 33 到 76 秒;之后的 26 次是 6 到 20 秒,只有一次例外,94 秒。而且同一个提交还加了占位图和第二种卡片尺寸。
这个应用的镜像在 Fly 的构建机上构建,依赖文件先于代码复制进去,uv 的下载缓存放在 BuildKit 的缓存挂载里(RUN --mount=type=cache,target=/root/.cache/uv)。不过最大的改进并不是缓存。旧的 .dockerignore 是黑名单,漏掉了一个放额外 git worktree 的文件夹,每个 worktree 都带着自己的依赖和构建产物,结果每次部署都有 27.7 万个文件、7.7 GB 被传到构建机。改写成白名单之后,构建上下文大约 40 MB。这种写法的示例:
# Exclude everything, then let back in only what the Dockerfile COPYs.
*
!pyproject.toml
!uv.lock
!manage.py
!myapp
!static
# Rules match the whole relative path and the last match wins,
# so these re-excludes apply inside the allowed directories too.
**/__pycache__/
**/*.pyc
**/.DS_Store
**/*.log密钥,放在尽量少的地方#
密钥放在三个地方。GitHub Actions 的密钥用于工作流要做的事,两个 Hugo 仓库各自只有一个,CLOUDFLARE_API_TOKEN。Fly 的密钥是应用运行时的配置,用 flyctl secrets set 设置,从不经过 Actions。最后,我笔记本上被 gitignore 的 .env 里,放着部署脚本清 Cloudflare 缓存用的令牌。
PyPI 完全不需要密钥。pyhtml2text 用的是可信发布(trusted publishing):PyPI 接受 GitHub 签发给工作流的短期 OIDC 令牌,所以发布 job 只申请 id-token: write,仓库的密钥页面是空的。没有可泄露的,也没有要轮换的。
令牌只给它需要的权限。这个应用在 GitHub 上的 Fly 令牌,服务于一个只列出机器的定时检查,所以是用 fly tokens create readonly 创建的。Pages 部署只申请 contents: read 和 deployments: write,别无其他。
输入一律经过 env:,绝不直接塞进脚本。OTA 工作流接收自由填写的发布说明。如果在 run: 里写成 ${{ inputs.notes }},文本会在 bash 读取之前原样贴进脚本,于是一条带引号和分号的说明就成了代码。作为环境变量传进去,它就始终只是数据。这种写法的示例:
- name: Publish
env:
OTA_API_TOKEN: ${{ secrets.OTA_API_TOKEN }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ROLLOUT: ${{ inputs.rollout }}
INPUT_NOTES: ${{ inputs.notes }}
run: |
args=("$INPUT_PLATFORM")
[ -n "$INPUT_ROLLOUT" ] && args+=(--rollout "$INPUT_ROLLOUT")
[ -n "$INPUT_NOTES" ] && args+=(--notes "$INPUT_NOTES")
./scripts/publish-ota.sh "${args[@]}"有些密钥干脆不放在 GitHub 上。有一份定时报告需要一个对应用登录后端有管理员权限的密钥,所以它从定时工作流搬到了我 Mac 上的一个定时任务。工作流仍然留着作为手动备用,只是会略过需要那个密钥的部分。
部署:每次推送、靠脚本、按 tag#
Hugo 网站每次推送都部署#
这是博客工作流的示例版本,省掉了第一次运行时创建 Pages 项目的那一步:
name: Deploy to Cloudflare Pages
on:
push:
workflow_dispatch:
env:
HUGO_VERSION: "0.165.0"
PAGES_PROJECT: my-site
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
# Processed images are gitignored; restore them so Hugo only
# re-encodes what is new. An older cache still helps.
- uses: actions/cache@v4
with:
path: resources/_gen
key: hugo-resources-${{ hashFiles('content/**/*.jpg', 'content/**/*.png', 'assets/img/**') }}
restore-keys: |
hugo-resources-
# A multilingual site has no root 404.html; without one, Pages
# answers unknown paths with index.html and a 200.
- name: Build
run: |
hugo --gc
cp public/en/404.html public/404.html
# ...
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}--branch=${{ github.ref_name }} 让 main 成为生产,其他每个分支都得到预览 URL。那个客户网站的工作流和它是同一个结构,只是在构建前多了一行 rm -rf public,因为那边的 public/ 是提交进仓库的,不这样做的话,已删除的页面会一直留在上传内容里。
Hugo 的版本只在这里固定,别处都不固定。10 月份一次包管理器升级把我笔记本上的 Hugo 升到了 0.168,比主题支持的最新版本还高一个。部署一直停在 0.165,毫无察觉。
而构建本身就是测试。9 月 14 日,某个分支的预览部署连续失败了三次:那个分支上的一篇文章,用 Hugo 的 ref 链接到了一篇还是草稿的文章。CI 和生产一样,构建时不包含草稿,所以这个链接什么都指不到,Hugo 以 REF_NOT_FOUND 停了下来。在此期间,main 照常部署。
应用的 API 靠脚本部署#
部署脚本的核心部分,示例如下:
IMAGE_LABEL="deploy-$(git rev-parse --short HEAD)-$(date +%Y%m%d%H%M%S)"
# Build first, then wake a spare machine, then roll out exactly that image.
flyctl deploy --config fly.toml --build-only --push --image-label "$IMAGE_LABEL"
curl -fsS --retry 5 https://myapp.fly.dev/health > /dev/null
flyctl deploy --config fly.toml --image "registry.fly.io/myapp:$IMAGE_LABEL"以及 fly.toml 里对应的几行:
[deploy]
# max_unavailable only counts machines that are running,
# which is why the script wakes one first.
strategy = "rolling"
max_unavailable = 1镜像只构建一次,以提交和时间组成的标签推到 Fly 的镜像仓库,再按这个标签部署,所以滚动上线的正是构建出来的那个东西。
让它做到零停机的,是先唤醒一台备用机器。max_unavailable = 1 只计算正在运行的机器,而这个应用空闲时一台运行中的机器都不留(挂起的机器大约一秒就能恢复)。所以以前那台唯一醒着的机器,会和睡着的机器在同一批里被替换掉,身后没有一台热着的机器。9 月 28 日,我用探针持续请求源站:直接 flyctl deploy 时出现了 7 次超时和一段 7.3 秒的空白;而用脚本部署时,先构建、在滚动开始前就有一台机器醒着,2,514 个请求一个都没失败。
脚本的其余部分,会在错误告警所在的团队聊天频道里留下部署标记,这样一阵错误涌来时,可以对照刚刚改了什么;然后清掉 Cloudflare 缓存,再把机器预热一遍。Web 前端完全不经过镜像,这部分我写在不重新部署 API 也能发布 Web 前端里。
迁移不是流水线的一个步骤。Fly 的 release_command 在我这里会卡住,所以由第一台用新镜像启动的机器拿锁并执行迁移,其他机器等着。因此每次迁移都必须兼容仍在运行的代码:一次部署里加列,下一次部署不再使用它,第三次再删掉。
这个脚本里没有哪一处非得在我的笔记本上不可,但有几个只在本地的文件把它留在了那里:Cloudflare 凭据,以及一份最后部署了什么的记录,全新的 runner 上不会有它们。它还会把未提交的改动也一起部署,并在聊天频道里说明这一点,而 Web 的发布脚本会拒绝不干净的工作区。在每个分支一个环境里我写过,这些事手工来做,只有你一个人时没问题;从第二个开发者开始,它就必须因为 git 里发生了什么而发生。这个应用现在还是我一个开发者。那篇文章里的分支环境工作流,还留在我当初搭建它们的那个分支上。
OTA 工作流只能手动运行,因为发布应该是一个决定,也因为在有部分发布(partial rollout)进行中时,更新服务会拒绝新的发布,换成推送触发的话,每次金丝雀发布都会撞上。这个工作流一次都没跑过。到目前为止,每一次 OTA 更新都是从我的笔记本上,通过工作流调用的同一个脚本发出去的。保持工作流轻薄的理由,一句话就是这个:不管发布从哪里开始,跑的都是同一个脚本。
pyhtml2text 按 tag 发布#
发布 job 如下,包名换成了通用的名字:
release:
runs-on: ubuntu-latest
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
needs: [linux, macos, windows, sdist]
environment:
name: pypi
url: https://pypi.org/p/mypackage
permissions:
id-token: write # trusted publishing and signing
contents: write
attestations: write
steps:
- uses: actions/download-artifact@v7
- uses: actions/attest@v4
with:
subject-path: 'wheels-*/*'
- uses: astral-sh/setup-uv@v7
if: ${{ startsWith(github.ref, 'refs/tags/') }}
- name: Publish to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
run: uv publish --trusted-publishing always 'wheels-*/*'发布 job 会等所有构建 job 完成,只有 tag 才会真正发布;手动运行会构建并生成证明(attestation),但什么都不上传。actions/attest 记录每个 wheel 是由哪个工作流构建的,uv publish --trusted-publishing always 用 OIDC 令牌而不是密码来上传。这个包本身,我写在一个被关闭的 Pull Request、一个 fork 和一个 PyPI 包里。
定时工作流只是尽力而为#
这个应用有一个定时工作流,检查 Fly 启动的机器有没有超过基线,不检查的话,第一次发现会是在账单上。它靠故意失败来报警,因为定时运行失败时 GitHub 会给仓库所有者发邮件。它设的是 cron: '*/15 * * * *',一天 96 次。可过去两周里,它每天只跑了三到八次,两次运行之间间隔的中位数大约三小时四十分。GitHub 文档给出的唯一解释是负载:Actions 忙的时候,定时运行可能会延迟,忙到一定程度还可能被丢掉。对持续的扩容来说,几个小时足够了。对于必须在几分钟内报警的东西,GitHub 的 cron 不是监控。
我会问一条流水线的几个问题#
不管你是在搭新的,还是在审视现有的:
- 如果 GitHub Actions 停摆一整天,你还能发布吗?不能的话,你的发布逻辑就在 YAML 里,2024 年那篇依然适用。
- CI 跑的解释器、CPU 架构和数据库大版本,和生产一致吗?我的 CI 能抓到一个编译器 bug,只是因为 runner 和生产都是 x86_64,而我的笔记本不是。
- 全新的检出能通过吗?通不过的话,通常是那些自动生成、又被 gitignore 的文件在作怪。
- 一次运行的时间到底花在哪儿?加缓存之前,先看每一步的耗时。
- 每个密钥放在几个地方?它真的需要放在 GitHub 上吗?
