03. 配置存储:一个 YAML 是怎么变成 config.Config 的 原创
上一篇讲了 krt 怎么把"变更 → 重算"这件事做对。这一篇往上退一层,回答更前面的一个问题:你在终端里
kubectl apply的那一坨 YAML,究竟在哪一行变成 istiod 内存里那个config.Config。读完你能做到三件事:拿到任意一个
kind,能说出它从 API Server 到PushRequest走了哪几道门;能解释"我写了这个字段,配置没生效而且没报错"最常见的那几个原因;能用几条curl把一个config.Config从存储里挖出来看原样。源码基线
istio/istio1.31.4,定位用文件路径 + 函数名,不用行号。阅读提示:这一篇是纯"读路径"。写路径(
istioctl、webhook 校验)只在必要处提一句,不展开。
一、先看一个真实对象
先把终点摆出来,不然中间那些函数名没有落点。一个最普通的 VirtualService:
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: reviews
namespace: default
spec:
hosts: ["reviews"]
http:
- route:
- destination: {host: reviews, subset: v1}它在 istiod 内存里长这样(pkg/config/model.go 的 Config,用 %#v 打出来):
config.Config{
Meta: config.Meta{
GroupVersionKind: config.GroupVersionKind{Group: "networking.istio.io", Version: "v1", Kind: "VirtualService"},
Name: "reviews",
Namespace: "default",
Domain: "cluster.local", // ← 不是从 YAML 来的
Labels: nil,
Annotations: nil,
ResourceVersion: "12345",
CreationTimestamp: ...,
UID: "8f2c...",
Generation: 1,
},
Spec: (*v1alpha3.VirtualService)(0xc000...), // ← 注意类型名还是 v1alpha3
Status: (*v1alpha1.IstioStatus)(nil),
Extra: nil,
}三件事值得先注意:
Spec的类型是*v1alpha3.VirtualService,但GroupVersionKind.Version是v1。 这不是 bug,是 Istio 的版本别名机制——后面 4.2 节专门讲。Domain: "cluster.local"是 istiod 塞进去的,YAML 里根本不存在这个字段。它决定了短名reviews最后会解析成reviews.default.svc.cluster.local。Meta里没有spec的任何信息。Meta是"这是谁",Spec是"它说什么",Status是"别人怎么看它"。三者彻底分开,是这套模型能同时兼容 K8s 和本地文件的原因。
Config 本身的定义很短(pkg/config/model.go):
type Config struct {
Meta
Spec Spec // 实际类型是某个 protobuf message 的指针
Status Status
Extra map[string]any // 内部处理用的旁路信息,比如 gateway-api 的父资源
}
type Spec any
type Status anySpec 和 Status 都是 any。听起来不严谨,但这是刻意的:这套模型要能装下 K8s 原生类型(*v1.Service 是 struct)、Istio 的 proto(*v1alpha3.VirtualService)、以及代码生成的合成对象。用 any 加类型断言,比定义十个接口要省事得多。
代价是每个读 Spec 的地方都得自己断言:
spec := cfg.Spec.(*networking.VirtualService) // 这行如果类型不对,直接 panic这类断言在 pilot/pkg/model/ 里遍地都是。它们之所以不会炸,只因为一件事成立:Spec 的类型是被 Schema 钉死过的(第 4 节)。这条不变量一破,整个控制面就是一堆随机 panic。
1.1 唯一的身份:Key()
Config 在内存里的主键不是名字,是五行拼的字符串(pkg/config/model.go):
func Key(grp, ver, typ, name, namespace string) string {
return grp + "/" + ver + "/" + typ + "/" + namespace + "/" + name
}
func (meta *Meta) Key() string {
return Key(meta.GroupVersionKind.Group, meta.GroupVersionKind.Version,
meta.GroupVersionKind.Kind, meta.Name, meta.Namespace)
}上面那个 VirtualService 的 key 就是:
networking.istio.io/v1/VirtualService/default/reviewsgroup 在最前面,namespace 在 name 前面。 这两个顺序都不是随便定的:
group打头,是为了让"同一类资源"在排序和前缀匹配时聚在一起(/debug/configz的输出是按类型排的)。namespace/name这个顺序和 K8s 的约定一致,也和 krt 的Cache.NamespaceIndex一致——krt里GetKey拿到的字符串是namespace + "/" + name,正好是Key()的后半截。
顺带一个坑:
config.Key()和 krt 的GetKey()不是同一个东西。前者是grp/ver/kind/ns/name,后者是ns/name。crdclient.Get()用的是后者,/debug/configz输出里看到的是前者。别把两者混着传。
二、第一道门:kind 注册表决定"认不认识"
YAML 到 API Server 这一段不归 istiod 管。kubectl apply 把 YAML 变成 JSON 发给 API Server,API Server 靠 CRD 的 OpenAPI schema 决定"这个字段合不合法、是不是未知字段"。
istiod 拿到的已经不是 YAML 了,是 informer 里的强类型 Go 对象。 这一步是今天理解整条链路最关键的前提,因为很多人的排障方向从这里就错了:
| 你以为的失败点 | 实际发生的地方 |
|---|---|
| "istiod 没识别我这个字段" | API Server 的 CRD schema 决定(kubectl apply 就报错了,或直接被截断成 {}) |
| "istiod 版本太老不认这个 API" | istiod 的 kind 注册表(第 4 节) |
| "istiod 认了但没生效" | 校验、exportTo、Sidecar 作用域、NeedsPush/Equals 抑制(第 8、10 节) |
先看第一栏那个"被截断成 {}"是什么意思。CRD 的默认行为是结构性 schema(structural schema)裁剪:YAML 里写了 CRD 不认识的字段,API Server 不报错,直接把它删掉再存。所以你 kubectl get 回来的对象里,那个字段就没了。从 istiod 的视角看,这个字段从来就没存在过。
真正在 istiod 侧"认不认识"的判断,发生在三处,判断依据都是同一个东西:collection.Schemas。
2.1 Schemas:一张 GVK → Schema 的表
pkg/config/schema/collection/schemas.go 里,Schemas 就是一个 map 加一个有序切片:
type Schemas struct {
byCollection map[config.GroupVersionKind]resource.Schema
byAddOrder []resource.Schema
}byAddOrder 不是冗余:它让 /debug/configz 和 Schemas().ForEach 的输出顺序稳定,不受 Go map 随机遍历影响。
对外提供四个查找函数,语义差别很大,选错了就是 bug:
func (s Schemas) FindByGroupVersionKind(gvk config.GroupVersionKind) (resource.Schema, bool)
func (s Schemas) FindByGroupVersionAliasesKind(gvk config.GroupVersionKind) (resource.Schema, bool)
func (s Schemas) FindByGroupKind(gvk config.GroupVersionKind) (resource.Schema, bool)
func (s Schemas) FindByGroupVersionResource(gvr schema.GroupVersionResource) (resource.Schema, bool)拿 networking.istio.io/v1/VirtualService 和它的几个变体实测(collections.Pilot):
| 输入的 GVK | FindByGroupVersionKind | FindByGroupVersionAliasesKind | FindByGroupKind |
|---|---|---|---|
networking.istio.io/v1/VirtualService | ✅ | ✅ | ✅ |
networking.istio.io/v1beta1/VirtualService | ❌ | ✅(别名) | ✅ |
networking.istio.io/v9/VirtualService | ❌ | ❌ | ✅(只看 group+kind) |
core/v1/VirtualService | ❌ | ❌ | ❌ |
FindByGroupKind 会把 v9 也匹配上。 这就是它注释里写的"future proofing against versions we don't yet know about; given we don't know them, its risky"。凡是处理外部输入的路径都必须用 FindByGroupVersionAliasesKind,因为外部能写出任意版本号。
三条路径分别用的是哪个:
| 调用点 | 用的函数 | 输入来源 |
|---|---|---|
crd.ParseInputs(读本地文件) | FindByGroupVersionAliasesKind | 文件里的 apiVersion |
validation webhook(pkg/webhooks/validation/server) | FindByGroupVersionAliasesKind | 用户提交的对象 |
crdclient.NewForSchemas(建 informer) | 申请单里给的 Schemas 直接 ForEach | 由 collections.Pilot 生成 |
2.2 只有一份"申请单":collections.Pilot
那份"申请单"就是 pkg/config/schema/collections/collections.gen.go 里的一个变量:
Pilot = collection.NewSchemasBuilder().
MustAdd(AuthorizationPolicy).
MustAdd(DestinationRule).
MustAdd(EnvoyFilter).
MustAdd(Gateway).
MustAdd(PeerAuthentication).
MustAdd(ProxyConfig).
MustAdd(RequestAuthentication).
MustAdd(ServiceEntry).
MustAdd(Sidecar).
MustAdd(Telemetry).
MustAdd(TrafficExtension).
MustAdd(VirtualService).
MustAdd(WasmPlugin).
MustAdd(WorkloadEntry).
MustAdd(WorkloadGroup).
Build()十五个。这就是 istiod 能"看见"的全部 Istio CRD。 没有别的路了。
这份清单的选择发生在 pilot/pkg/bootstrap/configcontroller.go 的 makeKubeConfigController():
schemas := collections.Pilot
if features.EnableGatewayAPI {
schemas = collections.PilotGatewayAPI() // 25 个,多了 Gateway API 全家桶
}
schemas = schemas.Add(collections.Ingress)
return crdclient.NewForSchemas(s.kubeClient, opts, schemas)注意两件事:
EnableGatewayAPI默认是true(PILOT_ENABLE_GATEWAY_API)。所以实际跑起来的 istiod 建的是 25 个 informer,不是 15 个。你以为的"轻量校验"和线上行为差着一整个 Gateway API。collections.Ingress是额外加的,因为 K8s 原生Ingress不在Pilot里。
2.3 一个被"过滤掉"的例外
crdclient.NewForSchemas 干的第一件事是把 Schemas 过滤一遍:
for _, s := range schemas.All() {
if !s.IsSynthetic() {
name := fmt.Sprintf("%s.%s", s.Plural(), s.Group()) // virtualservices.networking.istio.io
schemasByCRDName[name] = s
} else {
scope.Debugf("excluded %s synthetic resource from watching in crdclient", s.Identifier())
}
}IsSynthetic() 为真的会被跳过。哪些是 synthetic 的? 翻 pkg/config/schema/metadata.yaml:
- kind: "MeshConfig"
group: ""
version: "v1alpha1"
proto: "istio.mesh.v1alpha1.MeshConfig"
synthetic: true
- kind: "MeshNetworks"
group: ""
version: "v1alpha1"
proto: "istio.mesh.v1alpha1.MeshNetworks"
synthetic: true就这两个。它们有 GVK、有 proto、能在 kind.MeshConfig 里被引用,但不会被建 informer。
这就是本文章最后要重点收口的那个误解的第一块拼图:MeshConfig 在 config 这套模型里有个"户口",但它不通过配置存储走路,走的是完全另一条(第 7 节)。synthetic 这个词的含义就是"存在于类型系统里,但不存在于存储里"。
三、YAML → IstioKind → config.Config:两道转换
crdclient 拿到的对象已经是 *v1alpha3.VirtualService 这样的强类型 Go struct。但从 API Server 到 config.Config,还隔着两步。
3.1 第一步:IstioKind,一个"通用外壳"
pilot/pkg/config/kube/crd/config.go 定义了一个只做序列化用的壳:
type IstioKind struct {
metav1.TypeMeta
metav1.ObjectMeta `json:"metadata"`
Spec json.RawMessage `json:"spec"`
Status *json.RawMessage `json:"status,omitempty"`
}Spec 是 json.RawMessage——故意不解析。它只负责把 YAML 文档切出 metadata 和 spec 两块,spec 保持字节原样,交给下一步按 schema 反序列化。
为什么要多这一层?因为 ParseInputs 的输入是"一坨不知道是什么类型的 YAML",必须先"看一眼 apiVersion/kind 才能决定反序列化成什么"。IstioKind 就是那个"看一眼"的载体。
ParseInputs 的核心循环(pilot/pkg/config/kube/crd/conversion.go):
yamlDecoder := kubeyaml.NewYAMLOrJSONDecoder(reader, 512*1024)
for {
obj := IstioKind{}
err := yamlDecoder.Decode(&obj)
if err == io.EOF { break }
if reflect.DeepEqual(obj, empty) { continue } // 跳过 --- 之间的空文档
gvk := obj.GroupVersionKind()
s, exists := collections.PilotGatewayAPI().FindByGroupVersionAliasesKind(resource.FromKubernetesGVK(&gvk))
if !exists {
// 不认识的类型:不报错,塞进 others 返回
others = append(others, obj)
continue
}
cfg, err := ConvertObject(s, &obj, "")
if err != nil { return nil, nil, fmt.Errorf("cannot parse proto message for %v: %v", obj.Name, err) }
if withValidate {
if _, err := s.ValidateConfig(*cfg); err != nil {
return nil, nil, fmt.Errorf("configuration is invalid: %v", err)
}
}
varr = append(varr, *cfg)
}三个细节:
第一,循环的粒度是"文档"不是"文件"。 一个文件里用 --- 分隔多个对象、甚至混装不同类型的对象,都能正确切分并各自反序列化。这是 kubectl apply -f 能一次装一堆东西的原因。
第二,认不出的 kind 不报错,而是静默跳过。 返回的 others 在 istioctl 里会被当成"不是 Istio 资源"忽略掉。所以"我 apply 了一个 kind 拼错的 YAML,apply 成功了但啥也没发生"——如果走的是文件路径,答案就在这个 continue。
第三,yaml → json 的翻译用的是 protobuf 的 JSON 映射,不是 Go 的 encoding/json。 这一点后面 3.3 会展开。
3.2 第二步:ConvertObject,把 json.RawMessage 装进 Spec
ConvertObject 干的事很朴素(pilot/pkg/config/kube/crd/conversion.go):
func ConvertObject(schema resource.Schema, object IstioObject, domain string, convert ConversionFunc) (*config.Config, error) {
js, err := json.Marshal(object.GetSpec()) // RawMessage → string
spec, err := convert(schema, string(js)) // ← 关键:按 schema 决定目标类型
status, err := StatusJSONFromMap(schema, object.GetStatus())
meta := object.GetObjectMeta()
return &config.Config{
Meta: config.Meta{
GroupVersionKind: schema.GroupVersionKind(), // ← 不是 obj 里的 gvk
Name: meta.Name,
Namespace: meta.Namespace,
Domain: domain, // ← 外面传进来的
Labels: meta.Labels,
Annotations: meta.Annotations,
ResourceVersion: meta.ResourceVersion,
CreationTimestamp: meta.CreationTimestamp.Time,
},
Spec: spec,
Status: status,
}, nil
}注意 GroupVersionKind: schema.GroupVersionKind()。
这不是转抄对象上的 apiVersion/kind,而是取自 schema。所以你写 networking.istio.io/v1beta1 也好、v1alpha3 也好,进到 Meta 里都被"归一化"成 schema 声明的主版本:
输入 apiVersion: networking.istio.io/v1alpha3
→ Meta.GroupVersionKind = networking.istio.io/v1/VirtualService这个归一化非常关键,因为 Config.Key() 用的是 Meta.GroupVersionKind。如果不归一化,同一个 VirtualService 用 v1alpha3 和 v1 写,会得到两个不同的 key,Merge 时会被当成两个对象。 第 4.2 节会专门讲这个。
Domain 也一样——ConvertObject 的调用方传。"cluster.local" 就是这么进来的,YAML 里没有这个字段。
3.3 FromJSON:proto 的 JSON 映射:"宽容"与"死板"各一半
convert 参数默认是 FromJSON:
func FromJSON(s resource.Schema, js string) (config.Spec, error) {
c, err := s.NewInstance() // 按 ReflectType 造一个空 proto,类型由 schema 决定
if err != nil { return nil, err }
if err = config.ApplyJSON(c, js); err != nil { return nil, err }
return c, nil
}s.NewInstance() 就是从 resource.Schema 里读 ReflectType 反射造对象(pkg/config/schema/resource/schema.go 的 NewInstance())。Spec 的类型在这里被钉死,第 1 节那个"断言不会炸"的不变量,源头就在这一行。
config.ApplyJSON 里有段值得逐字看的代码(pkg/config/model.go):
func ApplyJSON(s Spec, js string) error {
if _, ok := s.(protoreflect.ProtoMessage); ok {
if pb, ok := s.(proto.Message); ok {
err := protomarshal.ApplyJSON(js, pb)
return err
}
}
return json.Unmarshal([]byte(js), &s)
}而 protomarshal.ApplyJSON(pkg/util/protomarshal/protomarshal.go):
func ApplyJSON(js string, pb proto.Message) error {
reader := strings.NewReader(js)
m := jsonpb.Unmarshaler{}
if err := m.Unmarshal(reader, legacyproto.MessageV1(pb)); err != nil {
log.Debugf("Failed to decode proto: %q. Trying decode with AllowUnknownFields=true", err)
m.AllowUnknownFields = true // ← 第二次尝试:放过未知字段
reader.Reset(js)
return m.Unmarshal(reader, legacyproto.MessageV1(pb))
}
return nil
}先严格解一遍,失败就开 AllowUnknownFields 再解一遍。 这个"重试"是整个配置读路径里最容易埋坑的一处。
实测三个行为(collections.Pilot + crd.ConvertObject):
① 未知字段被静默丢弃,只留下一个 Debug 日志。
spec:
hosts: ["r"]
http:
- routez: [{destination: {host: r}}] # 拼错:route → routezconvert err: <nil> ← 不报错
spec json: {"hosts":["r"],"http":[{}]} ← routez 被吞了,http[0] 变成空对象
validate: HTTP route, redirect or direct_response is required ← 只有在导出这份配置时才会被告发这份配置能成功 apply,能进 istiod 的存储,能被列出来,/debug/configz 里也能看到它,但它永远不起作用。 而且 AllowUnknownFields 的日志走的是 log.Debugf——生产上 istiod 的日志级别通常是 info,你连这行日志都看不到。
这就是"配置没生效还不报错"的第一大来源,比 exportTo 和 Sidecar 加起来还常见。
② 类型错误会真报错。 把 principals: ["x"] 写成 principals: "x":
json: cannot unmarshal string into Go value of type []json.RawMessage因为第二次带 AllowUnknownFields 的重试只放行未知字段,不放行类型错误。这条边界挺重要:字段名写错会静默,字段类型写错会硬失败。
③ 但 proto 的枚举是"死板"的那一半。 这条不在上面那段重试逻辑里,得单独记住:
{GroupVersionKind: {Group: "networking.istio.io", Version: "v1", Kind: "VirtualService"},
Spec: (*v1alpha3.VirtualService)(...)} // 输入的 apiVersion 是 v1alpha3——没问题apiVersion 的版本、kind 的大小写都能对(FindByGroupVersionAliasesKind 做的),但枚举值必须写对:
# ❌ 拼错枚举值
spec:
outboundTrafficPolicy:
mode: REGISTRY_ONLYY
# ✅
spec:
outboundTrafficPolicy:
mode: REGISTRY_ONLY枚举拼错时 jsonpb 直接报错,实测原文是:
unknown value "\"REGISTRY_ONLYY\"" for enum istio.networking.v1alpha3.OutboundTrafficPolicy.Mode这类错误也是硬失败,因为它是"值不认识",不是"字段不认识"。注意 ConvertObject 这一层就炸了,对象根本进不了存储——这反而是好事,比静默丢掉强。
一句话总结这条边界:结构(字段名)宽容且静默,值(类型、枚举)严格且报错。 所以踩坑最多的永远是"字段名拼错",因为它连日志都没有。
3.4 为什么不用 dynamic informer
crdclient 的包注释里有一段专门解释这件事:
This code relies heavily on code generation for performance reasons; to implement the Istio store interface, we need to take dynamic inputs. Using the dynamic informers results in poor performance, as the cache will store unstructured objects which need to be marshaled on each Get/List call.
翻译:如果用 dynamic informer,缓存里存的是 map[string]any,每次 Get/List 都要重新序列化一遍。 而 Pilot 在一次全量 PushContext 里会把 VirtualService 列表读好几遍、DR 读好几遍,每次序列化的开销是致命的。
代价值得说清楚:pilot/pkg/config/kube/crdclient/types.gen.go 是一个一万多行的生成文件,里面有:
- 每个 kind 一个
create/update/delete的 switch 分支(走强类型 client) - 每个 kind 一个
translationMap条目,手把手把 K8s 对象映射成config.Config
那个 translationMap 长这样(以 VirtualService 为例,types.gen.go):
gvk.VirtualService: func(r runtime.Object) config.Config {
obj := r.(*apiistioioapinetworkingv1.VirtualService)
return config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.VirtualService,
Name: obj.Name,
Namespace: obj.Namespace,
Labels: obj.Labels,
Annotations: obj.Annotations,
ResourceVersion: obj.ResourceVersion,
CreationTimestamp: obj.CreationTimestamp.Time,
OwnerReferences: obj.OwnerReferences,
UID: string(obj.UID),
Generation: obj.Generation,
},
Spec: &obj.Spec,
Status: &obj.Status,
}
},十六个字段,每个 kind 抄一遍。这就是"为了不做序列化"付的税。
注意这里
Spec: &obj.Spec是指针,指向 informer 缓存里的那份对象。所以config.Config的Spec和 informer 里的对象是共享内存的。pkg/config/model.go里ConfigStore接口的注释提前警告了:Object references supplied and returned from this interface should be treated as read-only.谁要想改,必须DeepCopy()。
四、Schema:一个 kind 的完整身份证
前面反复提到 resource.Schema,但一直没摊开。它是整条链路里信息密度最高的一个接口(pkg/config/schema/resource/schema.go):
type Schema interface {
fmt.Stringer
GroupVersionKind() config.GroupVersionKind // 主 GVK,比如 networking.istio.io/v1/VirtualService
GroupVersionResource() schema.GroupVersionResource // GVR,建 informer 用
GroupVersionAliasKinds() []config.GroupVersionKind // 所有可接受的别名 GVK
IsClusterScoped() bool // 有没有 namespace
IsSynthetic() bool // 存在类型系统里、不存在存储里
IsBuiltin() bool // K8s 原生资源,走 builtin informer
Kind() / Plural() / Group() / Version() / APIVersion() string
Proto() / ProtoPackage() string
NewInstance() (config.Spec, error) // 造一个空 Spec,返回的静态类型 = ReflectType
Status() (config.Status, error)
StatusKind() string // status 字段的 Go type 名
ValidateConfig(cfg config.Config) (validation.Warning, error)
Equal(other Schema) bool
}不是手写的。它是代码生成的产物,唯一输入是一份 YAML(pkg/config/schema/metadata.yaml):
- kind: "VirtualService"
plural: "virtualservices"
group: "networking.istio.io"
version: "v1"
versionAliases:
- "v1alpha3"
- "v1beta1"
proto: "istio.networking.v1alpha3.VirtualService"
protoPackage: "istio.io/api/networking/v1alpha3"
statusProto: "istio.meta.v1alpha1.IstioStatus"
statusProtoPackage: "istio.io/api/meta/v1alpha1"跑一次 go generate ./pkg/config/schema/...(实际是 pkg/config/schema/codegen/tools/collections.main.go),metadata.yaml 里每一条会生成七八份代码:
| 生成文件 | 生成什么 |
|---|---|
pkg/config/schema/gvk/resources.gen.go | gvk.VirtualService 常量 + FromKind/ToKind 映射 |
pkg/config/schema/gvr/resources.gen.go | gvr.VirtualService(还有 gvr.VirtualService_v1alpha3 这样的别名版) |
pkg/config/schema/kind/resources.gen.go | kind.VirtualService 的 iota 枚举 |
pkg/config/schema/collections/collections.gen.go | VirtualService 这个 resource.Builder + Pilot/PilotGatewayAPI 两个集合 |
pilot/pkg/config/kube/crdclient/types.gen.go | 读写 switch + translationMap |
pkg/config/schema/kubeclient/resources.gen.go | 强类型 client 的获取函数 |
这条生成链是理解"为什么某个 kind 不能用"的唯一正确入口。 不是配置写错,是 metadata.yaml 里没有它。
4.1 三个 auto 字段
metadata.yaml 里还有几个字段是可以省略、会被生成器自动推导的(pkg/config/schema/ast/ast.go 的 UnmarshalJSON):
if r.Validate == "" {
validateFn := "Validate" + asResourceVariableName(r.Kind) // Kind → ValidateVirtualService
if !validation.IsValidateFunc(validateFn) {
validateFn = "validation.EmptyValidate" // 找不到就用空校验
}
...
}
if r.Identifier == "" {
r.Identifier = r.Kind
}于是:
Validate自动推导,按Validate<Kind>命名去validation包里找函数。找不到就退化成EmptyValidate——一个合法但什么都不检查的校验函数。Identifier缺省等于Kind,且必须唯一,重复直接报错。
这条自动推导解释了一个常见疑惑:"为什么我写的某个字段没被校验?" 可能连这个 kind 整体都没有校验函数,EmptyValidate 一路放行。
4.2 版本别名:为什么 Spec 是 v1alpha3
回到第 1 节那个"看起来不协调"的现象:
Meta.GroupVersionKind.Version = "v1"
Spec: (*v1alpha3.VirtualService)(0xc000...)原因在 metadata.yaml 的这三行:
version: "v1" # ← K8s 侧 CRD 的版本
versionAliases: ["v1alpha3", "v1beta1"]
proto: "istio.networking.v1alpha3.VirtualService" # ← Go 类型是 v1alpha3Istio 的 API proto 包是 istio.io/api,那个仓库里 networking 只有 v1alpha3 这一份 Go 类型。K8s 上架的 CRD 对外宣称 v1(存储版本),但从 v1 到 v1alpha3 之间没有 conversion webhook——因为它俩的 schema 完全一样,不需要转换。
所以:"版本别名"是一个纯逻辑概念,不是 K8s 的版本转换。 FindByGroupVersionAliasesKind 做的就是"把 v1alpha3 认成 v1",仅此而已。
func (s schemaImpl) GroupVersionAliasKinds() []config.GroupVersionKind {
gvks := make([]config.GroupVersionKind, 0, len(s.versionAliases)+1)
for _, va := range s.versionAliases {
g := s.gvk
g.Version = va // 只换 Version,Group 和 Kind 不动
gvks = append(gvks, g)
}
return append(gvks, s.GroupVersionKind())
}实测的对应关系:
| YAML 里写 | schema 认成 | Config.Key() 的一部分 |
|---|---|---|
networking.istio.io/v1alpha3 | v1 | networking.istio.io/v1/VirtualService/default/reviews |
networking.istio.io/v1beta1 | v1 | 同上(同一个 key) |
networking.istio.io/v1 | v1 | 同上 |
三个版本、一个 key。这就是"归一化"的实际效果。
反过来,
EnvoyFilter在metadata.yaml里没有versionAliases,version就是v1alpha3。所以你写apiVersion: networking.istio.io/v1+kind: EnvoyFilter时,FindByGroupVersionAliasesKind找不到,实测返回"不认识的类型"。这不是你的错,是这个类型本身没升级到v1。(kubectl apply这一步可能已经被 CRD 拒了,取决于 CRD 有没有v1alpha3之外的版本。)
4.3 StatusKind():一个容易忽略的返回值
func (s schemaImpl) StatusKind() string {
if s.statusType == nil { return "" }
return s.statusType.Name()
}对绝大多数 Istio CRD,返回 "IstioStatus"。注意它返回的是 Go 类型名(Name()),不是 kind 字符串。
这个细节在写 Gateway API 的状态逻辑时有实际影响——krt.NewStatusCollection 拿到 status 时,得按 Go 类型而不是 kind 去断言。而且 Status() 在 statusType == nil 时返回 errors.New("unknown status type"),不是 nil, nil。所以"这个类型不支持 status"和"这个类型支持,但当前没写 status"是两种不同的信号,别混着用一个 nil 判断。
五、存储层:crdclient 用 krt 装的四层
到这里,config.Config 已经有形状了,但它还没进存储。存储层是 pilot/pkg/config/kube/crdclient,而它内部是一个 krt 的"四孔叠罗汉":
flowchart LR K8S["API Server"] --> INF["informer<br/>强类型 + Filter"] INF --> WRAP["krt.WrapClient"] WRAP --> MAP["krt.MapCollection<br/>translationMap → config.Config"] MAP --> IDX["krt.NewNamespaceIndex<br/>ns/name → Config"] IDX --> COL["Collection[config.Config]"] COL --> AGG["aggregate 层<br/>JoinCollection 合并多 store"]
源码(crdclient/client.go 的 addCRD)逐行对得上:
var kc kclient.Untyped
if s.IsBuiltin() {
kc = kclient.NewUntypedInformer(cl.client, gvr, filter)
} else {
kc = kclient.NewDelayedInformer[controllers.Object](cl.client, gvr, kubetypes.StandardInformer, filter)
}
wrappedClient := krt.WrapClient(kc, opts.WithName("informer/"+resourceGVK.Kind)...)
collection := krt.MapCollection(wrappedClient, func(obj controllers.Object) config.Config {
cfg := translateFunc(obj)
cfg.Domain = cl.domainSuffix
return cfg
}, opts.WithName("collection/"+resourceGVK.Kind)...)
index := krt.NewNamespaceIndex(collection)
cl.kinds[resourceGVK] = nsStore{collection: collection, index: index, handlers: [...]}四个键东西:
IsBuiltin()决定用哪种 informer。 K8s 原生资源(Service、Pod、ConfigMap)用NewUntypedInformer——它是同步建的,因为肯定存在。Istio CRD 用NewDelayedInformer——CRD 有可能还没装上,所以不能直接建。krt.WrapClient把 informer 变成Collection[controllers.Object]。krt.MapCollection+translateFunc就是translationMap那一步,把 K8s 对象(或unstructured)映射成config.Config。Domain在这里被补上。krt.NewNamespaceIndex让List(kind, ns)能从 O(n) 变成 O(1)(走ns/name的 namespace 索引)。
GetKey("default/reviews") 返回的也是这个 index/collection 里的指针:
func (cl *Client) Get(typ config.GroupVersionKind, name, namespace string) *config.Config {
h, f := cl.kinds[typ]
if !f { cl.logger.Warnf("unknown type: %s", typ); return nil }
var key string
if namespace == "" { key = name } else { key = namespace + "/" + name }
return h.collection.GetKey(key)
}注意 cl.kinds[typ] 这个 map lookup。 typ 是调用方传进来的 GVK,如果传的是 v1alpha3 而注册表里只有 v1,这里直接 nil + 一条 warn 日志。这是第二节"归一化"之外的第二道坎:Get 不做别名解析,必须传规范化后的 GVK。
5.1 NewDelayedInformer:CRD 不存在时的"空壳"
pkg/kube/kclient/client.go 的 NewDelayedInformer 先问一句 delay.KnownOrCallback(...):CRD 已经装了就立刻建真 informer;没装就先返回一个"空壳",等 crdWatcher 通知之后再换:
readyNow := delay.KnownOrCallback(func(stop <-chan struct{}) {
inf := getInf()
...
log.Infof("%v is now ready, building client", gvr.GroupResource())
delayedClient.set(fc) // ← 空壳被替换成真 client
})
if !readyNow {
log.Debugf("%v is not ready now, building delayed client", gvr.GroupResource())
return delayedClient
}这里的 HasSynced() 语义很微妙(pkg/kube/kclient/delayed.go):
func (s *delayedClient[T]) HasSynced() bool {
if c := s.inf.Load(); c != nil {
return (*c).HasSynced()
}
// If we haven't loaded the informer yet, we want to check if the delayed filter is synced.
hs := s.delayed.HasSynced()
return hs
}注释说得很清楚:还没建 informer 时返回 true 的条件是"crdWatcher 已经稳了、并且确认这个 CRD 不存在",不是"永远 false"。
这个设计解决的是一个很现实的启动顺序问题:如果 CRD 不存在就永远 HasSynced()==false,那么整个 istiod 会永久卡在 cache sync,连那些已经存在的 CRD 都用不了。 而如果用 HasSynced()==true 直接跳过,那等 CRD 后装上来,也不会有人建 informer。
delayedClient 的解法是"假装同步好了、但什么都查不到",然后靠 crdWatcher 在 CRD 出现时补建。代价是那段时间里的 List() 返回空且不报错——和上一篇讲的"直接 List() 一个没 synced 的集合"是同一类坑。
5.2 inRevision:给多版本控制面分流
addCRD 里还要拼一组 filter:
filter := kubetypes.Filter{
ObjectFilter: kubetypes.ComposeFilters(namespaceFilter, cl.inRevision, extraFilter),
ObjectTransform: transform,
FieldSelector: fieldSelector,
}
if resourceGVK == gvk.KubernetesGateway {
filter.ObjectFilter = kubetypes.ComposeFilters(namespaceFilter, extraFilter) // ← 例外
}cl.inRevision 的定义:
func (cl *Client) inRevision(obj any) bool {
object := controllers.ExtractObject(obj)
if object == nil { return false }
return config.LabelsInRevision(object.GetLabels(), cl.revision)
}而 LabelsInRevision(pkg/config/model.go):
func LabelsInRevision(lbls map[string]string, rev string) bool {
configEnv, f := lbls[label.IoIstioRev.Name] // istio.io/rev
if !f { return true } // 没有 rev 标签 → 全局对象,所有控制面都要
if rev == "" { return true } // 我这个控制面没指定 rev → 全都要
return configEnv == rev
}多版本控制面(canary upgrade)就是这么实现的:同一个集群、同一个 CRD,不同 revision 的 istiod 只看自己标签的对象。 没打 istio.io/rev 的对象是"公共的",所有 revision 都看得到。
那个 KubernetesGateway 的例外很关键:Gateway API 的资源(Gateway、HTTPRoute)不能按 revision 过滤,因为它们是社区标准、是多个控制器共享的,istio.io/rev 在它们的语义里没有位置。所以唯独 gvk.KubernetesGateway 这一项把 inRevision 从 filter 链里摘掉了。
这解释了一个真实会遇到的困惑:"我这里 HTTPRoute 明明打了 istio.io/rev: canary,为什么默认 revision 的 istiod 也处理它了?" —— 因为那条路根本不看 rev。
5.3 事件怎么流出去
RegisterEventHandler 是把 krt 的批量事件翻译成 model.EventHandler 的私有单事件回调(crdclient/client.go):
c.handlers = append(c.handlers, c.collection.RegisterBatch(func(o []krt.Event[config.Config]) {
for _, event := range o {
switch event.Event {
case controllers.EventAdd:
handler(config.Config{}, *event.New, model.Event(event.Event))
case controllers.EventUpdate:
handler(*event.Old, *event.New, model.Event(event.Event))
case controllers.EventDelete:
handler(config.Config{}, *event.Old, model.Event(event.Event))
}
}
}, false))Add 和 Delete 都补了一个零值 config.Config{} 上去,让 handler 的签名统一成 (old, new, event)。这个"补零值"的约定贯穿整个 model 包,读 handler 代码时经常看到 if old.Spec == nil,就是查这个零值。
标准 store 接口那四个写方法(Create/Update/Delete/UpdateStatus)在 types.gen.go 里对每个 kind 有独立分支,走强类型 client。这里不展开,但有一个细节容易踩:Create/Update 都先检查 cfg.Spec == nil 并报 "nil spec for %v/%v"。 所以如果你手写代码构造一个 config.Config 去写,忘了填 Spec,报错是这个,不是 CRD 那边的事。
六、aggregate:多个 store 装成一个
initConfigController 里,s.ConfigStores 是个切片,会被塞进好几样东西(pilot/pkg/bootstrap/configcontroller.go):
s.ConfigStores = append(s.ConfigStores, configController) // crdclient
s.ConfigStores = append(s.ConfigStores, extensionController) // WasmPlugin → TrafficExtension
s.ConfigStores = append(s.ConfigStores, s.environment.GatewayAPIController) // 合成 VS/Gateway
s.ConfigStores = append(s.ConfigStores, ic) // Ingress(可选)
aggregateConfigController, err := configaggregate.MakeCache(s.ConfigStores)
s.configController = aggregateConfigController
s.environment.ConfigStore = aggregateConfigController下游(PushContext 初始化那一堆 initXxx)只看到一个 ConfigStoreController。configaggregate 负责把它们装成一个(pilot/pkg/config/aggregate/config.go):
for _, schema := range schemas.All() {
gvk := schema.GroupVersionKind()
schemaCollections := []krt.Collection[config.Config]{}
for _, store := range storeTypes[gvk] {
if collection := store.KrtCollection(gvk); collection != nil {
schemaCollections = append(schemaCollections, collection)
}
}
if len(schemaCollections) != 0 {
collection := krt.JoinCollection(schemaCollections, kopts.WithName(gvk.Kind)...)
kindStores[gvk] = kindStore{collection: collection, index: krt.NewNamespaceIndex(collection)}
}
}krt.JoinCollection 就是上一篇讲的"多来源合并、后者让位"。 一个 VirtualService 可能来自 crdclient(用户写的),也可能来自 gateway.NewController(HTTPRoute 翻译出来的合成对象)。JoinCollection 把它们按 key 合到一起,冲突时按集合在列表里的顺序决定权威来源。
这里 storeTypes 的顺序是按 s.ConfigStores 的 append 顺序。所以 crdclient 排在 gateway 控制器前面,用户手写的 VirtualService 会盖掉 Gateway API 合成出来的同名对象。这个优先级不是显式声明的,是 append 顺序这个实现细节决定的。
还有一个必须知道的差异:写操作只走一个 writer。
s.RWConfigStore, err = configaggregate.MakeWriteableCache(s.ConfigStores, configController)MakeWriteableCache(stores, writer) 里的 writer 就是那个 crdclient。所以读是"合并视图",写是"只有 crdclient 能写"。aggregate.store.Delete/Create/Update 一律转给 cr.writer,其他几个 store 的写能力被彻底忽略。
这就是为什么网关合成出来的 VirtualService 是只写的对立面——只读的:它能被读、能被推送,但你 kubectl delete 它没有意义,因为它根本不在 etcd 里。
七、第二类配置:MeshConfig 走的是另一条路
到这里 VirtualService 的路走完了。现在处理那个误解:MeshConfig 也存在 ConfigMap 里,为什么它看起来哪儿都不一样?
答案是:它压根没走 crdclient。 那条 ConfigMap 是被一个专门的 krt 集合 Fetch 出来的。
7.1 入口:initMeshConfiguration
pilot/pkg/bootstrap/server.go 的装配顺序里,initMeshConfiguration 干得非常早:
s.initMeshConfiguration(args, s.fileWatcher)
// Setup Kubernetes watch filters
if s.kubeClient != nil {
namespaces := kclient.New[*corev1.Namespace](s.kubeClient)
filter := namespace.NewDiscoveryNamespacesFilter(namespaces, s.environment.Watcher, s.internalStop)
s.kubeClient = kubelib.SetObjectFilter(s.kubeClient, filter) // ← 注意它排在后面
}
s.initMeshNetworks(args, s.fileWatcher)
s.initMeshHandlers(configGen.MeshConfigChanged)initMeshConfiguration 必须在 SetObjectFilter 之前。 因为那个 namespace filter 自己要读 meshConfig 里的 discoverySelectors——先有 mesh 才能决定"要看哪些 namespace",先有 namespace 才能过滤其他 informer。 这个顺序错了,就会出现"discoverySelectors 改了但 namespace 过滤没跟着变"的诡异现象。
initMeshConfiguration 本身只有五行(pilot/pkg/bootstrap/mesh.go):
func (s *Server) initMeshConfiguration(args *PilotArgs, fileWatcher filewatcher.FileWatcher) {
col := s.getMeshConfiguration(args, fileWatcher)
col.AsCollection().WaitUntilSynced(s.internalStop) // ← 显式等!
s.environment.Watcher = meshwatcher.ConfigAdapter(col)
...
}WaitUntilSynced(s.internalStop) 是同步阻塞的。 注释解释了为什么用 internalStop 而不是普通的 stop:
We need to get mesh configuration up-front, before we start anything, so we use internalStop rather than scheduling a task to run later.
internalStop 在 Server.Start() 之前还没被接管,所以这里能等得到。换成 stop 就是一个永久阻塞。 这条和上一篇"Sync 是一条链"是同一件事:装配阶段想等,只能用那个还没启用的 stop。
7.2 getConfigurationSources:配置文件 > ConfigMap
func (s *Server) getConfigurationSources(args *PilotArgs, fileWatcher filewatcher.FileWatcher, file, cmKey string) []meshwatcher.MeshConfigSource {
var userMeshConfig *meshwatcher.MeshConfigSource
if features.SharedMeshConfig != "" && s.kubeClient != nil {
userMeshConfig = ptr.Of(kubemesh.NewConfigMapSource(s.kubeClient, args.Namespace, features.SharedMeshConfig, cmKey, opts))
}
if _, err := os.Stat(file); !os.IsNotExist(err) {
fileSource, err := meshwatcher.NewFileSource(fileWatcher, file, opts)
if err == nil {
return toSources(fileSource, userMeshConfig) // ← 文件存在就用文件,ConfigMap 全部让位
}
}
if s.kubeClient == nil { return nil }
configMapName := getMeshConfigMapName(args.Revision) // istio / istio-<revision>
primary := kubemesh.NewConfigMapSource(s.kubeClient, args.Namespace, configMapName, cmKey, opts)
return toSources(primary, userMeshConfig)
}三条路径,优先级从高到低:
| 条件 | 用的源 |
|---|---|
--meshConfig 指向的文件存在 | 文件(+ SHARED_MESH_CONFIG 指定的 ConfigMap) |
| 文件不存在、集群里有 kube client | ConfigMap istio 或 istio-<revision> 的 mesh 键 |
| 都不行 | 只打一条 Warnf("Using default mesh - missing file %s and no k8s client"),用内置默认值 |
"文件存在就彻底不用 ConfigMap"是个硬规则。 这解释了那个高频困惑:"我改了 istio ConfigMap 的 mesh 键,为什么没反应?" 第一件该查的事就是 --meshConfig 那个文件在不在(默认 /etc/istio/config/mesh,由 DaemonSet/Deployment 的 volume 挂载)。挂载了的话,ConfigMap 改了对这个 istiod 就是完全无关的事。
7.3 ConfigMap → 字符串 → MeshConfig:三步
kubemesh.NewConfigMapSource(pkg/config/mesh/kubemesh/watcher.go)做了一件很"抠"的事:
func NewConfigMapSource(client kube.Client, namespace, name, key string, opts krt.OptionsBuilder) meshwatcher.MeshConfigSource {
clt := kclient.NewFiltered[*v1.ConfigMap](client, kclient.Filter{
Namespace: namespace,
FieldSelector: fields.OneTermEqualSelector(metav1.ObjectNameField, name).String(), // ← 只订阅这一个
})
cms := krt.WrapClient(clt, opts.WithName("ConfigMap_"+name)...)
// Start informer immediately instead of with the rest.
clt.Start(opts.Stop())
cmKey := types.NamespacedName{Namespace: namespace, Name: name}.String()
return krt.NewSingleton(func(ctx krt.HandlerContext) *string {
cm := ptr.Flatten(krt.FetchOne(ctx, cms, krt.FilterKey(cmKey)))
return meshConfigMapData(cm, key)
}, opts.WithName(fmt.Sprintf("ConfigMap_%s_%s", name, key))...)
}四个细节,每一个都在解决一个具体问题:
FieldSelector: metadata.name=<name>:不是列出整个 namespace 的 ConfigMap 再筛,而是让 API Server 直接只返回这一个。集群里 ConfigMap 成千上万,这一步省下来的是 API Server 的带宽和 istiod 的内存。clt.Start(opts.Stop()):立刻启动 informer,不等kubeClient.RunAndWait。因为 mesh 配置必须在装配阶段就可用。krt.FilterKey(cmKey):这里用的是可索引的过滤器(上一篇讲的FilterKey),所以反查索引是精确的——不会有"随便哪个 ConfigMap 变了都触发 mesh 重算"。krt.NewSingleton的返回值是*string,不是*MeshConfig。这一层只负责"取出 YAML 文本",不负责解析。
真正的解析在 meshwatcher.NewCollection(pkg/config/mesh/meshwatcher/collection.go):
return krt.NewSingleton[MeshConfigResource](
func(ctx krt.HandlerContext) *MeshConfigResource {
meshCfg := mesh.DefaultMeshConfig()
for _, attempt := range sources {
s := krt.FetchOne(ctx, attempt.AsCollection())
if s == nil {
log.Debugf("mesh configuration source missing") // ← ConfigMap 不存在是合法的
continue
}
n, err := mesh.ApplyMeshConfig(*s, meshCfg)
if err != nil {
// For backwards compatibility, keep inconsistent behavior
if len(sources) == 1 {
log.Warnf("invalid mesh config, using last known state: %v", err)
ctx.DiscardResult() // ← 保留旧值
return &MeshConfigResource{mesh.DefaultMeshConfig()}
}
log.Warnf("invalid mesh config, ignoring: %v", err)
continue
}
meshCfg = n
}
return &MeshConfigResource{meshCfg}
}, opts.WithName("MeshConfig")...,
)ctx.DiscardResult() 是这段代码里最重要的一个调用。 它的作用是"这次变换的结果作废,集合保持上一次的值"。
换句话说:你写了一坨解析不了的 mesh YAML,istiod 不会崩、不会退回默认值、不会打回滚——它会继续用上一份能解析的配置,只打一条 warn。 这解释了两种完全相反的现象:
- "我改完 mesh 配置,日志里有 warn,但行为没变" → 改的那份没生效,
DiscardResult保住了旧的。 - "我改完 mesh 配置,重启 istiod 之后才生效" → 因为重启时没有"上一份"了,只能退回
mesh.DefaultMeshConfig(),反而可能不是你想要的。
那个 if len(sources) == 1 的分支更微妙:多源(文件 + SHARED_MESH_CONFIG)时解析失败是"跳过这一源",单源时是"整份作废"。 注释里直接写了这是 For backwards compatibility, keep inconsistent behavior,还挂了 issue #54615。这是已知的不一致,不是你以为的 bug。
顺带把 MeshConfig 的"宽容度"也实测一下,因为它和 VirtualService 用的不是同一套解析——mesh.ApplyMeshConfig 走的是 protomarshal.ApplyYAML:
| mesh YAML | 结果 |
|---|---|
trustDomain: my.domain | 生效,trustDomain="my.domain" |
trustDomainz: my.domain(拼错字段) | 静默忽略,trustDomain 保持默认 cluster.local |
trustDomain: 123(类型错) | 报错 json: cannot unmarshal number into Go value of type string |
extensionProviders[].portz: 4317(拼错字段) | 静默忽略,port 变 0,只在 validation 层打一条 warn |
和 VirtualService 一样:字段名错静默,类型错硬失败。 那条第 4 行的 warn 值得看一眼:
warn validation found invalid extension provider (can be ignored if the given extension provider is not used): invalid extension provider otel: port number 0 must be in the range 1..65535日志里的 can be ignored if the given extension provider is not used 是 Istio 自己的说法(pkg/config/validation/agent/validation.go 的 ValidateMeshConfig):
if err := validateExtensionProvider(mesh); err != nil {
scope.Warnf("found invalid extension provider (can be ignored if the given extension provider is not used): %v", err)
}也就是说,extension provider 的校验失败不会拦住配置应用,只是打一条 warn。所以你可能会遇到"我配了一个 tracings provider 但就是没生效"——回头看日志,很早以前就有一条 warn 在那了。
7.4 MeshConfig 变了 → 一次 Forced 的全量推送
meshwatcher 只负责"算出当前 mesh 配置",谁去触发推送?initMeshHandlers(pilot/pkg/bootstrap/server.go):
func (s *Server) initMeshHandlers(changeHandler func(_ *meshconfig.MeshConfig)) {
s.environment.AddMeshHandler(func() {
changeHandler(s.environment.Mesh()) // configGen.MeshConfigChanged:清 access log 缓存
s.XDSServer.ConfigUpdate(&model.PushRequest{
Reason: model.NewReasonStats(model.GlobalUpdate),
Forced: true, // ← 没有 ConfigsUpdated
})
})
}两个关键点:
第一,Forced: true 且没有 ConfigsUpdated。 回顾 PushRequest 的定义(pilot/pkg/model/push_context.go):
// ConfigsUpdated keeps track of configs that have changed.
// This is used as an optimization to avoid unnecessary pushes to proxies that are scoped with a Sidecar.
// If this is empty, then all proxies will get an update.
ConfigsUpdated sets.Set[ConfigKey]ConfigsUpdated 为空 = 所有代理都要推。这是 mesh 配置的特性决定的:trustDomain、discoverySelectors、defaultConfig、defaultVirtualServiceExportTo 这些字段影响的是每一条代理的每一条配置,没有"只影响一部分"的可能。所以这里干脆放弃精确性,全推。
第二,Forced: true 会让 PushContext 走全量重建。 回到 InitContext:
if pushReq == nil || oldPushContext == nil || !oldPushContext.InitDone.Load() || pushReq.Forced {
ps.createNewContext(env) // 全量重建
} else {
ps.updateContext(env, oldPushContext, pushReq) // 增量复用
}所以 "改一次 istio ConfigMap = 一次 PushContext 全量重建 + 给所有代理推一遍全量配置"。这就是为什么改 mesh 配置在万级 Pod 的集群里要谨慎——它不是一次增量更新,是一次全局抖动。
7.5 ConfigMap 和 VirtualService 两条路的对照
现在可以回答了:
| 维度 | VirtualService | MeshConfig |
|---|---|---|
| 存在哪 | etcd 里的 CR | etcd 里的 ConfigMap.data.mesh(或磁盘文件) |
| 谁读的 | crdclient → Collection[config.Config] | kubemesh.NewConfigMapSource → Collection[string] |
| 变成什么 | config.Config{Spec: *v1alpha3.VirtualService} | MeshConfigResource{*meshconfig.MeshConfig} |
在 Schemas 里吗 | 在(collections.Pilot) | 在,但 synthetic: true,所以不建 informer |
| 变更事件 | krt collection 事件 → xdsPush | AddMeshHandler → ConfigUpdate |
| 推送范围 | 精确到 ConfigsUpdated 里的 key | Forced,全量 |
| 解析失败怎么办 | ConvertObject 直接报错,对象根本进不来 | DiscardResult,保留上一份能用的 |
| 改完要重启吗 | 不要 | 不要(但要注意文件挂载覆盖的情况) |
"改 ConfigMap 必须重启 istiod" 这个说法是错的,initMeshConfiguration 走的是 krt.NewSingleton + WaitUntilSynced,是活的集合,不是启动时一次性读取。真正会让人产生"必须重启"错觉的,是 7.2 节那个"配置文件存在时 ConfigMap 完全不参与"的优先级规则,以及 7.3 节那个"解析失败保旧值"的行为——你改了一个错的,istiod 保持旧的,你以为没生效,重启后反而变成默认值了。
八、出存储之后:ConfigStore 的读口子
PushContext 初始化那一堆 initXxx(pilot/pkg/model/push_context.go 的 createNewContext)从 env.ConfigStore 拿数据:
func (ps *PushContext) createNewContext(env *Environment) {
ps.initServiceRegistry(env, nil)
ps.initKubernetesGateways(env)
ps.initVirtualServices(env) // ← 从 env.VirtualServiceController 拿
ps.initDestinationRules(env)
ps.initAuthnPolicies(env)
ps.initAuthorizationPolicies(env)
ps.initTelemetry(env)
ps.initProxyConfigs(env) // ← GetProxyConfigs(env.ConfigStore, env.Mesh())
ps.initTrafficExtensions(env)
ps.initEnvoyFilters(env, nil, nil) // ← env.List(gvk.EnvoyFilter, NamespaceAll)
ps.initGateways(env)
ps.initAmbient(env)
ps.initSidecarScopes(env) // Must be initialized in the end
}注意中间那一行被"绕开"了:initVirtualServices 不直接读 ConfigStore。
func (ps *PushContext) initVirtualServices(env *Environment) {
...
vservices := env.VirtualServiceController.MergedVirtualServices() // ← 来自 controller,不是 store
...
}因为 VirtualService 还有一层"delegate 展开 + 合并"的加工(第 06 篇会细讲),那层加工由 model.VirtualServiceController 里的 krt 集合完成,PushContext 拿的是加工后的 MergedVirtualService。
所以 ConfigStore 并不是唯一入口。 有些类型直接读 store(EnvoyFilter、ProxyConfig、Telemetry),有些走专用 controller(VirtualService),有些走 service registry(Service/Endpoint 根本不在这套 config 模型里)。
这里顺带解释一个常见疑问:
Service为什么在kind枚举里有,但你在collections.Pilot里找不到? 因为Service是 K8s 原生资源,走的是pilot/pkg/serviceregistry/kube/controller那条完全不同的路,kind.Service只在ConfigsUpdated里当"变更标记"用。有一处代码甚至专门为它加了断言(pilot/pkg/xds/discovery.go的ConfigUpdate):goif features.EnableUnsafeAssertions { if model.HasConfigsOfKind(req.ConfigsUpdated, kind.Service) { panic("assertion failed kind.Service can not be set in ConfigKey") } }
kind.Service不允许出现在ConfigsUpdated里,因为 Service 走的是SvcUpdate/EDSCacheUpdate,不走ConfigUpdate。混用会在开断言的时候直接 panic。
8.1 从"变了一下"到"推了一次":NeedsPush 这道窄门
存储变了之后,触发推送的是各控制器的 handler。以 VirtualService 为例(pilot/pkg/model/virtualservice_controller.go):
func (c *VirtualServiceController) xdsPush(events []krt.Event[MergedVirtualService]) {
if c.xdsUpdater == nil { return }
cu := sets.New[ConfigKey]()
for _, e := range events {
// 只有"实质变了"才推
if e.Old != nil && e.New != nil && !NeedsPush(*e.Old.Config, *e.New.Config) {
continue
}
for _, vs := range e.Items() {
cu.Insert(ConfigKey{Kind: kind.VirtualService, Name: vs.Name, Namespace: vs.Namespace})
}
}
if len(cu) == 0 { return }
c.xdsUpdater.ConfigUpdate(&PushRequest{ConfigsUpdated: cu, Reason: NewReasonStats(ConfigUpdate)})
}NeedsPush(pilot/pkg/model/config_compare.go)是整条链路里最值得抄的一段"窄门":
func NeedsPush(prev config.Config, curr config.Config) bool {
if prev.GroupVersionKind != curr.GroupVersionKind { return true }
// 非 istio.io 的组(比如 Gateway API)一律推
if !strings.HasSuffix(prev.GroupVersionKind.Group, "istio.io") { return true }
// 显式开了后门就推
if alwaysPush(prev.Meta) || alwaysPush(curr.Meta) { return true }
// 只有 istio.io 相关的 label/annotation 变了才推
if istioMetadataChanged(prev.Meta.Labels, curr.Meta.Labels) ||
istioMetadataChanged(prev.Meta.Annotations, curr.Meta.Annotations) {
return true
}
// 最后看 spec 有没有真变
prevspecProto, okProtoP := prev.Spec.(proto.Message)
currspecProto, okProtoC := curr.Spec.(proto.Message)
if okProtoP && okProtoC {
return !proto.Equal(prevspecProto, currspecProto)
}
return true
}四条规则,落地成四个实操结论:
- 改
spec才推,改非istio.io的 metadata 不推。 Helm 的meta.helm.sh/*、Argo CD 的argocd.argoproj.io/*、kubectl.kubernetes.io/last-applied-configuration这三类高频扰动的 annotation,在 VirtualService 上是不触发推送的。这是很多人"折腾了半天 annotation 为什么没反应"的答案。 istio.io/*的 label/annotation 变了会推。 因为像istio.io/rev、internal.istio.io/parents这些是参与逻辑的。- 实在要它推,打
internal.istio.io/always-push。 这个后门在pkg/config/constants/constants.go里叫AlwaysPushLabel,label 或 annotation 都认。这是排障时最有用的一招:当你怀疑某个变更被NeedsPush吞了,先打上它,如果打了就生效了,那问题就在NeedsPush。 - Gateway API 的资源不享受这个优化。
!strings.HasSuffix(..., "istio.io")让它直接return true——Gateway API 的对象靠status字段来回写,spec没变但行为可能变了(比如GatewayClass的Accepted状态),所以不敢优化。
8.2 ConfigsUpdated 只能"缩",不能"扩"
ConfigsUpdated 是一个 sets.Set[ConfigKey],ConfigKey 比 Config 的 Key() 简单得多(pilot/pkg/model/config.go):
type ConfigKey struct {
Kind kind.Kind // ← 枚举,不是 GVK
Name string
Namespace string
}注意这里是 kind.Kind(kind.VirtualService),是生成出来的 iota 枚举,不是 config.GroupVersionKind。 两者可以互转(gvk/resources.gen.go 的 FromKind/ToKind),但代码里混着看容易乱。
这个 key 的粒度决定了推送的精确度,而它会被 12 篇讲的 ProxyNeedsPush 用来判断"这个代理要不要知道"。所以 ConfigsUpdated 漏填一个,就可能出现"配置生效了但某个代理没收到"——而这类 bug 在客户端表现是"这台机器行为不对、那台机器对",极难查。
反过来说,ConfigsUpdated 填多了不会出错,只会多推。所以打 always-push 那个后门虽然"作弊",但安全——它把一个可能漏推的情况变成了必然多推。排障时先保功能,再谈优化。
九、把 config.Config 挖出来看
三个命令,全部对着一个 demo profile 的 istiod。
9.1 /debug/configz:当前存储里有什么
kubectl -n istio-system port-forward deploy/istiod 8080:8080 >/dev/null 2>&1 &
sleep 1
# 每种类型有多少个对象
curl -s localhost:8080/debug/configz | jq -r '.[].kind' | sort | uniq -c
# 只看 VirtualService 的 spec(注意字段名是 kind / apiVersion,不是 type)
curl -s localhost:8080/debug/configz \
| jq -r '.[] | select(.kind=="VirtualService") | .metadata.name'
# 拿单份配置的 spec,和 kubectl get 的 spec 对比
curl -s localhost:8080/debug/configz \
| jq '.[] | select(.kind=="VirtualService" and .metadata.name=="reviews") | .spec'/debug/configz 每一项的 JSON 长度是 K8s 那种 kind/apiVersion/metadata/spec 结构,而 kind 就是 VirtualService 本身。这是一个必须注意的细节:这项 JSON 是 crd.ConvertConfig() 从 config.Config 反向转回来的 K8s 形态,而 ConvertConfig 会丢掉我们关心的 domain、uid:
type kubernetesConfig struct{ config.Config }
func (k kubernetesConfig) MarshalJSON() ([]byte, error) {
cfg, err := crd.ConvertConfig(k.Config)
if err != nil { return nil, err }
return json.Marshal(cfg)
}所以想看未经翻译的 Meta 全貌,用第 9.3 节的 krtz,不要用 configz。
configz 的实现(pilot/pkg/xds/debug.go):
func (s *DiscoveryServer) configz(w http.ResponseWriter, req *http.Request) {
configs := make([]kubernetesConfig, 0)
s.Env.ConfigStore.Schemas().ForEach(func(schema resource.Schema) bool {
cfg := s.Env.ConfigStore.List(schema.GroupVersionKind(), "")
for _, c := range cfg {
configs = append(configs, kubernetesConfig{c})
}
return false
})
writeJSON(w, configs, req)
}它是遍历 Schemas() 而不是遍历"实际存在的类型",所以输出里会包含那些一个对象都没有的 kind。这一点有用:如果你的类型压根没出现在 /debug/configz 里,说明它不在 Schemas 里,也就是说 metadata.yaml 没登记、或者 collections.Pilot 没加进去。
List(gvk, "") 走的是 aggregate 层的 JoinCollection,所以看到的是 crdclient + gateway 合成 + extensions 合成合并后的视图。如果你想知道某个对象是不是合成的,就看 extra 字段里有没有 internal.istio.io/parents。
9.2 /debug/mesh:MeshConfig 看这里,不在 configz 里
curl -s localhost:8080/debug/mesh | jq '.trustDomain, .defaultConfig.holdApplicationUntilProxyStarts'实现就一行(pilot/pkg/xds/debug.go):
func (s *DiscoveryServer) meshHandler(w http.ResponseWriter, req *http.Request) {
writeJSON(w, s.Env.Mesh(), req)
}s.Env.Mesh() 读的是 Environment.Watcher(第 7 节那条路),不是 ConfigStore。 这就是"MeshConfig 不和 VirtualService 一起出现在 /debug/configz"的原因——它虽然在 Schemas 里有户口(synthetic: true),但 configz 去 List(kind.MeshConfig) 会得到空,因为没有任何 store 提供这个 collection。
9.3 /debug/krtz:看这条链路本身
# 哪些集合同步好了
curl -s localhost:8080/debug/krtz \
| jq -r '.[] | select(.name|tostring|test("crdclient|VirtualService")) | "\(.name)\tsynced=\(.state.synced)\toutputs=\(.state.outputs|length // 0)"'
# 某个 VirtualService 的依赖是谁
curl -s localhost:8080/debug/krtz \
| jq -r '.[] | select(.name=="crdclient/VirtualService") | .state.inputs | keys[]' | headcrdclient 给每个集合起的名字是 "collection/" + Kind 和 "informer/" + Kind(addCRD 里的 opts.WithName(...))。所以 krtz 里能直接看到 informer/VirtualService → collection/VirtualService 这条边。
这条排查路径直接把第 5 节那张"四层叠罗汉"变成了可观测的:outputs 空了就是 informer 没数据(查 CRD、查 inRevision、查 RBAC),synced=false 就是 CRD 还没出现(delayedClient 的空壳期)。
9.4 一个必查的日志
kubectl -n istio-system logs deploy/istiod | grep -E "Starting Pilot K8S CRD controller|Pilot K8S CRD controller synced"crdclient.Run 里有这两行:
cl.logger.Infof("Starting Pilot K8S CRD controller")
if !kube.WaitForCacheSync("crdclient", stop, cl.informerSynced) {
cl.logger.Errorf("Failed to sync Pilot K8S CRD controller cache")
} else {
cl.logger.Infof("Pilot K8S CRD controller synced in %v", time.Since(t0))
}而 informerSynced() 会逐个报谁还在 syncing:
for gk, ctl := range cl.kinds {
if !ctl.collection.HasSynced() {
cl.logger.Infof("controller %q is syncing...", gk)
return false
}
}卡启动时这行日志直接点名是哪一个 kind 拖住了。 结合 5.1 节的 delayedClient 语义,如果点名的是一个你确实没装的 CRD,那大概率是 crdWatcher 自己没同步完,而不是那个 CRD 的问题。
十、五个"存储层"专属的坑
前面散落了一些,这里集中四个只在存储层出现的。
10.1 坑一:AllowUnknownFields 把拼写错误变成了静默失效
第 3.3 节实测过。补充一个实战判据:
# 看这份配置在 istiod 眼里到底长什么样(不是 kubectl get 的样子)
kubectl get vs reviews -n default -o json | jq '.spec'
# 对比
curl -s localhost:8080/debug/configz \
| jq -r '.[] | select(.name=="reviews" and .type|test("VirtualService")) | .spec'如果 kubectl get 里有、configz 里没有,那就是被 AllowUnknownFields 吞了。 如果是 kubectl get 里就已经没有,那问题在 API Server 的 CRD schema 裁剪,更早一层。
根治手段两个:一是 kubectl apply --validate=strict(让 API Server 报未知字段),二是 istioctl analyze(第 28 篇会展开,它能查出"路由规则里什么都没有"这类结构性问题)。
10.2 坑二:Get() 不做别名解析
第 5 节说过。再强调一次,因为它是写代码时最容易犯的错:
// ❌ 传原始的 gvk,可能是 v1alpha3,map 里没有这个 key
cl.Get(cfg.GroupVersionKind, name, ns)
// ✅ 先规范化
s, _ := cl.Schemas().FindByGroupVersionAliasesKind(cfg.GroupVersionKind)
cl.Get(s.GroupVersionKind(), name, ns)cl.kinds 这个 map 的 key 是 s.GroupVersionKind()(规范化后的 v1),不是别名。查不到只打一行 Warnf("unknown type: %s", typ),返回 nil。 又是一次静默。
10.3 坑三:Spec 是和 informer 共享的指针
第 3.4 节末尾提过。展开一下后果:
cfg := cl.Get(gvk.VirtualService, "reviews", "default")
spec := cfg.Spec.(*networking.VirtualService)
spec.Hosts[0] = "evil" // ← 直接改了 informer 缓存里的对象这一改会污染整个 istiod 的所有消费者,而且因为 Equals 比较的是同一个对象(上一篇讲的"Old 和 New 变成同一个对象"),Equal 恒为 true,事件永远发不出去。
正确的做法只有一条:要改就 config.DeepCopy(cfg.Spec)。model.ResolveVirtualServiceShortnames 就是这么写的,还专门留了注释:
// values returned from ConfigStore.List are immutable.
// Therefore, we make a copy
r := config.DeepCopy()10.4 坑四:ParseInputs 走的是 PilotGatewayAPI(),不是 Pilot
第 3.1 节那段代码里有一行容易被忽略:
s, exists := collections.PilotGatewayAPI().FindByGroupVersionAliasesKind(...)读本地文件用的是 PilotGatewayAPI()(25 个),不是 Pilot(15 个)。 这意味着通过 --configDir 起 istiod 时,即使你把 PILOT_ENABLE_GATEWAY_API 关了,文件里的 HTTPRoute 还是能被解析成 config.Config。
更细的差别在 pilot/pkg/config/kube/file/controller.go:它虽然按 schemas(传进来的是 collections.Pilot)建 collection,但 parseInputs 用的是 crd.ParseInputs,也就是 PilotGatewayAPI()。结果就是:解析认得 25 个类型,但只有 15 个会被建 collection。 多出来的那 10 个 config.Config 被解析出来之后直接丢掉了。
这是个真实的"解析放宽、存储收窄"的不对称,不是 bug,但读代码时容易误判。
10.5 坑五:NeedsPush 会吞掉"看起来变了"的更新
第 8.1 节讲了 NeedsPush 的四条规则。这里补一个最容易被它吞掉的具体场景:
# 给一个 VS 打上 Helm / Argo CD 的注解,什么都不会发生
kubectl annotate vs reviews -n default meta.helm.sh/release-name=foo
kubectl annotate vs reviews -n default argocd.argoproj.io/tracking-id=abc因为 NeedsPush 只关心含 istio.io 的 key:
func istioMetadataChanged(prev, curr map[string]string) bool {
for k, v := range curr {
if strings.Contains(k, "istio.io") {
if pv, f := prev[k]; !f || pv != v { return true }
}
}
for k := range prev {
if strings.Contains(k, "istio.io") { n-- }
}
return n != 0
}这是故意的:GitOps 工具每次同步都会重写这些注解,如果每次都触发推送,集群会一直处于推送中。
但反过来,这也是一个真实的踩坑点:如果你有一个 Operator 靠写非 istio.io 的注解来驱动 Istio(比如某些自研的灰度平台),那些改动不会触发推送。所以 Istio 留了 internal.istio.io/always-push 这个后门——它存在的意义就是给这类场景用,不是调试专用的。
十一、收口
这篇回答了一个问题:一个 YAML 是怎么变成 config.Config 的。 答案是一条五段的读路径,加上一条完全平行的第二路径:
flowchart TD Y["virtualservice.yaml"] -->|"kubectl apply"| API["API Server + CRD schema<br/>(裁剪未知字段)"] API --> INF["informer<br/>强类型 Go 对象"] INF --> WRAP["krt.WrapClient"] WRAP --> MAP["krt.MapCollection<br/>translationMap"] MAP --> CFG["config.Config<br/>Meta + Spec + Status"] CFG --> JOIN["aggregate JoinCollection<br/>+ gateway/extensions 合成"] JOIN --> STORE["Environment.ConfigStore"] STORE --> PC["PushContext.initXxx"] PC --> PUSH["PushRequest"] CM["ConfigMap istio / 磁盘 mesh 文件"] --> KMS["kubemesh.NewConfigMapSource<br/>Collection[string]"] KMS --> MW["meshwatcher.NewCollection<br/>Collection[MeshConfigResource]"] MW --> ENVW["Environment.Watcher"] ENVW -->|"AddMeshHandler → Forced"| PUSH classDef alt fill:#e7f5ff,stroke:#1971c2,color:#0b3d6b class CM,KMS,MW,ENVW alt
带走四条:
- 第一道门在 API Server,不在 istiod。 CRD 的 structural schema 会静默裁掉未知字段。
kubectl get里就没有的字段,istiod 从来没见过。 Schema是唯一的身份证,metadata.yaml是唯一的源头。 某个 kind 不能用,九成是它没登记在那份 YAML 里,或者不在collections.Pilot/PilotGatewayAPI里。版本别名让v1/v1beta1/v1alpha3归一化成同一个 key,所以Spec的类型名可以是v1alpha3而 GVK 是v1。- 解析宽容是这一层最大的坑。
AllowUnknownFields让"字段名写错"变成"静默丢字段",DiscardResult让"mesh 写错"变成"继续用旧的"。两个都不报错,两个都只能靠configz/mesh对比出来。 MeshConfig有config模型的户口,但没有存储。 它synthetic: true、不建 informer、走Environment.Watcher不走ConfigStore、变了就Forced全量推。它和VirtualService唯一的共同点是都能用config.GroupVersionKind描述,此外没有任何共享代码。
下一篇是这条链的下游:PushContext 拿到这些 config.Config 之后,怎么把它们摊成一份"全局快照",以及 createNewContext() 全量重建和 updateContext() 增量复用这两条路,各自在什么条件下被选中、选错了会怎样。