前言:那个把 APIServer 写崩的 controller
凌晨 3 点告警:集群 APIServer 全部 503,kubectl 全部超时。
切到 dashboard 一看:APIServer 的 apiserver_request_total{code="503"} 飙到天上去了。
最后排查到根因——同事新部署的一个自定义 controller 有个死循环 bug:每秒疯狂创建 50+ event,几个副本一起 ~8000 QPS。我们集群关了 APF(保留默认的 --max-requests-inflight=400),event 写请求把池子占满,list/watch 全部排不进队,全部超时。
最讽刺的是:event 本来就不重要,但因为没有优先级隔离,它把核心的 list/watch 都饿死了。
这次事故后我们做了三件事:
- 启用 APF(K8s 1.20+ 默认 GA,但生产集群居然有一半没启用)
- 配置
EventRateLimit准入控制器兜底 - 跑去啃了一遍 APIServer 限流的源码——这篇就是那次啃源码的笔记
本节重点
- MaxInFlightLimit:chan 信号量实现的整体限流
- Client 限流:client-go 默认 QPS=5 的来历
- EventRateLimit:专门限 event 的令牌桶
- APF:按优先级+公平队列的现代方案(生产必备)
一、四种限流策略全景图
客户端 (kubectl / controller)
│
│ ① Client 限流 (client-go QPS=5, Burst=10)
▼
┌──────────────────────────┐
│ APIServer HTTP 入口 │
└──────────┬───────────────┘
▼
┌──────────────────────────────────────────────┐
│ ② 整体限流(二选一) │
│ ├─ MaxInFlightLimit (默认 / 老方案) │
│ │ ├─ --max-requests-inflight=400 │
│ │ └─ --max-mutating-requests-inflight=200 │
│ └─ APF / PriorityAndFairness (1.20+ GA) │
│ ├─ FlowSchema:分类 │
│ └─ PriorityLevelConfiguration:并发预算 │
└──────────┬───────────────────────────────────┘
▼
┌──────────────────────────┐
│ ③ 准入控制层 │
│ └─ EventRateLimit │ ← 只限 event
└──────────┬───────────────┘
▼
进入业务处理(认证/鉴权/storage)
💡 关键认知:MaxInFlight 和 APF 是互斥的——开了 APF,MaxInFlight 就关闭。EventRateLimit 是个独立的兜底,专门给 event 用。
二、MaxInFlightLimit:用 chan 做信号量
2.1 它解决什么问题?
APIServer 默认有两个旗子:
--max-requests-inflight=400:只读请求并发上限--max-mutating-requests-inflight=200:修改请求并发上限
为什么分开? 因为修改请求要写 etcd,比只读贵得多。如果不区分,一波写请求把池子占满,整个集群的 watch 都断了。
2.2 入口:DefaultBuildHandlerChain
位置:staging/src/k8s.io/apiserver/pkg/server/config.go
if c.FlowControl != nil {
// APF 模式
requestWorkEstimator := flowcontrolrequest.NewWorkEstimator(c.StorageObjectCountTracker.Get)
handler = filterlatency.TrackCompleted(handler)
handler = genericfilters.WithPriorityAndFairness(handler, c.LongRunningFunc, c.FlowControl, requestWorkEstimator)
handler = filterlatency.TrackStarted(handler, "priorityandfairness")
} else {
// MaxInFlight 模式
handler = genericfilters.WithMaxInFlightLimit(handler, c.MaxRequestsInFlight, c.MaxMutatingRequestsInFlight, c.LongRunningFunc)
}
二选一:
c.FlowControl != nil→ 走 APF- 否则 → 走 MaxInFlight
💡 怎么判断当前集群开没开 APF?
kubectl get --raw '/metrics' | grep apiserver_flowcontrol_request_concurrency_limit # 有输出 = APF 开了 # 无输出 = MaxInFlight 模式
2.3 注册 watermark 维护 hook
位置同上,GenericAPIServer.New 里:
if c.FlowControl != nil {
const priorityAndFairnessFilterHookName = "priority-and-fairness-filter"
if !s.isPostStartHookRegistered(priorityAndFairnessFilterHookName) {
err := s.AddPostStartHook(priorityAndFairnessFilterHookName, func(context PostStartHookContext) error {
genericfilters.StartPriorityAndFairnessWatermarkMaintenance(context.StopCh)
return nil
})
}
} else {
const maxInFlightFilterHookName = "max-in-flight-filter"
if !s.isPostStartHookRegistered(maxInFlightFilterHookName) {
err := s.AddPostStartHook(maxInFlightFilterHookName, func(context PostStartHookContext) error {
genericfilters.StartMaxInFlightWatermarkMaintenance(context.StopCh)
return nil
})
}
}
这是给 metrics 用的 watermark 维护协程——周期性把"当前最高并发数"(watermark)上报,让 Prometheus 能看到。
2.4 WithMaxInFlightLimit 核心逻辑
位置:staging/src/k8s.io/apiserver/pkg/server/filters/maxinflight.go
① 短路:limit=0 时不限流
if nonMutatingLimit == 0 && mutatingLimit == 0 {
return handler
}
🚨 生产坑:有运维同事为了"图省事"把
--max-requests-inflight=0——以为是不限流,结果就是真的不限流,APIServer 任由 OOM。0 不是无限大,0 是关闭限流。要无限大用一个超大数(如 10000)。
② 信号量 = 带缓冲 chan
var nonMutatingChan chan bool
var mutatingChan chan bool
if nonMutatingLimit != 0 {
nonMutatingChan = make(chan bool, nonMutatingLimit)
watermark.readOnlyObserver.SetX1(float64(nonMutatingLimit))
}
if mutatingLimit != 0 {
mutatingChan = make(chan bool, mutatingLimit)
watermark.mutatingObserver.SetX1(float64(mutatingLimit))
}
核心技巧:用一个长度 = limit 的带缓冲 chan 当信号量。
- 来一个请求 →
chan <- true(占一个槽) - 请求完成 →
<-chan(释放一个槽) - chan 满了 → 写入
default分支 → 返回 429
💡 为什么用 chan 而不用计数器+锁? chan 在 Go runtime 里是 lockfree 的(无竞争场景)、自带 FIFO 语义、
select default天然支持非阻塞写——比手写锁简单且更高效。这是个值得记下来的 Go 并发模式。
③ 跳过长连接请求
// Skip tracking long running events.
if longRunningRequestCheck != nil && longRunningRequestCheck(r, requestInfo) {
handler.ServeHTTP(w, r)
return
}
BasicLongRunningRequestCheck 判断的"长连接请求"包括:
func BasicLongRunningRequestCheck(longRunningVerbs, longRunningSubresources sets.String) apirequest.LongRunningRequestCheck {
return func(r *http.Request, requestInfo *apirequest.RequestInfo) bool {
if longRunningVerbs.Has(requestInfo.Verb) { // watch
return true
}
if requestInfo.IsResourceRequest && longRunningSubresources.Has(requestInfo.Subresource) { // exec/log/portforward
return true
}
if !requestInfo.IsResourceRequest && strings.HasPrefix(requestInfo.Path, "/debug/pprof/") {
return true
}
return false
}
}
主要是 watch、exec、log、portforward、pprof——这些请求会长时间挂着,如果纳入限流,几个 watch 就把池子占满了。
🚨 踩坑:但这也意味着 watch 不受 MaxInFlight 控制——如果有客户端疯狂建 watch 不关,APIServer 内存会涨爆(每个 watch ~几 MB)。APF 修复了这点——watch 也纳入 SeatCount 计算。
④ 选择 chan:只读 or 修改
var c chan bool
isMutatingRequest := !nonMutatingRequestVerbs.Has(requestInfo.Verb)
if isMutatingRequest {
c = mutatingChan
} else {
c = nonMutatingChan
}
nonMutatingRequestVerbs 是个固定集合:{"get", "list", "watch"}——其他 verb(create/update/patch/delete/…)全算 mutating。
⑤ 核心:select + default 非阻塞写
select {
case c <- true:
// 进入处理
if isMutatingRequest {
watermark.recordMutating(len(c))
} else {
watermark.recordReadOnly(len(c))
}
defer func() {
<-c // 处理完释放槽位
if isMutatingRequest {
watermark.recordMutating(len(c))
} else {
watermark.recordReadOnly(len(c))
}
}()
handler.ServeHTTP(w, r)
default:
// 队列已满,走 429 分支
...
}
精髓:select + default 实现了非阻塞的信号量获取——拿不到立刻走 default 分支,绝不会卡住请求。
⑥ 队列满了:system:masters 永远放行
default:
if currUser, ok := apirequest.UserFrom(ctx); ok {
for _, group := range currUser.GetGroups() {
if group == user.SystemPrivilegedGroup {
handler.ServeHTTP(w, r)
return
}
}
}
SystemPrivilegedGroup = "system:masters",对应 ClusterRole cluster-admin。
💡 为什么 system:masters 不限流? 集群内部组件(controller-manager、scheduler)、管理员 kubeconfig 都属于这个组——如果连管理员都被限流,集群挂了你都救不回来。
🚨 反过来的坑:千万别给业务应用绑 cluster-admin。我见过把 controller 跑成 cluster-admin 的——结果它疯狂请求时绕过限流把 APIServer 打爆,限流形同虚设。最小权限原则永远不过时。
⑦ 真的满了:返回 429
if isMutatingRequest {
metrics.DroppedRequests.WithContext(ctx).WithLabelValues(metrics.MutatingKind).Inc()
} else {
metrics.DroppedRequests.WithContext(ctx).WithLabelValues(metrics.ReadOnlyKind).Inc()
}
metrics.RecordRequestTermination(r, requestInfo, metrics.APIServerComponent, http.StatusTooManyRequests)
tooManyRequests(r, w)
tooManyRequests 内部做了三件事:
- 设置
Retry-After: 1响应头 - HTTP 状态码 429
- 返回 “Too many requests” 消息体
💡 客户端怎么处理 429? client-go 看到 429 +
Retry-After头会自动重试(exponential backoff)——所以业务代码大部分时候感觉不到限流。但指标里能看到:rest_client_requests_total{code="429"}。
2.5 MaxInFlightLimit 的硬伤
- ❌ 没有优先级:低优先级的 event 和高优先级的 leader election 抢同一个池子
- ❌ 没有公平性:一个发疯的 client 能占完所有槽位
- ❌ 粒度太粗:只区分读写,不区分用户/资源
- ❌ watch 不受控:长连接全部豁免
这就是为什么社区搞了 APF。
三、Client 限流:那个让你 list 半天的 QPS=5
3.1 client-go 默认值的来历
// staging/src/k8s.io/client-go/rest/config.go
const (
DefaultQPS = 5
DefaultBurst = 10
)
你没看错——client-go 默认 QPS 只有 5,Burst 10。这是个 2014 年定下的"保守"值,到现在都没改。
3.2 怎么影响你的代码
config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)
// 默认 QPS=5, Burst=10
clientset, _ := kubernetes.NewForConfig(config)
// list 1000 个 namespace 下的 pod
for _, ns := range namespaces {
clientset.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
}
// 跑得贼慢——客户端自己限速到 5 QPS
🚨 生产踩坑:写过一个 controller 同步几千个 namespace 的资源,跑得贼慢,CPU 也没满、APIServer 也不忙——抓 pprof 一看大量 goroutine 卡在
rate.Wait。根因:client-go 默认 QPS=5。修复:
config.QPS = 100 config.Burst = 200
3.3 实现:golang.org/x/time/rate(令牌桶)
client-go 用的是标准库 golang.org/x/time/rate 的令牌桶:
type RateLimiter interface {
TryAccept() bool
Accept() // 阻塞直到拿到 token
Stop()
QPS() float32
Wait(ctx context.Context) error
}
每次请求前 Accept() 阻塞等令牌——所以客户端限流的本质是延迟,不是拒绝。
3.4 Client 限流的局限
- ❌ 客户端自己管自己:管理员无法控制
- ❌ 作弊太容易:直接改代码绕过
- ❌ 粒度只到 client 实例:一个 process 起 10 个 client 就×10
所以 client 限流只是"礼貌",不是"安全网"。真正的限流必须在 server 端。
四、EventRateLimit:专门为 event 设计的兜底
4.1 它解决什么问题?
event 是 K8s 里最容易爆炸的资源——一个出错的 controller 一秒可以产 100+ event。MaxInFlight 不区分资源,event 会直接把 mutating 池占满。
EventRateLimit 在 1.13+ 作为 admission plugin 出现,专门给 event 加层令牌桶。
4.2 启用方式
kube-apiserver \
--enable-admission-plugins=EventRateLimit \
--admission-control-config-file=/etc/kubernetes/admission-config.yaml
admission-config.yaml:
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: EventRateLimit
path: /etc/kubernetes/event-config.yaml
event-config.yaml:
apiVersion: eventratelimit.admission.k8s.io/v1alpha1
kind: Configuration
limits:
- type: Namespace
qps: 50
burst: 100
cacheSize: 2000
- type: User
qps: 10
burst: 50
- type: Server
qps: 5000
burst: 20000
四种 limit type:
- Server:全局,所有 event 共享一个桶
- Namespace:每个 namespace 一个桶
- User:每个用户一个桶
- SourceAndObject:每个 (source, involvedObject) 一个桶
4.3 原理
每种 limit 都用独立的令牌桶:
来一个 event create 请求
│
▼
遍历每个匹配的 limit
│
├─ Server 桶:能拿到 token?
├─ Namespace 桶:能拿到 token?
└─ User 桶:能拿到 token?
│
全部 ok → 放行
任意一个失败 → 返回 429
注意是"全部匹配"——任何一个桶满了都拒绝。
4.4 优缺点
✅ 实现简单、可分级
✅ 在 admission 层拦截,比 APF 更靠后但更精准
❌ 只限 event,其他资源不管
❌ 通过 webhook/admission 实现,只能拦截 mutating 请求
❌ 所有 namespace 的限流一视同仁(没有 priority 概念)
💡 生产建议:即使开了 APF,EventRateLimit 仍然推荐配上——APF 按用户/优先级分流,EventRateLimit 按资源类型兜底。两个一起用最稳。


7276

被折叠的 条评论
为什么被折叠?



