ci: CI 瞬态故障自动重试机制
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m28s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m19s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m11s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m16s
AI Code Review / AI Code Review (pull_request) Successful in 9m3s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m32s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m44s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3m16s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m35s

问题:
  当 runner 出现瞬态故障(磁盘满/docker挂/网络抖动),分配到该 runner
  的 job 会在 checkout/setup 阶段失败。这种失败与代码无关,但没有自动
  重试机制,需要人工干预。

方案:
  ci_transient_retry.sh - 每 5 分钟扫描最近 30 分钟的 runs:
  - 检测特征:失败 job 的 checkout/setup 阶段失败 + 同 run 其他 job 成功
  - 自动 re-run 整个 workflow(Gitea API /rerun)
  - 每个 run 最多重试 1 次(状态文件跟踪,24h 自动清理)
  - 支持 --dry-run 模式
  - 日志记录到 /var/log/ci-auto-retry.log

判定逻辑:
  - 失败 job 第一个 step 是 checkout/setup 且 conclusion=failure → 瞬态
  - 同一 run 至少 2 个 job 成功(排除代码全挂)
  - 纯代码失败(lint/test/compile)→ 不重试

部署:
  - systemd timer: ci_transient_retry.{service,timer}
  - 或 crontab: */5 * * * *
  - Token 通过环境变量 / EnvironmentFile 注入,不硬编码
This commit is contained in:
xiaoxia
2026-08-31 14:57:34 +08:00
parent ab23ca7e5a
commit 115dc462d7
3 changed files with 330 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=CI Transient Fault Auto-Retry
After=network.target
[Service]
Type=oneshot
# 安全设置
ProtectSystem=strict
ReadWritePaths=/opt/act-runner-docker/ci-retry-state /var/log
PrivateTmp=true
NoNewPrivileges=true
# Token 从环境文件加载(不要用明文写在 unit 里)
EnvironmentFile=/opt/act-runner-docker/ci-retry-state/env
ExecStart=/opt/act-runner-docker/ci-retry-state/ci_transient_retry.sh
+305
View File
@@ -0,0 +1,305 @@
#!/bin/bash
# ============================================================================
# CI 瞬态故障自动重试脚本
# ============================================================================
#
# 解决什么问题:
# 当某个 runner 出现瞬态故障(磁盘满、docker 挂掉、网络抖动等),分配到该
# runner 的 job 会在 "Set up job" / "Checkout code" 阶段就失败。这种失败
# 与代码无关,换到其他 runner 重跑就能通过,但 CI 没有内置重试机制,只能
# 人工干预。
#
# 检测逻辑:
# 1. 查询最近 30 分钟内完成的 workflow runs
# 2. 找到 conclusion=failure 的 run
# 3. 检查失败 run 中的 jobs
# a. 失败的 job 的第一个 step 必须是 "Checkout code" 或 "Set up job"
# 且 conclusion=failure(说明 runner 环境有问题,不是代码问题)
# b. 同一 run 中至少有 2 个成功的 job(排除代码本身全挂的情况)
# c. 失败 job 数量 < 总 job 数量的 50%(多数成功=瞬态故障)
# 4. 满足以上条件 → 判定为瞬态故障 → 自动 re-run 整个 workflow
#
# 安全措施:
# - 每个 run 最多自动重试 1 次(通过状态文件跟踪,避免死循环)
# - 状态文件 24 小时后自动清理
# - 所有操作记录日志,便于审计
#
# 用法:
# ./scripts/ci/ci_transient_retry.sh # 正常运行
# ./scripts/ci/ci_transient_retry.sh --dry-run # 只检查不重试
# ./scripts/ci/ci_transient_retry.sh --verbose # 详细日志输出
#
# 环境变量:
# GITEA_API_TOKEN - Gitea API token(必须设置)
# GITEA_API_URL - Gitea API 基础地址(默认 https://git.xiaoxiajianji.com/api/v1
# GITEA_REPO - 仓库路径(默认 xiaoxia/xiaoxia-saas
# RETRY_STATE_DIR - 重试状态目录(默认 /opt/act-runner-docker/ci-retry-state
# LOG_FILE - 日志文件(默认 /var/log/ci-auto-retry.log
#
# 部署方式:
# 1. 将本脚本复制到 CI 服务器(如 /opt/act-runner-docker/ci-retry-state/
# 2. 创建 env 文件: echo 'GITEA_API_TOKEN=xxx' > /opt/act-runner-docker/ci-retry-state/env
# 3. 安装 systemd timer:
# cp scripts/ci/ci_transient_retry.{service,timer} /etc/systemd/system/
# systemctl daemon-reload
# systemctl enable --now ci_transient_retry.timer
# 或用 crontab:
# */5 * * * * GITEA_API_TOKEN=xxx bash /opt/act-runner-docker/ci-retry-state/ci_transient_retry.sh
# ============================================================================
set -eu
# ---- 配置 ----
GITEA_API_URL="${GITEA_API_URL:-https://git.xiaoxiajianji.com/api/v1}"
GITEA_REPO="${GITEA_REPO:-xiaoxia/xiaoxia-saas}"
RETRY_STATE_DIR="${RETRY_STATE_DIR:-/opt/act-runner-docker/ci-retry-state}"
LOG_FILE="${LOG_FILE:-/var/log/ci-auto-retry.log}"
WINDOW_MINUTES=30 # 检查最近 N 分钟内的 run
MAX_RETRY_PER_RUN=1 # 每个 run 最多重试次数
STATE_TTL_HOURS=24 # 状态文件过期时间(小时)
MIN_SUCCESS_JOBS=2 # 至少 N 个 job 成功才算瞬态故障
MAX_FAIL_RATIO=50 # 失败 job 占比上限(%
# ---- 参数解析 ----
DRY_RUN=false
VERBOSE=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--verbose) VERBOSE=true ;;
esac
done
# ---- 日志 ----
log() {
local level="$1"; shift
local ts
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
local msg="[$ts] [$level] $*"
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
if [ "$level" = "ERROR" ] || [ "$level" = "WARN" ] || $VERBOSE || $DRY_RUN; then
echo "$msg"
fi
}
log_info() { log INFO "$@"; }
log_warn() { log WARN "$@"; }
log_error() { log ERROR "$@"; }
log_debug() { $VERBOSE && log DEBUG "$@" || true; }
# ---- 前置检查 ----
if [ -z "${GITEA_API_TOKEN:-}" ]; then
log_error "GITEA_API_TOKEN 未设置,退出"
exit 1
fi
mkdir -p "$RETRY_STATE_DIR" 2>/dev/null || true
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
API_BASE="${GITEA_API_URL}/repos/${GITEA_REPO}/actions"
log_info "========== CI 瞬态故障检测开始 =========="
$DRY_RUN && log_info "[DRY-RUN 模式] 不会实际触发重试"
# ---- 清理过期状态文件 ----
find "$RETRY_STATE_DIR" -name "*.retry" -mmin "+$((STATE_TTL_HOURS * 60))" -delete 2>/dev/null || true
# ---- 临时文件(用 mktemp 避免引号/转义问题) ----
TMPDIR_WORK=$(mktemp -d)
trap "rm -rf $TMPDIR_WORK" EXIT
# ---- 查询最近完成的 runs ----
curl -sf -H "Authorization: token ${GITEA_API_TOKEN}" \
"${API_BASE}/runs?status=completed&limit=20" > "${TMPDIR_WORK}/runs.json" 2>/dev/null || echo '[]' > "${TMPDIR_WORK}/runs.json"
# ---- 主分析逻辑(全部用 python3,避免 bash JSON 处理陷阱) ----
python3 - "$TMPDIR_WORK" "$API_BASE" "$GITEA_API_TOKEN" "$RETRY_STATE_DIR" \
"$WINDOW_MINUTES" "$MIN_SUCCESS_JOBS" "$MAX_FAIL_RATIO" "$MAX_RETRY_PER_RUN" \
"$DRY_RUN" "$VERBOSE" "$LOG_FILE" << 'PYEOF'
import json, sys, os, subprocess, urllib.request
from datetime import datetime, timezone, timedelta
tmpdir = sys.argv[1]
api_base = sys.argv[2]
token = sys.argv[3]
state_dir = sys.argv[4]
window_min = int(sys.argv[5])
min_success = int(sys.argv[6])
max_fail_pct = int(sys.argv[7])
max_retry = int(sys.argv[8])
dry_run = sys.argv[9] == "true"
verbose = sys.argv[10] == "true"
log_file = sys.argv[11]
def log(level, msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
line = f"[{ts}] [{level}] {msg}"
try:
with open(log_file, "a") as f:
f.write(line + "\n")
except:
pass
if level in ("ERROR", "WARN") or verbose or dry_run:
print(line)
# 加载 runs
with open(os.path.join(tmpdir, "runs.json")) as f:
runs_data = json.load(f)
if isinstance(runs_data, dict):
runs_data = runs_data.get("workflow_runs", runs_data.get("runs", []))
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window_min)
# 筛选最近 N 分钟内完成的失败 runs
candidates = []
for r in runs_data:
completed_str = r.get("completed_at", "")
if not completed_str or completed_str.startswith("1970"):
continue
try:
completed = datetime.fromisoformat(completed_str.replace("Z", "+00:00"))
if completed < cutoff:
continue
except:
continue
if r.get("conclusion") != "failure":
continue
candidates.append(r)
if not candidates:
log("INFO", f"没有发现最近 {window_min} 分钟内失败的 runs")
log("INFO", "========== 检测结束 ==========")
sys.exit(0)
log("INFO", f"发现 {len(candidates)} 个失败的 runs 需要分析")
retry_count = 0
headers = {"Authorization": f"token {token}"}
for run in candidates:
run_id = run["id"]
run_number = run.get("run_number", run_id)
branch = run.get("head_branch", "")
event = run.get("event", "")
log("INFO", f"分析 Run #{run_number} (id={run_id}) branch={branch} event={event}")
# 检查是否已重试
state_file = os.path.join(state_dir, f"{run_id}.retry")
prev_attempts = 0
if os.path.exists(state_file):
try:
with open(state_file) as f:
prev_attempts = int(f.read().strip())
except:
pass
if prev_attempts >= max_retry:
log("INFO", f" Run #{run_number} 已重试过 {prev_attempts} 次,跳过")
continue
# 获取 jobs
try:
req = urllib.request.Request(
f"{api_base}/runs/{run_id}/jobs",
headers=headers
)
with urllib.request.urlopen(req, timeout=15) as resp:
jobs_data = json.loads(resp.read())
except Exception as e:
log("ERROR", f" 获取 jobs 失败: {e}")
continue
jobs = jobs_data.get("jobs", [])
total = len(jobs)
success_count = 0
fail_count = 0
transient_jobs = []
code_fail_jobs = []
for j in jobs:
conclusion = j.get("conclusion", "")
if conclusion == "success":
success_count += 1
elif conclusion == "failure":
fail_count += 1
steps = j.get("steps", [])
if steps:
first = steps[0]
fname = first.get("name", "").lower()
fconc = first.get("conclusion", "")
# 瞬态故障特征:第一个 stepcheckout/setup)失败
if fconc == "failure" and any(kw in fname for kw in ["checkout", "set up", "setup"]):
transient_jobs.append({
"name": j["name"],
"runner": j.get("runner_name", "?"),
})
else:
code_fail_jobs.append(j["name"])
else:
code_fail_jobs.append(j["name"])
skip_count = total - success_count - fail_count
log("INFO", f" Jobs: total={total} success={success_count} fail={fail_count} skip={skip_count}")
if transient_jobs:
log("INFO", f" 瞬态故障 jobs: {', '.join(j['name'] for j in transient_jobs)}")
if code_fail_jobs:
log("INFO", f" 代码失败 jobs: {', '.join(code_fail_jobs)}")
# 判定
is_transient = False
reason = ""
if transient_jobs and not code_fail_jobs:
if success_count >= min_success:
is_transient = True
reason = f"所有失败 job 在 checkout/setup 阶段失败,{success_count} 个 job 成功"
else:
reason = f"checkout 失败但成功 job 数不足 ({success_count} < {min_success})"
elif transient_jobs and code_fail_jobs:
if fail_count < total * max_fail_pct / 100 and success_count >= min_success:
is_transient = True
reason = f"{len(transient_jobs)} 瞬态 + {len(code_fail_jobs)} 代码,但成功 job 占多数"
else:
reason = f"混合失败: {len(transient_jobs)} 瞬态 + {len(code_fail_jobs)} 代码"
elif code_fail_jobs:
reason = f"纯代码失败: {', '.join(code_fail_jobs[:3])}"
else:
reason = "无失败 job"
log("INFO", f" 判定: {reason}")
if not is_transient:
log("INFO", " → 非瞬态故障,跳过")
continue
# 触发重试
log("WARN", f" → 检测到瞬态故障!准备重试 Run #{run_number}")
if dry_run:
log("INFO", " [DRY-RUN] 跳过实际重试")
continue
# 调用 re-run API
try:
req = urllib.request.Request(
f"{api_base}/runs/{run_id}/rerun",
headers={**headers, "Content-Type": "application/json"},
method="POST",
data=b""
)
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
new_run_id = result.get("id", "?")
new_status = result.get("status", "?")
# 记录重试状态
with open(state_file, "w") as f:
f.write(str(prev_attempts + 1))
log("INFO", f" ✅ Re-run 成功! 新 Run ID: {new_run_id}, 状态: {new_status}")
retry_count += 1
except Exception as e:
log("ERROR", f" ❌ Re-run 失败: {e}")
log("INFO", f"========== 检测结束: 检查 {len(candidates)} 个失败 runs,重试 {retry_count} 个 ==========")
PYEOF
exit 0
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Run CI Transient Fault Auto-Retry every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
[Install]
WantedBy=timers.target