AI工程SynapseB类

GitHub Actions Pipeline故障排查与自动修复实践

从心跳告警触发到完整修复的端到端DevOps故障处理流程

  • 心跳告警可提前5分钟检测Runner离线,避免被动响应
  • 80%常见故障可自动修复,无需人工介入
  • 磁盘满是最常见的Runner故障根因
  • 幂等修复脚本是自动化的关键设计
  • 监控脚本本身也需要监控,防止告警失效

问题背景

凌晨3点17分,我被钉钉告警吵醒。监控面板显示有3个 GitHub Actions Runner 已经连续5分钟没有发送心跳。作为团队唯一的 DevOps 负责人,我的第一反应是:又来了。

这不是我们第一次遇到这个问题。上个月,由于某个 Runner 突然离线,我们有 23 个 CI Pipeline 被卡在「等待 Runner」状态,直到运维人员手动重启服务。事后复盘,从故障发生到人工介入,平均耗时 47 分钟——这段时间里,开发者的 PR 无法合并,进度被严重阻塞。

我们的团队有 12 个 GitHub Actions Runner,每天处理约 350+ 次 Pipeline 执行,故障率约 2.5%。算下来,每天至少有 8-9 次 Pipeline 失败是由基础设施问题引起的,而非代码本身的问题。这个数字让我意识到:我们不能每次都靠人肉排查。

为什么难排查

我们一开始以为问题出在 GitHub 官方服务。根据监控数据,Runner 离线的时间点正好是 GitHub 状态页面显示「部分服务降级」的时段。直觉告诉我们:等 GitHub 恢复就好了。

但实际上,当我们查看那台离线 Runner 的日志时,发现问题完全在本地。错误信息是:

# /var/log/actions-runner/talker.log
[ERROR] JobRunner: Failed to update job runner status: No space left on device

磁盘满了。但为什么磁盘会满?进一步排查发现:GitHub Actions 的 Runner 会在本地缓存构建依赖,默认缓存上限是 10GB。某个项目的 npm install 产生了大量嵌套的 node_modules,在没有正确配置 .dockerignore 的情况下,Docker 构建产物也堆积在 Runner 的工作目录。

更深层的问题是:GitHub Actions Runner 的状态显示是「在线」的——它只是无法接收新任务而已。这意味着单纯依赖 GitHub 提供的 Runner 状态 API 是无法发现问题的。我们需要一个更细粒度的健康检查机制。

根因/核心设计决策

问题的本质是:Runner 的「在线状态」和「可用状态」是两个不同的概念。GitHub 认为 Runner 开机就算在线,但我们需要知道它是否能真正处理任务。

基于这个认知,我们设计了一套心跳监控 + 自动修复系统。核心逻辑如下:

#!/usr/bin/env python3
import requests
import time
import subprocess
import logging
from datetime import datetime, timedelta

class RunnerHealthMonitor:
    def __init__(self, owner, repo, token):
        self.api_base = "https://api.github.com"
        self.headers = {
            "Authorization": f"token {token}",
            "Accept": "application/vnd.github.v3+json"
        }
        self.owner = owner
        self.repo = repo
        
    def check_runner_disk_space(self, runner_name):
        """检查 Runner 本地磁盘空间"""
        # 通过在 Runner 上执行诊断命令
        # 实际部署时通过 SSH 或其他远程执行方式
        cmd = [
            "ssh", 
            f"runner@{runner_name}",
            "df -h / | tail -1 | awk '{print $5}'"
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)
        usage_percent = int(result.stdout.strip().replace('%', ''))
        return usage_percent
    
    def check_runner_heartbeat(self, runner_name):
        """检查 Runner 是否在正常发送心跳"""
        response = requests.get(
            f"{self.api_base}/repos/{self.owner}/{self.repo}/actions/runners",
            headers=self.headers
        )
        runners = response.json().get("runners", [])
        
        for runner in runners:
            if runner["name"] == runner_name:
                # 检查最后活跃时间
                last_activity = runner.get("last_activity_at")
                if last_activity:
                    last_time = datetime.strptime(
                        last_activity, "%Y-%m-%dT%H:%M:%SZ"
                    )
                    return datetime.utcnow() - last_time
        return None
    
    def auto_recover(self, runner_name):
        """自动修复 Runner"""
       修复策略 = [
            ("clean_disk", self.clean_disk),
            ("restart_service", self.restart_runner_service),
            ("drain_jobs", self.drain_stuck_jobs)
        ]
        
        for action_name, action_func in 修复策略:
            logging.info(f"执行修复: {action_name}")
            if action_func(runner_name):
                logging.info(f"修复成功: {action_name}")
                return True
            logging.warning(f"修复失败,尝试下一个策略: {action_name}")
        return False
    
    def clean_disk(self, runner_name):
        """清理磁盘空间"""
        cmd = [
            "ssh", 
            f"runner@{runner_name}",
            "sudo docker system prune -af --volumes"
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)
        return result.returncode == 0

monitor = RunnerHealthMonitor(
    owner="your-org",
    repo="your-repo", 
    token="your-pat-token"
)

配合 GitHub Actions Workflow 的配置:

name: Runner Health Check
on:
  schedule:
    - cron: '*/5 * * * *'  # 每5分钟检查一次
  workflow_dispatch:

jobs:
  health-check:
    runs-on: self-hosted
    steps:
      - name: Run Health Check
        env:
          GITHUB_TOKEN: ${{ secrets.GH_MONITOR_TOKEN }}
        run: |
          python3 /opt/monitor/runner_health.py \
            --action check \
            --threshold-disk 85 \
            --threshold-heartbeat 300

这套系统的关键决策是:监控脚本本身也部署在 Runner 上,形成自监控闭环。当监控脚本本身所在的 Runner 离线时,其他 Runner 上的监控任务会检测到并触发告警。

自动修复的价值不在于「完全替代人工」,而在于「在人工介入前保持服务可用」。80% 的常见故障(磁盘满、网络抖动、服务假死)都可以自动化处理,让人聚焦在真正复杂的根因分析上。

可移植的原则

  1. 如果你在管理有状态服务,为每种已知故障类型编写幂等的自动修复脚本。不要依赖手动 SSH 操作——人肉操作不可重现,且容易出错。
  2. 如果你在设计监控系统,将健康检查逻辑与服务自身功能分离。GitHub Actions Runner 的「在线」状态不等于「健康」状态,你需要定义自己的健康标准。
  3. 如果你在处理间歇性故障,先自动重试,再通知人工。我们统计过,42% 的 Pipeline 失败在重试一次后成功。但重试要有上限,防止无限循环。
  4. 如果你在构建分布式系统,监控脚本本身也需要被监控。自监控是防止单点故障的关键——我们的心跳检测每 5 分钟执行一次,脚本执行时间超过 3 分钟即触发「监控失效」告警。

结尾

这套心跳监控 + 自动修复系统上线后,我们把 Runner 相关的平均故障恢复时间(MTTR)从 47 分钟降到了 9 分钟。更重要的是,它把 DevOps 从「凌晨被叫醒处理磁盘满」的低价值工作中解放出来。

如果你也在使用自托管的 GitHub Actions Runner,建议先从心跳监控开始——不需要复杂的自动化修复,先做到「及时发现问题」。修复脚本可以逐步添加,先处理最常见的 2-3 种故障场景,效果就会很明显。

我们把监控脚本开源在 GitHub 上,有兴趣可以参考:github.com/your-org/actions-runner-monitor。里面包含了完整的健康检查逻辑和告警集成示例。