欢迎光临
我们一直在努力

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

前言:那个把 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/eventconfig.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 按资源类型兜底。两个一起用最稳。

    赞(0)
    未经允许不得转载:171主机测评 » K8s 源码:APIServer 限流四件套——MaxInFlight、Client、EventRateLimit、APF 全解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址