OpenAI CI/CD 问题的完整解答:精确一次(exactly-once)到底能诚实地意味着什么、幂等触发器、工作流解析、作业状态转换、带围栏机制的租约、跨租户的公平调度、镜像缓存、隔离与密钥,以及实时日志。
作者:The Forward Deployed 编辑团队已审阅
本文属于 OpenAI 系统设计题库。该题目能代表该轮面试的水平。分析与解答为本站原创。
问题描述
开发者推送代码。Git 托管平台调用 webhook,携带仓库 ID 和提交哈希。服务读取该提交处的 YAML 工作流文件,创建一次运行(run)及其线性作业链,将每个作业调度到在容器中运行它的 worker 上,将日志流式传输到 UI,并记录结果。该服务是多租户的:许多公司共享同一个 worker 集群。
澄清问题
- 工作流的形态? 线性作业链;作业 2 只有在作业 1 成功后才开始。并行作业是后续扩展。
- 运行的是什么? 任意的客户代码:构建、测试和部署。
- 这里的精确一次意味着什么? 要主动提问。通常是指:每个作业的结果只被记录一次,且崩溃绝不会导致同一作业的两个副本同时执行。
- 规模? 练习用数字:5,000 个租户,每天 200,000 次运行,每次运行 5 个作业,每个作业平均 6 分钟。
- 密钥? 作业需要租户的密钥,例如部署密钥。
- 日志保留期? 90 天。
CI/CD 为什么难
三个问题决定了这轮面试的成败。
通常表述下的精确一次是不可能实现的。worker 可能在完成部署后、报告成功之前崩溃。系统无法将这种情况与部署开始前的崩溃区分开来。任何声称队列能实现精确一次的设计都是错误的,面试官深知这一点。
客户代码默认是敌对的。构建步骤可能尝试读取其他租户的密钥、逃逸出容器,或攻击网络。
而且租户之间会竞争。一家公司在一小时内推送 500 次提交,绝不能让其他所有人的构建都卡住。
因此,核心矛盾是吞吐量与安全性保障之间的权衡。worker 必须在所有租户间尽可能多地运行作业,同时每个作业的效果只发生一次、每个租户保持隔离,并且公平性得到保证。
flowchart LR GH[Git host]:::user -->|webhook: repo, commit| TR[Trigger service]:::svc TR --> WF[Workflow service:<br/>parse YAML, create run + jobs]:::svc --> DB[(Runs, jobs)]:::store SCH[Scheduler: fair queues]:::svc --> DB W[Workers]:::svc -->|lease jobs| SCH W --> LOGS[Log service]:::svc --> UI([Web UI]):::user classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
关键思路。 尽早说明投递是至少一次的。精确一次是通过租约、围栏机制和幂等步骤构建出来的效果。
核心概念
至少一次投递加幂等性
消息队列可以保证一个任务至少被投递一次。如果同一个任务被处理两次与处理一次的效果相同,那么两者组合起来就表现为恰好一次。这是获得该保证的唯一诚实方式。
租约与围栏令牌
工作节点以有限时间租用一个任务,并通过心跳续租。每次租约都会递增一个尝试编号,即围栏令牌。所有关于该任务的写入都必须携带它,过期的尝试会被拒绝。
幂等的外部副作用
部署到客户集群发生在你的系统之外。向目标传递一个幂等键,例如运行 ID 加任务名,或者先检查其状态,这样重复部署同一个提交就不会产生任何效果。
加权公平队列
为每个租户保留一个队列,并以每个租户获得的工作节点份额与其权重相匹配的方式挑选下一个任务,无论它排队了多少任务。
核心思想。 至少一次投递、带围栏的租约、幂等副作用,以及每个租户的公平队列。
需求
在继续阅读之前。 写下需求,然后用一句诚实的话定义“恰好一次”。
1.1 功能需求
- 接收推送 webhook;为每个仓库、提交和工作流创建一次运行。
- 解析该提交处的工作流文件。
- 按顺序运行任务,每个任务在一个容器中,并将结果向下游传递。
- 展示实时状态和日志;日志保留 90 天。
- 取消运行;手动重试失败的任务。
- 将租户的密钥注入任务。
1.2 非功能需求
- 每个任务恰好一次的效果:每个任务只有一条记录的结果,且不会有两个工作节点同时处理同一个任务。
- 容错性:工作节点、调度器和数据库的故障不会丢失运行。
- 隔离性:任何租户都不能读取其他租户的代码、密钥或日志。
- 公平性:一个租户的突发流量不能饿死其他租户。
- 水平扩展:通过增加工作节点来增加容量。
1.3 约束与属性
正确且隔离的执行是属性。 工作节点容量是约束。 它在租户之间共享,是主要成本,并迫使系统采用公平调度和缓存。
粗略估算
- 每日任务数:200,000 次运行 × 5 = 100 万。
- 平均到达率:100 万 / 86,400 ≈ 每秒 11.6 个任务;工作时段峰值约为 3 倍:每秒 35 个。
- 峰值并发任务数(Little 定律):35 × 360 秒 = 12,600。
- 工作节点:按每台机器 4 个任务计算,峰值约需 3,150 台机器,夜间更少;使用自动扩缩容。
- 日志:每个任务平均 1 MB = 每天 1 TB;90 天共 90 TB,压缩后可能只有四分之一。
- 调度器写入:每个任务几次状态变更,加上 12,600 个任务每 10 秒一次的心跳 = 每秒约 1,300 次写入。单个关系型数据库即可处理;之后再按租户分片。
核心思想。 峰值时约有 12,600 个任务在运行。调度器的写入负载并不大;真正占主导的是 worker 容量和镜像拉取。
API 设计
继续阅读前先思考。 Git 托管平台对同一次推送发送了两次相同的 webhook。会创建多少个运行(run)?
一个。运行表在仓库、提交和工作流名称上设有唯一键。第二次插入不会生效,并返回已存在的运行。
3.1 外部 API
POST /v1/hooks/push (signed by the git host)
{repo_id, commit_sha, ref}
-> 202 {run_id} (existing run_id if already created)
GET /v1/runs/:id -> {status, jobs: [{name, status, started_at, ...}]}
POST /v1/runs/:id/cancel
POST /v1/runs/:id/jobs/:name/retry
GET /v1/runs/:id/jobs/:name/logs?from_seq= (WebSocket upgrade for live tail)3.2 Worker API
POST /internal/lease {worker_id, labels: [linux, 4cpu], cached_images[]}
-> {job_id, attempt, spec, secrets_token, lease_expires_at} | 204
POST /internal/heartbeat {job_id, attempt} -> {lease_expires_at, cancel}
POST /internal/logs {job_id, attempt, seq, bytes}
POST /internal/finish {job_id, attempt, exit_code, outputs}每次 worker 调用都携带尝试编号(attempt)。任何携带过期 attempt 的调用都会返回 409。
数据模型
runs (id, tenant_id, repo_id, commit_sha, workflow_name, status, created_at)
unique (repo_id, commit_sha, workflow_name)
jobs (id, run_id, tenant_id, seq, name, image, commands, needs_secrets[],
status: waiting | ready | leased | running | succeeded | failed | cancelled,
attempt int, max_attempts, worker_id, lease_expires_at,
exit_code, outputs_json, log_ref, queued_at, started_at, finished_at)
index (tenant_id, status, queued_at) -- per-tenant ready queues
index (status, lease_expires_at) -- sweeper
tenants (id, plan, weight, max_concurrent_jobs)
secrets (tenant_id, name, ciphertext, key_id) -- encrypted per tenant
log_chunks (job_id, attempt, seq, object_ref)高层设计
5.1 在单台机器上运行构建的 cron 任务
单台服务器接收 webhook 并循环执行构建。这对一个团队来说够用。但崩溃会丢失进行中的运行,不同公司的构建共享一台机器,而且一个重度租户会阻塞整个队列。
5.2 修复 1:持久化的运行与任务模型
Webhook 以幂等方式在数据库中创建运行和任务。第一个任务变为就绪状态,其余任务等待。运行可以在任何服务崩溃后存活。
5.3 修复 2:worker 通过租约拉取任务
Worker 通过条件更新租用就绪任务,运行期间发送心跳,并以自己的 attempt 编号完成任务。一个清扫器(sweeper)将过期的租约恢复为就绪状态。
flowchart LR W[Worker]:::svc -->|lease| S[Scheduler]:::new S -->|UPDATE jobs SET status='leased', attempt=attempt+1, worker=W<br/>WHERE id=? AND status='ready'| DB[(Jobs)]:::store W -->|heartbeat with attempt| S W -->|finish with attempt| S SW[Sweeper]:::new -->|leased + expired -> ready| DB classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.4 修复 3:每个租户的公平队列
调度器按租户间的加权公平份额选取下一个任务,并以每个租户的并发上限为约束。
5.5 修复 4:沙箱化执行与作用域受限的密钥
每个任务在全新的 microVM 或沙箱化容器中运行。密钥在启动时通过一个仅限该任务使用的短期令牌获取。
5.6 修复 5:日志服务
Worker 以序列号流式传输日志块;日志服务存储这些块,并将其发布到每个任务的实时频道。
5.7 组合后的设计
sequenceDiagram
autonumber
participant GH as Git host
participant T as Trigger
participant D as Database
participant S as Scheduler
participant W as Worker
participant L as Log service
participant U as UI
GH->>T: push webhook (repo, sha)
T->>D: insert run (unique repo+sha+workflow), jobs, job 1 ready
W->>S: lease (labels, cached images)
S->>D: pick fairly across tenants, lease job 1, attempt 1
S-->>W: spec, secrets token
W->>W: start sandbox, pull image (cache), run commands
loop while running
W->>S: heartbeat (attempt 1)
W->>L: log chunk (seq n)
L-->>U: live tail
end
W->>S: finish (attempt 1, exit 0)
S->>D: job 1 succeeded AND job 2 ready (one transaction, attempt checked)核心思想。 幂等的触发器、持久化的任务链、带围栏的租约、公平调度、带作用域密钥的沙箱,以及带序列号的日志流。
深度剖析
6.1 诚实地说,恰好一次
继续阅读前先思考。 一个 worker 执行部署任务,部署成功,但 worker 在调用 finish 之前崩溃了。接下来会发生什么?你还能提供什么保证?
租约过期。清扫器将任务恢复为就绪状态。另一个 worker 以 attempt 2 租用它并再次执行部署。CI 系统无法知道第一次部署已经发生;这一认知随着 worker 一起消亡了。
因此,提供三个保证,并且要精确:
- 同一时间只有一个所有者。 租约加防护机制意味着最多只有一个 worker 能更改任务的状态,而租约已过期的 worker 无法再记录任何内容。
- 只记录一个结果。 完成写入仅对当前尝试成功,并且只将任务移动到最终状态一次。
- 幂等效果是任务自身的责任,但系统会提供帮助。 系统为每个任务提供一个稳定的幂等键,例如运行 ID 加任务名,在多次尝试之间保持一致。部署步骤将其传递给目标系统,或检查该提交是否已部署,这样第二次运行就是无操作。
明确说明这个限制:系统至少运行一次任务并恰好记录一次;系统之外的副作用只有在幂等的情况下才能保证恰好执行一次。
区分回答的关键:恰好一次
弱:声称队列保证了这一点
声称队列提供恰好一次投递,然后一带而过。
良好:使用租约并重试
使用租约并重试崩溃的任务,并指出任务可能会运行两次。
强:精确的保证
区分单一所有权(防护机制)、单一记录结果(条件完成)和幂等的外部效果(跨尝试的稳定键),并指出无法保证的部分。
6.2 没有竞态的状态转换
每个任务仅通过条件更新在状态之间流转:
ready -> leased WHERE status = 'ready' attempt += 1 leased -> running WHERE attempt = :a AND status = 'leased' running -> succeeded WHERE attempt = :a AND status = 'running' running -> failed WHERE attempt = :a AND status = 'running' leased|running -> ready (sweeper) WHERE lease_expires_at < now()
当任务 N 成功时,将任务 N+1 置为就绪的操作发生在同一个事务中。如果事务失败,两个变更都不会发生,重试是安全的。一次运行不能同时有两个就绪的任务,也不能跳过某个任务。
取消操作在运行上设置一个标志。就绪和等待中的任务立即转为已取消;运行中的任务在下一次心跳时看到
cancel: true
该标志并停止。
6.3 公平调度
在继续阅读之前。 租户 A 排队了 2,000 个任务。租户 B 排队了 5 个。B 要等多久?
使用单一的全局 FIFO 队列时,B 要排在 A 的全部 2,000 个任务之后,可能要等一个小时。使用公平排队时,B 大约只需等待一个调度周期。
为每个租户维护一个就绪队列。当 worker 请求工作时,在拥有符合该 worker 标签的就绪任务的租户中,选择运行中任务数与权重之比最低的租户。然后取该租户最旧的就绪任务。每个租户还根据其套餐有一个并发上限,因此即使其他租户空闲,大型租户也无法占用整个集群——除非你允许借用空闲容量。
flowchart LR
QA[[Tenant A: 2,000 ready<br/>running 40, weight 4]]:::svc --> P{Pick min running/weight}:::new
QB[[Tenant B: 5 ready<br/>running 0, weight 1]]:::svc --> P
QC[[Tenant C: 30 ready<br/>running 12, weight 2]]:::svc --> P
P -->|B: 0/1 is lowest| W[Worker]:::store
classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;6.4 隔离与密钥
在继续阅读之前。 一个恶意工作流试图读取另一个租户的密钥。请梳理阻止它的每一层机制。
- 执行。 每个任务都在全新的 microVM 或用户态内核沙箱中运行,任务结束后即销毁。普通容器会与其他租户的任务共享宿主机内核。
- 专用容量。 大型或受监管的租户拥有自己的工作节点池。
- 密钥。 按租户加密存储。任务会收到一个短期令牌,只能获取其工作流所声明的、属于本租户的密钥,且仅在任务的租约有效期内有效。
- 日志。 日志服务在存储输出之前,会先屏蔽其中已知的密钥值。
- 网络。 任务无法访问 CI 系统的内部服务或云元数据端点。
- 制品和缓存。 按租户进行键控;租户 A 的缓存绝不会挂载给租户 B。
来自 fork 的拉取请求构建是一种特殊情况:它们运行来自租户外部的代码。默认情况下,运行它们时不提供密钥。
6.5 镜像缓存
每个任务都拉取一个 2 GB 的镜像会浪费数分钟时间和带宽。每个工作节点都保留一个最近使用镜像的本地缓存。工作节点在请求任务时会报告自己已缓存的镜像,调度器在公平性规则允许的范围内,优先将任务分配给镜像已缓存的工作节点。每个区域都部署了一个注册表镜像,可快速处理缓存未命中。热门基础镜像会在新工作节点启动时预先拉取。
6.6 实时日志
阅读下文之前。 用户在任务运行到一半时打开该任务的日志页面。他们如何看到之前的输出,然后继续跟踪新的输出?
工作节点以递增的序列号分块发送输出。日志服务将每个块追加到对象存储中该任务和尝试对应的路径下,并将其发布到该任务的实时频道。
UI 打开一个 WebSocket,请求从序列号 0 开始的日志。服务先回放已存储的块,然后切换到实时频道。如果连接断开,UI 会用它最后渲染的序列号重新连接,只接收错过的部分。重试的任务会有新的尝试编号,因此其日志会单独显示,绝不会与失败尝试的日志混在一起。
任务结束后,各块会被压缩为每个尝试一个的压缩文件,保留 90 天。
6.7 故障处理
| 故障 | 恢复方式 |
|---|---|
| 工作节点崩溃 | 租约过期;任务以新的尝试重试 |
| 工作节点网络分区 | 其后续写入带有过期的尝试编号,会被拒绝 |
| 调度器崩溃 | 状态保存在数据库中;其他实例继续运行 |
| Webhook 丢失 | 一个协调器轮询最近的提交并创建缺失的运行 |
| 任务每次尝试都失败 | 达到最大尝试次数后停止;运行被标记为失败并附带错误信息 |
| 数据库故障转移 | 租约时长超过故障转移时间,因此正在运行的任务不会被重新排队 |
6.8 缓存构建依赖
阅读下文之前。 每个任务在运行测试前都要下载 800 MB 的依赖。如何在租户之间不发生泄露的前提下削减这部分开销?
按租户、按内容键缓存。工作流声明一个缓存键(通常是 lockfile 的哈希)和一个路径。任务开始时,worker 从对象存储拉取
(tenant, key)
缓存并解压;任务结束时,如果键是新的,则上传该路径。lockfile 变更会生成新键,因此过期的依赖永远不会混入。
作用域很重要。来自 PR 分支的缓存不能污染主分支的缓存,因此读取时从分支回退到主分支,但写入只留在分支上。缓存绝不跨租户。
算一笔账:每天 100 万个任务 × 800 MB 就是每天 800 TB 的下载量。若 90% 的缓存命中由区域对象存储提供,公共仓库的流量降到 80 TB,任务还能提前几分钟启动。
6.9 端到端走一遍运行流程
一个面试官可以跟得上的具体走查:
- 10:00:00 Push 到达。创建 Run 881;任务包括 build、test、deploy;build 就绪。
- 10:00:01 租户 T 的 10 个槽位中已占用 3 个;调度器将 build 租约给 worker W7,尝试 1。
- 10:00:05 W7 启动一个 microVM,恢复依赖缓存,从本地缓存拉取镜像。
- 10:02:40 Build 成功。在一个事务中:build 成功(尝试 1 已勾选),test 就绪。
- 10:02:41 Worker W12 租约 test,尝试 1。
- 10:04:10 W12 所在主机断电。心跳停止。
- 10:04:40 租约过期;清扫器将 test 置为就绪。
- 10:04:41 W3 租约 test,尝试 2;UI 中尝试 2 的日志从零开始。
- 10:06:30 Test 成功;deploy 就绪。
- 10:06:31 Deploy 以幂等键 run-881/deploy 运行。目标端看到新键并执行部署。
- 10:07:10 Deploy 成功。Run 881 成功。
如果 W12 在 10:05 恢复并以尝试 1 调用 finish,更新将匹配不到任何行。
拉开差距的关键:具体性
弱:抽象的方框图
只描述组件,但从不把一次运行贯穿这些组件走一遍。
良好:一次正常路径走查
把一次运行从 push 走到成功。
优秀:带故障和缓存的走查
把一次运行贯穿 worker 丢失、新尝试的重试、缓存恢复和幂等部署,并在最关键的时刻展示防护检查。
变体
7.1 并行任务
对于任务图,当某个任务的所有依赖都成功时,它变为就绪。为每个任务跟踪剩余依赖数,并在 finish 事务中递减。
7.2 自托管 runner
租户在自己的网络内运行自己的 worker。Worker 只长轮询其租户的任务。同样的租约和防护协议依然适用。
7.3 部署门禁
部署任务需要等待审批。将其建模为一个任务状态
awaiting_approval
由人工将其置为就绪,并记录审批以供审计。
7.4 任务量扩大十倍
在每天 1000 万个任务的规模下,峰值并发超过 12 万。调度器按租户分片,每个分片负责一组租户的队列和任务,worker 从多个分片拉取。日志存储在压缩前每天约达 10 TB。区域化的 worker 池配合本地镜像和依赖缓存,可将启动时间保持在较低水平。
可迁移的模式
CI/CD 本质上是一个在不可信代码之上运行的租用任务队列,并在租户之间保证公平。这些组件可以迁移到任何共享执行平台:幂等的接入、条件状态转换、带围栏的租约、按租户的公平队列、一次性沙箱、范围受限的密钥,以及有序的日志流。
回顾:30 秒答案
- 幂等触发器。 每个仓库、提交和工作流的运行记录唯一。
- 运行至少一次,记录恰好一次。 带围栏的租约;条件式完成;对外部影响使用稳定的幂等键。
- 每个租户的公平队列。 加权份额加并发上限。
- 全新沙箱和范围受限的密钥。 不共享内核,日志脱敏,不接入内部网络。
- 有序日志。 先回放再跟随;从上一个序列号重连。
测验
+为什么系统无法保证部署恰好执行一次?
worker 可能在完成部署后、记录结果之前崩溃。系统无法将这种情况与部署前的崩溃区分开,因此必须重新运行该任务。只有让部署步骤本身幂等,重复执行才无害。
+尝试次数(attempt number)保护的是什么?
它为每个租约设置围栏。租约已过期的 worker 仍持有旧的尝试次数,因此其心跳和完成调用会被拒绝,也无法覆盖当前持有者的结果。
+为什么要在标记任务 N 成功的同一事务中让任务 N+1 变为就绪?
这样两次操作之间发生的崩溃就不会导致运行卡住——任务 N 已完成但任务 N+1 永远不会被调度,也不会把任务 N+1 调度两次。
+公平队列如何帮助小租户?
调度器选择运行中任务数与权重之比最低的租户。一个没有任何任务在运行的租户会获得下一个空闲的 worker,无论大租户排了多少任务。
+在运行中途打开日志页面的用户如何看到全部内容?
日志服务从序列号零开始回放已存储的日志块,然后切换到实时通道。重连时,客户端会从其上次的序列号开始请求。
+依赖缓存如何避免在租户和分支之间泄露?
缓存以租户和 lockfile 的哈希为键。分支构建可以读取主分支的缓存,但只能写入自己的缓存,且任何缓存都不会跨租户读取。
+在演练中,为什么返回的 worker 的完成调用会失败?
它的租约已过期,另一个 worker 以尝试次数 2 接手了该任务。返回的 worker 的调用携带的是尝试次数 1,因此条件更新匹配不到任何行。
参考资料与延伸阅读
- 如何实现分布式锁 解释了 fencing token(栅栏令牌)。
- 公平排队算法的分析与仿真 引入了公平排队。
- Firecracker 提供用于隔离不受信任工作负载的 microVM。
- 租约与栅栏(lease-and-fencing)模式也是视频生成流水线的核心。沙箱机制在云端 IDE中有介绍。