前言:那个让我看了三天源码的诡异 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 pods
type REST struct {
*genericregistry.Store // 嵌入通用 Store
proxyTransport http.RoundTripper // exec/log 用的代理
}
主 store 的初始化(上节看过的代码):
store := &genericregistry.Store{
NewFunc: func() runtime.Object { return &api.Pod{} },
NewListFunc: func() runtime.Object { return &api.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(事务前钩子)
if e.BeginCreate != nil {
fn, err := e.BeginCreate(ctx, obj, options)
if err != nil {
return nil, err
}
finishCreate = fn
defer func() {
finishCreate(ctx, false)
}()
}
BeginCreate 是个可选的事务钩子,让 Strategy 能"开启一个事务",并在 defer 里收尾(成功 commit 或失败 rollback)。
💡 大部分资源没有 BeginCreate。它主要给一些需要跨资源事务的场景留的口子,比如某些 Aggregated APIServer 实现里会用。Pod 自己没用这个。
3.2 第②步:BeforeCreate(PrepareForCreate + Validate)
if err := rest.BeforeCreate(e.CreateStrategy, ctx, obj); err != nil {
return nil, err
}
这个 BeforeCreate 不是简单一行——它内部干了4 件事,看下简化版源码:
// 简化版的 BeforeCreate 实现
func BeforeCreate(strategy RESTCreateStrategy, ctx context.Context, obj runtime.Object) error {
// ① 调 Strategy 的 PrepareForCreate(补默认值、改字段)
strategy.PrepareForCreate(ctx, obj)
// ② 生成 UID
if err := EnsureObjectMeta(obj); err != nil { ... }
// ③ 调 Strategy.Validate(业务校验)
if errs := strategy.Validate(ctx, obj); len(errs) > 0 {
return errors.NewInvalid(...)
}
// ④ 处理 Canonicalize(标准化字段)
strategy.Canonicalize(obj)
return nil
}
🚨 开头那个 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.go
func (podStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
pod := obj.(*api.Pod)
// 1. 强制设状态为 Pending
pod.Status = api.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.seccompProfile)
💡
DropDisabledPodFields是 K8s 的"防穿越"机制:如果你的集群关了某个 feature,但有人传了相关字段,APIServer 会静默丢弃而不是报错。好处是新客户端能向后兼容老集群;坏处是字段被丢了你都不知道——所以排查"字段莫名其妙消失"问题时,先检查 feature gate。
四、深入:QoS 是怎么算出来的?
Pod 的 QoS 分类(Guaranteed / Burstable / BestEffort)就是在 PrepareForCreate 里被打上去的。
4.1 QoS 三档简介
BestEffort ←────── 优先级递增 ──────→ Guaranteed
| QoS 类 | requests/limits 关系 | OOM 优先级 |
|---|---|---|
| Guaranteed | requests == limits(且都不为 0) | 最不容易被 kill |
| Burstable | requests < limits(且不全为 0) | 中等 |
| BestEffort | 完全没设 requests/limits | 第一个被 OOM kill |
💡 QoS 的两个本质影响:
- 调度:scheduler 只看 requests——所以 Guaranteed 和 Burstable 在调度时一样,关键看
requests总量- OOM 时被驱逐顺序:节点内存压力时,kubelet 按
BestEffort → Burstable → Guaranteed顺序驱逐
4.2 GetPodQOS 源码
位置:pkg/apis/core/helper/qos/qos.go
第一步:遍历所有容器,累加 requests:
for _, container := range allContainers {
// process requests
for name, quantity := range container.Resources.Requests {
if !isSupportedQoSComputeResource(name) {
continue
}
if quantity.Cmp(zeroQuantity) == 1 {
delta := quantity.DeepCopy()
if _, exists := requests[name]; !exists {
requests[name] = delta
} else {
delta.Add(requests[name])
requests[name] = delta
}
}
}
第二步:累加 limits:
// process limits
qosLimitsFound := sets.NewString()
for name, quantity := range container.Resources.Limits {
if !isSupportedQoSComputeResource(name) {
continue
}
if quantity.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: 都没设 → BestEffort
if len(requests) == 0 && len(limits) == 0 {
return core.PodQOSBestEffort
}
// 规则 2: limits == requests 且齐全 → Guaranteed
if isGuaranteed && len(requests) == len(limits) {
return core.PodQOSGuaranteed
}
// 规则 3: 其他 → Burstable
return core.PodQOSBurstable
4.3 实战例子
# 例 1: Guaranteed
resources:
requests:
cpu: 100m
memory: 100Mi
limits:
cpu: 100m # 和 requests 完全相等
memory: 100Mi
# 例 2: Burstable(最常见)
resources:
requests:
cpu: 100m
memory: 100Mi
limits:
cpu: 1000m # limits > requests
memory: 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()
if err := e.Storage.Create(ctx, key, obj, out, ttl, dryrun.IsDryRun(options.DryRun)); err != nil {
err = storeerr.InterpretCreateError(err, qualifiedResource, name)
err = rest.CheckGeneratedNameError(ctx, e.CreateStrategy, err, obj)
if !apierrors.IsAlreadyExists(err) {
klog.Warningf("failed to create %s: %v", qualifiedResource, err)
}
return nil, err
}
几个关键点:
e.Storage是DryRunnableStorage类型(外层包装),内部才是真正的etcd3.storekey=/registry/pods/<namespace>/<name>——这是 Pod 在 etcd 里的存储路径out是新建的空对象,用于接收 etcd 返回的最新版本(带resourceVersion)ttl大多数资源是 0(不过期),只有 Event 这种带 TTLdryrun.IsDryRun(options.DryRun)——kubectl apply --dry-run=server走这里
5.1 DryRunnableStorage:dryRun 在这里拦截
位置:staging/src/k8s.io/apiserver/pkg/registry/generic/registry/dryrun.go
func (s *DryRunnableStorage) Create(ctx context.Context, key string,
obj, out runtime.Object, ttl uint64, dryRun bool) error {
if dryRun {
// 仅做对象解码到 out,不写 etcd
if err := s.copyInto(obj, out); err != nil {
return err
}
// 模拟一个 resourceVersion 以满足客户端
return s.Versioner.UpdateObject(out, 0)
}
return s.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.go
func (s *store) Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error {
// ① 计算最终 key
preparedKey, err := s.prepareKey(key)
if err != nil {
return err
}
// ② 序列化为 []byte(默认 protobuf)
data, err := runtime.Encode(s.codec, obj)
if err != nil {
return err
}
// ③ 加 TTL(如果有)
opts, err := s.ttlOpts(ctx, int64(ttl))
if err != nil {
return err
}
// ④ Transformer 加密
newData, err := s.transformer.TransformToStorage(ctx, data, authenticatedDataString(preparedKey))
if err != nil {
return storage.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)
if err != nil {
return err
}
if !txnResp.Succeeded {
return storage.NewKeyExistsError(preparedKey, 0)
}
...
}
逐项拆:
① 序列化:runtime.Encode
K8s 内部用的不是 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.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {} # 兜底:不加密
🚨 生产踩坑:Secret 加密一定要配——否则 etcd snapshot 泄露 = Secret 全裸。但加密 key 必须备份——丢了等于所有加密数据永久丢失。
③ 为什么用 Txn 而不是 Put?
s.client.KV.Txn(ctx).If(
notFound(preparedKey), // 条件:key 必须不存在
).Then(
clientv3.OpPut(preparedKey, string(newData), opts...),
).Commit()
K8s 不直接用 etcd 的 Put,而是用 事务(Txn)+ notFound 条件:
- 如果 key 已存在 → 事务失败 → 返回
AlreadyExists错误 - 如果 key 不存在 → 事务成功 → 写入数据
💡 为什么不用 Put? etcd 的 Put 是覆盖语义——如果 key 已存在会被覆盖。而 K8s 的 Create 必须保证不能覆盖已有对象——所以用 Txn 实现原子的 CAS(Compare-And-Set)。
这也是为什么并发创建同名 Pod 时,第二个会拿到
AlreadyExists错误——etcd Txn 层就拦住了。
5.3 第⑤步:写入后回填 + Decorator
putResp := txnResp.Responses[0].GetResponsePut()
err = decode(s.codec, s.versioner, data, out, putResp.Header.Revision)
decode 把刚写的数据反序列化回 out 对象,并把 etcd 返回的 Revision 填充到 out.ResourceVersion——客户端拿到的 Pod 对象就有 resourceVersion: "12345" 字段了,后续 watch、update 都靠它。
最后回到 Store.Create:
if e.Decorator != nil {
e.Decorator(out)
}
if e.AfterCreate != nil {
e.AfterCreate(out, options)
}
fn = finishCreate
return out, nil
- Decorator:可选的对象装饰器,少数资源会用(比如给 Pod 加上 ephemeral 信息)
- AfterCreate:可选钩子,目前主线 K8s 资源几乎都不用
整个流程结束,APIServer 把 out(带 resourceVersion 的完整 Pod 对象)通过 HTTP 200 返回给 kubectl。
六、完整数据流:从 kubectl 到 etcd
kubectl 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 绑定到合适的 Node
- kubelet 拉取:被调度的 Node 上的 kubelet 拉到 Pod,开始创建容器
七、踩坑实录:Pod 创建链路上 8 个常见坑
| # | 现象 | 根因 | 排查命令 | 修复 |
|---|---|---|---|---|
| 1 | Pod 创建后 status.phase 为空 | mutating webhook 改了 status | kubectl get mutatingwebhookconfiguration 查看 webhook 列表,dump 检查规则 | webhook 只改 spec,不碰 status |
| 2 | Pod 创建瞬间被拒:AlreadyExists | 重名/并发创建 | kubectl get pod -n <ns> <name> | 删除旧 Pod 或换名 |
| 3 | QoSClass 为 Burstable 但预期 Guaranteed | requests/limits 没完全相等 | kubectl get pod -o yaml | grep qosClass | 检查每个容器(含 init)的 requests/limits 是否完全一致 |
| 4 | Pod spec 某字段神秘消失 | feature gate 没开,DropDisabledPodFields 静默丢弃 | kube-apiserver --feature-gates 查看启用列表 | 启用对应 feature gate,或升级集群 |
| 5 | etcd 数据无法解密:storage: data from the storage is not transformable | encryption key 被换/丢 | 查看 /etc/kubernetes/encryption-config.yaml | 用旧 key 解密迁移再换新 key |
| 6 | 写入超时:etcdserver: request timed out | etcd 慢 / fsync 慢 | etcdctl endpoint status -w table + disk_backend_commit_duration 指标 | 换 SSD / 减负载 / 集群分片 |
| 7 | kubectl apply --dry-run=server 没真写但报错 | webhook/validation 失败 | kubectl apply --dry-run=server -v=8 | 修复 webhook / spec 校验 |
| 8 | Pod 体积过大:Request entity too large | 超过 etcd 1.5MB 限制 | kubectl get pod -o json | wc -c | 缩减 annotations / 拆分 ConfigMap 引用 |
八、思考题
- 如果
BeforeCreate失败(比如 Validate 失败),etcd 已经写入了吗?为什么? - 一个 Pod 在 etcd 里大概占多少字节?怎么估算?
- 如果禁用 protobuf 强制走 JSON,etcd 体积会变化多少?性能呢?
Txn().If(notFound).Put()和Put()的区别在并发场景下表现如何?- dryRun 走完所有流程但不落 etcd——它能模拟出
resourceVersion吗?给出的 RV 准不准?
九、本节小结
这节啃完,你应该掌握了:
- 整条链路:Pod 从 HTTP 进来 → BeforeCreate → Strategy.PrepareForCreate → Validate → DryRunnable → etcd3.store → Txn Put
- PrepareForCreate:强制设 Pending、计算 QoS、丢弃禁用字段
- QoS 算法:requests vs limits 的对比规则,三档的实战影响
- 存储层细节:protobuf 序列化、Transformer 加密、Txn 事务实现 CAS
- dryRun 在哪一层拦截:DryRunnableStorage,所有上层逻辑都跑,只是不真的落库
下一节我们会继续往后看:APIServer 的限流策略(MaxInFlight、APF)——一个高 QPS 集群里 APIServer 怎么不被打爆。

258

被折叠的 条评论
为什么被折叠?



