---
date: 2026-05-06
tags: [ci, uat-runner, docker, postgres, redis, fail-fast, concurrency, race-condition]
status: root-cause-found
---

# UAT runner 上 post-deploy-regression 整轮被 fail-fast 终止：测试容器不可达

## 现象

Run #1217 / job `post-deploy-regression`（runner=`uat-with-docker`）执行
`L1 全量跨模块回归` 时，第一个 test suite (`iam-admin/emergency-bypass.api.test.ts`)
在 `createTestApp → app.init` 阶段拿到致命错误，`db-fail-fast` 主动 `process.exit(2)`，
整轮终止。

关键日志：

```
PrismaClientInitializationError: Can't reach database server at `127.0.0.1:35432`
Redis 连接错误：connect ECONNREFUSED 127.0.0.1:36379
🚨 [test-fail-fast] 检测到测试基础设施不可用，立即终止整轮测试
原因：createTestApp app.init 命中致命 DB 错误模式
exit status 2
```

退出码 2 = infra 故障（区别于 jest 默认 1=断言失败），由
`backend/helpers/db-fail-fast.ts:52` 显式 abort。

## 根因（已确认）

**两个 CI job 共享同名全局测试容器，发生 docker rm -f 竞争。**

时间线（run #1217）：
- 14:11:54 `deploy-uat::post-deploy-regression` 启动（uat-with-docker）
- 14:12:48 复用现有 `ffoa-test-postgres` / `ffoa-test-redis`
- 14:14 → 14:23:13 跑了 28 个 suite，全部 PASS
- **14:23:06** PR #235 推送，触发 `quality-gates::backend-integration`（同一个 uat-with-docker host runner）
- 14:23:0X 该 job step 1 执行 `quality-gates.yml:197`：
  ```bash
  docker rm -f ffoa-test-postgres ffoa-test-redis 2>/dev/null || true
  ```
  把另一个 job 正在使用的容器**强删**
- 14:23:21 `post-deploy-regression` 跑下一个 suite 时 ECONNREFUSED → fail-fast exit 2

`docker rm -f` 是为修 issue #211（上轮 PR 残留连接挡 DROP DATABASE）加的，
动机正确，但忽略了"还有其他 job 可能正在使用这个容器"。

### 为什么会并发？

act_runner 注册 label `uat-with-docker:host`，配置允许同时跑多个 task。
这俩 job `runs-on: uat-with-docker` 共享同一 host runner，但**不互斥**。
全局容器名 + 无 lock = 经典 race。

## 区分清楚（重要）

**这不是 staging→production 合并直接造成的问题。**
任何一次 push staging 都会复现，跟代码内容无关。属于 CI 基础设施漂移。

### 关键证据：代码没变化

- 上一个成功的 deploy-uat 是 run #1214（SHA `f853d1c8`）
- 失败的是 run #1217（SHA `16a072ea`，"Merge branch 'production' into staging"）
- `git diff f853d1c8 16a072ea` **输出为空** —— 两个 merge commit 内容树完全一致
- 同样的 SHA 在更早的 run #1183 (success) / #1182 (failure) / #1181 (failure)
  也表现出"重跑会变绿"的抖动 pattern

→ 结论：runner 环境状态不稳，跟代码、跟合并方向都无关。

`db-fail-fast` 的 `exit 2` 是**保护性行为**，不是 bug——它防止后续 suite 全部
因为 `connection closed` 失败浪费 CI 时间。看到这个 exit 2，应该把视线从测试代码
转向"测试容器为什么没起来"。

## 修复方案

给所有用全局 `ffoa-test-postgres/redis` 容器的 job 加 concurrency group，强制串行：

```yaml
concurrency:
  group: shared-test-containers
  cancel-in-progress: false
```

涉及 job：
- `.gitea/workflows/deploy-uat.yml::post-deploy-regression`
- `.gitea/workflows/quality-gates.yml::backend-integration`

不要 cancel-in-progress：让在跑的跑完，新来的排队，避免半路被 rm。

### 备选（更彻底但改动大）

每个 job 用唯一容器名（用 `${{ github.run_id }}` 做后缀），完全隔离 —
代价是每个 job 都要从零拉起 postgres + 加载 schema + 跑 seed，慢且贵。
当前 reuse 模式速度快得多，concurrency lock 是更划算的折中。

## 排查方法（下次类似 infra 抖动）

1. **先看测试日志的 PASS/FAIL 时间线**：连续大量 PASS 后突然 ECONNREFUSED，
   极可能是外部干扰，不是测试代码本身的问题
2. **对照同一时段的其他 CI run**：`actions/runs?limit=20` 列出 started_at 重叠的，
   看哪个 job 跟当前共用 runner / 共用资源
3. **grep `docker rm` / `stop_test_db` / `cmd_down`** 在所有 workflow 和 deploy
   脚本里，定位"谁会清掉共享资源"
4. **runner label 是不是 :host**：`:host` runner 直接操作宿主机 docker，所有 job 共
   享 daemon；多 job 同时跑时，全局容器名就是雷区

## 相关文件

- `backend/helpers/db-fail-fast.ts:52` — 致命错误触发 exit 2 的位置
- `testing/backend/helpers/app.helper.ts:73` — `checkAndAbortIfFatal` 调用点
- `testing/scripts/run-backend-integration.sh` — 拉起测试容器入口
- `.gitea/workflows/deploy-uat.yml:65-99` — `post-deploy-regression` job 定义
