
1. 项目背景路由聚合这个问题到底卡在哪1.1 一个入口背后有多个功能服务我先完整还原一下我最近遇到的场景。线上有一个老应用同时承担用户登录、订单查询、支付回调三块功能。后来团队开始拆微服务订单模块先拆成独立服务用户模块还留在老应用里前端不能改接口地址域名也只有一个。于是问题就变成了同一个域名下/api/order开头的请求要落到新拆出来的订单服务/api/user开头的请求继续回老应用甚至同一个订单功能里一部分请求要按请求头切到灰度版本。这不是普通网关的简单转发而是要把散落在不同服务上的路由规则重新聚合成一个对外入口。用 Istio 来做这件事核心就是 VirtualService 里的路由聚合配置。这个问题的典型特征有三个入口固定、后端拆分、路由规则需要灵活调整。传统做法是在 Nginx 里写一堆 location但服务多了以后location 会变得又臭又长每次加服务都要改配置重载。Istio 把流量规则从数据面解耦出来通过 Gateway、VirtualService、DestinationRule 三种资源描述清楚“流量从哪进来、按照什么条件转发、最终到哪个服务哪个版本”规则更新不需要重启任何网关进程。1.2 Istio 路由聚合到底聚合了什么很多初学者容易把 Istio 的 VirtualService 理解成“负载均衡器”这是不对的。VirtualService 本身不处理实际流量它只是规则描述。真正接收流量的是 ingressgateway 这个 Envoy 实例或者是业务 Pod 旁边的 sidecar Envoy。也就是说你写的 VirtualService 最终会被 Istio 控制面转换成 Envoy 的 route 配置下发到对应的 Envoy 上。因此路由聚合可以理解为把多个后端服务的路由规则组合到同一个 VirtualService 里让不同的 path、header、method 条件命中不同的 destination。这里的“聚合”不是把多个服务合并成一个进程而是把原本要写在多个地方的路由规则集中到一份 YAML 里统一管理、统一下发、统一排错。这也带来了一个隐蔽的收益当你的服务足够多时路由规则是可以用 Git 管理的。谁在什么时间改了哪条规则review 起来比在 Nginx 配置里翻 location 要清楚得多。后面我会用一个非常具体的例子把 Gateway、VirtualService、DestinationRule 三者之间的关系串起来。1.3 适合谁读这篇文章这篇文章面向的是刚接触 Istio、已经经历过服务拆分痛苦的人。你不一定需要懂 Envoy 源码也不需要会写复杂的 Lua 脚本只需要有最基础的 Kubernetes 使用经验能看懂 Deployment 和 Service 就行。我会从一套可复现的本地环境开始逐步把“同一个应用不同功能的路由聚合”这个命题拆开包括配置怎么写、为什么这么写、遇到问题怎么排查最后再补充一些我在生产环境里踩过的坑。2. 环境准备从零搭起一套可复现的路由聚合环境2.1 安装 Kubernetes 集群与 Istio 控制面在本地模拟这套方案我推荐用 kind 或者 k3d机器资源占用比 minikube 小而且创建集群速度很快。我这里以 kind 为例先创建一个单节点集群kind create cluster --name istio-demo如果你没有 kind也可以用任何已有的 Kubernetes 集群版本建议 v1.25 以上。集群准备好之后安装 Istio。这里我直接使用官方脚本下载并安装最新稳定版本curl -L https://istio.io/downloadIstio | sh - cd istio-version export PATH$PWD/bin:$PATH istioctl install --set profiledemo -yprofiledemo的好处是内置了 ingressgateway并且安装了所有常用的附加组件适合本地学习和实验。安装完成后确认一下组件状态kubectl get pods -n istio-system看到istio-ingressgateway和istiod都是 Running环境就算准备好了。这一步有个容易忽略的点如果你用 kind本地没有云厂商的 LoadBalanceringressgateway 的 EXTERNAL-IP 会一直处于 pending 状态。这不代表环境有问题后面调试时用kubectl port-forward把网关端口映射出来访问即可。2.2 部署两个功能服务作为聚合对象下面我要模拟“同一个应用的不同功能”。为了简单我直接部署两个独立的功能服务order-service负责订单user-service负责用户。每个服务都只是返回一段文本方便我们通过 response 判断流量到底去了哪里。在正式环境中这两个服务可能一开始是同一个应用的两个 deployment后来才被拆开。路由聚合解决的问题就是入口不变内部流量按路径被准确分发到不同后端。我先创建一个命名空间并开启 sidecar 自动注入kubectl create namespace app kubectl label namespace app istio-injectionenabled然后创建两个 Deployment 和对应的 ServiceapiVersion: apps/v1 kind: Deployment metadata: name: order-service-v1 namespace: app spec: replicas: 1 selector: matchLabels: app: order-service version: v1 template: metadata: labels: app: order-service version: v1 spec: containers: - name: order image: hashicorp/http-echo args: - -textorder-v1 - -listen:8080 ports: - containerPort: 8080 --- apiVersion: v1 kind: Service metadata: name: order-service namespace: app spec: selector: app: order-service ports: - name: http port: 8080 targetPort: 8080 --- apiVersion: apps/v1 kind: Deployment metadata: name: user-service-v1 namespace: app spec: replicas: 1 selector: matchLabels: app: user-service version: v1 template: metadata: labels: app: user-service version: v1 spec: containers: - name: user image: hashicorp/http-echo args: - -textuser-v1 - -listen:8080 ports: - containerPort: 8080 --- apiVersion: v1 kind: Service metadata: name: user-service namespace: app spec: selector: app: user-service ports: - name: http port: 8080 targetPort: 8080这里我特意给 Service 的端口起了名字叫http。这是 Istio 识别 HTTP 协议的一个关键约定端口名不带http前缀的话Istio 可能会把它当 TCP 处理HTTP 路由规则就不会生效。很多人第一步就栽在这里。创建完成后确认 Pod 状态kubectl get pods -n app如果两个 Pod 都是 Running并且容器是 2/2 Running说明 sidecar 已经注入成功。如果显示 1/2说明 namespace 的istio-injectionenabledlabel 没加上或者注入配置没生效。3. 核心配置用 VirtualService 把多个功能路由聚合到同一个入口3.1 用 Gateway 暴露统一入口在 Istio 里外部流量进入集群需要先经过 Gateway。Gateway 控制的是“入口能不能进”VirtualService 控制的是“进来之后怎么走”。两者是分层的缺一不可。我创建一个公网入口绑定一个虚拟域名app.example.comapiVersion: networking.istio.io/v1beta1 kind: Gateway metadata: name: app-gateway namespace: app spec: selector: istio: ingressgateway servers: - port: number: 80 name: http protocol: HTTP hosts: - app.example.comselector指定的是 istio-system 命名空间下的 ingressgateway Pod因为默认网关资源的 label 就是istio: ingressgateway。hosts 这里写的是虚拟主机名不是 DNS 解析不需要真的去申请域名。这个 Gateway 创建好之后只表示“app.example.com 的 80 端口请求可以进入 ingressgateway”。如果这时候还没有任何 VirtualService 指向它访问也会返回 404因为网关不知道要把流量转给谁。3.2 VirtualService 按路径做路由聚合接下来是核心配置。我把订单服务的路由和用户服务的路由聚合到同一个 VirtualService 里根据请求路径的前缀转发到不同后端apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: app-vs namespace: app spec: hosts: - app.example.com gateways: - app-gateway http: - name: order-route match: - uri: prefix: /api/order/ route: - destination: host: order-service port: number: 8080 - name: user-route match: - uri: prefix: /api/user/ route: - destination: host: user-service port: number: 8080这段 YAML 的含义很直白hosts和 Gateway 里的 hosts 保持一致。gateways指定使用刚才创建的 app-gateway。http列表里从上到下写了两个规则每个规则都有一个 match 条件。当请求 URI 以/api/order/开头时转发到order-service的 8080 端口。当请求 URI 以/api/user/开头时转发到user-service的 8080 端口。这就是“路由聚合”落地到 Istio 资源后的形态。它把两个原本分散在不同服务的路由规则合并进一个 VirtualService。后续再增加支付功能、库存功能只需要往这个 YAML 里继续追加规则不需要碰 Gateway。有人可能会问为什么这里没有写 DestinationRule因为当前只需要路由到 Service 这一层不需要细分服务内部的版本。如果将来要给order-service分 v1、v2 版本才需要 DestinationRule 配合。3.3 验证路由聚合结果应用上面的 YAML 后开始验证实际效果。先看 ingressgateway 的访问地址kubectl get svc -n istio-system istio-ingressgateway如果你在云环境直接使用 EXTERNAL-IP。如果是在 kind 本地环境先开一个端口转发kubectl port-forward -n istio-system svc/istio-ingressgateway 8080:80然后分别访问两个路径curl -s http://localhost:8080/api/order/123 \ -H Host: app.example.com # 期望输出order-v1 curl -s http://localhost:8080/api/user/info \ -H Host: app.example.com # 期望输出user-v1只要分别返回order-v1和user-v1说明路径聚合已经生效。这里必须带着Host: app.example.com请求头否则 ingressgateway 不知道你访问的是哪个虚拟主机会直接返回 404。如果你在真实服务器上已经配置了域名解析Host 会被浏览器自动带上不需要手动加。但是在用 curl 调试时这一步非常容易漏掉。3.4 路由顺序与匹配边界处理刚才配置里我用的是prefix: /api/order/注意这个末尾的斜杠。Istio 的 prefix 匹配是简单的字符串前缀匹配/api/order/会匹配/api/order/123、/api/order/detail但不会匹配/api/order本身也不会匹配/api/order-extra。如果你把前缀写成/api/order那么/api/order-extra/xxx也会被匹配进去这往往不是你想要的结果。所以在做路径规划时我建议统一约定所有接口路径都以/结尾比如/api/order/、/api/user/前缀匹配的边界会清晰很多。另外还要注意 VirtualService 里多个 match 规则是按顺序从上到下匹配的第一个命中之后就不会再继续往下走。因此如果有更特殊的规则比如/api/order/internal/要专门走另一个服务一定要把这个特殊规则写在/api/order/规则的前面。4. 进阶场景同一功能多版本的路由聚合与灰度4.1 给同一个服务增加第二个版本前面解决的是“不同功能聚合到不同服务”但实际工作中更常见的是“同一个功能有多个版本要根据不同条件聚合到同一个服务的不同版本”。我继续用订单服务举例现在新增一个 v2 版本模拟订单功能做了逻辑升级apiVersion: apps/v1 kind: Deployment metadata: name: order-service-v2 namespace: app spec: replicas: 1 selector: matchLabels: app: order-service version: v2 template: metadata: labels: app: order-service version: v2 spec: containers: - name: order image: hashicorp/http-echo args: - -textorder-v2 - -listen:8080 ports: - containerPort: 8080这个 Deployment 和 v1 共用一个 Serviceorder-service因为 Service 的 selector 只匹配app: order-service不关心version。这时候如果你不配置任何额外规则直接访问/api/order/流量会落到两个 v1/v2 Pod 上因为 Service 的 Endpoint 里同时包含两个版本。这种不可控的分布在生产环境是不可接受的我们必须在 Istio 层把版本区分开。4.2 用 DestinationRule 定义版本子集DestinationRule 的作用是给同一个 Service 定义不同的 subset每个 subset 通过 label 选择对应的 Pod。继续把 v1 和 v2 两个子集定义出来apiVersion: networking.istio.io/v1beta1 kind: DestinationRule metadata: name: order-dr namespace: app spec: host: order-service subsets: - name: v1 labels: version: v1 - name: v2 labels: version: v2注意这里的host是order-service对应 Service 的名字。subset 的labels必须和 Pod 模板里的 labels 一致。有了 DestinationRule 之后VirtualService 里的 destination 就可以引用 subset 了apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: app-vs namespace: app spec: hosts: - app.example.com gateways: - app-gateway http: - name: order-route match: - uri: prefix: /api/order/ route: - destination: host: order-service subset: v1 port: number: 8080 - name: user-route match: - uri: prefix: /api/user/ route: - destination: host: user-service subset: v1 port: number: 8080这样做之后/api/order/的流量就稳定地只走 v1 版本。subset 这种能力天然适合“同一个应用不同功能”路由聚合的扩展因为聚合的粒度从“服务”细化到了“服务内部的版本”。4.3 按 Header 做灰度路由聚合现在如果要把测试人员的请求切到 v2最常用的方法是用 Header 匹配。比如约定请求头env: canary时走 v2其他请求继续走 v1apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: app-vs namespace: app spec: hosts: - app.example.com gateways: - app-gateway http: - name: order-canary match: - uri: prefix: /api/order/ headers: env: exact: canary route: - destination: host: order-service subset: v2 port: number: 8080 - name: order-default match: - uri: prefix: /api/order/ route: - destination: host: order-service subset: v1 port: number: 8080 - name: user-route match: - uri: prefix: /api/user/ route: - destination: host: user-service subset: v1 port: number: 8080验证时用不同 Header 访问curl -s http://localhost:8080/api/order/123 \ -H Host: app.example.com # 期望输出order-v1 curl -s http://localhost:8080/api/order/123 \ -H Host: app.example.com \ -H env: canary # 期望输出order-v2这个案例非常贴近生产前端在发布页面加一个“灰度用户”入口请求带上env: canary后端不用改代码只改 Istio 规则就能完成灰度流量调度。这也是为什么很多人愿意把路由规则放到 Istio而不是塞在 Nginx 配置里。4.4 按比例分配流量实现金丝雀发布Header 匹配适合小范围灰度但如果你要按比例放量比如 90% 流量到 v110% 流量到 v2VirtualService 同样支持- name: order-weight match: - uri: prefix: /api/order/ route: - destination: host: order-service subset: v1 port: number: 8080 weight: 90 - destination: host: order-service subset: v2 port: number: 8080 weight: 10这个配置放在同一个路由规则里不再需要单独的 match。Istio 会按照权重把/api/order/的 90% 流量发给 v110% 流量发给 v2。权重值可以不用累计到 100但为了可读性和避免误解我建议显式写清楚总和保持 100。需要特别注意的是权重路由只适用于同一个 host 下的 subset 分配。如果你想在两个完全不同的 Service 之间按比例分流同样可以在 destination 里写两个不同的 host但这种情况实际很少见不同服务应该通过路径区分而不是靠随机权重。5. 常见问题与排查实录5.1 配置已 apply 但访问返回 404这是我最常被问到的问题。规则都 apply 成功了curl 却返回 404。绝大多数原因是请求头里的 Host 和 VirtualService 里的 hosts 不匹配。使用 curl 访问 ingressgateway 时如果没有显式加Host头curl 默认会把localhost或者你访问的 IP 当作 Host。而我们的 VirtualService 只匹配了app.example.com自然就找不到路由。解决方法是我在上文再三强调的调试时务必带上curl -s http://localhost:8080/api/order/123 \ -H Host: app.example.com还有一种情况是 Gateway 和 VirtualService 的 namespace 不匹配。Istio 默认只在同一个 namespace 内查找 VirtualService 引用的 Gateway建议把 Gateway、VirtualService、DestinationRule 放在同一个 namespace 下省去很多心智负担。5.2 配置规则正确但流量不按预期走如果 VS 和 DR 都创建了但 subset 不生效先检查 DestinationRule 创建了没有。很多人先创建了 VirtualService 里的subset: v2但忘了创建 DR结果 Istio 控制面虽然接收了配置流量却仍然会打到 Service 的所有后端 Pod 上。正确的检查方式是kubectl get destinationrule -n app然后查看 DR 里的 subset 是否和 Pod 的 labels 完全匹配。这里必须严格要求 key 和 value 都一致多一个空格都会导致匹配失败。还有一个非常隐蔽的坑如果 Pod 是手工创建的而不是通过 Deployment 创建的Pod 的 label 可能没有打全。Istio sidecar 注入只负责网络层面label 是 Kubernetes 资源管理层面的两者互不影响但 missing label 会直接导致 subset 找不到可用 Endpoint。5.3 出现 503 或 upstream connect error访问时如果返回 503尤其是带有upstream connect error的日志大概率是后端的 Service 端口协议和 Istio 配置不一致。比如 Service 端口名没有以http开头或者服务本身没有正常监听对应端口。可以先检查后端 Service 的 Endpoint 是否正常kubectl get endpoints -n app order-service如果 Endpoints 为空说明 Service 的 selector 没有匹配到任何 Pod。如果 Endpoints 有地址但 curl 还是 503可能是 sidecar 或者后端容器本身异常。这时进入业务 Pod 直接测试kubectl exec -it -n app deploy/order-service-v1 -c order -- \ wget -q -O - http://localhost:8080/ping这一步可以区分问题出在业务容器还是出在 istio-proxy 上。5.4 用 istioctl 命令做最终定位istioctl是 Istio 排错的最佳工具。最常用的几个命令我都列在表格里排查目标命令说明配置检查istioctl analyze -n app检查 VS、DR、Gateway 配置是否冲突或缺少依赖查看网关虚拟路由istioctl proxy-config route deploy/istio-ingressgateway -n istio-system确认 ingressgateway 实际生效的路由规则查看 Envoy Clusteristioctl proxy-config cluster deploy/istio-ingressgateway -n istio-system确认是否有对应后端的 cluster 配置查看 Endpointistioctl proxy-config endpoint deploy/istio-ingressgateway -n istio-system确认后端地址是否在 Envoy 的负载均衡池里大部分路由问题都可以通过istioctl analyze和proxy-config组合定位。尤其是你想确认“规则是不是真的下发到了网关”不要光看 kubectl apply 的结果而是要看 Envoy 内部实际的路由表。6. 实操心得与避坑清单6.1 路径聚合规划的几个原则我在生产环境里的第一原则是前缀不要重叠。比如有/api/order/和/api/order/pay/两个功能必须评估两者是否存在包含关系。如果后面的规则更具体就要把更具体的规则放在前面否则会被前面的前缀规则抢先匹配。第二原则是路径统一小写不要混用大小写。Istio 的 URI 匹配默认是大小写敏感的/API/Order和/api/order会被当成两个完全不同的路径。如果历史原因导致业务方大小写混用可以在 Gateway 或者 VirtualService 层面做大小写归一但是会增加额外规则能避免就尽量避免。第三原则是不要滥用正则。Istio 支持regex匹配但正则很难调试而且 Envoy 的正则引擎对复杂表达式性能影响明显。能用 prefix 解决的问题绝对不要用 regex。6.2 先从最简单的规则开始很多人一上来就想把 Header 匹配、权重路由、故障注入全部写进一个 VirtualService结果出现问题后很难定位是哪个规则在捣乱。我个人的经验是先把路径聚合跑通再逐步加版本分组最后加灰度策略。每加一步都用 curl 验证一次。这样做虽然看起来慢但积累下来的每一层验证都能帮你建立“配置和实际生效”之间的反馈。6.3 最后分享一个小技巧在公司内部我习惯把每个业务域的 VirtualService 单独一个 YAML 文件管理比如order-virtual-service.yaml、user-virtual-service.yaml。如果两个服务确实需要聚合到同一个入口我也不会把几十条规则都塞进一个文件而是用 Istio 的合并机制同一个 host 的多个 VirtualService 是可以共存的Istio 会按照创建顺序合并规则。不过这里有一个坑多个 VirtualService 同时匹配同一个 host规则顺序不如单个文件里那么直观容易互相覆盖。所以我最终的选择是“聚合入口用一个 Gateway规则按域拆成多个 VirtualService每个 VirtualService 内的规则数量控制在一定范围内”。这样既享受了聚合带来的统一入口又不会让单个配置文件失控。从“一个应用一个服务”到“一个应用多个功能服务”路由聚合是必经之路。Istio 的 VirtualService 是一个足够灵活的规则载体但灵活不等于可以乱写。把路径规范、subset 设计、规则顺序这三件事想清楚再复杂的聚合场景也不会失控。