三周才能开一套环境
《裂隙战场》要做一次跨区联调,需要一套隔离的预发布环境。负责人拉了七个群:找平台开机器、找 DBA 建库、找观测配看板、找发布平台接流水线、找安全过白名单。每一步都有人负责,但没有一步有明确的等待时间。以下场景与数字均为示例值。
三周后环境终于可用,需求已经改了两版。更糟的是,这套环境没有登记:配额从共享池里扣了,负责人却不知道扣了多少;联调结束后没人回收,机器又空转了一个月。
这就是内部开发者平台要解决的问题。它不是把工单系统搬到网页上,而是把“开环境”这件事变成一条有默认值、有审批边界、有状态、可回收的路径。
自助能力清单
平台先要明确哪些动作可以自助。北辰收敛出四类,每一类都给 Golden Path 和例外流程:
| 自助能力 | Golden Path 产物 | 例外流程 |
|---|---|---|
| 新建游戏/环境 | 命名空间、数据库、缓存、默认告警 | 特殊规格、跨区部署、独立账号体系 |
| 申请配额 | CPU、内存、连接数、存储的租户配额 | 超过池上限、临时活动扩容 |
| 接入观测 | 日志、指标、链路、默认看板 | 自定义指标、独立采集器 |
| 发布流水线 | 构建、测试、灰度、回滚模板 | 特殊发布顺序、外部依赖审批 |
Golden Path 的关键不是“快”,而是“默认安全”。默认开启日志、默认有告警、默认能回滚、默认登记归属。走例外流程不是禁止,而是要多一个具名审批和到期时间。
Golden Path 与例外流程的边界
| 维度 | Golden Path | 例外流程 |
|---|---|---|
| 审批 | 自动通过或单人确认 | 具名审批人,记录理由 |
| 时限 | 分钟级到小时级 | 明确 SLA,例如两个工作日(示例值) |
| 配额 | 从租户池自动扣减 | 人工评估共享池余量 |
| 回收 | 到期自动提醒并回收 | 到期前人工复核 |
| 变更 | 平台统一升级 | 冻结版本,单独维护 |
例外流程必须有到期时间。没有到期的例外,最后都会变成事实标准,反过来绑架平台演进。
门户不是唯一真相
很多内部平台的第一版是一个门户:表单提交、后台调 API、写数据库。半年后会发现,门户数据库成了唯一真相,Git 里的声明式配置、流水线里的环境定义、监控里的标签各自漂移。
正确的顺序是 API 与声明式配置优先,门户只是视图:
| 层 | 承担什么 | 不应该承担什么 |
|---|---|---|
| 声明式配置 | 环境期望状态、配额、归属 | 运行时状态 |
| 平台 API | 校验、审批、状态推进、幂等 | 长期保存业务数据 |
| 门户 | 展示、发起申请、查看进度 | 直接改数据库、绕过审批 |
| 控制器 | 对齐期望状态与实际状态 | 接受无审计的手工变更 |
门户可以是唯一入口,但不能是唯一真相。判断标准很简单:关掉门户,API 和控制器还能不能继续工作?如果答案是否定的,平台就被自己的 UI 锁死了。
环境状态机
环境申请不是布尔值,而是一条状态链。每个状态都要有明确的进入条件、责任方和超时动作。
| 状态 | 含义 | 允许的下一步 | 责任方 |
|---|---|---|---|
| requested | 已提交,等待决策 | approved、rejected | 平台 API |
| approved | 已批准,等待执行 | provisioning、rejected | 平台控制器 |
| provisioning | 正在创建资源 | ready、failed | 平台控制器 |
| ready | 可用,已计入配额 | 无(回收走独立流程) | 平台控制器 |
| failed | 创建失败,可重试 | provisioning、rejected | 平台控制器 |
| rejected | 已拒绝,终态 | 无 | 审批人 |
状态只能通过显式动作推进,不能让调用方直接写 state 字段。否则门户、API、脚本会各自发明一套状态,排障时无法判断环境到底在哪一步。
幂等创建
创建环境是最容易重复的请求:客户端超时重试、用户连点两次、流水线失败后重跑。如果每次重试都新建一套资源,配额会被迅速耗尽。
幂等创建要满足三条规则:
- 同一个幂等键加同一个请求体,返回第一次的结果,不重复创建。
- 同一个幂等键配不同请求体,直接报冲突,不能悄悄覆盖。
- 幂等键有有效期,过期后允许复用,但复用记录要可审计。
第三条经常被忽略。永久保存幂等键会带来存储和隐私问题;保存太短又会让迟到的重试创建出第二套环境。有效期应该覆盖客户端的最长重试窗口,而不是拍一个“七天”。
配额校验放在哪一层
配额不能只在前端校验。前端可以提示,但真正的拒绝必须发生在平台 API,并且要在两个时点各查一次:
- 提交时校验,避免明显超限的请求进入审批队列;
- 批准时再校验,因为池子可能在等待期间缩小或被他人在用。
配额的使用量只在环境进入 ready 时计入。创建失败的尝试不扣配额,否则一次失败的编排会永久占用容量。回收时再减回去,形成闭环。
平台自身的度量
内部平台也需要 SLO,否则它会成为“没有告警的关键路径”。建议至少看五个指标:
| 指标 | 定义 | 示例目标 |
|---|---|---|
| 接入时长 | 从提交到 ready 的 p50/p95 | p95 小于 30 分钟 |
| 自助率 | 不走例外流程的申请占比 | 大于 80% |
| 变更成功率 | 平台变更未导致回滚的比例 | 大于 95% |
| 平台可用性 | API 与控制器可用时间 | 99.9% |
| 例外存量 | 未到期的例外申请数量 | 逐月下降 |
这些数字都是示例值,不能直接当行业基准。接入时长要按环境类型分开统计:预发布环境和生产环境的风险不同,混在一起看会掩盖长尾。
六种反模式
| 反模式 | 表现 | 后果 |
|---|---|---|
| 门户直接写库 | 表单提交后后台拼 SQL | 绕过审批、状态不一致 |
| 审批靠聊天 | “领导在群里说可以” | 无法追责、无法审计 |
| 配额只在前端 | 页面提示余额不足,API 不校验 | 脚本绕过限制打满共享池 |
| 创建接口非幂等 | 重试就多一套环境 | 配额泄漏、资源僵尸 |
| 平台无 SLO | 平台挂了,产品只能等 | 关键路径没有告警 |
| 例外无到期 | 临时方案永久保留 | 平台版本无法收敛 |
上线检查表
- 四类自助能力都有 Golden Path 和例外流程。
- 环境状态机有明确的状态、动作和责任方。
- 创建接口按幂等键去重,冲突时返回明确错误。
- 配额在提交与批准两个时点校验。
- 配额只在 ready 时计入,失败与回收能正确冲销。
- 声明式配置是期望状态的来源,门户不直接改库。
- 所有例外申请都有具名审批人和到期时间。
- 平台 API、控制器、门户分别有可用性监控。
- 接入时长按环境类型分桶统计。
- 环境到期会自动提醒并进入回收流程。
可运行片段
下面的包实现环境生命周期、配额校验和幂等提交。状态与请求定义在配套的 state.go,这里展示真正决定行为的部分:转移表、幂等键、配额复查和用量计费。
// file: internal/idpportal/portal.go
// tier: L1
// verify: go test ./internal/idpportal/
package idpportal
import (
"fmt"
"strings"
"sync"
)
// Quota is the pool a portal may hand out, not a suggestion. Usage is charged
// when an environment becomes ready, so failed attempts do not consume it.
type Quota struct {
MaxCPU int
MaxMemoryGB int
MaxEnvs int
}
type Usage struct {
CPU int
MemoryGB int
Envs int
}
func (q Quota) Validate() error {
if q.MaxCPU <= 0 || q.MaxMemoryGB <= 0 || q.MaxEnvs <= 0 {
return fmt.Errorf("%w: quota limits must be positive", ErrInvalid)
}
return nil
}
func (q Quota) Check(r Request, used Usage) error {
if used.Envs+1 > q.MaxEnvs {
return fmt.Errorf("%w: environments %d+1 > %d", ErrQuota, used.Envs, q.MaxEnvs)
}
if used.CPU+r.CPU > q.MaxCPU {
return fmt.Errorf("%w: cpu %d+%d > %d", ErrQuota, used.CPU, r.CPU, q.MaxCPU)
}
if used.MemoryGB+r.MemoryGB > q.MaxMemoryGB {
return fmt.Errorf("%w: memory %d+%d > %d", ErrQuota, used.MemoryGB, r.MemoryGB, q.MaxMemoryGB)
}
return nil
}
// Portal owns the state transitions. Callers may read requests but cannot
// assign a state; this keeps the API and the UI from drifting apart.
type Portal struct {
mu sync.Mutex
quota Quota
requests map[string]Request
keys map[string]string
usage Usage
}
func New(q Quota) (*Portal, error) {
if err := q.Validate(); err != nil {
return nil, err
}
return &Portal{quota: q, requests: map[string]Request{}, keys: map[string]string{}}, nil
}
func (p *Portal) Usage() Usage {
p.mu.Lock()
defer p.mu.Unlock()
return p.usage
}
func (p *Portal) Get(id string) (Request, error) {
p.mu.Lock()
defer p.mu.Unlock()
r, ok := p.requests[id]
if !ok {
return Request{}, fmt.Errorf("%w: %s", ErrNotFound, id)
}
return r, nil
}
// Submit is idempotent per key. Replaying the same intent returns the stored
// request with created=false; reusing a key for a different intent fails
// loudly instead of silently creating a second environment.
func (p *Portal) Submit(key string, r Request) (Request, bool, error) {
if strings.TrimSpace(key) == "" {
return Request{}, false, fmt.Errorf("%w: idempotency key is empty", ErrInvalid)
}
if err := r.Validate(); err != nil {
return Request{}, false, err
}
p.mu.Lock()
defer p.mu.Unlock()
if id, ok := p.keys[key]; ok {
stored := p.requests[id]
if stored.fingerprint() != r.fingerprint() {
return Request{}, false, fmt.Errorf("%w: key %s", ErrKeyReuse, key)
}
return stored, false, nil
}
if _, ok := p.requests[r.ID]; ok {
return Request{}, false, fmt.Errorf("%w: %s", ErrDuplicate, r.ID)
}
if err := p.quota.Check(r, p.usage); err != nil {
return Request{}, false, err
}
r.State = StateRequested
p.requests[r.ID] = r
p.keys[key] = r.ID
return r, true, nil
}
// Decide re-checks quota because the pool may have shrunk since submission.
// The golden path is auto-approved; the exception path needs a named approver.
func (p *Portal) Decide(id, approver string) (Request, error) {
p.mu.Lock()
defer p.mu.Unlock()
r, ok := p.requests[id]
if !ok {
return Request{}, fmt.Errorf("%w: %s", ErrNotFound, id)
}
if err := CanTransition(r.State, StateApproved); err != nil {
return r, err
}
if r.Path == ExceptionPath && strings.TrimSpace(approver) == "" {
return r, fmt.Errorf("%w: %s", ErrApproval, id)
}
if r.Path == GoldenPath && strings.TrimSpace(approver) == "" {
approver = "auto:golden-path"
}
if err := p.quota.Check(r, p.usage); err != nil {
return r, err
}
r.State = StateApproved
r.Approver = approver
p.requests[id] = r
return r, nil
}
func (p *Portal) Reject(id, reason string) (Request, error) {
return p.move(id, StateRejected, func(r *Request) error {
r.Reason = reason
return nil
})
}
func (p *Portal) Start(id string) (Request, error) {
return p.move(id, StateProvisioning, nil)
}
// Finish is the only place usage is charged. A failed provisioning attempt
// leaves the pool untouched, so retries cannot leak capacity.
func (p *Portal) Finish(id string, success bool) (Request, error) {
to := StateReady
if !success {
to = StateFailed
}
return p.move(id, to, func(r *Request) error {
if to == StateReady {
p.usage.CPU += r.CPU
p.usage.MemoryGB += r.MemoryGB
p.usage.Envs++
}
return nil
})
}
func (p *Portal) move(id string, to State, before func(*Request) error) (Request, error) {
p.mu.Lock()
defer p.mu.Unlock()
r, ok := p.requests[id]
if !ok {
return Request{}, fmt.Errorf("%w: %s", ErrNotFound, id)
}
if err := CanTransition(r.State, to); err != nil {
return r, err
}
if before != nil {
if err := before(&r); err != nil {
return r, err
}
}
r.State = to
p.requests[id] = r
return r, nil
}
读代码:四个约束点
第一,CanTransition 用显式转移表拒绝非法跳转。requested 不能直接到 ready,ready 也不能回到 provisioning。状态机不是文档,而是代码里的白名单。
第二,Submit 先查幂等键,再查请求 ID。同一个键配不同请求体会返回 ErrKeyReuse,而不是静默覆盖。这样客户端重试是安全的,误用键则会被立即发现。
第三,Decide 在批准时重新执行 quota.Check。提交时通过不代表批准时还能通过,共享池的余量会变化,复查是防止超卖的最后一道闸。
第四,Finish 只在成功时累加用量。创建失败不扣配额,重试成功后只扣一次。这个顺序如果写反,失败重试会不断泄漏容量。
失败路径与兜底
| 失败路径 | 现象 | 处理 |
|---|---|---|
| 编排卡在 provisioning | 资源创建一半,状态不前进 | 超时标记 failed,触发补偿清理 |
| 门户不可用 | 用户无法提交申请 | API 与流水线保持可用,门户只做视图 |
| 配额漂移 | 实际用量大于账面 | 定期对账,以资源标签为准修正 |
| 幂等键冲突 | 两个不同请求用了同一个键 | 返回冲突,要求换键并记录审计 |
| 环境被遗忘 | 联调结束无人回收 | 到期提醒、宽限期、自动回收 |
兜底的核心是“可回收”。平台能创建资源,就必须能识别、标记并回收资源。只会创建不会回收的平台,最终会把成本问题留给下一年。
与相邻章节的边界
这一节讲平台怎么把能力交付给产品。能力本身承诺什么,在 1.1 平台团队与产品团队的契约 ;平台服务怎么升级而不破坏调用方,在 1.3 平台服务的版本与兼容 。
观测接入的具体指标设计可以对照 5.1 日志、指标与链路追踪 ,平台规则怎么自动化检查可以对照 8.3 架构规则的自动化检查 。
小结
内部开发者平台的价值,不是把工单搬到网页,而是把“开环境”变成一条有默认值、有审批边界、有状态、可回收的路径。Golden Path 负责覆盖大多数请求,例外流程负责处理少数风险,状态机和幂等键负责让重复操作安全。
平台越自助,越需要契约和边界。下一节讨论平台服务自身的版本演进:当能力要升级、旧客户端还没走完时,怎么避免把玩家卡在登录页。
阅读导航:上一节:1.1 平台团队与产品团队的契约 · 下一节:1.3 平台服务的版本与兼容 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。