☰
深入kube-apiserver:Pod创建链路中的认证、准入与存储机制
2026/10/19 5:48:50 网站建设 项目流程

这篇文章在计划里躺了一阵子了。上一篇我从 Pod 创建这条链路入手,把整体架构和组件分工过了一遍;这一篇专门盯kube-apiserver这一个组件,把它在 Pod 创建过程中到底做了什么、代码在哪些目录、关键逻辑长什么样彻底聊透。适合已经在用 Kubernetes、准备深入源码或者日常调试 API 服务的人,也适合好奇“kubectl apply 之后那一秒发生了什么”的同学。我会尽量用从业者的口吻讲,不写教科书,也不弹窗式罗列代码,而是带着排查问题的思路去拆它。

先说一个结论:在 Pod 创建的整条链路里,kube-apiserver并不是那个真正“创建 Pod”的人,它是整个集群的交通警察和档案管理员。所有写操作都从它这里过,所有读操作都有它的一份缓存,所有组件之间的状态同步都以它为准。把这一点想清楚,后面读源码会顺很多。

1. 一条 Pod 请求在 api-server 里的完整路径

1.1 为什么先啃 api-server

很多初次接触源码的人习惯从kubelet开始看,因为感觉 Pod 是 kubelet 拉起来的。但我个人建议先看kube-apiserver,原因是它是整条链路上信息最密集的地方,也是几乎所有 Kubernetes 对象“合法化”的关卡。

一个 Pod 从你敲下kubectl apply开始,到真正出现在某台节点上,中间经历的认证、授权、准入、校验、存储、通知,绝大部分逻辑都在kube-apiserver进程里。它本质上是 apiserver 进程内的一套 HTTP 服务加一套存储抽象层。读懂了这一层,后面看kube-scheduler、kube-controller-manager、kubelet会轻松很多,因为它们的交互对象都是“经过 api-server 洗过的对象”,而不是原始 YAML。

另外,排障的时候你也会发现,大部分 “Pod 创建失败”“请求超时”“字段被丢弃”的问题根因都落在 api-server 这一段。比如 RBAC 没配好、准入插件拦截、资源版本冲突、CRD schema 校验不过,这些错误信息全部由 api-server 抛出来。所以学会看它的源码,等于学会解读集群里一半以上的错误日志。

1.2 请求从 kubectl 到 API 对象的转化

我们先看入口。kubectl apply提交的是一份 YAML,但 api-server 内部处理对象的时候用的是强类型的 Go struct,HTTP 请求的 Content-Type 通常是application/json或者application/yaml,还有application/vnd.kubernetes.protobuf这种二进制格式。

这一步的转化在客户端其实已经做了一半。kubectl会把 YAML 解析成unstructured.Unstructured或者直接反序列化成对应的类型化对象,然后发送给 apiserver。如果是kubectl apply,客户端还会额外计算上一次的配置快照,用于三方合并,这个我们后面展开讲存储时再说。

服务端这边,请求进入kube-apiserver后,第一步是经过一个分发器(路由),也就是k8s.io/apiserver/pkg/server/route那一套。路由根据 URL 路径(比如/api/v1/namespaces/default/pods)、HTTP 方法(POST、PUT、PATCH、GET、DELETE)、以及资源类型,分派给对应的 handler。Pod 资源在 API 组里是v1核心组,路径形如:

/api/v1/namespaces/{namespace}/pods

这个路径不是随便定义的,它对应pkg/registry/core/pod这个注册表里注册的 REST 实现。请求一路往深处走,最终会调到存储层,也就是registry/generic/registry/store.go里的Store对象。这么一层套一层的设计,是 Kubernetes apiserver 架构里最核心的地方:越往上层越面向用户,越往下层越接近存储。

2. 三道门:认证、授权与准入控制的源码落点

2.1 认证阶段:谁在敲门

当一个 HTTP 请求到达 api-server 时,第一件要做的事情是搞清楚“你是谁”。这个阶段叫认证(Authentication),它不是一个单独的模块,而是一组 handler 串联起来的认证链。

源码上,认证逻辑在k8s.io/apiserver/pkg/authentication里。核心接口是authenticator.Request,它只有一个方法AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error)。返回值里包含用户信息user.Info,里面主要是用户名、UID、组列表和扩展属性。后续的授权和准入都会用这份用户信息做判断。

实际认证器是链式组合的,也就是多个认证器按顺序尝试,只要有一个返回成功,就停止。常见的有:

  • 客户端证书认证:通过--client-ca-file配置的 CA 来验签,提取证书 CN 和 OU 作为用户名和组。
  • Bearer Token 认证:读取 HTTP Header 里的Authorization: Bearer xxx,然后用静态 token 文件或者 ServiceAccount token 校验。
  • Webhook 认证:把 token 发给外部认证服务,让它告诉你 token 对应哪个用户。

我特别想说一下 ServiceAccount 的细节。Pod 创建请求如果是来自集群内部组件,比如kube-controller-manager使用高权限证书,而kubelet使用的证书有它自己的身份识别。但普通用户通过kubectl访问时,kubeconfig 里用的是客户端证书或者 token。在认证阶段通过之后,请求会往上下文里塞入一个request.Context,后续的授权判断可以直接从 context 取出用户信息。

注意:认证通过不等于有权限。很多人把这两个混为一谈,导致排查问题时走弯路。认证只回答“你是谁”,授权才回答“你能干什么”。

2.2 授权阶段:RBAC 和规则匹配是怎么算出来的

接下来进入授权。api-server 的授权逻辑在k8s.io/apiserver/pkg/authorization,职责是检查请求中的动词(verb)、资源(resource)、命名空间(namespace)、资源名(name)是否匹配到该用户的某个 RBAC 规则。

授权器同样支持多级组合,常见的有RBAC、Node、Webhook、AlwaysAllow等。默认集群启用的是Node和RBAC的组合。Node 授权器是专门给 kubelet 用的,它会让 kubelet 只能操作与自己节点相关的资源;RBAC 则是面向普通用户和 ServiceAccount。

RBAC 的核心计算逻辑可以理解为“三层匹配”:先找到用户所属的所有 RoleBinding 和 ClusterRoleBinding,然后把这些 binding 引用的 Role 或 ClusterRole 里面的规则全部聚合起来,再逐条比对请求的 attributes。

正常情况下,如果你提交的 Pod 里引用了不存在的 ServiceAccount,或者当前用户没有被授权在某个 namespace 创建 Pod,你会收到一条 403 Forbidden 错误,里面还会带上 RBAC 的参考信息。这是很多人第一次接触 RBAC 时最容易踩的坑:不是凭证无效,而是授权不足。

实操心得:排查 403 时,不要只盯着 authentication 配置,先看kubectl auth can-i create pods -n default --as <username>的输出。它能快速告诉你当前这个身份有没有权限,省得反复翻日志。

2.3 准入控制:提交前的最后修改机会

过了授权,请求才算真正进入“修改对象”阶段,但这里还有一道关键关卡:准入控制(Admission Control)。它在k8s.io/apiserver/pkg/admission里,是一组插件链,每个插件可以在对象创建、更新、删除时介入,既可以校验,也可以修改对象。

准入控制的执行顺序有点讲究。比如NamespaceLifecycle会拒绝在 Terminating 的 namespace 里创建对象,LimitRanger会为没有设置资源配额的 Pod 注入默认的 request/limit,ResourceQuota会检查当前 namespace 的配额是否足够,ServiceAccount插件会自动给 Pod 补上默认的 ServiceAccount。这些插件都是有序执行的,顺序定义在kube-apiserver启动参数--admission-control或者--enable-admission-plugins里。

还有一个特殊插件叫MutatingAdmissionWebhook和ValidatingAdmissionWebhook。它们会把请求发给外部 webhook 服务,外部服务返回的 JSON Patch 会被合并进最终的对象。这就是很多开源项目实现“自动注入 sidecar”的原理。webhook 超时或者失败会直接导致请求失败,这也是集群里比较常见的 api-server 层故障源。

源码上,admission链的入口是AdmissionHandler,它会遍历所有插件,然后调用Admit(ctx, a, attr, o)。其中attr是请求属性对象,包含了资源、子资源、旧对象、新对象等。很多插件都会同时实现MutationInterface和ValidationInterface,一个负责改对象,一个负责拦错误。

我在实际排查过程中发现一个规律:如果错误信息是admission webhook "xxx" denied the request,那基本就是 webhook 校验不通过;如果错误信息是Internal error occurred: admission webhook "xxx" failed,那多半是 webhook 服务本身不可用或者超时。这两个错误要区分开,排查方向完全不同。

3. 校验、默认值和版本协商:请求如何变成“干净的”存储对象

3.1 类型校验与 OpenAPI schema

过了准入控制之后,请求里的对象已经不是原始的 YAML 字符串了,而是被转换成了内部的 API struct。但此时还不安全,还必须做一层“类型校验”和“策略校验”。

类型校验在k8s.io/apiextensions-apiserver或者k8s.io/apimachinery/pkg/api/validation里,简单说就是把对象里每个字段按照 schema 检查一遍。核心资源(比如 Pod)的校验函数都在pkg/apis/core/validation里,比如ValidatePod就是专门检查 Pod 的一整套函数。

这里面有很多细节值得注意。首先,Pod 的name字段必须符合 DNS 子域名规范,最长 253 个字符;restartPolicy必须是Always、OnFailure、Never三选一;imagePullPolicy如果没填,系统会根据镜像 tag 是否为空或latest来决定默认值。这些看起来是“行为”的东西,其实都在校验和默认值阶段做了处理。

其次是资源字段的归一化,比如 CPU 和内存的quantity字符串会被解析成内部的小数表示。Pod.Spec.Containers[].Resources.Requests这种嵌套字段,如果填了cpu: 1,内部存储会变成类似quantity{Format: DecimalSI, ...}的结构。所以如果你用 etcd 直接去看存储对象,看到的字段和 YAML 里的原始写法不一定完全相同,这是正常的,因为已经过了规范化。

实操心得:用kubectl get pod <name> -o yaml看一个 Pod 时,如果发现某些字段跟原始 YAML 不一样(比如多了status、补了默认值、资源格式变了),不要惊讶,这是 api-server 在存储前做过的“清洗”。如果你在用 client-go 写控制器,也要习惯这一点,不要假设对象会原样存进去。

3.2 默认值填充与版本转换

另一个关键机制是默认值填充和 API 版本转换。Kubernetes 内部其实存在多个版本的 API 对象,比如核心组就有v1,但历史上还有v1beta1、v1alpha1。每个版本的对象都能通过scheme做转换。api-server 使用的内部表示通常是“internal version”,比如pkg/apis/core下定义的 Pod,不同于pkg/apis/core/v1下的 Pod。

默认值填充发生在请求进入存储之前,对应的代码在pkg/apis/core/v1/defaults.go。比如DefaultResourceContainer、SetDefaults_PodSpec这些函数,会在对象从外部版本转换到内部版本之后,给没有显式设置的字段填上默认值。以dnsPolicy为例,如果没有填,默认会设成ClusterFirst;terminationMessagePath默认是/dev/termination-log。

做完默认值填充后,还需要做一次跨版本转换。比如客户端提交的是v1的对象,内部先用v1接收,再转成 internal,存储到 etcd 的时候可能又转回某个外部版本。整个转换框架在k8s.io/apimachinery/pkg/runtime/schema和k8s.io/apimachinery/pkg/runtime里,核心是Scheme对象的Convert方法。

这个机制之所以重要,是因为它解释了为什么你升级集群后,某些字段“不见了”或者被改写了。比如一个v1beta1的字段在新版本里被废弃,转换时会直接丢弃或映射到新字段。如果你在控制器代码里直接操作v1.Pod对象,然后通过 client-go 更新,这个对象会被自动转成存储版本再写进去,期间可能发生字段丢失。这一点在对接 CRD 或者写自定义控制器时尤其容易踩坑。

4. 深入 registry:Pod 是如何被写入 etcd 的

4.1 RESTHandler 与 Store

准入、校验都通过之后,请求就进入存储层了。前面提到过,Pod 的存储实现在pkg/registry/core/pod,但实际承担读写逻辑的是通用的registry/generic/registry/store.go里的Store。

到这一步,apiserver的 REST 层会调用Store.Create,它内部会做几件事:

  1. 把传入的对象从 request payload 转换到 internal 版本;
  2. 调用BeforeCreate钩子,统一给对象补上ObjectMeta的必填字段(比如UID、CreationTimestamp、ResourceVersion);
  3. 调用存储层Storage.Create写入 etcd;
  4. 如果创建成功,再把存储的返回对象转回外部版本,生成 HTTP 201 响应。

这个流程里比较有意思的是BeforeCreate。如果你观察过一个新创建的 Pod 对象,会发现它的metadata.uid是 api-server 生成的(UUID),metadata.resourceVersion也是在这个阶段初设的。这些内容不是你提供的,也不是 kubelet 填的,而是存储层在写入前统一生成的。

但这里也有一个“陷阱”:如果两次请求创建同一个名字的 Pod,第二次会收到 409 Conflict,提示AlreadyExists。因为 etcd 里的 key 是<namespace>/<name>这种确定性路径,不存在“追加”语义。

4.2 etcd 存储、resourceVersion 与冲突控制

继续往底层看,数据最终在 etcd 里是放在/registry/pods/<namespace>/<name>这个 key 下的,实际值是一条 protobuf 或 JSON 序列化后的对象。这个路径不是 Pod 自己定义的,而是在vet上注册资源时指定的存储路径,Pod 对应的store创建时设置了KeyFunc:namespacedName,最终拼出上面的 key。

资源版本(resourceVersion)是并发控制的关键。你可以把它理解成一个全局的乐观锁版本号。etcd 里每个 key 的修改都会让这个 key 的版本递增,但 Kubernetes 的resourceVersion是一种更宏观的版本号,它是 etcd 全局的 revision。

举个例子,当 kubelet 更新 Pod 状态时,它会带上上一次读到的resourceVersion,api-server 在写入时会检查这个版本是否与 etcd 当前版本一致。如果这段时间内同一个 Pod 被其他控制器更新过,版本就不一致,请求会被拒绝,返回 409 Conflict。kubelet 会收到冲突,然后重新获取最新版本再更新。

实操心得:如果日志里频繁出现Operation cannot be fulfilled on pods ... the object has been modified,说明有多个客户端在写同一个 Pod,最常见的就是 kubelet 和某个自定义控制器在同时更新状态或注解。这时候不要急着调大 apiserver 超时,先找到是谁在频繁更新对象,用kubectl describe看metadata.annotations里的kubectl.kubernetes.io/last-applied-configuration,再结合 deploy 的副本数猜测谁在参与写。

4.3 watch 机制与 kubelet 的联动

Pod 写进 etcd,不代表 kubelet 立刻就会创建容器。中间还有一个核心联动机制:watch。

kube-apiserver的存储层实现了Watch方法,它在pkg/registry/core/pod/store.go里通过rest.Store的Watch接口向外提供事件流。kubelet 和 scheduler 都是通过 ListWatch 的方式监听 Pod 的变化。ListWatch 的语义是:先 List 一次全量数据,然后通过 watch 持续接收增量变化。

watch 机制在源码里是通过 etcd 的 watch 加上 apiserver 内的一个缓存层实现的。k8s.io/apiserver/pkg/storage里有一个Cacher,它会订阅 etcd 的变化,然后在自己的内存里维护一份最近事件的缓存。这样即使某个客户端 watch 断开了,也能用缓存的resourceVersion继续追,不用全量重放。这也是为什么 api-server 是“有状态”的,它内部有很多这种缓存。

kubelet 主动 watch Pod,只关心调度到它自己节点上的 Pod。它通过--node-name判断当前节点,然后加上一个字段选择器,只 watchspec.nodeName=<自己的节点名>的 Pod。这就是为什么即使有成千上万个 Pod,每个 kubelet 也只处理自己那一份。

再看 scheduler,它 watch 的是没有spec.nodeName的 Pod,然后做调度决策,把nodeName写到 Pod 上,这个写操作又通过 api-server 进 etcd。kubelet watch 到 Pod 的spec.nodeName等于自己后,才会真正去拉镜像、启容器。

这个“写着 nodeName 就算创建成功”的模型,很多人一开始会觉得奇怪。其实它背后有一个理念:Kubernetes 的对象状态变更不是由一个中心组件直接下发指令,而是通过“声明式 + 监听”的方式让每个组件各司其职。

5. 日常排障实录:api-server 层的问题定位思路

5.1 典型问题速查表

针对 Pod 创建过程,我把平时最容易碰到的 api-server 层问题整理了一个速查表,遇到可以直接对照。

现象典型错误信息原因方向排查建议
创建被拒forbidden: User "system:serviceaccount:xxx" cannot create resource "pods"RBAC 权限不足kubectl auth can-i --as ... create pods验证角色绑定
创建被拒admission webhook "xxx" denied the requestwebhook 校验失败看 webhook 配置和业务侧日志
创建失败Internal error occurred: admission webhook "xxx" failedwebhook 服务不可用或超时检查 webhook service 健康状态和 timeoutSeconds
创建冲突already exists同名 Pod 已存在检查旧 Pod 是否在 Terminating 状态
更新冲突the object has been modifiedresourceVersion 不一致找多个 controller 写同一对象的源头
字段被丢弃无显式错误,但对象缺少字段API 版本转换或默认值填充对比kubectl get -o yaml和原始 YAML
配额不足exceeded quota: xxxResourceQuota 限制查看 namespace 的resourcequotas
请求超时context deadline exceededwebhook 慢、etcd 慢或 apiserver 高负载kubectl get --raw /metrics看 apiserver 指标

这个表只能算一个起点。真正定位问题的时候,我通常先看 api-server 日志里对应请求的requestURI和code,比如 403、409、422 每个状态码背后的处理逻辑完全不同。kubectl apply返回的 YAML 错误信息往往经过客户端包装,会丢一些细节,直接查 apiserver 日志更清晰。

5.2 调试方法与抓包技巧

如果只看日志还定位不了,就得上“硬核”手段了。我自己的调试顺序一般是:先看日志,再看事件,最后才考虑抓包。

api-server 的日志默认打在标准输出,如果部署在 Pod 里,可以用kubectl logs -n kube-system <apiserver-pod>查看。日志级别可以通过-v参数控制,直接看-v=4就能看到大多数请求的Verb、URI、User和响应码。如果再往上到-v=6,会有完整的请求和响应体,信息量极大,但也很吵,生产环境不建议长时间开。

抓包在很多环境下不好做,因为 api-server 走的是 HTTPS,证书校验会挡住中间人。如果你只是想看请求内容,更简单的方式是在 kubeconfig 里临时加一个代理,比如用kubectl --v=8直接打出客户端发出去的 HTTP 请求和响应。这个 verbose 级别对客户端侧的调试已经足够了。

如果要看服务端到底发生了什么,可以临时给 apiserver 加一个审计日志配置。审计日志是 Kubernetes 自带的功能,它按策略记录每个请求的请求头、响应状态、request body 和 response body。打开之后基本能把用户请求和准入链路上发生的一切都留下来。注意审计策略要限定级别,比如RequestResponse级别记录全部内容,但会消耗大量磁盘空间,生产环境要谨慎配置。

另外,定位 webhook 或者 RBAC 问题的时候,我习惯用一个小技巧:直接在控制面里跑一个带curl的 Pod,用它去请求 apiserver 的 Service,这个 Pod 的 ServiceAccount 可以动态绑定各种 RBAC 权限,非常适合安全地模拟用户请求。比如:

kubectl run curl-test --image=curlimages/curl --restart=Never -- sleep 3600 kubectl exec -it curl-test -- \ curl -k -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ https://kubernetes.default.svc/api/v1/namespaces/default/pods

然后通过绑定一个带create权限的 Role 来测试当前 ServiceAccount 到底能不能创建 Pod。这套方法比一遍遍改 kubeconfig 快得多,也方便复现问题。

6. 一点读源码的个人方法

最后分享一个我自己的读源码顺序,不回放“代码在哪一行”这种细节,但给后来者一个通用的切入思路。

第一遍读源码,千万别从最底层etcd读写开始看,很容易被各种 interface 绕晕。先沿着一条最简单的请求链路走:kubectl create -f pod.yaml,然后在 apiserver 的日志里看到 POST 请求到达,然后顺着cmd/kube-apiserver/app的启动顺序,找InstallLegacyAPI、InstallAPIGroup这类的注册入口,再跟到pkg/registry/core/pod。到了这里基本就能看到 REST 实现和Store的关联,再往深了走才是storage.Interface。

第二遍就是带着问题去看。比如你想理解默认值是怎么填的,就直接搜SetDefaults_前缀;想理解准入控制怎么串起来的,就搜admission.NewAdmissionHandler。源码阅读不要追求从头读到尾,那样大部分人会劝退。Kubernetes 这一层抽象非常多,很多代码是为了扩展性服务的,真正的业务逻辑往往埋在几个不起眼的函数里。

我个人建议把kube-apiserver里面 “REST → registry → storage → etcd” 这条主线先弄通,主线的核心代码不到十个文件,剩下的都是插件和外围扩展。主线通了,看任何控制器的代码都会快很多,因为你始终在围绕同一个对象的生命周期在思考。

这篇先说到这。下一篇我计划把kube-scheduler在 Pod 创建链路里的调度决策展开写,到时候会讲到调度队列、插件机制和预选/优选阶段,接着把控制面和数据面的闭环补完整。

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

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

立即咨询