☰
kube-apiserver源码拆解:Pod创建请求从HTTP到etcd的完整旅程
2026/10/20 15:03:55 网站建设 项目流程

kubectl apply 敲下去之后,Pod 创建请求的第一站永远是 kube-apiserver。很多人对 apiserver 的理解停留在"它就是一个转发请求的网关",但实际上它是整个 Kubernetes 控制面里逻辑密度最高的组件之一:认证、鉴权、审计、准入、资源配额、对象默认值、持久化、事件通知,全都在这一层完成。上一篇我们从 Pod 对象的定义和客户端入手,这一篇把镜头移到服务端,沿着源码把一条POST /api/v1/namespaces/{ns}/pods的请求从进门到落盘完整走一遍,重点拆解 kube-apiserver 的代码路径。

先说明一点:我下面对源码路径的描述基于近几个版本(1.20 到 1.28 左右)的主干逻辑,Kubernetes 演进很快,细节可能随版本微调,但主干设计非常稳定。读源码时认准那条主线,比盯着一行行 diff 重要得多。

1. kube-apiserver 接收请求的第一道工序:从 HTTP 到路由分发

1.1 请求先撞上的是 Go 的 net/http 层

无论发起方是 kubectl、client-go、controller 里的 informer 还是 kubelet,Pod 创建最终都收敛成同一个动作:向 kube-apiserver 的 HTTPS 端口发送一个 POST 请求,路径是/api/v1/namespaces/<namespace>/pods,body 是一个序列化后的 Pod 对象。

kube-apiserver 启动时通过cmd/kube-apiserver/app/server.go里的Run函数拉起 HTTP 服务,底层就是net/http的Server。genericapiserver包里的NewAPIServerHandler会生成一个完整的 HTTP Handler 链,这段代码藏在staging/src/k8s.io/apiserver/pkg/server/config.go的BuildHandlerChain里。我简化后的调用顺序大致是:

handler := director handler = genericapifilters.WithAudit(handler, ...) handler = genericapifilters.WithAuthentication(handler, ...) handler = genericapifilters.WithImpersonation(handler, ...) handler = genericapifilters.WithAuthorization(handler, ...) handler = genericfilters.WithMaxInFlightLimit(handler, ...) handler = genericfilters.WithTimeout(handler, ...)

每一层WithXxx都是标准的 Go 中间件模式:包一层http.HandlerFunc,在调用下一个 handler 前后做自己的事。很多人调试问题时只看/logs/kubernetes.log里的业务日志,忽略了这几层中间件本身也是排查重点——比如用户报"请求很慢",很多时候问题不是出在你提交的 Pod 字段上,而是卡在WithMaxInFlightLimit的队列里,或者被 APF(API Priority and Fairness)的 flow schema 限流了。

1.2 路由注册:Pod 的 handler 是怎么挂上去的

HTTP 请求经过一堆过滤器之后,最终到达路由分发层。kube-apiserver 的路由用的是 gorilla/mux 那套模式,但做了大量的自定义封装。Pod 这类核心资源的 REST 端点注册路径大致是:

k8s.io/kubernetes/pkg/registry/core/rest/storage.go里把 Pod 的 storage 组装好,然后在APIInstallation的过程中,endpoints/installer.go的registerResourceHandlers方法会遍历每个资源支持的 verb(create、get、list、watch、update、delete...),为每个 verb 生成对应的 HTTP handler。

关键点在于,Pod 的 create 请求最终对应的是staging/src/k8s.io/apiserver/pkg/endpoints/handlers/create.go里的CreateResource。这个函数返回一个http.HandlerFunc,内部处理完解码、默认值、准入、校验之后,调用 rest.Store 的 Create 方法把对象真正写下去。我经常跟人说,想要读懂 kube-apiserver,一定要抓住两个文件:create.go(请求入口的加工流水线)和registry/store.go(对象存储的核心逻辑)。这两个文件能把 80% 的"创建类"问题给你讲明白。

不过在实际阅读时容易踩一个坑:create.go里的代码往往被大家直接跳过了,因为大家默认"create 就是调 Storage.Create"。但你跳过 decode、default、admission 这三步,就永远搞不清楚为什么一个看起来合法的 Pod 会被拒绝——尤其是配额类和 webhook 类的问题,根子全在这三步骤里。

2. 认证、授权、审计:创建请求进门前的三道安检

2.1 认证链路:先确认你是谁

请求从路由分发层正式进入业务之前,要先通过认证。认证器在staging/src/k8s.io/apiserver/pkg/authentication里实现,核心接口是:

type Authenticator interface { AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) }

Response里的User携带用户名、UID、Groups 和 Extra 信息,之后整条链路里的鉴权、准入、审计都用这组信息做判断。

kube-apiserver 的认证不是单一策略,而是一个"链"。启动时根据配置往链里塞一堆认证器:Bearer Token(service account 的 JWT、静态 token、bootstrap token)、客户端证书、Webhook Token 认证等等。源码里buildAuthenticator会把它们组合成authenticatorunion.NewUnionAuthenticator,逐个尝试,直到某个认证器明确返回成功。

如果你的请求最终谁都没认出来,会走到handler.go里对anonymous用户的兜底处理——把这个请求标记为system:anonymous用户,然后继续向后走。注意这个设计点:认证不通过并不会直接 401,而是降级成匿名用户,真正决定请求生死的在下一层鉴权。所以调试 401 的时候要分清是"认证器本身报错"还是"匿名用户权限不够被 403"。

2.2 授权:verb 和 resource 的匹配游戏

认证通过后进入鉴权层,核心接口在k8s.io/apiserver/pkg/authorization:

type Authorizer interface { Authorize(ctx context.Context, a Attributes) (Decision, string, error) }

Attributes 里最关键的是几个字段:User、Verb(对创建请求来说是 create)、Resource(pods)、Namespace、Subresource。WithAuthorization过滤器把这几个字段从 RequestInfo 里解析出来,去问配置的鉴权器——默认是 RBAC。

RBAC 的源码在pkg/registry/rbac里,但真正做匹配的规则引擎在staging/src/k8s.io/apiserver/pkg/authorization/authorizerfactory和pkg/registry/rbac/validation里。每个请求会跟集群里的 Role、ClusterRole、RoleBinding、ClusterRoleBinding 匹配:先聚合出用户能用的全部规则,再看目标操作是否命中某条规则的(verb, resource)。这里有个容易忽略的细节:RBAC 匹配只看(verb, resource, namespace)三层元组,完全不管 object body 里的具体内容。也就是说,RBAC 只能回答"你能不能创建 Pod",不能回答"你能不能创建带特权字段的 Pod"——后者是准入控制(Admission Control)的责任。

2.3 审计和追踪:出了问题要找“谁在哪一层拒绝的”

审计层在认证之前还是之后,很多人记混,我直接说结论:WithAudit排在整个链很靠前的位置,但真正的事件输出是"开始处理"和"完成处理"两个钩子,分别记录请求进入和离开审计过滤器的时间点。也就是说,审计能覆盖后面所有阶段——认证、鉴权、准入、存储都发生在审计过滤器之后。

源码实现在genericapifilters/audit.go,它把整个下流 handler 的执行包在一个audit.LogAnnotation加audit.ObserveRequest的结构里。如果启用了 record 策略,你能在 audit log 里看到stage=RequestReceived和stage=ResponseComplete两笔记录,中间标记的requestURI、user、verb、responseStatus足够定位请求到底死在哪个环节。

现在的版本还支持 OpenTelemetry tracing,--feature-gates=APIServerTracing=true打开后,create.go里的trace.Step会把每个阶段的时间片记录下来。排性能问题时这个很好用:能看到时间耗在 authentication、admission 还是 etcd 写入。

3. admission 与默认值:真正决定 Pod 能长成什么样的关卡

3.1 create handler 里 admission 的调用逻辑

过了鉴权之后,请求终于进入CreateResource的业务逻辑。这里我建议你先记住一句话:admission 不是全局过滤器,而是资源 handler 内部的显式调用。早期版本里 admission 确实作为 HTTP filter 统一执行,后来被重构到各个资源 handler 里,原因很简单:不同操作(create/update/delete)的 AdmissionAttributes 不同,全局 filter 很难精确表达这些差异。

create.go里的核心片段,简化后大概长这样:

if scope.Defaulter != nil { if err := scope.Defaulter(obj); err != nil { // 给对象填默认值 } } attributes := admission.NewAttributesRecord( obj, nil, scope.Kind, reqInfo.Namespace, reqInfo.Name, scope.Resource, admission.Create, opts, ) admissionObjectInterfaces := ... if m, ok := admit.(admission.MutationInterface); ok { if err := m.Admit(ctx, attributes, admissionObjectInterfaces); err != nil { // 变更型准入失败 } } if v, ok := admit.(admission.ValidationInterface); ok { if err := v.Validate(ctx, attributes, admissionObjectInterfaces); err != nil { // 校验型准入失败 } }

注意NewAttributesRecord里明确把 Operation 标记为admission.Create,而且传入的obj是经过 defaulter 处理后的对象。顺序是固定的:先 mutating,后 validating。这个顺序是硬约束,因为 mutating admission 会修改对象内容(比如 LimitRanger 补 requests 和 limits),validating admission 必须基于修改后的最终对象做判断。

3.2 和 Pod 创建强相关的内置插件

内置 admission 插件的注册表在pkg/kubeapiserver/admission和staging/src/k8s.io/apiserver/pkg/admission/plugin下。Pod 创建路径上常见的几个:

插件类型对 Pod 创建做了什么
NamespaceLifecyclevalidatingnamespace 不存在或正在终止时拒绝创建
LimitRangermutating / validating给 Pod 的容器补默认 request/limit,并校验不超过 LimitRange
ResourceQuotavalidating校验 namespace 的资源配额是否够用,并登记占用
PodSecurityvalidating校验 Pod 是否违反 Pod Security Standards(privileged 等)
DefaultStorageClassmutating给 PVC 补默认 StorageClass(间接影响 Pod)
ServiceAccountmutating给 Pod 补默认 ServiceAccount(若没指定)
PodNodeSelectormutating / validating按 namespace 的注解约束 nodeSelector(已标记弃用但还在)

以ResourceQuota为例,它内部维护一个resourceQuotaRegistry,创建请求到达时先检查配额余额,通过后再“预占”资源。这就是为什么很多团队会遇到"配额明明看着还有,但创建就报 exceeded"——预占和用量追踪之间存在时间窗口,尤其并发创建时很容易踩到。

3.3 内置 admission 与外置 Webhook 的衔接

除了内置插件,kube-apiserver 还会把配置的动态 admission webhook 聚合进来,入口是MutatingAdmission和ValidatingAdmission这两个实现,分别对应pkg/admission/plugin/webhook/mutating和.../validating。它们的执行时机和内置插件混在一起,但有个细节值得注意:webhook 配置里的failurePolicy决定 webhook 挂了时请求是 Fail 还是 Ignore;matchPolicy决定是匹配当前对象还是历史对象。这两个字段不搞清楚,生产环境出故障时排查路径会完全不同。

另外,mutating webhook 对对象做的修改,会在之后 validate 阶段再次被检查。这不是简单的信任机制,而是保证"变更后对象仍然合法"的安全兜底。所以在 webhook 里改了字段后报校验错误,不要惊讶,那是设计如此。

4. 从 Store 到 etcd:Pod 对象落盘的最后一公里

4.1 Strategy:对象在内存里如何“成熟”

admission 通过后,create.go调用rest.NamedCreater的 Create 方法。Pod 的 REST 实现在pkg/registry/core/pod/rest.go,它内部包了一个 generic registry 的store.Store。Store.Create 的第一件事是执行rest.BeforeCreate,这里才是真正的 Pod 专属逻辑:

func BeforeCreate(strategy RESTCreateStrategy, ctx context.Context, obj runtime.Object, opts options.CreateOptions, validateFn ObjectValidateFunc) error { if err := strategy.PrepareForCreate(ctx, obj); err != nil { ... } if err := validateFn(ctx, obj); err != nil { ... } if errs := strategy.Validate(ctx, obj); len(errs) > 0 { ... } return strategy.Canonicalize(ctx, obj) }

PrepareForCreate是用户在源码里定制对象的关键点。Pod 的 strategy 实现(pkg/registry/core/pod/strategy.go)里,简单逻辑是:

func (podStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { pod := obj.(*api.Pod) pod.Status = api.PodStatus{} pod.Status.Phase = api.PodPending pod.Generation = 1 // 抹掉由服务端填充字段、清理弃用字段等 }

注意 Server 在这里会强制把用户提交的status清空,并把Phase置为Pending。这就是为什么你 kubectl apply 时写再多status都没用——它根本不是客户端能控制的字段,只有status子资源(pods/status)的更新才会写进 status。

Validate阶段的实现是validation.ValidatePod,它完成对 Pod 字段的全面语法校验,比如nodeSelector格式、端口冲突、name的 DNS-1123 规范、容器镜像非空等。注意:这里的 Validate 是"对象格式合法性",和 validating admission 的"策略合法性"是两层不同的事情,经常被混淆。

4.2 序列化、静态加密与 etcd 事务写入

BeforeCreate通过后,store.Store.Create拿到对象的 name 和 key,然后调用底层 storage。跟 etcd 的交互实现在staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.go里,流程很清晰:

data, err := s.mediaCodec.EncodeWithAlloc(obj) // 序列化 newData, err := s.transformer.TransformToStorage(ctx, data, mediatype) // 静态加密 ... txn := s.client.KV.Txn(ctx) txn.If(notFound(key)) txn.Then(opPut(key, newData, leaseID)) tresp, err := txn.Commit()

这里藏了几个很值得展开的细节:

  • EncodeWithAlloc走的是SERALIZER,kube-apiserver 默认用 protobuf 格式做存储序列化,而不是 JSON。你kubectl get pod -o yaml看到的 JSON/YAML 只是对外展示格式。
  • TransformToStorage是静态加密插桩(ETCD 静态加密 AES-CBC / AES-GCM / KMS),如果没有启用,data 就是明文。
  • etcd 写入用了条件事务If(notFound(key)),也就是key 不存在时才写入。同一 key 的第二次并发创建会在这一层直接失败。所以报Operation cannot be fulfilled on pods: the object has been modified不是 etcd 的模糊错误,而是事务冲突的明确信号。
  • 写入时还会关联一个 lease(TTL),Pod 的 TTL 通常为 0,表示不设自动过期。

4.3 resourceVersion、Watch 通知和 201 响应

事务提交成功后,etcd 会返回写入后的 key-value 和ModRevision。store.go的afterCreation负责把 etcd 返回的二进制 value 重新 decode 回对象,并把resourceVersion设置成 etcd 的版本号。这个resourceVersion不是 apiserver 自己造的随机数,它直接来源于 etcd 的版本计数,这也解释了为什么 resourceVersion 是全局单调递增、为什么对象更新后它一定变大。

与此同时,缓存层的 watch cache 在监听 etcd 的 watch 事件(staging/src/k8s.io/apiserver/pkg/storage/cacher)。写入完成后,etcd watch 事件立刻被 cache 捕获,cache 更新自己的内存,并唤醒所有等待这个对象变化的 watcher。kube-scheduler、kubelet、各种 controller 之所以"几乎同时"感知到新 Pod,不是因为大家一起轮询 apiserver,而是因为大家都在 apiserver 上挂了 watch,而 watch 的源头在 etcd 事件。

最后,create.go将创建后的完整对象(带 resourceVersion、uid、creationTimestamp)序列化,以 HTTP 201 Created 返回给客户端。

5. 源码级体检:Pod 创建失败时如何逐层定位

5.1 返回码和日志对应到哪一层

调试 Pod 创建问题,我建议先看返回的状态码,再对照链路猜阶段。一个朴素的映射关系:

HTTP 状态码大概率阶段常见原因
400 Bad Requestdecode / validatebody 不是合法 Pod,字段类型错误
401 Unauthorized认证认证器全部失败,请求降级为匿名
403 Forbidden鉴权 / admissionRBAC 拒绝、配额超限、PodSecurity 拒绝
404 Not Found路由 / 资源namespace 不存在或 API 版本不存在
409 Conflictetcd 事务同一 key 并发创建或修改
422 Unprocessable Entityadmission validationadvisory validation 失败,例如名字格式
429 Too Many RequestsAPF / MaxInFlight流量整形或限流

配合 audit 和 apiserver 日志验证时,--v=4起步,很多 admission 日志在--v=2就能看到,etcd 层事务问题则要看--v=6及以上。在源码里追踪时,搜admission.Atomic和storage.AppendFunc这种标志性函数名会很有帮助。

5.2 几个高频异常的实际排查路径

我自己踩得比较多的问题有三个,分别对应链路上不同位置。

第一个是webhook 拒绝但日志里看不到拒绝原因。这类问题十有八九是failurePolicy或namespaceSelector配置把 webhook 过滤掉了,或者 webhook 调用的配置里service指向的端口不通。查的时候先确认 webhook 配置里的matchPolicy和namespaceSelector是否命中了当前请求,再看validatingwebhookconfigurations的事件日志。

第二个是配额报错。返回体里通常带着exceeded quota和具体资源名。这种问题不要只盯 apiserver,要同时看resourcequota.quota.admission.k8s.io的 reconcile 机制,配额的“当前使用量”是周期性 update 的,创建瞬间的余量可能被并发请求瞬间占满。源码上建议看pkg/quota/v1/resources的资源累加逻辑,理解Quota计算方式是按 requests 算而不是按 limits 算。

第三个是the object has been modified。刚才说过这是 etcd 事务冲突。出现频率最高的是 controller 同时更新同一个对象,或者 informer 缓存和实际 etcd 版本脱节。你kubectl get pod -o yaml拿到的未必是最新版本,修改前一定要用当前的resourceVersion,或者干脆用update的语义,让服务端做 compare-and-swap。

5.3 版本迁移时最容易踩的坑

最后提醒几件容易在升级 Kubernetes 版本后翻车的事:

  • admission 从 filter 到 handler 的重构让插件执行顺序在 1.20 前后有明显变化,升级后如果自定义 webhook 行为异常,先确认 webhook 是不是在错误阶段被误调。
  • APF 引入后--max-requests-inflight之外还有 flow schema 的并发控制,创建突发时 429 不一定代表资源不够,可能只是被限流了。
  • etcd 存储编码从 JSON 切换到 protobuf 是很早以前的事了,但如果你还在自己写 apiserver 扩展(CRD 或者自己聚合 API),务必使用k8s.io/apiserver的 negotiation 框架,别手搓序列化,否则很容易产生存储和 API 版本不一致的诡异问题。
  • 静态加密启用后,etcd 里是密文,你直接用ETCDCTL去查对象是看不到明文 Pod 的。这不是数据丢了,是加密转换器在起作用。排查的时候别在 etcd 层被误导。

我个人在排查这类问题上的习惯是:先看返回码,再开审计,最后进源码。返回码能快速缩小阶段,审计能给出精确的 user/verb/resource 三元组,源码能帮你确认具体代码路径里到底哪个条件把请求拦了下来。读源码的时候不要只盯着某一个文件,从create.go往store.go往etcd3/store.go这条主线往下钻,反复几次之后,你会对整个控制面的调度节奏产生真实的体感。后续如果有时间,我再把 kube-scheduler 和 kubelet 那两段写出来,毕竟 Pod 真正跑起来,后面还有更长的路要走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询