记得刚开始啃Kubernetes源码的时候,我走了不少弯路:打开仓库先找cmd,再翻pkg,结果一头扎进一堆接口和函数里,根本不知道一条创建Pod的请求到底是怎么被处理的。后来我才发现,把kube-apiserver单独拎出来,顺着一次Pod创建请求从头走到尾,比到处乱翻有效得多。这篇是系列的第二篇,我们就聚焦在kube-apiserver上,把从HTTP请求变成etcd里一个Pod对象,再触发下游scheduler感知的完整过程讲透。
如果你之前只是用过kubectl和YAML,对“创建Pod”的印象停留在客户端提交、后端默默干活的话,这篇文章会直接带你进到引擎盖下面,看清每个齿轮是怎么咬合的。我默认你有一定的Kubernetes使用基础,知道Pod、Deployment、namespace这些概念,但不用对源码有多深的理解,我会把关键函数名、调用顺序和设计意图都拆开讲。
1. 先看全貌:一次Pod创建请求在apiserver里的完整链路
1.1 apiserver在整个流程里的位置和职责
Kubernetes几乎所有写操作都要经过kube-apiserver,这不是因为我喜欢让它过一遍,而是架构上必须如此。整个集群里,只有kube-apiserver能直接跟etcd通信,scheduler、kubelet、controller-manager都只能以客户端的身份调用API,不能绕过apiserver去碰etcd。这带来一个非常重要的设计效果:所有状态变更都会经过统一的认证、授权、准入、校验逻辑,集群不会因为某个组件手滑而写进去脏数据。
所以你执行一条kubectl apply -f pod.yaml,看似只是提交了一个Pod清单,实际发生的是一连串典型流程。从宏观角度看,这条链路是:
- kubectl把YAML解析成Pod对象,向apiserver发起POST请求;
- apiserver的过滤链依次处理认证、审计、限流、授权;
- 请求被路由到Pod资源的创建Handler;
- Handler把请求体解码成Pod对象,做默认值填充和准入检查;
- Registry层调用Pod策略,完成创建前的准备和校验;
- 对象被序列化写入etcd,同时触发watch事件;
- scheduler watch到新的待调度Pod,开始调度。
这个顺序是理解所有控制面组件的基础。很多人在排查问题的时候,一上来就查scheduler或者kubelet,其实一半以上的创建问题都出在apiserver这一层,比如准入Webhook拒绝了请求、RBAC没放行、资源校验不过,根本还没轮到调度器出场。
1.2 一个请求在代码里对应哪些关键入口
如果只看代码,上面七步分别落在几个核心区域,我整理成一张速查表,方便你之后定位问题时不迷路:
| 阶段 | 关键代码位置 | 主要职责 |
|---|---|---|
| 过滤链 | k8s.io/apiserver/pkg/server/config.go中的BuildHandlerChain | 认证、审计、限流、授权 |
| 路由注册 | k8s.io/apiserver/pkg/endpoints/installer.go | 把REST动作映射成HTTP路由 |
| Handler实现 | k8s.io/apiserver/pkg/endpoints/handlers/create.go | 处理POST请求、解码、准入、调存储 |
| 策略逻辑 | pkg/registry/core/pod/strategy.go | Pod创建前默认值、校验、不可变字段处理 |
| 存储实现 | pkg/registry/core/pod/storage/storage.go | 定义PodStorage,连接GenericStore |
| etcd写入 | k8s.io/apiserver/pkg/storage/etcd3/store.go | 事务写、版本管理、watch事件 |
这张表里,我自己最常看的是create.go和strategy.go这两个文件,因为大多数“为什么这个Pod创建失败”的答案都藏在这两处。后面我会逐步往下拆。
1.3 不同版本源码有差异,怎么找准自己的版本
看源码前必须有一个心理准备:Kubernetes版本迭代很快,函数签名、过滤链顺序、目录结构都可能变。我前阵子帮同事定位一个创建问题,同一个createHandler,在v1.28和v1.30里参数和调用顺序就有变化。所以下文提到的函数名和位置,我以自己实际调试过的中近期版本为主,你在看的时候,第一步先确认自己手里的源码版本。
最稳妥的姿势是直接看当前版本tag下的代码:把仓库切到对应tag,再按我给出的相对路径去找。如果发现函数不在原路径,优先在pkg/registry/core/pod和k8s.io/apiserver/pkg/endpoints/handlers下搜索,改动再大,核心的调用链一般不会翻天覆地。
2. 过滤链:请求在见业务Handler之前过了哪些关卡
2.1 过滤链的组装顺序,比你想象中更重要
你可以把kube-apiserver的HTTP处理流程理解成洋葱模型,请求从最外层HTTP Server进来,经过一层又一层filter,最后才到达真正处理Pod创建逻辑的Handler。这层洋葱的组装入口在BuildHandlerChain里。
以我调试过的某个版本为例,代码大致长这样:
func (c *Config) BuildHandlerChain(apiHandler http.Handler) http.Handler { handler := apiHandler // 记录每个filter耗时 handler = filterlatency.TrackCompleted(handler) // 缓存与响应头相关处理 handler = genericfilters.WithCacheControl(handler) // 授权决策 handler = genericfilters.WithAuthorization(handler, c.AuthorizationResolver, c.AuthorizationConfig) // 并发请求限制 handler = genericfilters.WithMaxInFlightLimit(handler, c.MaxRequestsInFlight, c.MaxMutatingRequestsInFlight) // 审计 handler = genericfilters.WithAudit(handler, c.AuditSink, c.AuditPolicyRuleEvaluator, c.LongRunningFunc) // 认证 handler = genericfilters.WithAuthentication(handler, c.AuthenticationResolver, c.AuthenticationBalancer) // 请求信息注入 handler = apirequest.WithRequestInfo(handler, c.RequestInfoResolver) return handler }不同小版本里的装配顺序会有微调,比如某些版本把Impersonation单独提出来放在授权之前,但总体思想一致:认证在外层先确认身份,授权在内层按身份做决策,限流和审计嵌在中间。顺序为什么重要?如果先授权再认证,那你根本不知道当前请求是谁发起的,也就无法正确匹配RBAC规则;如果限流放在认证之前,那恶意攻击者可以用大量不合法请求占满并发槽位,把正常流量挤掉。所以这个顺序不是随便排的,每一层都依赖前一层产生的结果。
2.2 认证层:先搞清楚你是谁
认证过滤器的主要任务是解析请求里的身份信息。Kube-apiserver支持多种认证方式:客户端证书、Bearer Token、Webhook、OIDC等。它们共同组成一个Authenticator链,逐个尝试,只要有一个成功,就把请求对应的用户信息写入上下文。
这里有几个我踩过的细节坑。一是匿名请求,当你没有配置任何认证插件,或者证书校验失败时,请求会以system:anonymous身份继续走流程。所以看到system:anonymous不代表认证环节出错了,可能是客户端压根没带凭证。二是认证成功不代表授权成功,很多人把401和403混为一谈。认证失败返回401,授权失败返回403,这是两个不同阶段的失败。
排查认证问题最快的方式是在apiserver启动参数里加--v=10,日志会打印每个请求走了哪些Authenticator、命中了哪一条。不过注意生产环境别长期开这个级别,日志量太吓人,我一般只在测试环境用。
2.3 授权层:再决定你能不能干这件事
认证通过后,请求带着身份信息进入授权过滤器。默认情况下,授权器会基于RBAC策略做决策,判断当前用户对某个命名空间下的Pods资源是否拥有create权限。
授权判断依赖请求的几个属性:用户、动作(create/get/list/update/delete等)、资源类型、子资源、命名空间,以及是否属于非资源路径。对于创建Pod来说,就是检查你是否对pods资源有create权限。这里常见的坑是,用户有pods的create权限,但没有pods/status的更新权限,结果单独更新Pod状态时被拒绝,这和创建Pod是两条授权路径,容易搞混。
如果你开了RBAC,授权决策实际上会构造一个SubjectAccessReview请求,交给内部的authorizer处理。想快速验证某个用户能否创建Pod,不需要真的去调API,用一条命令就行:
kubectl auth can-i create pods --as=system:serviceaccount:default:my-sa这个命令会向apiserver发起一个本地SAR请求,直接告诉你结果,非常适合在写RBAC策略时做自检。
2.4 限流和审计:保护自己和留证据
限流过滤器WithMaxInFlightLimit维护两个队列:只读请求队列和写请求队列。创建Pod属于写操作,走的是mutating队列。队列长度由启动参数--max-mutating-requests-inflight控制,默认值在不同版本不太一样,通常大概是几百到几千。当队列满载,后续写请求会直接返回429,不会继续往下走。
审计过滤器则是把请求的关键信息记录下来:谁在什么时间、对什么资源做了什么操作,请求体和响应体是否被记录。对于创建Pod这种敏感操作,审计日志非常有用,问题排查时可以精确还原现场。不过要注意,如果审计策略配置成全量记录大对象,会严重拉高apiserver的内存和磁盘开销。我的建议是,请求体记录级别别开RequestResponse,默认记Request就够了,除非你需要排查准入插件修改对象的问题。
3. 路由、Handler与Strategy:从URL到createHandler
3.1 路由是怎么注册出来的
请求穿过过滤链后,接下来要按URL找到对应的处理函数。这个路由不是手写的switch-case,而是通过go-restful框架动态注册的。核心逻辑在registerResourceHandlers里,它为每一个REST资源遍历支持的动作,然后构造gorestful的Route。
创建Pod对应的是POST动作,在代码里会判断存储对象是否实现了rest.Creater接口。只有实现了这个接口的资源才允许POST创建。如果你自己扩展过CRD的API,一定对这个接口不陌生。Route注册完成后,请求POST /api/v1/namespaces/default/pods就会精确命中Pod的创建Handler。
3.2 createHandler逐段拆解
真正处理创建请求的函数在k8s.io/apiserver/pkg/endpoints/handlers/create.go里,名字就叫createHandler。它的核心逻辑我用精简代码还原一下:
func createHandler(r rest.NamedCreater, scope *RequestScope, admit admission.Interface, includeName bool) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { // 解析命名空间和对象名 namespace, name, err := scope.Namer.Name(req) // 读取请求体,有大小限制 body, err := limitedReadBody(req, scope.MaxRequestBodyBytes) // 把请求体解码成Pod对象,gvk转内部版本 obj, err := scope.CreateObject(ctx, body, namespace, ...) // 构建admission属性 attrs := scope.Attributes(ctx, name, namespace, ...) // 准入检查 if admit != nil { admit = admission.WithAudit(admit, scope.Attributes) err := admit.Admit(attrs, obj, scope.ObjectConvertor) } // 真正调用存储层创建 result, err := r.Create(ctx, name, obj, rest.ValidateAllObjectFunc(admit), options, subResource) // 写响应 writeCreatedResponse(ctx, w, req, result) } }我建议你重点理解两个环节:一是scope.CreateObject,它不只是反序列化,还会把对象从接口版本转换到内部版本,并执行默认值填充;二是admit.Admit,这是准入插件唯一一次全体出场的机会,所有Webhook和内置准入控制器都在这里工作。等这两个做完,对象才算真正被“批准”进入存储层。
3.3 默认值填充发生在哪个阶段
很多新手会疑惑,为什么我YAML里没写restartPolicy,最后创建出来的Pod却显示Always?答案就在scope.CreateObject对应的Decode流程里。代码会把外部API版本对象转换成内部版本对象,转换过程中调用Scheme的默认值函数,把没填的字段填上合理的初始值。
这里有个容易误判的排查点:如果你在准入Webhook里看到的Pod对象和最终etcd里的Pod对象不一致,先检查是不是默认值阶段产生的差异。默认值发生在准入之前,Webhook改完字段,后续校验和存储才基于修改后的对象继续。所以Webhook里修改字段是有效的,但如果你在Webhook里读的是默认值填充前的状态,某些字段可能不是你预期的值。
3.4 Strategy:Pod自己定的一套“规矩”
创建链路走到r.Create之后,存储层会调用Pod的策略对象。这个策略在pkg/registry/core/pod/strategy.go里定义,核心实现是PrepareForCreate和Validate两个方法。
PrepareForCreate在对象入etcd前做最后清洗:清空Status子资源里的内容,设置Generation为1,清理只能由服务端维护的字段。比如用户提交的Status信息会被直接忽略,因为Pod状态只能由kubelet通过status子资源更新。这是Kubernetes常见的安全设计:普通客户端没有权限直接写状态,只有特定组件能通过子资源路径更新。
Validate则是对整个Pod对象做完整校验:容器名是否合法、镜像名是否为空、restartPolicy枚举值是否正确、端口是否冲突、亲和性调度规则的引用是否存在等。校验失败会直接返回错误给客户端,根本不会进etcd。这也是为什么很多简单的YAML错误会在API层就被拦截,而不是等到scheduler或kubelet报错。
4. 落库与Watch:与etcd之间的关键交互
4.1 Store.Create:进入存储世界的入口
Pod的存储对象在pkg/registry/core/pod/storage/storage.go里,它把Restful存储接口和etcd3存储实现缝合在一起。当你调用Store.Create时,真正执行的是k8s.io/apiserver/pkg/registry/generic/registry/store.go里的方法。
这一步有几个关键操作:先调用rest.BeforeCreate,它会执行策略里的PrepareForCreate和Validate;接着通过对象名称生成器确定Pod名称,如果你没指定名字,这里会自动生成随机后缀;然后计算TTL,普通Pod的TTL是0,表示永不过期;最后构造etcd的存储key,比如/registry/pods/default/my-pod-xxxx。
只有走到这一步,对象才真正准备落盘。前面所有准入、校验、清洗工作,都是为了确保写进etcd的是一个合法、干净的对象。
4.2 etcd3 Create事务和资源版本
etcd3的Create实现里,有一个非常经典的分布式并发控制技巧,我单独拿出来讲一下。代码会开启一个事务,事务的条件是“该key当前的CreateRevision等于0”,也就是这个key还不存在。如果条件成立,就执行Put写入;否则就读取旧值并返回一个KeyExists错误。
txn := s.client.KV.Txn(ctx) txn.If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)) txn.Then(clientv3.OpPut(key, string(data), opts...)) txn.Else(clientv3.OpGet(key, clientv3.WithCreateRevision(0))) resp, err := txn.Commit() if err != nil { return nil, err } if !resp.Succeeded { return nil, storage.NewKeyExistsError(key, 0) }这个事务保证了即使在极端并发下,同一个Pod也不会被创建两次,创建操作具备天然幂等性。类似的CAS思路在Kubernetes里到处都是,比如更新对象时比较ResourceVersion,避免两个客户端互相覆盖。理解这个模式后,你再看其他资源的创建和更新逻辑,基本都是同一个套路。
4.3 对象序列化:etcd里存的到底是什么格式
写入etcd前,对象要先序列化成字节流。Kubernetes默认在存储层使用protobuf作为内部传输格式,但你如果直接去etcd里翻数据,看到的不一定是规整的JSON或二进制结构。etcd里存的是带key的KV数据,apiserver通过StorageCodec完成编码和解码。
这里有个排查技巧:你想确认某个Pod对象到底存了什么字段,不需要直接用etcdctl去解析二进制,用kubectl看到的内容已经足够,因为apiserver返回时已经做了反序列化和版本转换。万一遇到apiserver启动失败、怀疑etcd数据损坏,可以临时用etcdctl把value导出来看看前缀,确认数据是否存在,但不要依赖这把钥匙解决所有问题。
4.4 Watch机制如何通知scheduler
etcd写入成功后,整个创建链路还差最后一步:让集群里其他组件知道这件事。kube-apiserver在etcd上维护了watch,它监听key前缀的变化,把etcd的创建事件包装成Kubernetes的watch.Event,再通过API的Watch接口推送给所有订阅者。scheduler正是通过Informer的Watch机制,在Pod创建事件到来时收到消息。
这个流程的巧妙之处在于,scheduler和其他组件不需要主动轮询etcd,只需要和apiserver维持一条长连接,apiserver检测到etcd变化后就会推送。所以你在创建Pod后,scheduler的日志里很快会出现“Found new Pod”之类的记录,这条记录本质上就是watch事件到达后的处理结果。
5. 实操:本地调试kube-apiserver看Pod创建
5.1 搭建一个最小可调试环境
源码分析说得再多,不如自己动手打断点看调用栈。本地调试kube-apiserver最轻量的方式是用hack/local-up-cluster.sh,它会帮你启动etcd、apiserver、controller-manager和scheduler,不用起完整的kubelet集群。
如果你用的是GoLand或者VSCode,可以直接用Delve远程调试。思路是先把kube-apiserver以调试模式启动,再让api server监听一个调试端口,IDE里配置Go Remote连接。我实际用的时候会稍微改造一下local-up-cluster脚本,单独拉起一个apiserver进程,加--v=5参数,然后给它配一个独立的kubeconfig,方便用kubectl请求。
还有一种更干净的办法:用Kind建一个单节点集群,把本地编译好的kube-apiserver镜像替换进去。但这个流程比较重,适合需要反复改代码验证的场景。如果只是看清调用链,我推荐先用local-up-cluster。
5.2 推荐几个关键断点位置
结合前面的分析,我最常打断点的位置有五个:
createHandler里admit.Admit之前,看请求解码后的Pod对象;rest.BeforeCreate进入位置,看策略准备的调用时机;podStrategy.PrepareForCreate,看Pod清洗字段的过程;podStrategy.Validate,看校验逻辑执行细节;- etcd3的
store.Create事务提交前,看最终序列化数据和key。
调试时建议把这几处断点全部打上,然后用一条最简单的kubectl create命令触发,一次完整的创建请求会在每个断点依次停下。通过查看调用栈,你能非常直观地看到过滤链、Strategy、存储层是怎么串联起来的。
5.3 一次调试实录:我发现了什么
我印象最深的一次调试,是排查Pod创建时metadata.generation总是不对的问题。当时我直接在PrepareForCreate里打断点,发现Generation先被设置为1,但之后又被某个字段的变化重新计算。顺着调用栈往上追,才发现是某个准入插件修改了Pod的Label,触发了Generation在后续更新逻辑里的变更。这让我意识到,准入插件对对象的修改,会影响最终存储在etcd里的对象版本和Generation,排查类似问题时不能只看客户端提交的YAML。
打断点调试还有一个额外好处:你能直观看到rest.BeforeCreate和admit.Admit的执行顺序。很多人在文档里看到“准入在存储之前”,但不知道具体发生在多早。实际打断点后你会看到,对象进入Store.Create之前,准入就已经完成了,而且准入能看到默认值填充后的完整对象。
6. 常见问题与避坑心得
6.1 403/401错误到底怎么查
遇到创建Pod返回403,我一般按这个顺序排查:
- 用
kubectl auth can-i create pods -n <namespace> --as=<身份>确认RBAC权限; - 检查是否定义了ResourceQuota或PodSecurityPolicy等准入限制,这类拒绝通常会在响应消息里写清楚原因;
- 看apiserver日志里审计和授权决策部分,确认是哪个authorizer拒绝了请求;
- 如果用的是Webhook授权,还要检查Webhook服务的连通性和签名配置。
如果是401,优先查客户端证书、Token是否有效,匿名用户的错配也常在这里暴露。记住这两类错误本质不同,不要在授权策略里浪费时间去解决认证问题。
6.2 准入Webhook拖慢创建速度
当你给集群配置了MutatingAdmissionWebhook或者ValidatingAdmissionWebhook,每次创建Pod都要同步调用外部Webhook服务。如果Webhook接口响应慢,创建Pod的时延会被显著拉长。我见过一个集群,因为某个Webhook服务故障,所有Pod创建要等30秒超时才放行,表面现象是“Pod一直Pending”,实际问题根本不在调度器。
排查方式很简单:打开审计日志看requestReceivedTimestamp和responseCompleteTimestamp,如果时间差集中在调用外部服务的等待上,基本可以锁定Webhook问题。优化方向一个是做Webhook的并发调优,另一个是对非关键请求配置failurePolicy: Ignore,但生产环境需要对安全影响做好评估。
6.3 大对象和etcd限制
etcd单对象默认推荐不超过1.5MB,创建Pod一般不会碰到这个限制,但如果你的Pod有超大的annotation或大量环境变量,请求体可能被apiserver的--max-request-body-bytes拦截。这个参数一般默认1MB出头,超过会返回413。遇到这种情况,先检查是不是有组件或人可以清一下冗余标签和注解,而不是盲目调大限制。
6.4 读源码的路线建议
最后聊一点看源码的体会。如果你是第一次接触这部分,我不建议你从头到尾读整个apiserver包,那会非常劝退。更好的路线是:先用kubectl实践创建Pod几十次,把各种失败场景都触发一遍,再带着问题去看源码。比如“为什么这个Pod会创建失败”“为什么这个字段提交了却没生效”,问题越具体,读源码的效率越高。
我自己现在的习惯是:先跑一条请求,开详细日志,看调用链经过哪些组件,然后再去对应源码里搜关键函数。这套方法对Pod创建、Service创建、ConfigMap更新都一样适用。遇到看不懂的接口,先看它的实现类有哪几个,再看调用方在哪里,两跳下来基本就通了。
我调试到现在,最大的感触是:kube-apiserver不像它外表看起来那么神秘,它本质就是一个带状态校验和分布式存储的HTTP服务,只不过每一层都被拆得足够细。而Kubernetes的源码读多了之后,你再看其他分布式系统,会特别容易理解它们的设计取舍,因为很多模式你已经在Kubernetes里见过一遍了。
我个人在实际操作中的一个小建议是,所有源码追踪的过程中,一定要开着etcd的实时变更来做交叉验证。你既能从代码里看到对象准备写入,又能从etcd侧看到数据真实落盘,两个视角一旦对应上,整条链路就彻底通了。如果还有余力,下一篇可以把scheduler调度Pod这条线也顺着走一遍,那会让你对“创建Pod”这件事有更完整的掌控感。