blueguy23/ghostrunner

GitHub: blueguy23/ghostrunner

专为 WSL2 单机环境设计的自托管 GitHub Actions 临时 runner 基础设施,内置共享 MongoDB 与针对 WSL2 故障模式的多层自愈机制。

Stars: 0 | Forks: 0

# ghostrunner [bill-tracker](https://github.com/blueguy23/bill-tracker) 的自托管 GitHub Actions runner 基础设施。包含两个临时 runner 以及 Docker Compose 中共享的 MongoDB,专为单机上的 WSL2 设计。 ## 为什么会有这个项目 **GitHub 托管的 runner 无法访问本地的 MongoDB。** bill-tracker 的 CI 流水线针对真实数据库运行 E2E 测试——而不是使用 mock。GitHub 托管的 runner 需要一个云托管的 MongoDB(成本问题)或者每次运行都会重置的 service 容器(复杂度问题)。在同一个 Docker 网络中拥有 MongoDB 的自托管 runner 既简单又免费。 **WSL2 引入了在其他环境中不存在的故障模式。** 主机休眠/唤醒后的时钟漂移会破坏 TLS。TCP keepalive 默认为 2 小时,因此无法检测到死连接。DNS 在 VPN 循环后会悄悄中断。这个堆栈中的每个 sidecar 都是为了处理特定的 WSL2 故障模式而存在的——请参阅 [INCIDENTS.md](INCIDENTS.md) 了解我们实际遇到过的故障。 ## 架构 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ docker compose │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ ci-runner-1 │ │ ci-runner-2 │ Ephemeral runners │ │ │ │ │ │ Register → run 1 job → repeat │ │ │ entrypoint │ │ entrypoint │ │ │ │ ├ chronyd │ │ ├ chronyd │ Clock sync (WSL2 drift) │ │ │ ├ watchdog │ │ ├ watchdog │ Kill stuck retry loops │ │ │ ├ token-wt │ │ ├ token-wt │ PAT expiry detection │ │ │ └ run.sh │ │ └ run.sh │ GitHub runner binary │ │ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ └────────┬────────┘ │ │ │ │ │ ┌────────▼────────┐ │ │ │ ci-mongo │ MongoDB 7.0 — shared test database │ │ │ (mongo:27017) │ CI jobs connect here, not localhost │ │ └─────────────────┘ │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ autoheal │ │ disk-watcher │ Sidecars │ │ │ Restarts │ │ Prunes Docker│ │ │ │ unhealthy │ │ when disk │ │ │ │ containers │ │ < 10GB free │ │ │ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` ### 单个作业的生命周期 ``` 1. entrypoint.sh starts as root 2. preflight-check.sh validates PAT, scopes, binaries 3. chronyd starts, initial clock step, DNS fallback written 4. gosu drops to runner user 5. Background loops start (clock monitor, PAT re-validation) 6. Runner registers as ephemeral via GitHub API 7. run.sh picks up one job from the queue 8. Job completes → runner auto-deregisters (ephemeral) 9. Loop back to step 6 ``` ### Watchdog + 断路器 WSL2 会悄悄断开长轮询连接。runner 会进入一个“Retrying until reconnected”(重试直到重新连接)的循环,且永远无法自行恢复。Watchdog 会在 runner 日志中检测到这种模式,并终止该进程,以便主循环重新进行全新注册。 断路器防止了无限的重启循环:如果 Watchdog 在 1 小时内触发超过 `WATCHDOG_MAX_FIRES` 次,它会在重置前退避 10 分钟。当根本原因无法通过重启恢复时(例如,过期的 PAT、持续的网络中断),这可以防止系统发生抖动。 ### 健康检查层 | 层级 | 检查内容 | 失败时的操作 | |-------|---------------|-------------------| | **Docker healthcheck** (`deep-healthcheck.sh`) | 查询 GitHub API —— 这个 runner 真的“在线”吗? | 连续 3 次失败后将容器标记为不健康 | | **Autoheal sidecar** | 监控不健康的容器 | 重启容器 | | **Watchdog**(在 entrypoint 中) | 监控 runner 日志中是否出现“Retrying until reconnected” | 终止 runner 进程 → 主循环重新注册 | | **Token watch**(后台循环) | 每 6 小时根据 GitHub API 重新验证 PAT | 写入 `/tmp/token-invalid` 哨兵文件 → healthcheck 进行报告 | | **Clock monitor**(后台循环) | 每 60 秒读取一次 chronyd 偏移量 | 漂移超过 2 秒时发出警告(chronyd 通过 `makestep 1.0 -1` 自动修正) | ## 决策日志 ### 选择临时 runner 而非持久化 runner 持久化 runner 会积累状态:过期的凭证文件、残留的构建产物、作业之间的环境污染。临时 runner 在每次作业后重新注册,因此每次 CI 运行都是干净的。这种折衷是每个作业大约增加 5 秒的注册开销——相对于 4 分钟的流水线来说微不足道。 ### 选择 Chrony 而非 ntpdate `ntpdate` 是一次性同步——它只在启动时校正一次时钟,之后再也不会校正。WSL2 每次主机休眠时都会发生漂移,这每天可能发生多次。`chronyd` 作为 daemon 运行,并在漂移超过 1 秒时自动步进(`makestep 1.0 -1`)。后台循环只*监控*漂移——它不负责修正。请参阅 [INC-001](INCIDENTS.md#inc-001-clock-sync-loop-silently-failing-since-inception) 了解我们尝试从用户空间进行修正时发生的情况。 ### 断路器阈值(5 次触发 / 1 小时 / 10 分钟退避) 这些参数是根据真实事件调整出来的。健康的 runner 每天会触发 0-1 次 Watchdog(短暂的网络闪断)。1 小时内触发 5 次意味着出现了系统性问题——时钟漂移、PAT 过期或 GitHub 中断。10 分钟的退避时间足以让短暂的 GitHub 问题清除,但又足够短,使 runner 能够在合理的窗口内恢复。 ### SYS_TIME capability + Docker socket 挂载 两者都是在 `docker-compose.yml` 中记录为 `RISK ACCEPTED` 的安全折衷: - chrony 步进系统时钟需要 **SYS_TIME**。不存在能够保留时钟修正的替代方案。对于单用户本地 runner 是可接受的;请勿在共享环境中复制此配置。 - **Docker socket** 授予了主机上的有效 root 权限。之所以可接受,是因为此 runner 仅执行受信任的代码(我们自己的 repo)。如果该 runner 处理不受信任的 PR(fork、外部贡献者),请替换为 `tecnativa/docker-socket-proxy`。 ### TCP keepalive 调优 (60/10/6) WSL2 的默认 keepalive 是 7200 秒——死连接长达 2 小时都不会被检测到。runner 到 GitHub 的长轮询会悄悄断开,进程会一直挂起,直到 OS 超时。`60/10/6` 会在大约 2 分钟内检测到死连接(60 秒初始 + 6 次探测 × 10 秒)。 ## 设置 ### 前置条件 - Docker 和 Docker Compose - 具有 `repo` scope 的 GitHub PAT - 项目根目录下的 runner 二进制 tarball (`actions-runner.tar.gz`) ### 快速开始 ``` # 1. 下载 runner binary curl -fsSL https://github.com/actions/runner/releases/download/v2.322.0/actions-runner-linux-x64-2.322.0.tar.gz \ -o actions-runner.tar.gz # 2. 配置环境 cp .env.example .env # 编辑 .env — 设置 GITHUB_PAT, REPO_OWNER, REPO_NAME, DOCKER_GID # 3. 构建并启动 docker compose build docker compose up -d # 4. 验证 runner 是否在线 docker compose logs -f runner-1 runner-2 # 查找:"Runner registered. Waiting for a job..." ``` ### 环境变量 | 变量 | 必需 | 默认值 | 描述 | |----------|----------|---------|-------------| | `GITHUB_PAT` | 是 | — | 具有 `repo` scope 的 PAT | | `REPO_OWNER` | 是 | — | GitHub 用户名或组织 | | `REPO_NAME` | 是 | — | 仓库名称 | | `DOCKER_GID` | 是 | `1001` | 主机上 `/var/run/docker.sock` 的 GID(`stat -c '%g' /var/run/docker.sock`) | | `DOCKERHUB_USERNAME` | 否 | — | 防止匿名的拉取速率限制 | | `DOCKERHUB_TOKEN` | 否 | — | Docker Hub 访问 token | | `PRUNE_THRESHOLD_GB` | 否 | `10` | 磁盘监视器执行清理的阈值下限 | | `RUNNER_CPUS` | 否 | `3.0` | 每个 runner 容器的 CPU 上限 | | `SESSION_CONFLICT_WAIT` | 否 | `30` | 会话冲突时重试前等待的秒数 | | `WATCHDOG_MAX_FIRES` | 否 | `5` | 断路器触发前的 Watchdog 重启次数 | ## 运维操作 ``` # 启动所有内容 docker compose up -d # 查看日志(两个 runner) docker compose logs -f runner-1 runner-2 # 查询单个 runner docker compose logs -f runner-1 # 重启卡住的 runner(不是 `up -d` — 这只会启动已停止的 container) docker compose restart runner-1 runner-2 # 停止(自动从 GitHub 取消注册 runner) docker compose down # 检查 runner 健康状态 docker inspect --format='{{.State.Health.Status}}' ci-runner-1 # 检查 runner 内部的 clock drift docker exec ci-runner-1 chronyc tracking ``` ### 升级 runner 二进制文件 ``` curl -fsSL -o actions-runner.tar.gz docker volume rm runner_runner-1-config runner_runner-2-config docker compose build && docker compose up -d ``` 必须删除配置卷——旧的 runner 二进制文件会将其版本缓存到配置目录中。 ### 共享卷 | 卷名 | 用途 | 是否共享? | |--------|---------|---------| | `runner-N-config` | Runner 二进制文件 + 注册状态 | 每个-runner 独立 | | `runner-N-work` | 作业工作区 (`_work/`) | 每个-runner 独立 | | `playwright-cache` | 用于 E2E 测试的 Chromium 二进制文件 | 共享 | | `pnpm-store` | pnpm 内容寻址存储 | 共享 | | `mongo-data` | MongoDB 数据目录 | 共享 | Playwright 和 pnpm 缓存是共享的,以避免在每次作业时下载约 400MB 的二进制文件。磁盘监视器特意省略了 `docker volume prune`——它会清除这些缓存。 ## 文件映射 ``` ghostrunner/ ├── Dockerfile # Ubuntu 22.04 + Node 22 + runner binary + Playwright deps ├── docker-compose.yml # 2 runners + MongoDB + autoheal + disk watcher ├── entrypoint.sh # Root setup → gosu → ephemeral runner loop + watchdog ├── chrony.conf # Aggressive NTP sync for WSL2 clock drift ├── deep-healthcheck.sh # Queries GitHub API to verify runner is actually online ├── disk-watch.sh # Sidecar: prunes Docker artifacts when disk is low ├── .env.example # All configuration variables with descriptions ├── INCIDENTS.md # Operational incident log └── scripts/ ├── registration.sh # Token fetch, runner register/deregister (sourced) ├── background-loops.sh # Clock monitor + PAT re-validation loops (sourced) └── preflight-check.sh # Validates PAT scopes, binaries, env vars before start ```
标签:Docker Compose, GitHub Actions, MITM代理, WSL2, 版权保护, 自动化运维, 自动笔记, 自愈机制