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, 版权保护, 自动化运维, 自动笔记, 自愈机制