ait-runner:原生 CI 执行面
通过确切的 Worker Job 认领、Snapshot 物化、隔离的执行尝试、有界证据、租约和部署控制,运维 1.0.0 原生 runner。
适用人群: CI 运维、Repository 所有者与集成方
ait-runner 是干什么的#
ait-runner 是原生的执行面,跑的是 ait-server 接纳下来的 CI 工作。 服务端拥有 Repository 权威、Worker Job 记录、租约、状态迁移和被接纳 的结果。runner 只拥有一次有界的执行尝试:认领兼容的工作,把确切的 已记录源码重建出来,调用 Repository 的 CI 入口,返回证据,然后把自己 的尝试目录删掉。
CI 必须离开开发者的 worktree 执行时、不同机器服务不同 Repository 集合时,或者运维得重放某一对已知的 Worker Job 时,runner 就派上用场。 它不是 Repository 备份,不是策略引擎,不是独立于 ait-server 的调度 器,也不是某种语言专属的构建系统。
1.0.0 里对外可认领的 Binary Worker Job 只有两类:
| 固定 kind | 公开名称 | 执行目的 |
|---|---|---|
7 | patchset.ci | 为受管的就绪判定校验选定的 Patchset Snapshot。 |
11 | repo.ci | 针对某一个确切的已记录 Snapshot 跑 Repository CI。 |
持久的 job 身份是 (repository_index, worker_job_index) 这一对。诊断 或重放一个 Binary Worker Job 时,绝对不要拿 Repository 名字来顶这 一对值。
四种使用场景#
常驻的执行服务#
想让一个 worker 持续轮询、维持租约、执行兼容的 job、投递终态结果时, 把 serve 放在服务管理器下面跑:
ait-runner serve \
--server https://ait.example.internal \
--worker-id runner-taipei-01 \
--attempt-root /srv/ait/runner-attempts--worker-id 是必填的运维身份,不是环境层面的设置。服务端 URL、源码 根和尝试根同样是显式的命令参数,这样起出来的进程自己就把运行边界讲 清楚了。
按 Repository 划分的 worker 池#
重复给 --repository-index,就能把一个服务实例限制在确切的一组 Repository 权威上。重复的索引会被归一:
ait-runner serve \
--server https://ait.example.internal \
--worker-id runner-linux-arm64-02 \
--repository-index 3 \
--repository-index 8 \
--attempt-root /srv/ait/runner-attempts不加这个过滤,兼容队列就会在所有已注册的 Repository 之间挑。过滤器 是用来表达一条有意划下的机群边界的,不是拿来猜哪个 Repository 拥有 某个出错 job 的。
只跑一个确切的 Worker Job#
在受控的诊断或恢复过程里,用 run-job 处理一对已知的 Binary 值。它 只对那一对做认领、执行、心跳和收尾:
ait-runner run-job \
--server https://ait.example.internal \
--repository-index 3 \
--worker-job-index 42 \
--attempt-root /srv/ait/runner-attempts服务端必须声明当前的 ait.runner.native-job.v3 契约。这一对不可认领、 租约凭证无效,或者请求和认领到的 Repository 对不上,命令都会失败。
本地请求校验#
用 execute 跑一个带类型的本地目录请求,不认领任何服务端 job。这对 校验 runner 边界本身很有用;它不会创建也不会更新 Worker Job:
{
"contract": "ait.runner.native-job.v3",
"label": "local-repository-ci",
"source": {"kind": "local_directory", "path": "."},
"command": {
"argv": ["./ci/run"],
"working_directory": ".",
"environment": {}
},
"timeout_ms": 900000,
"suite_id": "repository-ci"
}ait-runner execute \
--request request.json \
--source-root /srv/ait/sources \
--attempt-root /srv/ait/runner-attempts--request - 从标准输入读同样的 JSON,这也是默认行为。服务端下发的 remote_snapshot 请求,需要 run-job 或 serve 用的那个服务端物化 provider;独立跑的 execute 走的是本地源码路径。
1.0.0 完整命令面#
ait-runner doctor --server <SERVER>
ait-runner execute \
[--request <REQUEST>] \
[--source-root <SOURCE_ROOT>] \
[--attempt-root <ATTEMPT_ROOT>]
ait-runner run-job \
--server <SERVER> \
--repository-index <REPOSITORY_INDEX> \
--worker-job-index <WORKER_JOB_INDEX> \
[--source-root <SOURCE_ROOT>] \
[--attempt-root <ATTEMPT_ROOT>]
ait-runner serve \
--server <SERVER> \
--worker-id <WORKER_ID> \
[--repository-index <REPOSITORY_INDEX>]... \
[--once] \
[--poll-interval-ms <MILLISECONDS>] \
[--heartbeat-interval-ms <MILLISECONDS>] \
[--source-root <SOURCE_ROOT>] \
[--attempt-root <ATTEMPT_ROOT>]| 命令 | 确切行为 |
|---|---|
doctor | 读取服务端的实时健康状态并协商 runner 契约,不认领任何 job。 |
execute | 解析一个有界的 v3 请求,在隔离的一次尝试里执行它的本地源码。 |
run-job | 认领并完成确切的一对 Binary Worker Job;需要当前的 v3 支持。 |
serve | 持续轮询;带上 --once 时,在一次空闲检查、一次 job 投递或一次出错之后退出。 |
execute、run-job 和 serve 的 --source-root 默认是 .。不给 --attempt-root 就用平台临时目录下的 ait-runner/attempts。serve 默认轮询间隔 1,000 毫秒,请求的心跳间隔 30,000 毫秒。两个间隔都不能 为零;实际生效的 Binary 心跳还会被当前租约进一步收紧。
端到端的 job 生命周期#
- 一个远程工作流或 Repository CI 请求,促使
ait-server为某个 Repository 提交一个带类型的 Worker Job。 - runner 检查服务端健康度,选出当前的 Binary runner 契约。
doctor到这一步就停了,什么都不认领。 serve认领下一个兼容的 job,或者run-job认领确切的(repository_index, worker_job_index)这一对。服务端返回租约凭证、 尝试次数、固定的 job kind 和带类型的运行时请求。- 执行之前,runner 会校验凭证、job kind、Repository 索引、请求大小、 路径、参数、环境、平台和 CI 入口。
- 对
remote_snapshot,它会把确切的 Snapshot Tree、Blob 包和锁定的 外部 Repository Snapshot 下载下来、验过,放进一次新的尝试里。 - 逻辑上的 argv 选择器
./ci/run,在 Unix 上解析成ci/run.sh,在 Windows 上解析成ci/run.ps1。runner 用直接的 argv 启动它,不是 拼一条 shell 命令。 - 进程跑着的时候,一条单独的心跳维持着服务端租约。超时会在证据定稿 之前,把它拥有的那个进程组终止掉。
- runner 把 stdout 和 stderr 收成有界的证据,记下退出和物化的事实, 删掉尝试目录,再校验终态结果的大小。
- runner 带着原来那份租约凭证提交 complete 或 fail。这次状态迁移还 能不能被接受,只有服务端说了算,被接纳的终态也由它存下来。
整个尝试期间,选定的 Snapshot 始终不可变。对一个远程 Binary job, runner 永远不会去执行开发者 worktree 里的脏内容。
请求契约#
当前的请求 schema 是 ait.runner.native-job.v3。未知字段一律拒收。 它的顶层字段是:
| 字段 | 契约 |
|---|---|
contract | 必填,必须正好是字符串 ait.runner.native-job.v3。 |
label | 可选的非空标签,最多 256 字节。 |
source | 必填,local_directory 或当前的 remote_snapshot source 对象。 |
command | 必填的直接 argv 命令对象。 |
timeout_ms | 可选;默认 900,000 毫秒,取值范围必须是 1 到 86,400,000 毫秒。 |
suite_id | 可选的非空套件标签,最多 256 字节。 |
服务端下发的 source 里,remote_snapshot 带着确切的 repository_index、repository_name、snapshot_id,以及一张从外部 Repository 名字到数字索引的映射。请求里的 Repository 索引,必须和认领 到的那一对 Worker Job 对得上。command 对象要求有 argv,而 working_directory 默认是 .,environment 默认是空映射。
argv 的第一个值永远是逻辑选择器 ./ci/run。后面的值会直接传给平台 入口。工作目录必须是相对路径,而且不能跑出物化出来的工作区。
物化与尝试隔离#
每次执行都用一个名字唯一的 attempt-* 目录,里面装着工作区、下载 下来的包和临时日志。serve 会在它配置的尝试父目录上拿一把排他锁, 而且只回收确切属于本 runner 的陈旧尝试目录。清理时会处理只读文件, 并且不会跟着符号链接跑到尝试目录之外。
远程物化在这些情况下会直接失败:清单格式不对、校验和对不上、记录未知 或过多、Tree 有环或过深、路径越界、source 或入口是符号链接,以及内容 超出声明的上限。锁定的外部 Repository,是从它们各自确切的 Snapshot 物化出来的,不是从某个现成的 checkout 里拿的。
CI 子进程会收到这几个由 runner 提供的值:
| 名称 | 含义 |
|---|---|
AIT_RUNNER_ATTEMPT_ROOT | 当前这次 runner 所属尝试的确切根目录。 |
AIT_RUNNER_WORKSPACE | 物化出来的主 Repository 工作区的确切路径。 |
AIT_EXTERNAL_<NAME>_REPO_ROOT | 某个归一化的、锁定的外部 Repository 物化出来的确切根目录。 |
这些是注入到进程边界上的值,不是启动 runner 用的设置。runner 唯一 支持的凭据环境变量,是可选的 AIT_SERVER_TOKEN。服务端 URL 和 worker 身份仍然走 --server 和 --worker-id;AIT_SERVER_URL 和 AIT_RUNNER_WORKER_ID 不是输入。
有界的执行与证据#
| 边界 | 1.0.0 上限 |
|---|---|
| 带类型的请求 | 1 MiB。 |
| 终态结果 | JSON 编码之后 64 KiB。 |
| 超时 | 默认 15 分钟;最长 24 小时。 |
| 命令 argv | 256 个值;每个 16 KiB;总共 128 KiB。 |
| 命令环境变量 | 256 条;键和值加起来共 256 KiB。 |
| stdout 与 stderr 证据 | 每条流取 8 KiB 尾部。 |
每条流的记录里包含总字节数、所有捕获字节的 SHA-256、base64 编码的 尾部、尾部字节数,还有一个截断标记。完整的临时日志会随尝试目录一起 删掉,所以终态证据是有意做成有界的,不是一份藏起来的日志归档。
远程 Snapshot 导入还会强制这些上限:清单 64 MiB、物化文件 10,000,000 个、物化字节 512 GiB、单个包下载 16 GiB,以及 8 个并发的包下载/解码 操作。这些是安全天花板,不是推荐的 job 规模;运维给真实 worker 主机 定容量预期时,应该定得低得多。
结果与诊断契约#
命令执行成功会写出一个 ait.runner.native-result.v1 对象。它的终态 status 是 succeeded、command_failed 或 timed_out,而 tests_status 只有在 succeeded 时才会是 pass。结果里包含套件 记录、退出码或信号、耗时、物化的文件数和字节数、有界的 stdout 与 stderr 证据,以及清理证据。
面向服务端的命令,会把被接纳的终态迁移包在 ait.runner.delivery.v1 里。其他对外的输出契约有:
| 契约 | 什么时候发出 |
|---|---|
ait.runner.doctor.v1 | doctor 确认健康,并报出选定的 runner 契约。 |
ait.runner.service.v1 | serve --once 没找到兼容的 job,返回 idle。 |
ait.runner.serve-event.v1 | 常驻的 serve 把一次有界的 job 失败报到 stderr,然后继续轮询。 |
ait.runner.error.v1 | 命令在能返回正常结果之前就失败了。 |
--once 是快速失败的,出了那一个结果就返回。常驻的 serve 在一次 有界的单 job 失败事件之后会继续跑。轮询连不上服务端时,常驻服务模式 会做有界的重连退避,最长约 30 秒。当前的 Binary 契约一旦选定,进程 就不会悄悄降级。心跳失败会让认领到的 job 失败;runner 不会把语义 含糊的心跳重试叠在一起。
部署#
1.0.0 为 macOS、Linux/glibc 和 Windows 发布原生 runner 可执行文件, arm64 和 x86_64 都有。Linux 的 OCI 索引覆盖 amd64 和 arm64:
ghcr.io/weita2026/ait-runner:1.0.0镜像入口是 ait-runner,工作目录是 /workspace,以数字 UID/GID 65532 运行。给尝试路径挂一个属于该身份、可写的卷;跑远程 Snapshot 的 worker 并不需要一份可变的本地 Repository checkout:
docker volume create ait-runner-attempts
docker run --rm \
--network ait-native-rc \
--volume ait-runner-attempts:/var/lib/ait-runner \
ghcr.io/weita2026/ait-runner:1.0.0 \
serve \
--server http://ait-server:8088 \
--worker-id runner-container-01 \
--repository-index 0 \
--attempt-root /var/lib/ait-runner \
--onceAIT_SERVER_TOKEN 用服务或容器的 secret 来传;别把 token 嵌进镜像、 命令、Repository 或者留存的日志里。上生产之前先核验发布的索引,然后 把不可变的 1.0.0 镜像摘要钉死。
安全与运维清单#
- 每个 worker 都跑在专用的操作系统身份或容器下,文件系统和网络权限 给到最小。
- 收紧服务端入站,在受信任的边界上用带认证的 TLS,并且别让
AIT_SERVER_TOKEN出现在 argv 里或尝试根下的文件里。 - 尝试用的存储放在 Repository 权威之外,也放在任何源码根之外。
- 别让两个同时跑的
serve进程共用一个尝试根;排他租约会拒掉它。 - 把
ci/run.sh和ci/run.ps1当成 Repository 经过评审的 CI 边界 来写。别指望 runner 自己猜出包管理器或测试命令。 - 盯着服务端的 Worker Job 状态、runner 的失败事件、租约丢失、磁盘 容量、清理证据和版本契约不匹配。
- 把
command_failed当作仓库 CI 的证据,把timed_out当作执行 超限,把ait.runner.error.v1当作 runner 或请求边界的失败。
先做诊断,不要认领工作:
ait-runner --version
ait-runner doctor --server https://ait.example.internal
ait repo jobs --remote origin --json
ait repo ci-capabilities --remote origin --json然后逐项对:确切的 Repository 和 Worker Job 索引、选定的 runner 契约、 尝试次数、租约时间、终态操作、流的摘要,以及清理证据。服务端那边的 job 证据要留好;清理了一半的尝试目录别留着,更别把它当成权威源码 再用一次。
HTTP 的认领、心跳、complete、fail、Snapshot 导入和包下载这些路由,见 ait-server REST API 参考。 服务端权威和异地恢复,见 ait-server:远程权威与恢复。 完整的公开环境变量清单在 附录:环境变量。