↓ 跳过正文

CI/CD GitHub Actions Docker

在 GitHub Actions 上搭 CI/CD 流水线:我现在实际在跑什么,以及 Docker Compose 去哪儿了

CI/CD 流水线就是一次改动从 git push 到生产环境的自动化路径:CI 在干净的机器上测试每一次推送,CD 用一个可重复的步骤把通过的东西发布出去。这篇写的是我 2026 年在 GitHub Actions 上实际在跑的流水线是什么样子,附上这些文件的示例版本:一个检查工作流,用和 Docker Compose 文件相同的 Postgres、Valkey 镜像测试 Django 和 Expo 应用;看过每一步耗时之后才决定的缓存;放在尽量少的地方的密钥;以及用脚本或 CLI、而不是 YAML 完成的部署。它是我 2024 年那篇“把 CI 逻辑放进 Compose”的续篇,没能留下来的部分也一并写了。

在 GitHub Actions 上搭 CI/CD 流水线:我现在实际在跑什么,以及 Docker Compose 去哪儿了

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 和 tag15 个构建目标的 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 和 Valkey18
Pythonuv sync --locked,缓存已热6
Pythonmypy27
PythonDjango 测试761
Appnpm ci,缓存已热60
AppJest333

两个依赖缓存都开着,而且都是便宜的那部分。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 不是监控。

我会问一条流水线的几个问题
#

不管你是在搭新的,还是在审视现有的:

  1. 如果 GitHub Actions 停摆一整天,你还能发布吗?不能的话,你的发布逻辑就在 YAML 里,2024 年那篇依然适用。
  2. CI 跑的解释器、CPU 架构和数据库大版本,和生产一致吗?我的 CI 能抓到一个编译器 bug,只是因为 runner 和生产都是 x86_64,而我的笔记本不是。
  3. 全新的检出能通过吗?通不过的话,通常是那些自动生成、又被 gitignore 的文件在作怪。
  4. 一次运行的时间到底花在哪儿?加缓存之前,先看每一步的耗时。
  5. 每个密钥放在几个地方?它真的需要放在 GitHub 上吗?