00. Istio 到底是什么:三个二进制、两类配置、一条链路 原创
这门课的 31 篇正文只讲一件事:你写在 YAML 里的一个
host,怎么变成 Envoy 里的一段 JSON。但动手之前得先有一张地图。这篇不安装、不讲概念,只用三条线把 Istio 摆清楚:哪几个进程在跑、有哪两类配置、配置从进来到生效走哪一段路。后面每篇开头都会回到这条路上标位置,所以这篇是唯一不能跳的。
文中所有源码引用基于
istio/istio1.31.4,定位用文件名 + 函数名,不用行号。
一、先数清楚有几个进程
判断一个组件是什么,别背架构图,去看它在你机器上跑成了什么。装完 Istio,kubectl get pod 里出现的容器一共三类;开 Ambient 会多第四类:
kubectl get pod -n istio-system -o wide
kubectl get pod -n bookinfo -o wide| 容器 | 二进制 | 干什么(数量) |
|---|---|---|
istiod | pilot-discovery | 唯一翻译配置的进程(1~3 个) |
istio-cni-node | install-cni | 装 CNI 插件、配重定向规则(每节点 1 个) |
业务 Pod 的 istio-proxy | pilot-agent + envoy | agent 管证书与转发,Envoy 执行规则(每 Pod 1 个容器、2 个进程) |
ztunnel | ztunnel(Rust) | 仅 Ambient:L4 数据面(每节点 1 个) |
istio-cni-node 里其实有两个东西:容器里的 install-cni 常驻进程,以及写到宿主 /opt/cni/bin 的 istio-cni 插件——后者不是你启动的,是 CRI 创建 Pod 时按 CNI 配置去调用的。
flowchart LR PD["pilot-discovery<br/>Start()"] --> XD["pilot/pkg/xds<br/>Stream()"] XD -.->|"xDS over gRPC"| PA["pilot-agent<br/>Run()"] PA -->|"exec.Command"| EN["envoy<br/>CDS · LDS · RDS · EDS"] CN["install-cni<br/>改流量重定向规则"] -.->|"不参与生成"| EN ZT["ztunnel<br/>Ambient 的 L4"] -.->|"不参与生成"| EN classDef edge fill:#e7f5ff,stroke:#1c7ed6,color:#1864ab class CN,ZT edge
标题里的"三个二进制"说的是参与配置翻译的那三个:pilot-discovery、pilot-agent、envoy。install-cni 和 ztunnel 在图上用浅色标出来,因为它们只改流量路径,不参与"YAML → JSON"这条链路——第 24 篇会专门解释它们的边界。
这张图里有个容易漏掉的事实:Envoy 从来不直接连 istiod。真正的连接链是三跳——Envoy 连本地 UDS,pilot-agent 在 UDS 上起一个 xDS 代理,再由它连 istiod:15012。
证据在 Envoy 的 bootstrap 模板里,xds-grpc 这个 cluster 的地址是一个 unix pipe,不是 istiod 的域名:
{
"name": "xds-grpc",
"type": "STATIC",
"load_assignment": {
"cluster_name": "xds-grpc",
"endpoints": [{
"lb_endpoints": [{
"endpoint": { "address": { "pipe": { "path": "{{ .config.ConfigPath }}/XDS" } } }
}]
}]
}
}所以:Envoy 以为自己连的是"配置服务器",其实连的是同容器里的 pilot-agent 开的一个 socket 文件。模板里写的是相对路径 ./etc/istio/proxy/XDS,容器工作目录是 /,所以实际就是 /etc/istio/proxy/XDS。pilot-agent 拿到请求之后,加上自己的身份证书再连 istiod。
这不是多此一举。三个理由在后续篇章反复出现:证书在 agent 手里(第 25 篇)、xDS 要能在断连时降级(第 23 篇)、DNS 与 metadata 要在本地拦截(第 23 篇)。如果以为 Envoy 直连 istiod,读到这些地方就会觉得代码在绕路。
反例:把 pilot-agent 从链路里摘掉(改 bootstrap 直连 istiod:15012),Envoy 照样能起、配置照样能下发——所以很多人真的以为它就是直连的。但一旦开启 strict mTLS,Envoy 就拿不出工作负载证书,15012 的握手直接失败。这就是"能跑"和"跑对了"的区别。
二、两类配置:一类带变更清单,一类不带
Istio 的配置分两族,它们的处理路径完全不同。这个区别在第 12 篇(推送链路)会变成一个 if,现在先记住形状:
| 族 | 存在哪 | 变更后 |
|---|---|---|
| 意图配置 | 15 个 CRD:VirtualService、DestinationRule、Gateway、Sidecar、ServiceEntry、AuthorizationPolicy 等 | 带变更清单,可只重算受影响部分 |
| 网格配置 | ConfigMap istio 的两个键 mesh、meshNetworks | 不带清单,强制全量重建 |
# 数一数这两族各有多少
kubectl get crd -o name | grep 'istio\.io' | wc -l # 15
kubectl -n istio-system get cm istio -o jsonpath='{.data}' | head -3flowchart LR
A["变更事件<br/>ConfigUpdate"] --> B["debounce<br/>攒窗 100ms"]
B --> C{"InitContext<br/>Forced?"}
C -->|"是:mesh"| D["createNewContext<br/>全量重建"]
C -->|"否:CRD"| E["updateContext<br/>只重算变更"]
D --> F["PushQueue<br/>逐代理过滤"]
E --> F
源码里这两条路的差别只有几行,但它决定了改一个 VirtualService 和改一次 MeshConfig 完全是两个量级的操作:
// pilot/pkg/bootstrap/server.go —— initMeshHandlers(),删去 changeHandler 部分
s.environment.AddMeshHandler(func() {
changeHandler(s.environment.Mesh())
s.XDSServer.ConfigUpdate(&model.PushRequest{
Reason: model.NewReasonStats(model.GlobalUpdate),
Forced: true, // 没有 ConfigsUpdated,等于"我不知道变了什么,全算一遍"
})
})换句话说,MeshConfig 一变,istiod 通知的是"全量重来",不是"哪一项变了"。因为 mesh 配置会渗透到几乎所有生成逻辑里(协议识别、Sidecar 默认值、按需生成开关),没有办法做字段级失效。
反例:把 MeshConfig 当 feature flag 用,用 kubectl patch 频繁改代理参数。每一次都会触发全量 PushContext 重建 + 全网格重算,在几千个 Pod 的集群上就是一连串推送尖峰。这不是 Istio 性能差,是它被当成了热更新配置中心。
反过来,CRD 那条路的代价被 ConfigsUpdated 这一个字段限住了:
// pilot/pkg/model/push_context.go —— InitContext() 的前半段
ps.initDefaultExportMaps() // 必须最先,后面的 initXxx 都要用
if pushReq == nil || oldPushContext == nil || !oldPushContext.InitDone.Load() || pushReq.Forced {
ps.createNewContext(env) // 全量
} else {
ps.updateContext(env, oldPushContext, pushReq) // 按 ConfigsUpdated 挑需要重算的部分
}是否全量,只由四个条件决定——没有推送请求、没有旧快照、旧快照没建完、或者请求标了 Forced。其余情况一律走增量。所以"为什么改个 VS 不会导致全网格重算"这个问题,答案就在这个 if 里。
反例:在 updateContext() 里新加一类配置但漏了 case(比如早期 Telemetry 就踩过类似的坑),后果很隐蔽——改这个 CRD 时增量路径什么都不做,看起来"没生效",重启 istiod 又好了。这就是为什么全量、增量两条路都得有测试,而不是只测全量。
三、一条链路:配置从进来到生效,只有六段
把上面两件事接起来,就是全课程反复用的那张图。它只有六段,顺序永远不变:
flowchart LR S1["① config 进<br/>crd client"] -.-> S2["② model 加工<br/>InitContext"] S2 -.-> S3["③ networking 生成<br/>BuildClusters"] S3 -.-> S4["④ xds 送出<br/>pushXds"] S4 -.-> S5["⑤ agent 接收<br/>XdsProxy"] S5 -.-> S6["⑥ Envoy 执行<br/>CDS · LDS · RDS · EDS"]
这张图的 ①~⑥ 是段号,不是分支号。导论不落进任何函数体,所以本篇只贴常量和接口约定,不贴函数实现,也就没有"图码共用编号"这回事。第三列给的是每段能
grep到的位置,具体函数在对应篇里才展开。
| 段 | 做什么 | 代码在哪(对应篇) |
|---|---|---|
| ① config 进 | informer 收 CRD、转译 Gateway API | pilot/pkg/config/kube/(02、03、09) |
| ② model 加工 | 整理成一次推送用的"可查询快照" | pilot/pkg/model/(04~08) |
| ③ networking 生成 | 把模型填进 Envoy 的四个对象 | pilot/pkg/networking/core/(15~20) |
| ④ xds 送出 | 攒窗、排队、按代理过滤、写流 | pilot/pkg/xds/(10~14) |
| ⑤ agent 接收 | UDS 转发、证书、bootstrap | pkg/istio-agent/(22~25) |
| ⑥ Envoy 执行 | 真正生效的地方 | 不在本仓库(15、28) |
第三段到第四段之间传的东西,就是 xDS 的"接口约定"。它是字符串常量,一个字符都不能错:
// pkg/model/xds.go —— TypeUrl 常量(节选)
const (
APITypePrefix = "type.googleapis.com/"
ClusterType = APITypePrefix + "envoy.config.cluster.v3.Cluster"
EndpointType = APITypePrefix + "envoy.config.endpoint.v3.ClusterLoadAssignment"
ListenerType = APITypePrefix + "envoy.config.listener.v3.Listener"
RouteType = APITypePrefix + "envoy.config.route.v3.RouteConfiguration"
SecretType = APITypePrefix + "envoy.extensions.transport_sockets.tls.v3.Secret"
ExtensionConfigurationType = APITypePrefix + "envoy.config.core.v3.TypedExtensionConfig"
AddressType = APITypePrefix + "istio.workload.Address" // Ambient 的 WDS
WorkloadType = APITypePrefix + "istio.workload.Workload"
)Envoy 不认识"Istio",只认识 type.googleapis.com/envoy.config.cluster.v3.Cluster 这样的 typeURL。CDS/LDS/RDS/EDS 这四个缩写不是设计方案,是 typeURL 的别名——GetShortType() 干的就是这件事,顺带还有 SDS(证书)、ECDS(扩展配置)、WDS(Ambient 的 Address/Workload)。
反例:自己写控制面时把 envoy. 写成 env.(第 31 篇会真的踩)。Envoy 不会报错,表现得像"连上了但什么都没下发"——因为它只是没订阅到它认识的那个 typeURL。没有错误日志的 bug 最难查,而 xDS 里绝大部分静默失败都来自这类字符串不匹配。
第四段送出的时候,每个代理都得被认出来。识别凭据是 node.id,格式是四段:
// pilot/pkg/model/context.go —— ParseServiceNodeWithMetadata()
parts := strings.Split(nodeID, serviceNodeSeparator) // "~"
out := &Proxy{Metadata: metadata}
if len(parts) != 4 {
return out, fmt.Errorf("missing parts in the service node %q", nodeID)
}
if !pm.IsApplicationNodeType(NodeType(parts[0])) {
return out, fmt.Errorf("invalid node type (valid types: %v) in the service node %q", NodeTypes, nodeID)
}
out.Type = NodeType(parts[0])
// parts[1] 是 IP(现在以 metadata 里的 InstanceIPs 为准,这里只做兜底)
out.ID = parts[2]
out.DNSDomain = parts[3]node.id = 类型~IP~名字~域,例如 sidecar~10.244.1.7~productpage-v1-6b746f74dc-abcde~default.svc.cluster.local。类型必须是 sidecar / router / waypoint / ztunnel / agentgateway 之一(pkg/model/proxy.go 里那五个常量)。
反例:把第一个字段写成 sidecarproxy,istiod 直接报 invalid node type;少写一段则报 missing parts。这两条错误你以后一定会在 istiod 日志里见到,看到就知道是注入模板或自定义 bootstrap 写错了,不用往网络方向查。
最后一个容易混的:ISTIO_DELTA_XDS 默认是 true。带 pilot-agent 的 sidecar,bootstrap 里的 ads_config.api_type 会是 DELTA_GRPC 而不是 GRPC,所以你在 config_dump 里看到的是 Delta 语义。但源码注释自己提醒了一句很重要的话:
DeltaXds = env.Register("ISTIO_DELTA_XDS", true,
"If enabled, pilot will only send the delta configs as opposed to the state of the world configuration on a Resource Request. "+
"While this feature uses the delta xds api, it may still occasionally send unchanged configurations instead of just the actual deltas.").Get()"走 Delta 协议"和"只发变化"不是同一件事。Istio 用的是 Delta 的协议外壳,但内容生成器(比如 EDS)仍然可能整体重发。第 10 篇会把这件事拆到底;现在只需要知道不要拿"用了 Delta 就一定增量"去推理。
四、三个总被混的概念,一句话区分
| 一组概念 | 别记错的地方 |
|---|---|
| 控制面 / 数据面 | pilot-agent 跑在业务 Pod 里,但它不是数据面——真正转发的是 Envoy |
| SotW / Delta | 默认走 Delta,但"Delta"不等于"只推变化" |
| sidecar / ambient | Ambient 不是"更轻的 sidecar",是把 L4 与 L7 拆成两跳:L4 归 ztunnel,L7 归 waypoint |
数据面自身也有一张固定的端口表,值得现在就记住,后面排障全靠它:
| 端口 | 属于谁 | 干什么 |
|---|---|---|
15000 | Envoy | admin,config_dump / clusters / stats 都在这 |
15001 | Envoy | 出站流量拦截入口 |
15006 | Envoy | 入站流量拦截入口 |
15008 | ztunnel / waypoint | HBONE 隧道(mTLS 里的 HTTP CONNECT) |
15020 | pilot-agent | 状态页 + 合并后的 Prometheus 指标 |
15021 | Envoy | 健康检查 |
15053 | pilot-agent | 本地 DNS 代理 |
15090 | Envoy | 原生 Prometheus 输出 |
控制面那侧则是五个:8080(debug + /ready)、15010(明文 gRPC)、15012(TLS gRPC,数据面真正连的就是它)、15017(注入与校验 webhook)、15014(监控与 /debug/*)。这些不是魔法数字,就是几个 flag 的默认值:
// pilot/cmd/pilot-discovery/app/cmd.go —— 端口类 flag 的默认值
c.PersistentFlags().StringVar(&serverArgs.ServerOptions.HTTPAddr, "httpAddr", ":8080", "Discovery service HTTP address")
c.PersistentFlags().StringVar(&serverArgs.ServerOptions.HTTPSAddr, "httpsAddr", ":15017", "Injection and validation service HTTPS address")
c.PersistentFlags().StringVar(&serverArgs.ServerOptions.GRPCAddr, "grpcAddr", ":15010", "Discovery service gRPC address")
c.PersistentFlags().StringVar(&serverArgs.ServerOptions.SecureGRPCAddr, "secureGRPCAddr", ":15012", "Discovery service secured gRPC address")
c.PersistentFlags().StringVar(&serverArgs.ServerOptions.MonitoringAddr, "monitoringAddr", ":15014", "HTTP address to use for pilot's self-monitoring information")所以:想让 istiod 只开 TLS 端口,就把 --grpcAddr 设为空。这时 initServers() 会走一条 multiplexGRPC 分支,把 gRPC 复用到 8080 上。
反例:真的这么干,然后保留原来只放行 15010 的 NetworkPolicy——数据面会表现为"能连上但一直在重连",因为流量现在走 8080。改端口是配置问题,但症状长得像网络问题,这类排障在第 28 篇有一整棵决策树。
五、动手验证:三分钟把地图对上实物
不需要改 Istio 代码,一个最小集群(istioctl install --set profile=demo -y + kubectl label ns default istio-injection=enabled)就够。下面所有命令都在这个前提上跑。
第一步,进程和端口对账。
kubectl get pod -n istio-system -o wide
kubectl get ds -n istio-system
kubectl -n istio-system get pod -l app=istiod \
-o jsonpath='{.items[0].spec.containers[*].name}{"\n"}'
# 预期: discoverykubectl get ds 的输出决定了你处在哪张图里:只有 istio-cni-node 说明是默认的 sidecar 模式;istio-cni-node + ztunnel 才是 Ambient。
第二步,确认 Envoy 的 xDS 地址是一个 socket 文件,不是 istiod 域名。
istioctl proxy-config bootstrap deploy/productpage-v1 -o json | \
jq '.bootstrap.static_resources.clusters[]
| select(.name=="xds-grpc")
| .load_assignment.endpoints[0].lb_endpoints[0].endpoint.address'预期:
{ "pipe": { "path": "./etc/istio/proxy/XDS" } }看到 pipe 就对了。这条命令本身也顺带验证了一件事:-o json 输出的顶层是 { bootstrap: ... },不是裸 bootstrap——因为 proxy-config bootstrap 取的是 config_dump 里那个 BootstrapConfigDump 对象。
第三步,同一件事从容器内再确认一遍(换个入口,看自己有没有被工具骗)。
kubectl exec deploy/productpage-v1 -c istio-proxy -- \
pilot-agent request GET "config_dump?mask=bootstrap" \
| jq -r '.configs[] | select(.["@type"] | test("Bootstrap")) | .bootstrap.node.id'预期:sidecar~<IP>~<Pod 名>~<ns>.svc.cluster.local。
pilot-agent request 只允许打本地四个端口(15000、15020、15021、15004),默认 15000 也就是 Envoy admin——它本质上就是替你在容器里 curl localhost:15000。白名单是硬编码的,跨出去会被直接拒。
第四步,node.id 的四段格式,对照代码验一遍。
第三步拿到的字符串按 ~ 切四段:类型、IP、名字、域。类型只认 sidecar / router / waypoint / ztunnel / agentgateway——这五个常量在 pkg/model/proxy.go。哪个环节写错会得到什么错误,第三节那段代码里已经列全了:少一段是 missing parts,类型不认识是 invalid node type。
第五步,从控制面这一侧对账:看 nonce 收发。
kubectl -n istio-system port-forward deploy/istiod 15014:15014 >/dev/null 2>&1 &
sleep 1
curl -s localhost:15014/debug/syncz \
| jq '.[0] | {proxy, proxy_type, cluster_sent, cluster_acked, route_sent, route_acked}'预期:*_acked 与对应的 *_sent 相等。
想亲眼见一次"没对上",把一条路由指向不存在的 subset:
kubectl patch vs productpage -n default --type merge -p \
'{"spec":{"http":[{"route":[{"destination":{"host":"reviews","subset":"nope"}}]}]}}'
sleep 2
curl -s localhost:15014/debug/syncz | jq '.[0] | {route_sent, route_acked}'两种结果都算验证成功,而它们的区别正好是后面两篇的主题:
route_sent涨了、route_acked没跟上 —— Envoy 回了 NACK。这是第 10 篇的 nonce/version 往返。- 两个都没动 —— 这条路由压根没生成或没下发。这是第 06 篇(VS 选择)和第 14 篇(跳过推送)。
第六步,顺手校验第二节那张表。
kubectl get crd -o name | grep 'istio\.io' | wc -l # 预期 15第七步(可选,但很值),看真实 bootstrap 里 api_type 是什么。
istioctl proxy-config bootstrap deploy/productpage-v1 -o json | \
jq -r '.bootstrap.dynamic_resources.ads_config.api_type'
# 预期: DELTA_GRPC这一行就是"默认走 Delta"的实物证据。把它和 ISTIO_DELTA_XDS 那个 feature flag 对上,第 10 篇读起来会轻松很多。
六、收口
这篇没讲一个函数怎么实现,只钉了三件事:参与翻译的只有三个二进制,其余两个只改流量路径、配置分两族且只有一族的变更能被精确失效、六段链路里 Envoy 其实不直接连 istiod。这三件事都不需要读代码就能验证,而后面 31 篇的每一句推理都建在它们之上。
下一篇从这里往下走一层:istiod 这个进程是怎么站起来的。pilot/cmd/pilot-discovery/main.go 里的 main() 只有 8 行,真正的顺序藏在 bootstrap.Server.Start() 里——其中有一条"监听必须放在 CA 注册之后"的注释,顺序错了会静默失败,正是接着本篇"能跑 ≠ 跑对了"这个话题最好的例子。