平时 4 分钟跑完的 GitHub Actions workflow 卡在部署步骤上 6 小时,最后被 The job running on runner X has exceeded the maximum execution time of 360 minutes 强杀。本地手动跑 vercel deploy --prod 或 firebase deploy 又一切正常。取消重跑有时成功有时还卡。原因几乎都是部署 CLI 在静默地等一个永远不会出现的东西:缺失的交互输入、需要公网回调的 webhook、卡死的 wait-for-deployment 轮询、SSH 部署等首次 host key 确认。6 小时是 job 上限不是部署上限 —— 你真正需要的是步骤级 timeout,再加一个真正的修复。
常见原因
按实际遇到频率排序。
1. 部署 CLI 在等交互提示
firebase deploy 检测到 config 变更会问 ? You're about to deploy a function in region X. Continue? (Y/n)。CI 里没有 stdin,它会无限静默等待。
如何识别:步骤日志在部署中途断尾、无报错。最后一行是 Detected target change. Continue?,或在 Starting deploy... 后无更新。
2. wait-for-deployment 轮询永不收敛
bobheadxi/deployments 或自定义 gh api 轮询会等部署状态更新。如果部署根本没回报(部署者用了别的 SHA、或状态 webhook 静默失败),轮询无限继续。
如何识别:YAML 里有 with: timeout: 600 或 while gh api ...; sleep 30; done。日志里看到反复的轮询行没有终止。
3. SSH 部署卡在 host key 确认
ssh user@host 首次会问 Are you sure you want to continue connecting (yes/no)?。CI runner 没有 known_hosts 条目,它一直等。
如何识别:步骤里有 ssh、scp、rsync,或类似 appleboy/ssh-action。SSH 连接尝试后日志再无输出。
4. 部署中途网络出站被限速 / 拒绝
云上传(S3、GCS、Cloudflare R2)撞到 runner 出站限速或公司代理时,TCP 连接不报错就那么挂着不再前进。
如何识别:日志先有进度,然后停在比如”uploaded 47/120 assets”再无下文。
5. 步骤没设 timeout-minutes,job 默认 360 分钟
GitHub Actions job 默认 6 小时(360 分钟)。步骤级没设 timeout-minutes,一个挂住的步骤把整个 job 预算吃干。
如何识别:workflow YAML 的部署步骤没有 timeout-minutes:。
6. 部署 hook 触发了但目标服务静默拒收
webhook 风格的部署(Render、Railway、带 hook URL 的 Fly)会回 200 OK,但根本不开构建。等部署完成的步骤永远等不到那个不存在的部署。
如何识别:hook 步骤成功;轮询步骤挂住;目标服务面板在那个时间根本没看到部署记录。
7. 缓存恢复卡在坏 blob 上
actions/cache@v3 恢复 5 GB 构建缓存时,缓存服务器可能返回一个卡死的流,不报错也不前进。
如何识别:Run actions/cache 显示 Downloading cache... 后再无完成行。新版有所改善但偶尔还撞。
开始排查前
- 取出失败 job 的 workflow YAML。
- 顺着日志确定卡在哪个步骤。
- 是必现还是偶发?
- 拿到目标部署平台的面板权限,能对照他们端真实发生了什么。
- 记录 runner 类型(ubuntu-latest、self-hosted、GitHub-hosted larger runner),出站 / DNS 行为不一样。
需要收集的信息
- 完整的部署步骤 YAML,含
with:、env:、命令。 - 步骤日志超时前最后 50 行。
- 同一时间窗口目标平台的面板日志(Vercel deployments、Firebase function logs)。
- 用了哪些第三方部署 action 及版本。
- 部署 CLI 期望的 secrets / env 在 workflow 里是否真的设了。
gh run view <run-id> --log输出,比 Web UI 抓日志更干净。
分步修复
顺序:先止血、再修根因。
步骤 1:给步骤加 timeout-minutes
任何部署步骤立即设上限:
- name: Deploy to production
timeout-minutes: 15
run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
job 现在会快失败,不再烧 6 小时 CI 分钟。卡住几分钟就能发现。每个部署 / 等待步骤都加。
步骤 2:让部署 CLI 进非交互模式
Firebase:
- run: firebase deploy --non-interactive --force --project=prod
Vercel:
- run: vercel deploy --prod --yes --token=${{ secrets.VERCEL_TOKEN }}
npm publish:
- run: npm publish --provenance --access public
env:
NPM_CONFIG_YES: "true"
任何提示自动按默认接受,不再静默等待。
步骤 3:稳妥跳过 SSH host key 提示
- name: Add SSH known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Deploy via SSH
timeout-minutes: 10
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: deploy
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /var/www && git pull && pnpm install --prod && systemctl reload app
预填 known_hosts 消掉首连提示。不要用 StrictHostKeyChecking=no,那等于关掉中间人防护。
步骤 4:让 wait-for-deployment 轮询快失败
加硬上限和心跳日志:
- name: Wait for Vercel deployment
timeout-minutes: 10
run: |
DEPLOY_ID="${{ steps.deploy.outputs.id }}"
if [ -z "$DEPLOY_ID" ]; then
echo "ERROR: deployment id missing" >&2
exit 1
fi
for i in $(seq 1 60); do
STATE=$(vercel inspect "$DEPLOY_ID" --token=${{ secrets.VERCEL_TOKEN }} | grep -E "^\s+state" | awk '{print $2}')
echo "[poll $i] state=$STATE"
[ "$STATE" = "READY" ] && exit 0
[ "$STATE" = "ERROR" ] && exit 1
sleep 10
done
echo "ERROR: deploy did not reach READY in 10m" >&2
exit 1
deployment id 缺失立即失败,轮询挂住 10 分钟超时而不是 6 小时。
步骤 5:给上传加进度看门狗
- name: Upload artifacts
timeout-minutes: 20
run: |
aws s3 sync ./dist s3://my-bucket --delete --no-progress \
| tee upload.log &
UP_PID=$!
while kill -0 $UP_PID 2>/dev/null; do
sleep 30
if [ -z "$(find upload.log -newer /tmp/_lastsize 2>/dev/null)" ]; then
echo "no progress in 30s" >&2
kill $UP_PID
exit 1
fi
touch /tmp/_lastsize
done
wait $UP_PID
上传卡住 30 秒就死,不再挂到 job 超时。
步骤 6:交叉核对目标平台自己的日志
有时其实不是卡住 —— workflow 在正确地等,而目标服务静默拒绝了部署:
- name: Check Vercel deployment exists
run: |
vercel ls --token=${{ secrets.VERCEL_TOKEN }} | head -5
目标面板上看不到最近这次部署,触发步骤就是静默失败了。把响应打到日志:
- name: Trigger deploy
run: |
RESPONSE=$(curl -fsSL -X POST "$DEPLOY_HOOK_URL")
echo "deploy hook response: $RESPONSE"
相关静默部署失败见 firebase deploy permission denied。
步骤 7:第三方 action 钉版本,缓存恢复要审计
把 action 钉到 commit SHA 避免随机回归:
- uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v3.3.2
with:
path: ~/.pnpm-store
key: pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
enableCrossOsArchive: false
@v3 这类浮动 tag 会带进回归。部署路径上的 action 都用 SHA 钉。
验证
- 重新跑 workflow,总耗时回到上次绿色构建的 1.5 倍以内。
- 每个部署 / 等待步骤都设了步骤级
timeout-minutes。 - 现在卡住是 10-15 分钟报错,而不是 6 小时被强杀。
- 部署 CLI 命令处处带非交互参数。
- wait-for-deployment 步骤在 deployment 没出现时非零退出。
长期预防
- 每个 CI workflow 强制步骤级
timeout-minutes,PR 里 lint 检测缺失。 - CI 上的部署 CLI 始终显式传非交互参数,哪怕默认看似 OK。
- 第三方 action 钉 commit SHA;升级前过一遍 CHANGELOG。
- 部署成功要通过查目标服务 API/CLI 来确认,不只是看触发步骤的退出码。
- 写一份
.github/workflows/CHECKS.md,列出每步最坏 timeout 和卡住时的 runbook。 - self-hosted runner 监控磁盘 + 内存;缓存恢复卡住常跟磁盘压力相关。
常见坑
- 设
timeout-minutes: 360以为修了卡住 —— 这本来就是默认值。要的是更低的步骤级上限。 - 用
StrictHostKeyChecking=no“解决 SSH 提示” —— 那是把 host 校验关了,是安全漏洞。用ssh-keyscan替代。 - 给步骤加
continue-on-error: true—— workflow 绿了但部署根本没发生。相关静默部署模式见 vercel build failed。 - 通知步骤用
if: always()而不检查steps.deploy.outcome == 'success',部署超时了 Slack 还在报”成功”。 - 部署 workflow 里随手升
actions/cache主版本而不测试 —— cache action 的回归曾让数千仓库 CI 卡几小时。
常见问答
Q: job timeout 能不能超过 360 分钟?
可以,job 级 timeout-minutes: 1440(24 小时上限)。但真的需要这么长,多半应该拆部署、不是延长。该用的是步骤级 timeout。
Q: workflow 失败但部署其实成功了,怎么办?
最难处理 —— 回滚困难。先去目标平台面板确认;部署确实进去了的话,手动 promote / approve,再去查 workflow 退出码为什么错了(多半是后面的烟雾测试步骤)。
Q: 部署和烟雾测试要不要拆成两个 job?
要。部署 job 把 URL 作为 output 输出,烟雾测试 job 消费它。部署 job 几分钟内结束保持干净状态,烟雾测试随便跑久也不堵部署日志。
Q: ubuntu-22.04 上跑通,runner 镜像升级后 ubuntu-latest 上挂了?
可能 —— 镜像变更偶尔影响默认 ~/.ssh/config 或 Node 默认版本。生产部署 workflow 把 runner 钉到具体镜像(ubuntu-22.04)。相关 runner 环境调试见 vercel build failed。