啃 K8s 源码:一个 Pod 到底是怎么存进 etcd 的,从 RESTStorage.Create 到事务 Put 全流程
前言那个让我看了三天源码的诡异 bug生产环境有次报 Pod “Pending”但kubectl describe一看status字段完全是空的——不是 Pending、不是 Running是字面意义上的空 status。调度器拒绝调度因为 status.phase 不对controller 也不更新——整个 Pod 就这么卡死在 etcd 里。排查到最后发现是定制的 mutating webhook 把pod.Status.Phase给改成了空字符串APIServer 写存储时保留了这个值导致 controller 集体迷惑。让我意外的是APIServer 在写 etcd 前明明有一道PrepareForCreate把 status 设为PodPending的逻辑——为什么没起作用啃了三天源码才搞明白webhook 是在 admission 阶段改的已经过了PrepareForCreate。APIServer 的执行顺序是有讲究的——准入控制webhook跑在 strategy 之后。这次踩坑让我彻底搞清楚了从 RESTStorage.Create 到 etcd Put 之间到底发生了什么。这篇就把这条链路逐行拆开讲并把每个容易踩坑的点标出来。本节重点kube-apiserver create Pod 时数据保存的完整链路从RESTStorage.Create到 etcdTxn().Put()之间的核心步骤隐藏在PrepareForCreate里的 QoS 计算逻辑dryRun、加密 Transformer、事务一致性的实现细节一、整体链路总览先看一张全景图知道我们要走哪些站APIServer 收到 POST /api/v1/namespaces/default/pods │ ▼ ┌──────────────────────────────────┐ │ HTTP handler chain │ │ (认证 → 鉴权 → 准入控制 webhook) │ └─────────────┬────────────────────┘ ▼ ┌──────────────────────────────────┐ │ podStorage.Create() │ │ (PodStorage 的 REST.Create) │ └─────────────┬────────────────────┘ ▼ ┌──────────────────────────────────┐ │ genericregistry.Store.Create() │ ◀── 本节重点 │ ① BeginCreate (可选钩子) │ │ ② BeforeCreate │ │ └─ Strategy.PrepareForCreate │ │ └─ 设 Pending、算 QoS │ │ ③ Validate │ │ ④ Storage.Create (写 etcd) │ │ ⑤ AfterCreate / Decorator │ └─────────────┬────────────────────┘ ▼ ┌──────────────────────────────────┐ │ DryRunnableStorage.Create │ │ └─ dryRun 拦截层 │ └─────────────┬────────────────────┘ ▼ ┌──────────────────────────────────┐ │ etcd3.store.Create │ │ ① 序列化 (runtime.Encode) │ │ ② Transformer.TransformToStorage│ ← 加密在这 │ ③ Txn().If(notFound).Put() │ ← 事务 Put └─────────────┬────────────────────┘ ▼ etcd v3 存储接下来一段一段拆。二、Pod 的 RESTStorage 长什么样上节讲到每种资源对应一个RESTStorage定义了如何跟存储打交道。Pod 的位置pkg/registry/core/pod/storage/storage.go// REST implements a RESTStorage for podstypeRESTstruct{*genericregistry.Store// 嵌入通用 StoreproxyTransport http.RoundTripper// exec/log 用的代理}主 store 的初始化上节看过的代码store:genericregistry.Store{NewFunc:func()runtime.Object{returnapi.Pod{}},NewListFunc:func()runtime.Object{returnapi.PodList{}},PredicateFunc:registrypod.MatchPod,DefaultQualifiedResource:api.Resource(pods),CreateStrategy:registrypod.Strategy,// ★ 关键UpdateStrategy:registrypod.Strategy,DeleteStrategy:registrypod.Strategy,ResetFieldsStrategy:registrypod.Strategy,ReturnDeletedObject:true,TableConvertor:printerstorage.TableConvertor{TableGenerator:printers.NewTableGenerator().With(printersinternal.AddHandlers),},}重点是CreateStrategy: registrypod.Strategy——Pod 创建时所有业务逻辑都在这个 Strategy 里。Strategy 是什么上节讲过Strategy 模式让通用 Store 处理 CRUD资源专属的校验/默认值/转换通过 Strategy 注入。Pod 的 Strategy 就在pkg/registry/core/pod/strategy.go。三、Store.Create 主流程逐行解读位置staging/src/k8s.io/apiserver/pkg/registry/generic/registry/store.go这是 K8s 所有资源 Create 的统一入口——Pod、Service、ConfigMap、CRD 都走这里。3.1 第①步BeginCreate事务前钩子ife.BeginCreate!nil{fn,err:e.BeginCreate(ctx,obj,options)iferr!nil{returnnil,err}finishCreatefndeferfunc(){finishCreate(ctx,false)}()}BeginCreate是个可选的事务钩子让 Strategy 能开启一个事务并在defer里收尾成功 commit 或失败 rollback。大部分资源没有 BeginCreate。它主要给一些需要跨资源事务的场景留的口子比如某些 Aggregated APIServer 实现里会用。Pod 自己没用这个。3.2 第②步BeforeCreatePrepareForCreate Validateiferr:rest.BeforeCreate(e.CreateStrategy,ctx,obj);err!nil{returnnil,err}这个BeforeCreate不是简单一行——它内部干了4 件事看下简化版源码// 简化版的 BeforeCreate 实现funcBeforeCreate(strategy RESTCreateStrategy,ctx context.Context,obj runtime.Object)error{// ① 调 Strategy 的 PrepareForCreate补默认值、改字段strategy.PrepareForCreate(ctx,obj)// ② 生成 UIDiferr:EnsureObjectMeta(obj);err!nil{...}// ③ 调 Strategy.Validate业务校验iferrs:strategy.Validate(ctx,obj);len(errs)0{returnerrors.NewInvalid(...)}// ④ 处理 Canonicalize标准化字段strategy.Canonicalize(obj)returnnil}开头那个 bug 就在这步发生PrepareForCreate把 status 设为 Pending 没错但它在准入控制 webhook 之前跑。我们的 webhook 又在PrepareForCreate之后把 status 改回空——APIServer 信任 webhook不会再纠正。正确做法webhook 应该只改 spec不要碰 status。需要改 status 应该等 Pod 创建后通过/status子资源单独改。3.3 Pod Strategy.PrepareForCreate 干了什么位置pkg/registry/core/pod/strategy.gofunc(podStrategy)PrepareForCreate(ctx context.Context,obj runtime.Object){pod:obj.(*api.Pod)// 1. 强制设状态为 Pendingpod.Statusapi.PodStatus{Phase:api.PodPending,QOSClass:qos.GetPodQOS(pod),// ★ 关键算 QoS}// 2. 丢掉禁用 feature gate 的字段podutil.DropDisabledPodFields(pod,nil)// 3. 处理 seccomp 字段的版本兼容applySeccompVersionSkew(pod)}三件事覆盖 status不管客户端传啥统统重置为{Phase: Pending, QOSClass: 计算结果}DropDisabledPodFields如果某些字段对应的 feature gate 没开比如老版本的临时容器就把这些字段从 spec 里删掉applySeccompVersionSkew处理 seccomp 字段的版本兼容老的seccompProfile注解 ↔ 新的securityContext.seccompProfileDropDisabledPodFields是 K8s 的防穿越机制如果你的集群关了某个 feature但有人传了相关字段APIServer 会静默丢弃而不是报错。好处是新客户端能向后兼容老集群坏处是字段被丢了你都不知道——所以排查字段莫名其妙消失问题时先检查 feature gate。四、深入QoS 是怎么算出来的Pod 的 QoS 分类Guaranteed / Burstable / BestEffort就是在PrepareForCreate里被打上去的。4.1 QoS 三档简介BestEffort ←────── 优先级递增 ──────→ GuaranteedQoS 类requests/limits 关系OOM 优先级Guaranteedrequests limits且都不为 0最不容易被 killBurstablerequests limits且不全为 0中等BestEffort完全没设 requests/limits第一个被 OOM killQoS 的两个本质影响调度scheduler只看 requests——所以 Guaranteed 和 Burstable 在调度时一样关键看requests总量OOM 时被驱逐顺序节点内存压力时kubelet 按BestEffort → Burstable → Guaranteed顺序驱逐4.2 GetPodQOS 源码位置pkg/apis/core/helper/qos/qos.go第一步遍历所有容器累加requestsfor_,container:rangeallContainers{// process requestsforname,quantity:rangecontainer.Resources.Requests{if!isSupportedQoSComputeResource(name){continue}ifquantity.Cmp(zeroQuantity)1{delta:quantity.DeepCopy()if_,exists:requests[name];!exists{requests[name]delta}else{delta.Add(requests[name])requests[name]delta}}}第二步累加limits// process limitsqosLimitsFound:sets.NewString()forname,quantity:rangecontainer.Resources.Limits{if!isSupportedQoSComputeResource(name){continue}ifquantity.Cmp(zeroQuantity)1{qosLimitsFound.Insert(string(name))delta:quantity.DeepCopy()if_,exists:limits[name];!exists{limits[name]delta}else{delta.Add(limits[name])limits[name]delta}}}}第三步判定 QoS 类// 规则 1: 都没设 → BestEffortiflen(requests)0len(limits)0{returncore.PodQOSBestEffort}// 规则 2: limits requests 且齐全 → GuaranteedifisGuaranteedlen(requests)len(limits){returncore.PodQOSGuaranteed}// 规则 3: 其他 → Burstablereturncore.PodQOSBurstable4.3 实战例子# 例 1: Guaranteedresources:requests:cpu:100mmemory:100Milimits:cpu:100m# 和 requests 完全相等memory:100Mi# 例 2: Burstable最常见resources:requests:cpu:100mmemory:100Milimits:cpu:1000m# limits requestsmemory:2500Mi# 例 3: BestEffort# 完全不写 resources 或者 requests/limits 全为 0生产实战建议核心业务务必 Guaranteed保证 OOM 时最后被杀。把 requests 和 limits 调成一样付出的代价是预留资源稍多大部分应用用 Burstable即可灵活省资源永远不要用 BestEffort 跑生产应用——节点稍微紧张就会被杀且没有任何 SLA 保障冷知识很多人不知道requests limits时还有个隐藏福利——kubelet 会启用 CPU 静态分配策略staticpolicy把 CPU 核心独占绑定避免上下文切换抖动。这对延迟敏感的服务如交易系统很重要。五、第③④步Storage.Create 写入存储回到 Store.Create 主流程out:e.NewFunc()iferr:e.Storage.Create(ctx,key,obj,out,ttl,dryrun.IsDryRun(options.DryRun));err!nil{errstoreerr.InterpretCreateError(err,qualifiedResource,name)errrest.CheckGeneratedNameError(ctx,e.CreateStrategy,err,obj)if!apierrors.IsAlreadyExists(err){klog.Warningf(failed to create %s: %v,qualifiedResource,err)}returnnil,err}几个关键点e.Storage是DryRunnableStorage类型外层包装内部才是真正的etcd3.storekey/registry/pods/namespace/name——这是 Pod 在 etcd 里的存储路径out是新建的空对象用于接收 etcd 返回的最新版本带resourceVersionttl大多数资源是 0不过期只有 Event 这种带 TTLdryrun.IsDryRun(options.DryRun)——kubectl apply --dry-runserver走这里5.1 DryRunnableStoragedryRun 在这里拦截位置staging/src/k8s.io/apiserver/pkg/registry/generic/registry/dryrun.gofunc(s*DryRunnableStorage)Create(ctx context.Context,keystring,obj,out runtime.Object,ttluint64,dryRunbool)error{ifdryRun{// 仅做对象解码到 out不写 etcdiferr:s.copyInto(obj,out);err!nil{returnerr}// 模拟一个 resourceVersion 以满足客户端returns.Versioner.UpdateObject(out,0)}returns.Storage.Create(ctx,key,obj,out,ttl)}dryRun 的意义让 webhook、validation、PrepareForCreate 全部跑一遍但不真的落 etcd——非常适合 CI 流水线做合规检查。5.2 etcd3.store.Create真正的写入位置staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.gofunc(s*store)Create(ctx context.Context,keystring,obj,out runtime.Object,ttluint64)error{// ① 计算最终 keypreparedKey,err:s.prepareKey(key)iferr!nil{returnerr}// ② 序列化为 []byte默认 protobufdata,err:runtime.Encode(s.codec,obj)iferr!nil{returnerr}// ③ 加 TTL如果有opts,err:s.ttlOpts(ctx,int64(ttl))iferr!nil{returnerr}// ④ Transformer 加密newData,err:s.transformer.TransformToStorage(ctx,data,authenticatedDataString(preparedKey))iferr!nil{returnstorage.NewInternalError(err.Error())}// ⑤ 用事务 Put!exists 才允许写入startTime:time.Now()txnResp,err:s.client.KV.Txn(ctx).If(notFound(preparedKey),).Then(clientv3.OpPut(preparedKey,string(newData),opts...),).Commit()metrics.RecordEtcdRequest(create,s.groupResourceString,err,startTime)iferr!nil{returnerr}if!txnResp.Succeeded{returnstorage.NewKeyExistsError(preparedKey,0)}...}逐项拆① 序列化runtime.EncodeK8s 内部用的不是 JSON是protobuf——比 JSON 快、紧凑约小 30%。data,err:runtime.Encode(s.codec,obj)s.codec在 RESTStorage 初始化时注入上节讲过的StorageConfig.Codec。CRD 用 JSON原生资源用 protobuf——这就是为什么大量自定义资源会比原生资源占更多 etcd 空间。② Transformer透明加密newData,err:s.transformer.TransformToStorage(ctx,data,authenticatedDataString(preparedKey))这是 K8s 的静态数据加密入口。如果集群配了EncryptionConfiguration比如对 Secret 启用 AES-CBC 加密这一步会把序列化后的 bytes 加密。# /etc/kubernetes/encryption-config.yamlapiVersion:apiserver.config.k8s.io/v1kind:EncryptionConfigurationresources:-resources:-secretsproviders:-aescbc:keys:-name:key1secret:base64-encoded-32-byte-key-identity:{}# 兜底不加密生产踩坑Secret 加密一定要配——否则 etcd snapshot 泄露 Secret 全裸。但加密 key 必须备份——丢了等于所有加密数据永久丢失。③ 为什么用 Txn 而不是 Puts.client.KV.Txn(ctx).If(notFound(preparedKey),// 条件key 必须不存在).Then(clientv3.OpPut(preparedKey,string(newData),opts...),).Commit()K8s不直接用 etcd 的 Put而是用事务TxnnotFound条件如果 key 已存在 → 事务失败 → 返回AlreadyExists错误如果 key 不存在 → 事务成功 → 写入数据为什么不用 Putetcd 的 Put 是覆盖语义——如果 key 已存在会被覆盖。而 K8s 的 Create 必须保证不能覆盖已有对象——所以用 Txn 实现原子的 CASCompare-And-Set。这也是为什么并发创建同名 Pod 时第二个会拿到AlreadyExists错误——etcd Txn 层就拦住了。5.3 第⑤步写入后回填 DecoratorputResp:txnResp.Responses[0].GetResponsePut()errdecode(s.codec,s.versioner,data,out,putResp.Header.Revision)decode把刚写的数据反序列化回 out 对象并把 etcd 返回的Revision填充到out.ResourceVersion——客户端拿到的 Pod 对象就有resourceVersion: 12345字段了后续 watch、update 都靠它。最后回到 Store.Createife.Decorator!nil{e.Decorator(out)}ife.AfterCreate!nil{e.AfterCreate(out,options)}fnfinishCreatereturnout,nilDecorator可选的对象装饰器少数资源会用比如给 Pod 加上 ephemeral 信息AfterCreate可选钩子目前主线 K8s 资源几乎都不用整个流程结束APIServer 把out带resourceVersion的完整 Pod 对象通过 HTTP 200 返回给 kubectl。六、完整数据流从 kubectl 到 etcdkubectl create -f pod.yaml │ │ POST /api/v1/namespaces/default/pods ▼ ┌──────────────────────┐ │ APIServer HTTP 入口 │ ├──────────────────────┤ │ 1. 认证 (Authn) │ ← 谁在调用 │ 2. 鉴权 (Authz) │ ← 有权限吗RBAC │ 3. Mutating Admission│ ← webhook 改 obj⚠️ 别碰 status │ 4. Schema Validation │ ← OpenAPI 校验 │ 5. Validating Admiss.│ ← webhook 校验 └──────────┬───────────┘ ▼ ┌──────────────────────┐ │ podStorage.Create() │ Pod 专属入口 └──────────┬───────────┘ ▼ ┌──────────────────────────────────────┐ │ genericregistry.Store.Create │ ├──────────────────────────────────────┤ │ ① BeginCreate (可选) │ │ ② BeforeCreate │ │ ├─ PrepareForCreate │ │ │ ├─ Status Pending │ │ │ ├─ QOSClass GetPodQOS(pod) │ │ │ └─ DropDisabledPodFields │ │ ├─ EnsureObjectMeta (生成 UID) │ │ ├─ Validate (业务规则) │ │ └─ Canonicalize │ │ ③ Storage.Create(key, obj, out) │ │ ④ Decorator (可选) │ │ ⑤ AfterCreate (可选) │ └──────────┬───────────────────────────┘ ▼ ┌──────────────────────────────────────┐ │ DryRunnableStorage.Create │ ├──────────────────────────────────────┤ │ if dryRun: 模拟返回不写 etcd │ │ else: 透传到下层 │ └──────────┬───────────────────────────┘ ▼ ┌──────────────────────────────────────┐ │ etcd3.store.Create │ ├──────────────────────────────────────┤ │ ① prepareKey → /registry/pods/... │ │ ② Encode(protobuf) │ │ ③ Transformer.TransformToStorage │ ← 加密可选 │ ④ Txn().If(!exist).Then(Put).Commit │ ← 原子写入 │ ⑤ decode(out)填充 ResourceVersion │ └──────────┬───────────────────────────┘ ▼ etcd v3 集群 │ │ Raft 共识 持久化 ▼ 磁盘 (boltdb)写入后watch 触发所有 watch /pods 的客户端scheduler、kubelet、controller-manager收到ADDED事件scheduler 调度把 Pod 绑定到合适的 Nodekubelet 拉取被调度的 Node 上的 kubelet 拉到 Pod开始创建容器七、踩坑实录Pod 创建链路上 8 个常见坑#现象根因排查命令修复1Pod 创建后 status.phase 为空mutating webhook 改了 statuskubectl get mutatingwebhookconfiguration查看 webhook 列表dump 检查规则webhook 只改 spec不碰 status2Pod 创建瞬间被拒AlreadyExists重名/并发创建kubectl get pod -n ns name删除旧 Pod 或换名3QoSClass 为 Burstable 但预期 Guaranteedrequests/limits 没完全相等kubectl get pod -o yaml | grep qosClass检查每个容器含 init的 requests/limits 是否完全一致4Pod spec 某字段神秘消失feature gate 没开DropDisabledPodFields静默丢弃kube-apiserver --feature-gates查看启用列表启用对应 feature gate或升级集群5etcd 数据无法解密storage: data from the storage is not transformableencryption key 被换/丢查看/etc/kubernetes/encryption-config.yaml用旧 key 解密迁移再换新 key6写入超时etcdserver: request timed outetcd 慢 / fsync 慢etcdctl endpoint status -w tabledisk_backend_commit_duration指标换 SSD / 减负载 / 集群分片7kubectl apply --dry-runserver 没真写但报错webhook/validation 失败kubectl apply --dry-runserver -v8修复 webhook / spec 校验8Pod 体积过大Request entity too large超过 etcd 1.5MB 限制kubectl get pod -o json | wc -c缩减 annotations / 拆分 ConfigMap 引用八、思考题如果BeforeCreate失败比如 Validate 失败etcd 已经写入了吗为什么一个 Pod 在 etcd 里大概占多少字节怎么估算如果禁用 protobuf 强制走 JSONetcd 体积会变化多少性能呢Txn().If(notFound).Put()和Put()的区别在并发场景下表现如何dryRun 走完所有流程但不落 etcd——它能模拟出resourceVersion吗给出的 RV 准不准九、本节小结这节啃完你应该掌握了整条链路Pod 从 HTTP 进来 → BeforeCreate → Strategy.PrepareForCreate → Validate → DryRunnable → etcd3.store → Txn PutPrepareForCreate强制设 Pending、计算 QoS、丢弃禁用字段QoS 算法requests vs limits 的对比规则三档的实战影响存储层细节protobuf 序列化、Transformer 加密、Txn 事务实现 CASdryRun 在哪一层拦截DryRunnableStorage所有上层逻辑都跑只是不真的落库下一节我们会继续往后看APIServer 的限流策略MaxInFlight、APF——一个高 QPS 集群里 APIServer 怎么不被打爆。参考资料Kubernetes 官方源码 (kubernetes/kubernetes)staging/src/k8s.io/apiserver/pkg/registry/generic/registry/store.gopkg/registry/core/pod/strategy.gopkg/apis/core/helper/qos/qos.gostaging/src/k8s.io/apiserver/pkg/storage/etcd3/store.goKubernetes 文档 - QoS 类Kubernetes 文档 - 静态数据加密etcd Txn API 文档