三款产品挤进同一个集群
北辰工作室把三款产品的服务端放进了同一个 northstar-platform 集群。理由很直接:单独为《泡泡工坊》维持一套低负载集群不划算,混部可以把夜间的闲置容量利用起来。
混部第一个月就出了事故。活动夜,《群岛纪元》的赛季结算批处理在晚上八点启动,导入任务没有设置内存上限,把节点页缓存吃满;同一节点上的《裂隙战场》房间服因为内存压力被内核杀掉,六个正在进行的对局中断。告警只写了“节点内存不足”,没人能立刻判断该杀谁、该保谁。
问题不在混部本身,而在于混部缺少三样东西:配额说明每个产品最多能用多少,隔离说明用了别人的资源会怎样,驱逐顺序说明资源不够时先牺牲谁。这一节按这个顺序展开。
配额是分配上限,不是物理容量
配额回答“谁能用多少”,容量回答“总共有多少”。两者用一个超卖比连接:可售总量 = 物理容量 × 超卖比。把配额直接设成物理容量,等于放弃混部;把配额设成容量的三倍,等于赌所有产品不会同时峰值。
配额至少分三层,缺一层就会出现无法解释的争抢:
| 层级 | 例子 | 作用 |
|---|---|---|
| 产品 | 《裂隙战场》整体上限 | 防止单个产品吃掉整个集群 |
| 环境 | prod / staging / perf | 防止压测挤占生产 |
| 玩法或服务 | 房间服、结算、回放转码 | 防止单个功能拖垮产品 |
环境这层最容易被省略。压测环境用同一套配额键,跑一次全链路压测就把生产房间服挤到没有余量。正确做法是让 env 进入配额主键,并且生产配额只能由发布系统修改,压测账号无权调整。
配额校验要在准入时做,不能只在月底看账单。需要拦住的请求有四类:超出单维度上限、累计超出、配额不存在、维度未声明。累计校验尤其重要:单次申请 40 核看起来没超 100 核的上限,但前面已经批了 70 核,第二个请求就该被拒绝。拒绝时返回“哪个产品、哪个环境、哪个维度、已用多少”,而不是只回一个 429。
什么能超卖,什么不能
超卖的本质是“假设不会同时用满”。这个假设对某些资源成立,对另一些会直接变成事故:
| 资源 | 能否超卖 | 原因 | 保护手段 |
|---|---|---|---|
| CPU | 可以,谨慎 | 大部分服务平均利用率低于峰值 | request/limit、CFS 配额、离线限速 |
| 内存 | 不可以 | 超卖导致节点级 OOM,杀谁由内核决定 | 硬 limit、禁止无 limit 的 Burstable |
| 网络带宽 | 不可以按均值 | 计费与拥塞都看峰值 | 入口整形、离线任务限速 |
| 磁盘 IOPS | 谨慎 | 离线任务会拖高在线延迟 | IO 权重、独立磁盘 |
| 连接数 | 可以 | 空闲连接不占实际处理能力 | 连接池上限、每产品配额 |
内存是最不能赌的一项。CPU 超卖后表现是变慢,内存超卖后表现是进程消失,而且内核选择受害者的依据是 OOM score,不是业务优先级。一个没有 memory limit 的离线导入任务,可能比房间服更容易被杀,也可能反过来把房间服挤死;结果不可预测,就不能称为隔离。
超卖比例要有依据。示例做法是看过去 30 天同时段峰值的 P95:如果三款产品的峰值很少同时出现,1.5 倍是保守起点;如果《群岛纪元》的结算和《裂隙战场》的晚高峰重叠,比例要降到 1.2 以下。比例不是拍脑袋的静态参数,要按季度用真实峰值复核。
混部的干扰来自四个方向
混部收益来自共享,事故也来自共享。干扰主要有四类:
CPU 争抢。 在线房间服需要稳定 tick,离线转码会短时打满核心。只设 request 不够,必须设 limit 或 CPU 配额;对 tick 敏感的房间服还应该绑核,避免和批处理调度到同一组核心。
内存与页缓存。 离线任务读大文件会占据页缓存,在线服务的冷启动因此变慢。内存 limit 能防 OOM,但不能防页缓存竞争;需要给离线任务设置 memory.high 或定期丢弃缓存,或者干脆把重 IO 任务放到独立节点池。
网络与磁盘。 离线备份、日志上传会占满出口带宽,导致房间服心跳超时。按产品做带宽整形,离线任务限速到链路的一定比例,比“出问题再排查”便宜得多。
调度抖动。 混部节点上的 Pod 密度更高,任何一次驱逐都会影响更多服务。节点要预留系统资源,不能让可分配量等于物理量;否则 kubelet、日志代理和内核自己先饿死。
CoLocationConflicts 把其中两条硬规则编码成检查:两个绑核的在线关键服务不能同节点;没有内存 limit 的 Burstable 任务不能和在线关键服务同节点。规则要少而硬,能自动阻断的就不要留给评审。
资源不够时,先杀谁
驱逐顺序必须在事故前定义,而不是在告警群里投票。排序依据是“中断代价”:
- 离线任务优先:报表重算、历史导入、备份校验,杀了可以重跑。
- 在线非关键其次:商城、个人中心、排行榜刷新,短暂不可用可接受。
- 在线关键最后:正在承载对局的房间服、匹配服、登录服。
- 同层内低优先级先杀;回收量大的先杀;新启动的先杀(重启成本低);最后按名称排序保证确定性。
“新启动的先杀”有一个前提:新实例还没有承载长连接或房间。对已经接量的房间服,启动时间不再是依据,应该看它承载的对局数和可迁移性。驱逐策略要和优雅下线配合,先停止接新房间、把现有对局迁走或等待结束,再释放资源;直接 SIGKILL 会把一次容量调整变成玩家投诉。
还要保留“不可驱逐”集合:支付回调、登录、赛季结算的关键实例。这个集合要小,通常不超过集群的 10%,否则容量紧张时没有可压缩空间。
数据模型与可运行片段
internal/resourcequota 把配额、超卖和驱逐落成可测试的函数:Validate 做逐请求与累计校验,Pool 计算可售量与余量,Admit 判断是否放行,EvictionOrder 给出确定性的牺牲顺序,CoLocationConflicts 检查混部硬规则。
// file: internal/resourcequota/resourcequota.go
// tier: L1
// verify: go test ./internal/resourcequota/
package resourcequota
import (
"errors"
"fmt"
"sort"
"strings"
)
// Kind is a quota dimension. Units are fixed by the platform, not by the
// caller, so two products cannot compare CPU with different meanings.
type Kind string
const (
CPU Kind = "cpu_millicore"
Memory Kind = "memory_mib"
Network Kind = "network_mbps"
)
// Quota is a hard ceiling for one product in one environment.
type Quota struct {
Product string
Env string
Limit map[Kind]int64
}
// Request is one admission check. Amount is in the unit of its Kind.
type Request struct {
Product string
Env string
Kind Kind
Amount int64
}
// Violation names the request index and the rule it broke.
type Violation struct {
Index int
Reason string
}
func quotaKey(product, env string) string {
return product + "/" + env
}
// Validate checks every request against its product/environment quota and
// against requests already accepted in the same batch. It never mutates the
// quota definitions.
func Validate(quotas []Quota, requests []Request) []Violation {
limits := make(map[string]map[Kind]int64, len(quotas))
for _, q := range quotas {
key := quotaKey(q.Product, q.Env)
if _, exists := limits[key]; exists {
continue // first definition wins; duplicates are a config bug, not a request bug
}
limits[key] = q.Limit
}
used := make(map[string]int64)
var out []Violation
for i, r := range requests {
if r.Amount <= 0 {
out = append(out, Violation{Index: i, Reason: "amount must be positive"})
continue
}
limit, ok := limits[quotaKey(r.Product, r.Env)]
if !ok {
out = append(out, Violation{Index: i, Reason: "no quota for product/environment"})
continue
}
ceiling, ok := limit[r.Kind]
if !ok {
out = append(out, Violation{Index: i, Reason: "kind not declared in quota"})
continue
}
key := quotaKey(r.Product, r.Env) + "|" + string(r.Kind)
used[key] += r.Amount
if used[key] > ceiling {
out = append(out, Violation{Index: i, Reason: "quota exceeded"})
}
}
return out
}
// Pool describes one schedulable resource pool.
type Pool struct {
Capacity int64 // provisioned units
Guaranteed int64 // reserved for online-critical workloads
Ratio float64 // oversell ratio, >= 1
}
// Validate rejects configurations that would silently sell nothing or sell
// below the guaranteed reservation.
func (p Pool) Validate() error {
if p.Capacity <= 0 {
return errors.New("resourcequota: capacity must be positive")
}
if p.Guaranteed < 0 || p.Guaranteed > p.Capacity {
return fmt.Errorf("resourcequota: guaranteed=%d outside [0,%d]", p.Guaranteed, p.Capacity)
}
if p.Ratio < 1 {
return fmt.Errorf("resourcequota: ratio=%.2f must be >= 1", p.Ratio)
}
return nil
}
// Sellable is the total number of units the scheduler may hand out. It is
// floored to avoid promising a fraction of a CPU.
func (p Pool) Sellable() int64 {
if p.Ratio <= 0 {
return p.Capacity
}
return int64(float64(p.Capacity) * p.Ratio)
}
// Headroom is what is left after current usage. It may be negative when the
// pool is already over its sellable limit.
func (p Pool) Headroom(used int64) int64 {
return p.Sellable() - used
}
// Admit answers whether a request fits. The returned headroom is computed
// before the request so callers can log the margin that was consumed.
func Admit(p Pool, used, request int64) (bool, int64) {
headroom := p.Headroom(used)
if request <= 0 || request > headroom {
return false, headroom
}
return true, headroom
}
// Tier orders workloads by how acceptable it is to interrupt them.
type Tier int
const (
TierOffline Tier = iota // batch, backfill, report rebuild
TierOnline // matchmaking, profile, shop
TierOnlineCritical // room servers serving live matches
)
// Workload is the minimum the eviction policy needs to know.
type Workload struct {
Name string
Tier Tier
Priority int // larger means more important inside the same tier
Reclaim int64 // units freed if evicted
StartedUnix int64
}
// EvictionOrder returns a copy sorted by "who gets killed first": offline
// before online, lower priority first, larger reclaim first, younger first,
// and finally name for a stable result.
func EvictionOrder(workloads []Workload) []Workload {
out := append([]Workload(nil), workloads...)
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Tier != b.Tier {
return a.Tier < b.Tier
}
if a.Priority != b.Priority {
return a.Priority < b.Priority
}
if a.Reclaim != b.Reclaim {
return a.Reclaim > b.Reclaim
}
if a.StartedUnix != b.StartedUnix {
return a.StartedUnix > b.StartedUnix
}
return a.Name < b.Name
})
return out
}
// Shape is what a workload asks the node for when co-located.
type Shape struct {
Name string
Tier Tier
Burstable bool // may consume spare CPU when idle
PinnedCPU bool // owns dedicated cores
MemoryLimit bool // hard memory cgroup
}
// Conflict is a rejected co-location pair with a reason.
type Conflict struct {
A string
B string
Reason string
}
// CoLocationConflicts reports pairs that must not share a node. The rules
// encode the isolation the platform actually enforces, not a wish list.
func CoLocationConflicts(shapes []Shape) []Conflict {
var out []Conflict
for i := 0; i < len(shapes); i++ {
for j := i + 1; j < len(shapes); j++ {
a, b := shapes[i], shapes[j]
switch {
case a.Tier == TierOnlineCritical && b.Tier == TierOnlineCritical &&
a.PinnedCPU && b.PinnedCPU:
out = append(out, Conflict{A: a.Name, B: b.Name,
Reason: "two pinned online-critical workloads compete for cores"})
case a.Burstable && b.Tier == TierOnlineCritical && !a.MemoryLimit:
out = append(out, Conflict{A: a.Name, B: b.Name,
Reason: "burstable workload without memory limit may starve match server"})
case b.Burstable && a.Tier == TierOnlineCritical && !b.MemoryLimit:
out = append(out, Conflict{A: a.Name, B: b.Name,
Reason: "burstable workload without memory limit may starve match server"})
}
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].A != out[j].A {
return out[i].A < out[j].A
}
return out[i].B < out[j].B
})
return out
}
// Describe renders the quota table in a log-friendly, sorted form.
func Describe(quotas []Quota) string {
lines := make([]string, 0, len(quotas))
for _, q := range quotas {
kinds := make([]string, 0, len(q.Limit))
for k, v := range q.Limit {
kinds = append(kinds, fmt.Sprintf("%s=%d", k, v))
}
sort.Strings(kinds)
lines = append(lines, quotaKey(q.Product, q.Env)+" "+strings.Join(kinds, ","))
}
sort.Strings(lines)
return strings.Join(lines, "; ")
}
本机验证命令与结果:
cd .local/checks/gsarch-lab
go test -count=1 ./internal/resourcequota/
ok gsarchlab/internal/resourcequota
该包共有 6 个表驱动测试,覆盖配额内、累计超限、配额缺失、维度未声明、超卖准入边界、配置校验、驱逐排序、混部冲突与配额描述;gofmt -l 无输出,go vet 退出码为 0。
配额的三种失效方式
配额碎片。 每个产品都只用了配额的六成,但集群整体没有余量,因为配额按产品切得太碎,无法互相借用。解决办法是保留一部分共享池,允许产品在低峰时借用,高峰前归还;共享池不能太大,否则等于没有配额。
优先级反转。 离线任务的优先级被配成比在线还高,资源紧张时先杀房间服。优先级只能由平台统一维护,产品不能自报;每次变更都要走和容量模型同样的评审。
把限流当隔离。 网关限流只能限制请求进入,不能阻止进程占满 CPU 或内存。隔离必须发生在资源层:cgroup、绑核、内存 limit、带宽整形。只在应用层做并发限制,节点仍然会被拖垮。
小结
配额是分配上限,容量是物理供给,两者用超卖比连接。配额分产品、环境、玩法三层,环境层不能省。CPU 可以谨慎超卖,内存不能,网络按峰值算。混部干扰来自 CPU、内存与页缓存、网络磁盘、调度密度四个方向,隔离要落在资源层而不是应用层。驱逐顺序在事故前定义:离线先走,在线关键最后,同层按优先级、回收量、启动时间和名称确定。
常见错误有五个:配额不含环境、内存不设 limit、超卖比拍脑袋、把限流当隔离、事故时临时决定杀谁。它们都会把一次可预测的容量调整变成不可预测的玩家事故。下一篇讨论成本与体验的取舍:配额管住上限,但省下来的钱值不值得用体验去换,需要另一套评分方法。
阅读导航:上一节:9.1 成本归因与单位经济 · 下一节:9.3 成本与体验的取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。