服务网格 E08:实现一个最小教学 xDS 控制面
端点换了,监听器为什么还没把请求送过去
独立 Envoy 的静态配置可以把 GET /quote?sku=paper 送到 inventory-v1,但每次换库存地址都要改文件、重载代理,无法看清资源更新过程。教学服务在 examples/service-mesh/cmd/teaching-xds/main.go:用官方 Go go-control-plane v0.13.4 的 SnapshotCache 和 ADS 服务发布一个 listener、一条精确匹配 /quote 的 route、一个 EDS cluster 与一个 endpoint。官方库携带 v3 protobuf(go-control-plane/envoy v1.32.3);真实 Envoy 1.37.0 已完成本例有限资源的转发、更新、拒绝和重连验证,不据此宣称所有 API 组合都兼容。
图里的资源依赖是 LDS → RDS → CDS → EDS,箭头表示引用或订阅需要,不是四条业务网络跳数。Envoy 读取 deploy/envoy/teaching-xds.yaml 的静态 bootstrap,向 loopback 127.0.0.1:18000 建 ADS 流;xDS 进程以固定 node ID teaching-envoy 发布版本 1。客户端 127.0.0.1:19000 是待启用的 Envoy listener,127.0.0.1:18080 是候选库存应用:本教学服务不代理任何报价数据。启动顺序、资源 warming 和当前活跃 listener 必须从实际 Envoy 状态检查,不能以“服务端 SetSnapshot 成功”替代。
版本、nonce 与拒绝的边界
控制面的工作可以拆成三个独立动作:把期望状态编码为 protobuf、把资源快照交给缓存、处理代理订阅及反馈。internal/teachingxds/snapshot.go 只负责第一个动作;server.go 把 SnapshotCache 接到库提供的 ADS server,并记录反馈;cmd/teaching-xds/main.go 负责 loopback 监听和标准输入命令。分开这些动作后,资源构造失败不会被误记成网络失败,连接断开也不会被误记成资源已经删除。
bootstrap 中的 xds cluster 是静态配置。它必须先存在,Envoy 才能连接控制面取得动态的 inventory cluster。这里的两个 cluster 不能互相替代:前者承载 HTTP/2 gRPC 控制流,后者承载报价请求。把控制面地址放进动态资源却不提供最初的连接入口,会出现启动依赖无法满足的问题。
动态监听器使用 HTTP connection manager,把 /quote 路径交给 quote-route。RDS 的精确路径匹配不把查询串作为路径的一部分,因此示例请求仍可携带 ?sku=paper。route 再引用 inventory,CDS 将该集群声明为 EDS 类型,EDS 给出 127.0.0.1:18080。每类资源只有一个名称,读者可以在配置转储中逐一核对。
| 变化 | 服务端响应 | 模拟客户端反馈 | 仍须到 Envoy 验证 |
|---|---|---|---|
| 初始订阅 | 版本 1、nonce 与 EDS | 版本 1 ACK | 活跃 listener 和实际库存版本 |
| 端口更新 | 版本 2、新 nonce | NACK 时带旧版本 1 | 旧请求去向与后端应用日志 |
| 断开并重连 | 最新快照版本 2 | 重新订阅 | 新连接是否完成 /quote |
Snapshot.Consistent() 检查本例中 listener 指定的 RDS 名称、EDS cluster 指定的端点名称是否齐全。它不是整个资源图的语义校验器:固定版本源码明确说明,CDS 和 LDS 采用无名称引用的请求方式,快照中的 cluster 列表不因此获得“所有路由目标都存在”的保证。实验删除 RDS、删除 EDS 时分别断言一致性检查失败;合法快照仍需交给代理做具体字段和扩展校验。
调用 port N 产生版本 2、3……的新快照并变更 EDS。端口输入限制为 1–65535,地址固定为 loopback;命令不会接受任意远程地址。为了让示例容易观察,四类资源使用同一个版本字符串,即使只有端点变化也会更新四类版本。这会产生冗余响应,不能作为大型控制面的推送效率设计。不同类型的响应也不会因为版本文字相同就成为全局原子事务。
服务端日志在收到 DiscoveryRequest 的 response_nonce 后,用流 ID、类型 URL 和 nonce 查找已发送的版本,再结合 error_detail 记录 ACK 或 NACK,以及客户端报来的 version_info。同一个 nonce 字符串可能在新流中再次出现,所以不能只用 nonce 做全局索引。无法关联的反馈记为 UNMATCHED,不能补猜成 ACK;关闭流时清理该流尚未确认的关联记录。
NACK 必须保留错误及客户端最后接受的版本,不能把 NACK 上的 version_info 当新版本已接受。示例测试明确断言:服务端发送版本 2,模拟客户端拒绝它时仍报告版本 1。SnapshotCache 中的期望状态不会因此自动回滚。再次连接时客户端订阅到的仍然是版本 2;要恢复有效状态,需要发布一个有效的新快照。这里使用 SotW ADS,不实现 Delta xDS。
这一处理方式可迁移到其他配置发布系统:发布版本 → 接收反馈 → 核对实际使用状态 是三个证据层级。ACK 证明代理接受了某次响应,但没有证明后端监听成功、业务认证成功或请求已经到达库存。后两者必须加入真实请求及应用日志。
故意错误怎样进入实验
标准输入命令 invalid 构造一个缺少路径匹配条件的 RDS route。protobuf 可以编码这份资源,快照中的名称依赖也完整,但生成的 ValidateAll() 会拒绝它,真实 Envoy 预期对该 RDS 响应发送 NACK。这个入口专用于拒绝实验,不代表生产控制面应把已知无效资源发给代理。
测试特意保留这一层差异:Consistent() 通过不等于配置字段合法。若把所有错误都提前截在生成器里,能够证明输入校验,却无法观察代理 NACK;若忽略所有提前校验,则很难定位到底是引用错误还是字段错误。实验固定其他资源,只破坏 route match,便于把拒绝归因到一个具体字段。
代理拒绝 RDS 后应继续使用先前接受的路由。运行脚本在看到 NACK ... sent_version=3 后,再发一条带请求 ID 的报价请求,并检查库存版本仍为 v2。随后发布有效版本 4,验证恢复到 v1,最后正常停止并重启 Envoy,检查新进程重新订阅后仍能访问 v1。这里只覆盖代理进程重启后的重连;没有模拟 TCP 半开、控制面集群切换或长时间网络分区。
运行与验收
在 examples/service-mesh/ 目录运行本地协议断言(需 Go 1.25.1 与网络可获取锁定模块;若 go 不在 PATH,设置 GO_BIN):
1 | |
测试通过内存 gRPC 流订阅 EDS,检查初始响应包含版本 1 和资源、回送相同 nonce 的 ACK、变更端口后的版本 2、对新 nonce 的 NACK 仍报旧版本及断流重连后收到最新资源;缺少 RDS 或 EDS 依赖的快照必须失败。测试客户端模拟反馈,不是 Envoy;NACK 反馈也不表示服务器自动回滚快照。测试使用 go test -race -count=1,避免把旧缓存结果算作新证据。若断言失败脚本退出非零并保存原始输出,证据放在 examples/service-mesh/evidence/E08/<UTC时间>-<PID>/。其它 E01–E07 的真实代理、集群实验没有因为此测试而通过。
真实 Envoy 必须与 Go 服务位于同一网络命名空间:这里全部地址都是 loopback。macOS 上单独启动容器中的 Envoy,再让它访问宿主 127.0.0.1,不会连接到宿主的库存与控制面。可以使用同一 Linux 宿主上的二进制,或把整套实验放进同一容器网络环境。环境具备后运行:
1 | |
脚本先执行协议断言和 bootstrap 校验,再启动两个库存进程与控制面。检查点分别保存 initial.json、updated.json、rejected-keeps-v2.json、recovered.json、reconnected.json;xds.log 保存发送与反馈,*-config.json 保存代理配置,两个库存日志保留应用侧证据。任何一步未得到预期结果,脚本退出非零,只有全部通过才写 envoy.result=PASS。默认运行只写协议层结果,并把数据面标为 NOT_RUN。
真实 Linux Envoy 1.37.0 运行证据位于 examples/service-mesh/evidence/E08/20261003T090715Z-2/,run.exit 为 0。初始请求返回 v1,端点更新后返回 v2;非法版本 3 的 RDS 被 Envoy 拒绝,日志保留 sent_version=3 accepted_version=2 和缺少 path_specifier 的错误,随后请求仍返回 v2;有效版本 4 恢复为 v1,代理进程重启后新 ADS 流再次 ACK 版本 4,报价仍为 v1。上述结果由五份响应 JSON、xds.log、配置转储和库存日志共同支持,已超过模拟客户端反馈的证据范围。
该运行也暴露了教学实现的限制:无效期望快照仍留在缓存时,日志可出现同一坏版本的多次 NACK;服务端没有发布去重、退避或自动回滚策略。测试以有效新版本结束拒绝过程,不能把它称为生产控制面的故障隔离。没有覆盖多代理收敛、网络分区或长连接保活中的更新,因此这些场景仍未验收。
项目故意不含多租户隔离、控制面认证与授权、证书/SDS、密钥管理、跨进程 HA、持久存储、故障隔离和增量订阅;端口更新输入甚至来自标准输入,只可放在隔离宿主。不能以它替代 Istiod,也不能向生产 Envoy 提供配置。先修 03 Envoy、06 路由 和 07 xDS。
练习与参考解答
- 画出从换 EDS 端口到确认
/quote命中新库存的证据链。参考解答:版本 2 的 EDS 响应及 nonce → 客户端 ACK/当前活跃 endpoint → 相同请求 ID 的代理访问记录、上游地址和新库存应用日志;单独SetSnapshot或 EDS ACK 都不能推出调用成功。 - 删掉 RDS 资源只保留 listener,测试应怎样失败?参考解答:
Snapshot.Consistent()应拒绝资源引用不完整;即使强行推送,Envoy 可能进入 warming/NACK,不能宣布“删掉路由就能访问默认后端”。检查缺失资源与客户端反馈,而不是降低断言强行通过。 - 版本 3 的 RDS 被拒绝后,把控制面缓存仍为版本 3 当成“代理已运行版本 3”,错在哪里?参考解答:缓存保存期望状态,NACK 表明对应响应未被接受。应检查该类型最后接受版本、配置转储及请求结果;恢复要发布有效的新版本,不能修改日志上的版本号。
| 遇到的问题 | 应核对的边界 | 本例入口 |
|---|---|---|
| 资源缺失 | 名称引用是否完整 | Snapshot.Consistent() |
| 配置拒绝 | 字段校验和代理错误详情 | invalid、NACK |
| 更新没有影响请求 | 接受状态、活跃配置、后端响应 | port N、config dump、库存版本 |
| 重连收到旧状态 | 流与 nonce、当前缓存版本 | 关闭流后重新订阅 |
资料与系列导航
Envoy 1.37 xDS 协议、go-control-plane 固定提交的 cache 与 server。核验记录 writing-plans/service-mesh/research/E08.md。
系列导航:E07 Ambient 多集群 → E08 教学 xDS(本文) → 35 采用决策。
