我推了一个新 commit,生产环境部署顺序全乱了——今天把 GitHub Actions concurrency 竞争的三个坑全拆开了

我跟你说个真实发生过的场景。

你正在发布线上新功能,老王不知道,直接 push 了一个 hotfix commit。GitHub Actions 触发第二个 workflow,第一个被 cancel-in-progress 取消掉了——听起来很合理对吧?

结果呢?第一个 workflow 已经在服务器上写了一半的新代码,还没写完。第二个 workflow 接着跑,跑的是新 commit 的逻辑,但数据库迁移脚本跑的是第一版的。两边对不上,线上开始报 500。

这不是故事,是真实的事故。问题出在:cancel-in-progress 取消的不是”状态”,是”进程”。今天把这个彻底说清楚。


第一个坑:取消 ≠ 干净退出

很多人以为 workflow 被取消之后,runner 上的所有状态都重置了。这是个误解。

当 GitHub Actions 执行 cancel-in-progress: true 时,它做的事是向正在运行的 job 发送 SIGTERM 信号,让它优雅退出。但如果你的 job 正在跑一个部署脚本,而这个脚本内部有多个步骤——比如先解压包、再迁移数据库、再重启服务——SIGTERM 只停止当前这个 step,不回滚已经完成的步骤。

一个典型的危险例子:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to server
        run: |
          # 这个脚本一旦开始,SIGTERM 无法中断
          ./deploy.sh  # 假设它写了一半文件到 /opt/app

      - name: Run migrations
        run: |
          # 如果上一个 step 被 cancel,这个不会跑
          # 但 /opt/app 已经被写了一半
          npm run migrate

当第二个 workflow 触发并 cancel 第一个时,./deploy.sh 写到一半被中断,服务器上留下不完整的代码状态。第二个 workflow 接着跑,跑的是新 commit 的代码,但底层文件已经是新旧混合。

正确做法:用 lock 文件控制部署节奏

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Acquire deployment lock
        run: |
          exec 200>/var/lock/deploy.lock
          flock -n 200 || { echo "Another deployment in progress"; exit 1; }
          trap "flock -u 200" EXIT

      - name: Deploy
        run: ./deploy.sh

      - name: Run migrations
        run: npm run migrate

把并发控制从”谁先到谁跑”变成”谁拿到锁谁跑”。cancel-in-progress 只在测试/CI 场景安全,生产部署不要依赖它。


第二个坑:concurrency group 的命名决定了竞争边界

concurrency 配置里最关键的是 group 的写法。很多团队配的是这样:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

这个配置的意思是:同一个 ref(分支)上的同一个 workflow,同时只能有一个实例。听起来合理,但有一个隐藏问题:

如果你的仓库有两个 workflow 都叫 “Deploy”,或者一个 workflow 有多个 job,它们默认共享同一个 concurrency group。第二个 deploy 触发时,第一个会被 cancel——即使第二个根本不是同一个 job。

更危险的是,${{ github.ref }} 在 PR 场景下指向的是 PR 的分支名,比如 pull/123/head。两个不同的 PR 如果触发同一个 workflow,它们的 ref 不同,不会互相 cancel——两个 deploy 同时跑在不同的 runner 上,争抢同一套服务器资源。

正确的 group 写法应该是:

# 场景1:按 branch + PR number,确保同分支同 PR 不重复
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || 'noprs' }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# 场景2:生产部署只允许一个,不允许 cancel
concurrency:
  group: production-deploy
  cancel-in-progress: false  # 生产部署不允许被取消

hyperskill 团队在 2026 年 5 月做过一次调整,专门为 beta 部署禁用 concurrency,理由是 beta 环境和生产环境的 deploy workflow 共享同一个 group,beta 的并发触发 cancel 了生产部署。他们的做法是按 environment 分组:

concurrency:
  group: ${{ github.workflow }}-${{ matrix.environment }}
  cancel-in-progress: ${{ matrix.environment != 'production' }}

第三个坑:被 cancel 的 workflow 里,secrets 还没清理

这是最容易被忽视的一个问题。

当 workflow 被 cancel 时,runner 会被终止,但以下几个东西有残留窗口:

1. 环境变量中的 secrets

GitHub Actions 在 job 启动时会把 secrets 注入环境变量。被 cancel 的 workflow 在 runner 被回收之前,这些环境变量还存在于内存中。如果 runner 是 self-hosted,且下一个 job 启动前没有彻底清理内存,这就成了一个信息残留点。

2. 缓存的依赖目录

如果你用了 actions/cache,被 cancel 的 workflow 可能已经解压了一部分依赖到 $RUNNER_TEMP,下一个 job 可能读到不完整的缓存。

3. GitHub token 的权限窗口

workflow 被 cancel 后,GITHUB_TOKEN 的 revocation 有几分钟的延迟。如果被 cancel 的 workflow 正在执行一个需要 token 的操作(推送 tag、发布 release、评论 PR),cancel 本身不会立即撤销 token 权限。

应对方案:让 job 对 cancel 做出响应

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup cleanup trap
        if: always()
        run: |
          cleanup() {
            echo "Cleaning up workspace..."
            rm -rf $RUNNER_TEMP/*
            rm -f /var/lock/deploy.lock
          }
          trap cleanup EXIT

      - name: Build
        run: npm ci && npm run build

      - name: Deploy
        if: github.ref == 'refs/heads/main'
        run: ./deploy.sh

if: always() 让 cleanup step 即使在 cancel 发生时也会执行,确保 runner 状态被正确回收。


总结:什么场景该用什么 concurrency 配置

场景 cancel-in-progress 推荐 group 格式
CI 测试 ✅ true ${{ github.workflow }}-${{ github.ref }}-${{ github.sha }}
PR 审查 build ✅ true ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
Staging 部署 ✅ true(有条件) ${{ github.workflow }}-staging
生产部署 ❌ false ${{ github.workflow }}-production
有状态 job(数据库迁移等) ❌ false 用外部锁替代

GitHub 官方文档明确说了:concurrency 的 cancel-in-progress 适用于”可以安全取消”的 job。对于有外部副作用的操作——写文件、改数据库、发请求——不要依赖 cancel-in-progress 来做并发控制。用分布式锁,或者直接让后到的不跑。

说到底,concurrency 配置解决的是”重复运行浪费资源”的问题,不是”运行到一半被中断怎么收拾”的问题。这两个问题用的是完全不同的解决思路,别混了。

评论区

0 条评论

登录后可评论。

阿监·前端工程化 642 阅读