K8s 源码:APIServer 限流四件套——MaxInFlight、Client、EventRateLimit、APF 全解析

📅 2026/6/22 21:05:48
K8s 源码:APIServer 限流四件套——MaxInFlight、Client、EventRateLimit、APF 全解析
前言那个把 APIServer 写崩的 controller凌晨 3 点告警集群 APIServer 全部 503kubectl 全部超时。切到 dashboard 一看APIServer 的apiserver_request_total{code503}飙到天上去了。最后排查到根因——同事新部署的一个自定义 controller 有个死循环 bug每秒疯狂创建 50 event几个副本一起 ~8000 QPS。我们集群关了 APF保留默认的--max-requests-inflight400event 写请求把池子占满list/watch 全部排不进队全部超时。最讽刺的是event 本来就不重要但因为没有优先级隔离它把核心的 list/watch 都饿死了。这次事故后我们做了三件事启用 APFK8s 1.20 默认 GA但生产集群居然有一半没启用配置EventRateLimit准入控制器兜底跑去啃了一遍 APIServer 限流的源码——这篇就是那次啃源码的笔记本节重点MaxInFlightLimitchan 信号量实现的整体限流Client 限流client-go 默认 QPS5 的来历EventRateLimit专门限 event 的令牌桶APF按优先级公平队列的现代方案生产必备一、四种限流策略全景图客户端 (kubectl / controller) │ │ ① Client 限流 (client-go QPS5, Burst10) ▼ ┌──────────────────────────┐ │ APIServer HTTP 入口 │ └──────────┬───────────────┘ ▼ ┌──────────────────────────────────────────────┐ │ ② 整体限流二选一 │ │ ├─ MaxInFlightLimit (默认 / 老方案) │ │ │ ├─ --max-requests-inflight400 │ │ │ └─ --max-mutating-requests-inflight200 │ │ └─ APF / PriorityAndFairness (1.20 GA) │ │ ├─ FlowSchema分类 │ │ └─ PriorityLevelConfiguration并发预算 │ └──────────┬───────────────────────────────────┘ ▼ ┌──────────────────────────┐ │ ③ 准入控制层 │ │ └─ EventRateLimit │ ← 只限 event └──────────┬───────────────┘ ▼ 进入业务处理认证/鉴权/storage关键认知MaxInFlight 和 APF 是互斥的——开了 APFMaxInFlight 就关闭。EventRateLimit 是个独立的兜底专门给 event 用。二、MaxInFlightLimit用 chan 做信号量2.1 它解决什么问题APIServer 默认有两个旗子--max-requests-inflight400只读请求并发上限--max-mutating-requests-inflight200修改请求并发上限为什么分开因为修改请求要写 etcd比只读贵得多。如果不区分一波写请求把池子占满整个集群的 watch 都断了。2.2 入口DefaultBuildHandlerChain位置staging/src/k8s.io/apiserver/pkg/server/config.goifc.FlowControl!nil{// APF 模式requestWorkEstimator:flowcontrolrequest.NewWorkEstimator(c.StorageObjectCountTracker.Get)handlerfilterlatency.TrackCompleted(handler)handlergenericfilters.WithPriorityAndFairness(handler,c.LongRunningFunc,c.FlowControl,requestWorkEstimator)handlerfilterlatency.TrackStarted(handler,priorityandfairness)}else{// MaxInFlight 模式handlergenericfilters.WithMaxInFlightLimit(handler,c.MaxRequestsInFlight,c.MaxMutatingRequestsInFlight,c.LongRunningFunc)}二选一c.FlowControl ! nil→ 走 APF否则→ 走 MaxInFlight怎么判断当前集群开没开 APFkubectl get--raw/metrics|grepapiserver_flowcontrol_request_concurrency_limit# 有输出 APF 开了# 无输出 MaxInFlight 模式2.3 注册 watermark 维护 hook位置同上GenericAPIServer.New里ifc.FlowControl!nil{constpriorityAndFairnessFilterHookNamepriority-and-fairness-filterif!s.isPostStartHookRegistered(priorityAndFairnessFilterHookName){err:s.AddPostStartHook(priorityAndFairnessFilterHookName,func(context PostStartHookContext)error{genericfilters.StartPriorityAndFairnessWatermarkMaintenance(context.StopCh)returnnil})}}else{constmaxInFlightFilterHookNamemax-in-flight-filterif!s.isPostStartHookRegistered(maxInFlightFilterHookName){err:s.AddPostStartHook(maxInFlightFilterHookName,func(context PostStartHookContext)error{genericfilters.StartMaxInFlightWatermarkMaintenance(context.StopCh)returnnil})}}这是给 metrics 用的 watermark 维护协程——周期性把当前最高并发数watermark上报让 Prometheus 能看到。2.4 WithMaxInFlightLimit 核心逻辑位置staging/src/k8s.io/apiserver/pkg/server/filters/maxinflight.go① 短路limit0 时不限流ifnonMutatingLimit0mutatingLimit0{returnhandler}生产坑有运维同事为了图省事把--max-requests-inflight0——以为是不限流结果就是真的不限流APIServer 任由 OOM。0 不是无限大0 是关闭限流。要无限大用一个超大数如 10000。② 信号量 带缓冲 chanvarnonMutatingChanchanboolvarmutatingChanchanboolifnonMutatingLimit!0{nonMutatingChanmake(chanbool,nonMutatingLimit)watermark.readOnlyObserver.SetX1(float64(nonMutatingLimit))}ifmutatingLimit!0{mutatingChanmake(chanbool,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.iflongRunningRequestCheck!nillongRunningRequestCheck(r,requestInfo){handler.ServeHTTP(w,r)return}BasicLongRunningRequestCheck判断的长连接请求包括funcBasicLongRunningRequestCheck(longRunningVerbs,longRunningSubresources sets.String)apirequest.LongRunningRequestCheck{returnfunc(r*http.Request,requestInfo*apirequest.RequestInfo)bool{iflongRunningVerbs.Has(requestInfo.Verb){// watchreturntrue}ifrequestInfo.IsResourceRequestlongRunningSubresources.Has(requestInfo.Subresource){// exec/log/portforwardreturntrue}if!requestInfo.IsResourceRequeststrings.HasPrefix(requestInfo.Path,/debug/pprof/){returntrue}returnfalse}}主要是watch、exec、log、portforward、pprof——这些请求会长时间挂着如果纳入限流几个 watch 就把池子占满了。踩坑但这也意味着watch 不受 MaxInFlight 控制——如果有客户端疯狂建 watch 不关APIServer 内存会涨爆每个 watch ~几 MB。APF 修复了这点——watch 也纳入 SeatCount 计算。④ 选择 chan只读 or 修改varcchanboolisMutatingRequest:!nonMutatingRequestVerbs.Has(requestInfo.Verb)ifisMutatingRequest{cmutatingChan}else{cnonMutatingChan}nonMutatingRequestVerbs是个固定集合{get, list, watch}——其他 verbcreate/update/patch/delete/…全算 mutating。⑤ 核心select default 非阻塞写select{casec-true:// 进入处理ifisMutatingRequest{watermark.recordMutating(len(c))}else{watermark.recordReadOnly(len(c))}deferfunc(){-c// 处理完释放槽位ifisMutatingRequest{watermark.recordMutating(len(c))}else{watermark.recordReadOnly(len(c))}}()handler.ServeHTTP(w,r)default:// 队列已满走 429 分支...}精髓selectdefault实现了非阻塞的信号量获取——拿不到立刻走 default 分支绝不会卡住请求。⑥ 队列满了system:masters 永远放行default:ifcurrUser,ok:apirequest.UserFrom(ctx);ok{for_,group:rangecurrUser.GetGroups(){ifgroupuser.SystemPrivilegedGroup{handler.ServeHTTP(w,r)return}}}SystemPrivilegedGroupsystem:masters对应 ClusterRolecluster-admin。为什么 system:masters 不限流集群内部组件controller-manager、scheduler、管理员 kubeconfig 都属于这个组——如果连管理员都被限流集群挂了你都救不回来。反过来的坑千万别给业务应用绑 cluster-admin。我见过把 controller 跑成 cluster-admin 的——结果它疯狂请求时绕过限流把 APIServer 打爆限流形同虚设。最小权限原则永远不过时。⑦ 真的满了返回 429ifisMutatingRequest{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” 消息体客户端怎么处理 429client-go 看到 429 Retry-After头会自动重试exponential backoff——所以业务代码大部分时候感觉不到限流。但指标里能看到rest_client_requests_total{code429}。2.5 MaxInFlightLimit 的硬伤❌没有优先级低优先级的 event 和高优先级的 leader election 抢同一个池子❌没有公平性一个发疯的 client 能占完所有槽位❌粒度太粗只区分读写不区分用户/资源❌watch 不受控长连接全部豁免这就是为什么社区搞了 APF。三、Client 限流那个让你 list 半天的 QPS53.1 client-go 默认值的来历// staging/src/k8s.io/client-go/rest/config.goconst(DefaultQPS5DefaultBurst10)你没看错——client-go 默认 QPS 只有 5Burst 10。这是个 2014 年定下的保守值到现在都没改。3.2 怎么影响你的代码config,_:clientcmd.BuildConfigFromFlags(,kubeconfig)// 默认 QPS5, Burst10clientset,_:kubernetes.NewForConfig(config)// list 1000 个 namespace 下的 podfor_,ns:rangenamespaces{clientset.CoreV1().Pods(ns).List(ctx,metav1.ListOptions{})}// 跑得贼慢——客户端自己限速到 5 QPS生产踩坑写过一个 controller 同步几千个 namespace 的资源跑得贼慢CPU 也没满、APIServer 也不忙——抓 pprof 一看大量 goroutine 卡在rate.Wait。根因client-go 默认 QPS5。修复config.QPS100config.Burst2003.3 实现golang.org/x/time/rate令牌桶client-go 用的是标准库golang.org/x/time/rate的令牌桶typeRateLimiterinterface{TryAccept()boolAccept()// 阻塞直到拿到 tokenStop()QPS()float32Wait(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-pluginsEventRateLimit\--admission-control-config-file/etc/kubernetes/admission-config.yamladmission-config.yaml:apiVersion:apiserver.config.k8s.io/v1kind:AdmissionConfigurationplugins:-name:EventRateLimitpath:/etc/kubernetes/event-config.yamlevent-config.yaml:apiVersion:eventratelimit.admission.k8s.io/v1alpha1kind:Configurationlimits:-type:Namespaceqps:50burst:100cacheSize:2000-type:Userqps:10burst:50-type:Serverqps:5000burst:20000四种 limit typeServer全局所有 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 概念生产建议即使开了 APFEventRateLimit 仍然推荐配上——APF 按用户/优先级分流EventRateLimit 按资源类型兜底。两个一起用最稳。