Repository: envoyproxy/gateway

Issue:
Locality Based Routing Support
*Description*:
Implement locality based routing support by default in EG. Now that we we can have individual endpoints as backend to EG. Can we support region/zone/subzone based routing based on EndpointSlice information, node labels etc.?



Discussion:
@arkodg: Hey @tanujd11 from a user perspective can you share what you like to happen on the data plane ( from gateway to multiple backend endpoints with different topology info ) @arkodg: I understand this is very useful for optimizing East West traffic within a cluster, is that also the case for north South ?@tanujd11: I think for an Envoy gateway running in us-east-1/us-east-1a should prefer the same zone backend to prevent cross zonal traffic. I think this behaviour could be made as default as cross zone communication is obviously costly. WDYT?@arkodg: thanks, here's something more to think about
* opt in or default - K8s service has a opt in annotation https://kubernetes.io/docs/concepts/services-networking/topology-aware-routing/#enabling-topology-aware-routing `service.kubernetes.io/topology-mode`
* Is this a soft preference (weighted) or hard preference (priority)@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@arkodg: there's a new field in the Service spec (trafficDistribution.preferClose) https://kubernetes.io/docs/concepts/services-networking/service/#traffic-distribution that we could consider using to automate priority amongst endpoints within a Service@aoledk: > there's a new field in the Service spec (trafficDistribution.preferClose) https://kubernetes.io/docs/concepts/services-networking/service/#traffic-distribution that we could consider using to automate priority amongst endpoints within a Service

Could be an option when this new field is stable and corresponding K8s version is adopted by massive companies.

Before that, IMO it's better to do load balancing accross endpoints in the cluster via Envoy's capability.

Currently EG has implemented locality weighted load balancing [^1], one `BackendRef` is translated to one `LocalityLbEndpoints`.

```go
locality := &endpointv3.LocalityLbEndpoints{
	Locality: &corev3.Locality{
		Region: fmt.Sprintf("%s/backend/%d", clusterName, i),
  	},
	LbEndpoints: endpoints,
	Priority:    0,
}
  
// Set locality weight
var weight uint32
if ds.Weight != nil {
	weight = *ds.Weight
} else {
	weight = 1
}
```

Actually endpoints inside a `LocalityLbEndpoints` may be running in different zone, cross zone cost can't be saved in this way.

---

Through Envoy's capability, priority levels [^2] or zone aware routing [^3] [^4] can archive the goal to save cross zone cost.

### priority levels

1. Backend endpoint should be set with correct zone, it can be retrieved from EndpointSlice, inherited from Node `topology.kubernetes.io/zone` label.
2. Envoy's command options should be set with `--service-zone` option, means which zone Envoy Pod is running in.
3. EG rearranges EDS resources for each Envoy, if Envoy and Backend endpoint are in same zone, priority as `0`, else `1`.


### zone aware routing

> This approach is mutually exclusive with locality weighted load balancing, since in the case of locality aware LB, we rely on the management server to provide the locality weighting, rather than the Envoy-side heuristics used in zone aware routing.

1. Backend endpoint should be set with correct zone, it can be retrieved from EndpointSlice, inherited from Node `topology.kubernetes.io/zone` label.
2. Envoy's command options should be set with `--service-zone` option, value meaning which zone Envoy Pod is running in.
3. Envoy's bootstrap config should be set with `cluster_manager. local_cluster_name`, means which fleet Envoy Pod belongs to, it will be `irKey` in implementation.
4. Add cluster corresponding to `cluster_manager. local_cluster_name` to CDS resources.
5. Design a mechanism to discover Envoy Pods belongs to `cluster_manager. local_cluster_name` as endpoints and add them to EDS resources.
6. Both Envoy and Backend cluster are not in panic mode [^5].

### personal preference

Since step 1 and 2 is required by both, priority levels can work with **implemented** locality weighed load balancing, but zone aware routing can't. Apparently priority levels are easier to implement. But it requires EDS resources should be arranged in `xds/cache` module for individual Envoy. No matter EG do this, or create new xDS Hook API, like `PostEndpointModify(ClusterLoadAssignment, Node)` which allow extension server to do this.

[^1]: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/locality_weight
[^2]: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/priority
[^3]: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/zone_aware
[^4]: https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/zone_aware_routing
[^5]: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/panic_threshold#arch-overview-load-balancing-panic-threshold@arkodg: thanks for outlining the steps @aoledk ! we currently have https://github.com/envoyproxy/gateway/issues/3055 open to get explicit priority per backendRef and program that into the xds cluster resource.

In the future, we can use this issue to make sure we track the auto priority work, the field in k8s `preferClose` could be the knob for users to say they want to opt in to this feature@guydc: Hi @aoledk, regarding:
> priority levels
> [...]
> EG rearranges EDS resources for each Envoy, if Envoy and Backend endpoint are in same zone, priority as 0, else 1.

Is this option viable? Can our XDS server produce different EDS for different envoy pods that are part of the same Envoy deployment? @modatwork: I think it's possible. xDS server can read the locality info of envoy node.

> The cache will be keyed based on a pre-defined hash function whose keys are based on the [Node information](https://github.com/envoyproxy/data-plane-api/blob/d4988844024d0bcff4bcd030552eabe3396203fa/api/base.proto#L26-L36).

```protobuf
// Identifies a specific Envoy instance. Remote server may have per Envoy configuration.
message Node {
  // An opaque node identifier for the Envoy node. This must be set.
  string id = 1;
  // The cluster that the Envoy node belongs to. This must be set.
  string cluster = 2;
  google.protobuf.Struct metadata = 3;
  Locality locality = 4;
  // This is motivated by informing a management server during canary which
  // version of Envoy is being tested in a heterogeneous fleet.
  string build_version = 5;
}
```@guydc: Thanks for pointing that out @modatwork. My other concerns wrt. to this approach are:
- Conflict with general-purpose use cases of priorities (e.g. to support things like active/passive failover)
- Possible impact on memory consumption if we have to maintain a copy of the cache for each locality. Not sure if that's already the situation today. @arkodg  - do you know?

In general: 
- I'm +1 to supporting zone-aware routing in EG. 
- I would avoid using priorities for this feature in EG's built-in feature set. 
- The the extension-server approach to EP priority manipulation could work. If we don't have a per-locality cache, maybe that should be an opt-in feature. 

Is there a reason to prefer the Priority-based approach? I'm not sure that it's significantly simpler than enabling zone-aware routing. @arkodg: is @modatwork the same person as @aoledk :) ?

> Possible impact on memory consumption if we have to maintain a copy of the cache for each locality. Not sure if that's already the situation today. @arkodg - do you know?

@guydc we have are dumuxing on gateway/IR, with `locality` it would add another dimension lookup and would increase memory by num localities * total (xds per gateway * gateway resources)@aoledk: @arkodg I work together with @modatwork @github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@arkodg: hey @aoledk , adding this issue to the v1.2 milestone, is this something you can help with ?

1. Lets configure zone aware routing in envoy by default https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/zone_aware_routing
2. If a Service has `TrafficDistribution` set to `PreferClose` https://kubernetes.io/docs/concepts/services-networking/service/#traffic-distribution, lets rearrange the EDS endpoint (so Service opts in)@aoledk: > hey @aoledk , adding this issue to the v1.2 milestone, is this something you can help with ?
> 
> 1. Lets configure zone aware routing in envoy by default https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/zone_aware_routing
> 2. If a Service has `TrafficDistribution` set to `PreferClose` https://kubernetes.io/docs/concepts/services-networking/service/#traffic-distribution, lets rearrange the EDS endpoint (so Service opts in)

@arkodg I can help.@arkodg: awesome thanks @aoledk !@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@arkodg: hey @aoledk still planning on working on this one for v1.2 ?@aoledk: > hey @aoledk still planning on working on this one for v1.2 ?

Hi @arkodg nowadays I'm working on bring in EG v1.1., next month I will continue on this feature, but not sure whether it can be merged into v1.2 (Due by October 30, 2024), maybe v1.3.  @arkodg: thanks for the update @aoledk, let us know if you hit any issues while running EG v1.1
moving this issue into backlog@aoledk: @arkodg LGTM.@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@flyik: Hi @aoledk! Are you still looking into implementing it yourself? If not, I’m interested in this feature and can work on bringing it to life.@aoledk: @flyik recently I'm busy with bringing in EG, you can go ahead.@aoledk: @flyik I've unassigned myself, you can assign to yourself.@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@kahirokunn: keep@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@kahirokunn: keep@jukie: Is anyone working on this who would like to collaborate? I'm interested in support for both [Traffic Distribution](https://kubernetes.io/docs/reference/networking/virtual-ips/#traffic-distribution) and [Topology Aware Routing](https://kubernetes.io/docs/concepts/services-networking/topology-aware-routing/)@arkodg: thanks @jukie assigning this one to you.
Rethinking https://github.com/envoyproxy/gateway/issues/1909#issuecomment-2261258513, we may need to make this opt in, using a field such as 'ZoneAwareRoutingEnabled' in the EnvoyProxy resource@jukie: @arkodg wouldn't opting in via the Kubernetes Service annotation (`service.kubernetes.io/topology-mode: Auto`) be a better place so that it can be configured per-service?

Updating [getIREndpointsFromEndpointSlice()](https://github.com/envoyproxy/gateway/blob/1fec0595e39aa6f06482b2a14afbd6a3500ed633/internal/gatewayapi/route.go#L1659) and `DestinationEndpoint` should be pretty simple to include topology info from Kubernetes but a topic with more complexity would be how individual Envoy instances should discover their locality zone. Before I get started, any preferences on the approach there?

The approach I thought of was to include the `--service-zone` arg for the Envoy proxy deployment. It would default to `--service-zone $SERVICE_ZONE` and we'd inject this env var separately through an init container or mutating webhook. In either setup it'd fail open for an empty env var resulting in no zone being set and falling back to current logic.@arkodg: I think there are two parts here 
1. Enabling settings in envoy proxy to enable the zone aware routing feature
2. Enabling zone aware routing per backend service using trafficDistribution.preferClose

I was referring to 1. to be opt in, in case setting those fields has an impact on common case performance, and might affect users not interested in the feature, and if it isn't, default is fine@flyik: Hi! Will there be an option to choose between [Zone-Aware Routing](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/zone_aware) and [Locality-Weighted Load Balancing](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/locality_weight) in the future? It looks like the approach with priorities has a lot to offer—starting with more than one "layer" for balancing (region/zone/subzone vs. just zone in the case of ZAR), the ability to [manually set](https://istio.io/latest/docs/tasks/traffic-management/locality-load-balancing/failover/) fallback locations, and the option to specify [weights](https://istio.io/latest/docs/tasks/traffic-management/locality-load-balancing/distribute/) across them.@jukie: @flyik yes, my PR is still very much WIP but the end result won't hardcode one or the other like it does currently.@jukie: From the contributors call it sounds like there's a strong preference against adding a Mutating Webhook so I was curious if anyone has another suggestion for how to handle zone discovery from the envoy proxy instances. An initContainer is the other option I had in mind.

The challenge is that we can't use downwardAPI to share underlying node topology labels and inject as an env var for example so we'll need something else to populate the `--service-zone` arg or bootstrap configuration.@arkodg: thanks for digging into this @jukie, +1 to the init container approach of injecting an env var and reusing it in the proxy container
@jukie: Is anyone interested in owning that piece of work? That can happen separately from my PR and would amount to the goal of getting the envoy proxy instances launched with `--service-zone=$SOME_ZONE` or `--service-zone=$(cat /some/shared/file/from/init)"`. @arkodg: sure @jukie feel free to create a GH issue or subtask for it, and someone else from the community can pick that piece up@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@kahirokunn: Keep@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@kahirokunn: keep@arkodg: keeping this open to track docs work@jukie: Is there any specific docs that I can work on @arkodg? EG now has support for zone aware routing but really it's just respecting native kubernetes Traffic Distribution.

Has there been any additional discussion on making it directly configurable via ir.LoadBalancer or another field?@arkodg: hey @jukie we'll need to add a doc in https://gateway.envoyproxy.io/docs/tasks/traffic/ similar to https://gateway.envoyproxy.io/docs/tasks/traffic/failover/ explaining how to use it @jukie: I'd also like to expose the configuration for zone aware lb and weighted localities under BTP load balancer settings and have a [WIP PR](https://github.com/envoyproxy/gateway/pull/5938) I plan to bring up at the contributors call.

Something like this is what I'd like to have available:
```
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: btp
spec:
  loadBalancer:
    requestDistribution:
      type: PreferLocalZone|WeightedZone
      preferLocalZoneConfig: # maps to ZoneAwareLbConfig and overrides Service config (e.g. TrafficDistribution)
        forceLocalZone: true
        forceLocalZoneMinZoneSize: 1
        minClusterSize: 1
      weightedZoneConfig: # maps to LocalityWeightedLbConfig and weights will be considered during buildXdsClusterLoadAssignment() 
        weights:
          zone1: 25
          zone2: 50
          zone3: 25
```

I have two main use cases in my environment:
* Support for the default zone aware routing config - We run Kubernetes in physical datacenters which can span hundreds of racks/zones. I'd like to set a preference that traffic stays local but I don't want to require running an Envoy pod on every rack in order to serve traffic to backends there. In most cases this will be true but I'd like the Envoy dataplane pods to have awareness of other locality zones and be able to distribute load evenly. In my PR I've added support to track the Envoy ProxyInfra and render an XDS cluster for this to work.
* Ability to directly set weights per locality zone - In situations with uneven incoming traffic between zones or a separate set of loadbalancing infra is present that Envoy isn't aware of it'd be great if as a user I was able to adjust the zone weights accordingly.

Solution patch:
diff --git a/examples/extension-server/go.mod b/examples/extension-server/go.mod
index 01f23345d..f6ac01e7e 100644
--- a/examples/extension-server/go.mod
+++ b/examples/extension-server/go.mod
@@ -4,8 +4,8 @@ go 1.24.4
 
 require (
 	github.com/envoyproxy/gateway v1.3.1
-	github.com/envoyproxy/go-control-plane v0.13.5-0.20250408134212-157c26b62099
-	github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250408134212-157c26b62099
+	github.com/envoyproxy/go-control-plane v0.13.5-0.20250430092421-68a532e11403
+	github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250430092421-68a532e11403
 	github.com/urfave/cli/v2 v2.27.7
 	google.golang.org/grpc v1.73.0
 	google.golang.org/protobuf v1.36.6
diff --git a/examples/extension-server/go.sum b/examples/extension-server/go.sum
index 425e04fde..d8cc6363b 100644
--- a/examples/extension-server/go.sum
+++ b/examples/extension-server/go.sum
@@ -9,10 +9,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/envoyproxy/go-control-plane v0.13.5-0.20250408134212-157c26b62099 h1:Shucu2sY2a/KjKzO10Y0st2iN0Flzj5QsTEgMFtd0MY=
-github.com/envoyproxy/go-control-plane v0.13.5-0.20250408134212-157c26b62099/go.mod h1:Kf4hNGzgvzKhoKdlSXD+IZtG55h9r2SOpO1kRKLI03o=
-github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250408134212-157c26b62099 h1:LFmrrIXbGktG4DrXsvFFyvZLe+HnimGn6Ed+xuiP6TU=
-github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250408134212-157c26b62099/go.mod h1:pY0vLp032ToSdejxKtzWZs/TzoAtdK0+50ELjTkXiYA=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250430092421-68a532e11403 h1:FjMl4h6bOc7ZZz0t90qi0UEEWEga8+XHsrKjff34NNo=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250430092421-68a532e11403/go.mod h1:D8/0gVH79cd9cXse2nAwJ7K0GujYzJ63JR0IEoWuu2M=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250430092421-68a532e11403 h1:hkrqmqBqYmHhisHsi1rDZ7tbnVGjxdiyM9pSbE7v3Tw=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250430092421-68a532e11403/go.mod h1:2dn73jFhVMBsgVCq7Z/jsPsTzhDKwYZOHLf3Uz8Rj0s=
 github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
 github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
diff --git a/go.mod b/go.mod
index cacd14ef0..a7d63453a 100644
--- a/go.mod
+++ b/go.mod
@@ -15,9 +15,9 @@ require (
 	github.com/docker/cli v28.2.2+incompatible
 	github.com/docker/docker v27.5.1+incompatible
 	github.com/dominikbraun/graph v0.23.0
-	github.com/envoyproxy/go-control-plane v0.13.5-0.20250408134212-157c26b62099
-	github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250408134212-157c26b62099
-	github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250408134212-157c26b62099
+	github.com/envoyproxy/go-control-plane v0.13.5-0.20250430092421-68a532e11403
+	github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250430092421-68a532e11403
+	github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250430092421-68a532e11403
 	github.com/envoyproxy/go-control-plane/ratelimit v0.1.0
 	github.com/envoyproxy/ratelimit v1.4.1-0.20230427142404-e2a87f41d3a7
 	github.com/evanphx/json-patch v5.9.11+incompatible
diff --git a/go.sum b/go.sum
index 1cffe8074..9dd0c3455 100644
--- a/go.sum
+++ b/go.sum
@@ -373,12 +373,12 @@ github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.13.5-0.20250408134212-157c26b62099 h1:Shucu2sY2a/KjKzO10Y0st2iN0Flzj5QsTEgMFtd0MY=
-github.com/envoyproxy/go-control-plane v0.13.5-0.20250408134212-157c26b62099/go.mod h1:Kf4hNGzgvzKhoKdlSXD+IZtG55h9r2SOpO1kRKLI03o=
-github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250408134212-157c26b62099 h1:YCkhFxkFlN1h7QVwZTo3a14QlxXf0p1MrycEBuE3wJc=
-github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250408134212-157c26b62099/go.mod h1:zD9QKAwQBnb9DWvRrkMa7cOuuMOt+ITrm4ZLoZ0aa30=
-github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250408134212-157c26b62099 h1:LFmrrIXbGktG4DrXsvFFyvZLe+HnimGn6Ed+xuiP6TU=
-github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250408134212-157c26b62099/go.mod h1:pY0vLp032ToSdejxKtzWZs/TzoAtdK0+50ELjTkXiYA=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250430092421-68a532e11403 h1:FjMl4h6bOc7ZZz0t90qi0UEEWEga8+XHsrKjff34NNo=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250430092421-68a532e11403/go.mod h1:D8/0gVH79cd9cXse2nAwJ7K0GujYzJ63JR0IEoWuu2M=
+github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250430092421-68a532e11403 h1:5wPocL1bGYhA4TtKZwcdVI5fsXo1JatkbcxPBcFQswc=
+github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250430092421-68a532e11403/go.mod h1:Xkwx/TGvEKRCL2mitdiuQWOD1ECvfM5krWWVo2vI2Zk=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250430092421-68a532e11403 h1:hkrqmqBqYmHhisHsi1rDZ7tbnVGjxdiyM9pSbE7v3Tw=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250430092421-68a532e11403/go.mod h1:2dn73jFhVMBsgVCq7Z/jsPsTzhDKwYZOHLf3Uz8Rj0s=
 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go
index 05edb5a00..379e812de 100644
--- a/internal/gatewayapi/route.go
+++ b/internal/gatewayapi/route.go
@@ -1409,7 +1409,7 @@ func (t *Translator) processDestination(name string, backendRefContext BackendRe
 		ds = t.processServiceDestinationSetting(name, backendRef.BackendObjectReference, backendNamespace, protocol, resources, envoyProxy)
 		svc := resources.GetService(backendNamespace, string(backendRef.Name))
 		ds.IPFamily = getServiceIPFamily(svc)
-		ds.ZoneAwareRoutingEnabled = isZoneAwareRoutingEnabled(svc)
+		ds.ZoneAwareRouting = processZoneAwareRouting(svc)
 
 	case egv1a1.KindBackend:
 		ds = t.processBackendDestinationSetting(name, backendRef.BackendObjectReference, backendNamespace, protocol, resources)
@@ -1571,12 +1571,12 @@ func (t *Translator) processServiceDestinationSetting(
 	}
 
 	return &ir.DestinationSetting{
-		Name:                    name,
-		Protocol:                protocol,
-		Endpoints:               endpoints,
-		AddressType:             addrType,
-		ZoneAwareRoutingEnabled: isZoneAwareRoutingEnabled(service),
-		Metadata:                buildResourceMetadata(service, ptr.To(gwapiv1.SectionName(strconv.Itoa(int(*backendRef.Port))))),
+		Name:             name,
+		Protocol:         protocol,
+		Endpoints:        endpoints,
+		AddressType:      addrType,
+		ZoneAwareRouting: processZoneAwareRouting(service),
+		Metadata:         buildResourceMetadata(service, ptr.To(gwapiv1.SectionName(strconv.Itoa(int(*backendRef.Port))))),
 	}
 }
 
@@ -1596,24 +1596,28 @@ func getBackendFilters(routeType gwapiv1.Kind, backendRefContext BackendRefConte
 	return nil
 }
 
-func isZoneAwareRoutingEnabled(svc *corev1.Service) bool {
+func processZoneAwareRouting(svc *corev1.Service) *ir.ZoneAwareRouting {
 	if svc == nil {
-		return false
+		return nil
 	}
 
 	if trafficDist := svc.Spec.TrafficDistribution; trafficDist != nil {
-		return *trafficDist == corev1.ServiceTrafficDistributionPreferClose
+		return &ir.ZoneAwareRouting{
+			MinSize: 1,
+		}
 	}
 
 	// Allows annotation values that align with Kubernetes defaults.
 	// Ref:
 	// https://kubernetes.io/docs/concepts/services-networking/topology-aware-routing/#enabling-topology-aware-routing
 	// https://github.com/kubernetes/kubernetes/blob/9d9e1afdf78bce0a517cc22557457f942040ca19/staging/src/k8s.io/endpointslice/utils.go#L355-L368
-	if val, ok := svc.Annotations[corev1.AnnotationTopologyMode]; ok {
-		return val == "Auto" || val == "auto"
+	if val, ok := svc.Annotations[corev1.AnnotationTopologyMode]; ok && val == "Auto" || val == "auto" {
+		return &ir.ZoneAwareRouting{
+			MinSize: 3,
+		}
 	}
 
-	return false
+	return nil
 }
 
 func (t *Translator) processDestinationFilters(routeType gwapiv1.Kind, backendRefContext BackendRefContext, parentRef *RouteParentContext, route RouteContext, resources *resource.Resources) (*ir.DestinationFilters, error) {
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index 7b2f53184..80c7da60e 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -1571,7 +1571,7 @@ func (r *RouteDestination) HasFiltersInSettings() bool {
 // HasZoneAwareRouting returns true if any setting in the destination has ZoneAwareRoutingEnabled set
 func (r *RouteDestination) HasZoneAwareRouting() bool {
 	for _, setting := range r.Settings {
-		if setting.ZoneAwareRoutingEnabled {
+		if setting.ZoneAwareRouting != nil {
 			return true
 		}
 	}
@@ -1629,9 +1629,11 @@ type DestinationSetting struct {
 	IPFamily *egv1a1.IPFamily    `json:"ipFamily,omitempty" yaml:"ipFamily,omitempty"`
 	TLS      *TLSUpstreamConfig  `json:"tls,omitempty" yaml:"tls,omitempty"`
 	Filters  *DestinationFilters `json:"filters,omitempty" yaml:"filters,omitempty"`
-	// ZoneAwareRoutingEnabled specifies whether to enable Zone Aware Routing for this destination's endpoints.
+	// ZoneAwareRouting specifies whether to enable Zone Aware Routing for this destination's endpoints.
 	// This is derived from the backend service and depends on having Kubernetes Topology Aware Routing or Traffic Distribution enabled.
-	ZoneAwareRoutingEnabled bool `json:"zoneAwareRoutingEnabled,omitempty" yaml:"zoneAwareRoutingEnabled,omitempty"`
+	//
+	// +optional
+	ZoneAwareRouting *ZoneAwareRouting `json:"zoneAwareRouting,omitempty" yaml:"zoneAwareRouting,omitempty"`
 	// Metadata is used to enrich envoy route metadata with user and provider-specific information
 	// The primary metadata for DestinationSettings comes from the Backend resource reference in BackendRef
 	Metadata *ResourceMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
@@ -3145,3 +3147,9 @@ type RequestBuffer struct {
 	// Limit defines the maximum buffer size for requests
 	Limit resource.Quantity `json:"limit" yaml:"limit"`
 }
+
+// ZoneAwareRouting holds the zone aware routing configuration
+// +k8s:deepcopy-gen=true
+type ZoneAwareRouting struct {
+	MinSize int `json:"minSize" yaml:"minSize"`
+}
diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go
index f938808c9..d9925b30d 100644
--- a/internal/ir/zz_generated.deepcopy.go
+++ b/internal/ir/zz_generated.deepcopy.go
@@ -939,6 +939,11 @@ func (in *DestinationSetting) DeepCopyInto(out *DestinationSetting) {
 		*out = new(DestinationFilters)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.ZoneAwareRouting != nil {
+		in, out := &in.ZoneAwareRouting, &out.ZoneAwareRouting
+		*out = new(ZoneAwareRouting)
+		**out = **in
+	}
 	if in.Metadata != nil {
 		in, out := &in.Metadata, &out.Metadata
 		*out = new(ResourceMetadata)
@@ -4179,3 +4184,18 @@ func (in *Xds) DeepCopy() *Xds {
 	in.DeepCopyInto(out)
 	return out
 }
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ZoneAwareRouting) DeepCopyInto(out *ZoneAwareRouting) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneAwareRouting.
+func (in *ZoneAwareRouting) DeepCopy() *ZoneAwareRouting {
+	if in == nil {
+		return nil
+	}
+	out := new(ZoneAwareRouting)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/internal/xds/extensions/extensions.gen.go b/internal/xds/extensions/extensions.gen.go
index ee628748b..bdb3e19d3 100644
--- a/internal/xds/extensions/extensions.gen.go
+++ b/internal/xds/extensions/extensions.gen.go
@@ -140,6 +140,7 @@ import (
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/oauth2/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/on_demand/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/original_src/v3"
+	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/proto_api_scrubber/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/proto_message_extraction/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rate_limit_quota/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ratelimit/v3"
@@ -266,6 +267,7 @@ import (
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/retry/host/previous_hosts/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/retry/priority/previous_priorities/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/router/cluster_specifiers/lua/v3"
+	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/router/cluster_specifiers/matcher/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/stat_sinks/graphite_statsd/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/stat_sinks/open_telemetry/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/stat_sinks/wasm/v3"
diff --git a/internal/xds/server/kubejwt/jwtinterceptor.go b/internal/xds/server/kubejwt/jwtinterceptor.go
index fcd8722ac..b2925592d 100644
--- a/internal/xds/server/kubejwt/jwtinterceptor.go
+++ b/internal/xds/server/kubejwt/jwtinterceptor.go
@@ -16,7 +16,6 @@ import (
 	"k8s.io/client-go/kubernetes"
 
 	"github.com/envoyproxy/gateway/internal/logging"
-	"github.com/envoyproxy/gateway/internal/xds/cache"
 )
 
 // JWTAuthInterceptor verifies Kubernetes Service Account JWT tokens in gRPC requests.
@@ -24,17 +23,15 @@ type JWTAuthInterceptor struct {
 	clientset *kubernetes.Clientset
 	issuer    string
 	audience  string
-	cache     cache.SnapshotCacheWithCallbacks
 	logger    logging.Logger
 }
 
 // NewJWTAuthInterceptor initializes a new JWTAuthInterceptor.
-func NewJWTAuthInterceptor(logger logging.Logger, clientset *kubernetes.Clientset, issuer, audience string, cache cache.SnapshotCacheWithCallbacks) *JWTAuthInterceptor {
+func NewJWTAuthInterceptor(logger logging.Logger, clientset *kubernetes.Clientset, issuer, audience string) *JWTAuthInterceptor {
 	return &JWTAuthInterceptor{
 		clientset: clientset,
 		issuer:    issuer,
 		audience:  audience,
-		cache:     cache,
 		logger:    logger.WithName("jwt-auth-interceptor"),
 	}
 }
diff --git a/internal/xds/server/kubejwt/tokenreview.go b/internal/xds/server/kubejwt/tokenreview.go
index 1a3a59612..1f448550d 100644
--- a/internal/xds/server/kubejwt/tokenreview.go
+++ b/internal/xds/server/kubejwt/tokenreview.go
@@ -9,11 +9,9 @@ import (
 	"context"
 	"fmt"
 	"slices"
-	"strings"
 
 	authenticationv1 "k8s.io/api/authentication/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apiserver/pkg/authentication/serviceaccount"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
@@ -68,38 +66,5 @@ func (i *JWTAuthInterceptor) validateKubeJWT(ctx context.Context, token, nodeID
 		}
 	}
 
-	// Check if the service account name in the JWT token exists in the cache.
-	// This is used to verify that the token belongs to a valid Envoy managed by Envoy Gateway.
-	// example: "system:serviceaccount:default:envoy-default-eg-e41e7b31"
-	parts := strings.Split(tokenReview.Status.User.Username, ":")
-	if len(parts) != 4 {
-		return fmt.Errorf("invalid username format: %s", tokenReview.Status.User.Username)
-	}
-	ns, sa := parts[2], parts[3]
-
-	irKeys := i.cache.GetIrKeys()
-	for _, irKey := range irKeys {
-		nn := irKey2ServiceAccountName(irKey)
-		if nn.Name == sa && nn.Namespace == ns {
-			return nil
-		}
-	}
-	return fmt.Errorf("service account for Envoy %s not found in the cache", sa)
-}
-
-// this is the same logic used in infra pkg func ExpectedResourceHashedName to generate the resource name.
-func irKey2ServiceAccountName(irKey string) types.NamespacedName {
-	names := strings.Split(irKey, "/")
-	if len(names) == 2 {
-		return types.NamespacedName{
-			Namespace: names[0],
-			Name:      names[1],
-		}
-	}
-
-	// Might be MergeGateways, should not happen
-	// but just in case, return the first part as name
-	return types.NamespacedName{
-		Name: names[0],
-	}
+	return nil
 }
diff --git a/internal/xds/server/runner/runner.go b/internal/xds/server/runner/runner.go
index 429438bab..f610935b5 100644
--- a/internal/xds/server/runner/runner.go
+++ b/internal/xds/server/runner/runner.go
@@ -118,7 +118,6 @@ func (r *Runner) Start(ctx context.Context) (err error) {
 			clientset,
 			defaultKubernetesIssuer,
 			saAudience,
-			r.cache,
 		)
 
 		creds, err := credentials.NewServerTLSFromFile(xdsTLSCertFilepath, xdsTLSKeyFilepath)
diff --git a/internal/xds/translator/cluster.go b/internal/xds/translator/cluster.go
index 8d811c2d6..8b2183261 100644
--- a/internal/xds/translator/cluster.go
+++ b/internal/xds/translator/cluster.go
@@ -185,11 +185,6 @@ func buildXdsCluster(args *xdsClusterArgs) (*buildClusterResult, error) {
 	}
 
 	for i, ds := range args.settings {
-		// If zone aware routing is enabled we update the cluster lb config
-		if ds.ZoneAwareRoutingEnabled {
-			cluster.CommonLbConfig.LocalityConfigSpecifier = &clusterv3.Cluster_CommonLbConfig_ZoneAwareLbConfig_{ZoneAwareLbConfig: &clusterv3.Cluster_CommonLbConfig_ZoneAwareLbConfig{}}
-		}
-
 		if ds.TLS != nil {
 			socket, err := buildXdsUpstreamTLSSocketWthCert(ds.TLS)
 			if err != nil {
@@ -345,7 +340,7 @@ func buildXdsCluster(args *xdsClusterArgs) (*buildClusterResult, error) {
 
 	if args.endpointType != EndpointTypeDynamicResolver {
 		for _, ds := range args.settings {
-			buildZoneAwareRoutingCluster(ds.ZoneAwareRoutingEnabled, cluster, args.loadBalancer)
+			buildZoneAwareRoutingCluster(ds.ZoneAwareRouting, cluster, args.loadBalancer)
 		}
 	}
 
@@ -359,8 +354,8 @@ func buildXdsCluster(args *xdsClusterArgs) (*buildClusterResult, error) {
 // cluster.LbPolicy and cluster.CommonLbConfig with cluster.LoadBalancingPolicy.
 // TODO: Remove cluster.LbPolicy along with clustercommongLbConfig and switch to cluster.LoadBalancingPolicy
 // everywhere as the preferred and more feature-rich configuration field.
-func buildZoneAwareRoutingCluster(enabled bool, cluster *clusterv3.Cluster, lb *ir.LoadBalancer) {
-	if !enabled {
+func buildZoneAwareRoutingCluster(cfg *ir.ZoneAwareRouting, cluster *clusterv3.Cluster, lb *ir.LoadBalancer) {
+	if cfg == nil {
 		return
 	}
 
@@ -370,10 +365,10 @@ func buildZoneAwareRoutingCluster(enabled bool, cluster *clusterv3.Cluster, lb *
 	localityLbConfig := &commonv3.LocalityLbConfig{
 		LocalityConfigSpecifier: &commonv3.LocalityLbConfig_ZoneAwareLbConfig_{
 			ZoneAwareLbConfig: &commonv3.LocalityLbConfig_ZoneAwareLbConfig{
-				// Future enhancement: differentiate between topology-aware-routing and trafficDistribution
-				// once https://github.com/envoyproxy/envoy/pull/39058 is merged
-				MinClusterSize:             wrapperspb.UInt64(1),
-				ForceLocalityDirectRouting: true,
+				MinClusterSize: wrapperspb.UInt64(1),
+				ForceLocalZone: &commonv3.LocalityLbConfig_ZoneAwareLbConfig_ForceLocalZone{
+					MinSize: wrapperspb.UInt32(uint32(cfg.MinSize)),
+				},
 			},
 		},
 	}
@@ -657,7 +652,7 @@ func buildXdsClusterLoadAssignment(clusterName string, destSettings []*ir.Destin
 		// if multiple backendRefs exist. This pushes part of the routing logic higher up the stack which can
 		// limit host selection controls during retries and session affinity.
 		// For more details see https://github.com/envoyproxy/gateway/issues/5307#issuecomment-2688767482
-		if ds.ZoneAwareRoutingEnabled {
+		if ds.ZoneAwareRouting != nil {
 			localities = append(localities, buildZonalLocalities(metadata, ds)...)
 		} else {
 			localities = append(localities, buildWeightedLocalities(metadata, ds))



Test patch:
diff --git a/internal/gatewayapi/testdata/httproute-with-enable-zone-discovery.out.yaml b/internal/gatewayapi/testdata/httproute-with-enable-zone-discovery.out.yaml
index be6a626b3..726f58635 100644
--- a/internal/gatewayapi/testdata/httproute-with-enable-zone-discovery.out.yaml
+++ b/internal/gatewayapi/testdata/httproute-with-enable-zone-discovery.out.yaml
@@ -159,7 +159,8 @@ xdsIR:
             name: httproute/default/httproute-1/rule/1/backend/0
             protocol: HTTP
             weight: 1
-            zoneAwareRoutingEnabled: true
+            zoneAwareRouting:
+              minSize: 1
         headerMatches:
         - distinct: false
           exact: bar
@@ -196,7 +197,8 @@ xdsIR:
             name: httproute/default/httproute-1/rule/0/backend/0
             protocol: HTTP
             weight: 1
-            zoneAwareRoutingEnabled: true
+            zoneAwareRouting:
+              minSize: 1
         hostname: '*'
         isHTTP2: false
         metadata:
diff --git a/internal/ir/xds_test.go b/internal/ir/xds_test.go
index 6c97e40d2..61a966bd1 100644
--- a/internal/ir/xds_test.go
+++ b/internal/ir/xds_test.go
@@ -1124,8 +1124,8 @@ func TestRouteDestination_NeedsClusterPerSetting(t *testing.T) {
 								Port: 8080,
 							},
 						},
-						AddressType:             ptr.To(FQDN),
-						ZoneAwareRoutingEnabled: true,
+						AddressType:      ptr.To(FQDN),
+						ZoneAwareRouting: &ZoneAwareRouting{MinSize: 1},
 					},
 					{
 						Endpoints: []*DestinationEndpoint{
@@ -1152,8 +1152,8 @@ func TestRouteDestination_NeedsClusterPerSetting(t *testing.T) {
 								Port: 8080,
 							},
 						},
-						AddressType:             ptr.To(FQDN),
-						ZoneAwareRoutingEnabled: true,
+						AddressType:      ptr.To(FQDN),
+						ZoneAwareRouting: &ZoneAwareRouting{MinSize: 1},
 					},
 				},
 			},
diff --git a/internal/xds/translator/cluster_test.go b/internal/xds/translator/cluster_test.go
index 1a2846a60..db2d0a489 100644
--- a/internal/xds/translator/cluster_test.go
+++ b/internal/xds/translator/cluster_test.go
@@ -60,25 +60,32 @@ func TestBuildXdsCluster(t *testing.T) {
 
 func TestCheckZoneAwareRouting(t *testing.T) {
 	tests := []struct {
-		name               string
-		zoneRoutingEnabled bool
-		loadBalancerCfg    *ir.LoadBalancer
+		name            string
+		zoneRouting     *ir.ZoneAwareRouting
+		loadBalancerCfg *ir.LoadBalancer
 	}{
 		{
-			name:               "zone-routing with default lb",
-			zoneRoutingEnabled: true,
+			name:        "zone-routing with default lb",
+			zoneRouting: &ir.ZoneAwareRouting{MinSize: 1},
 			loadBalancerCfg: &ir.LoadBalancer{
 				LeastRequest: &ir.LeastRequest{},
 			},
 		},
 		{
-			name:               "zone-routing with nil lb",
-			zoneRoutingEnabled: true,
-			loadBalancerCfg:    nil,
+			name:        "zone-routing with default lb and topology aware routing",
+			zoneRouting: &ir.ZoneAwareRouting{MinSize: 3},
+			loadBalancerCfg: &ir.LoadBalancer{
+				LeastRequest: &ir.LeastRequest{},
+			},
 		},
 		{
-			name:               "zone-routing with least request",
-			zoneRoutingEnabled: true,
+			name:            "zone-routing with nil lb",
+			zoneRouting:     &ir.ZoneAwareRouting{MinSize: 1},
+			loadBalancerCfg: nil,
+		},
+		{
+			name:        "zone-routing with least request",
+			zoneRouting: &ir.ZoneAwareRouting{MinSize: 1},
 			loadBalancerCfg: &ir.LoadBalancer{
 				LeastRequest: &ir.LeastRequest{
 					SlowStart: &ir.SlowStart{Window: &metav1.Duration{Duration: 1 * time.Second}},
@@ -86,8 +93,8 @@ func TestCheckZoneAwareRouting(t *testing.T) {
 			},
 		},
 		{
-			name:               "zone-routing with round robin",
-			zoneRoutingEnabled: true,
+			name:        "zone-routing with round robin",
+			zoneRouting: &ir.ZoneAwareRouting{MinSize: 1},
 			loadBalancerCfg: &ir.LoadBalancer{
 				RoundRobin: &ir.RoundRobin{
 					SlowStart: &ir.SlowStart{Window: &metav1.Duration{Duration: 1 * time.Second}},
@@ -95,13 +102,13 @@ func TestCheckZoneAwareRouting(t *testing.T) {
 			},
 		},
 		{
-			name:               "zone-routing with random",
-			zoneRoutingEnabled: true,
-			loadBalancerCfg:    &ir.LoadBalancer{Random: &ir.Random{}},
+			name:            "zone-routing with random",
+			zoneRouting:     &ir.ZoneAwareRouting{MinSize: 1},
+			loadBalancerCfg: &ir.LoadBalancer{Random: &ir.Random{}},
 		},
 		{
-			name:               "zone-routing with maglev",
-			zoneRoutingEnabled: true,
+			name:        "zone-routing with maglev",
+			zoneRouting: &ir.ZoneAwareRouting{MinSize: 1},
 			loadBalancerCfg: &ir.LoadBalancer{
 				ConsistentHash: &ir.ConsistentHash{
 					TableSize: proto.Uint64(65537),
@@ -109,8 +116,8 @@ func TestCheckZoneAwareRouting(t *testing.T) {
 			},
 		},
 		{
-			name:               "zone-routing with round robin",
-			zoneRoutingEnabled: true,
+			name:        "zone-routing with round robin",
+			zoneRouting: &ir.ZoneAwareRouting{MinSize: 1},
 			loadBalancerCfg: &ir.LoadBalancer{
 				RoundRobin: &ir.RoundRobin{
 					SlowStart: &ir.SlowStart{Window: &metav1.Duration{Duration: 1 * time.Second}},
@@ -118,8 +125,8 @@ func TestCheckZoneAwareRouting(t *testing.T) {
 			},
 		},
 		{
-			name:               "zone-routing disabled",
-			zoneRoutingEnabled: false,
+			name:        "zone-routing disabled",
+			zoneRouting: nil,
 			loadBalancerCfg: &ir.LoadBalancer{
 				LeastRequest: &ir.LeastRequest{},
 			},
@@ -129,8 +136,8 @@ func TestCheckZoneAwareRouting(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			bootstrapXdsCluster := getXdsClusterObjFromBootstrap(t)
 			ds := &ir.DestinationSetting{
-				Endpoints:               []*ir.DestinationEndpoint{{Host: envoyGatewayXdsServerHost, Port: bootstrap.DefaultXdsServerPort}},
-				ZoneAwareRoutingEnabled: tt.zoneRoutingEnabled,
+				Endpoints:        []*ir.DestinationEndpoint{{Host: envoyGatewayXdsServerHost, Port: bootstrap.DefaultXdsServerPort}},
+				ZoneAwareRouting: tt.zoneRouting,
 			}
 			args := &xdsClusterArgs{
 				name:         bootstrapXdsCluster.Name,
@@ -145,14 +152,13 @@ func TestCheckZoneAwareRouting(t *testing.T) {
 			clusterResult, err := buildXdsCluster(args)
 			dynamicXdsCluster := clusterResult.cluster
 			require.NoError(t, err)
-			buildZoneAwareRoutingCluster(tt.zoneRoutingEnabled, dynamicXdsCluster, args.loadBalancer)
 
-			if !tt.zoneRoutingEnabled {
+			if tt.zoneRouting == nil {
 				require.Nil(t, dynamicXdsCluster.LoadBalancingPolicy)
 				require.Equal(t, &clusterv3.Cluster_CommonLbConfig_LocalityWeightedLbConfig_{LocalityWeightedLbConfig: &clusterv3.Cluster_CommonLbConfig_LocalityWeightedLbConfig{}}, dynamicXdsCluster.CommonLbConfig.LocalityConfigSpecifier)
 			} else {
 				require.Nil(t, dynamicXdsCluster.CommonLbConfig.LocalityConfigSpecifier)
-				expectedLoadBalancingPolicy := getExpectedClusterLbPolicies(dynamicXdsCluster.LbPolicy, args.loadBalancer)
+				expectedLoadBalancingPolicy := getExpectedClusterLbPolicies(tt.zoneRouting, dynamicXdsCluster.LbPolicy, args.loadBalancer)
 				require.Equal(t, expectedLoadBalancingPolicy.Policies[0].TypedExtensionConfig.Name, dynamicXdsCluster.LoadBalancingPolicy.Policies[0].TypedExtensionConfig.Name)
 				require.Equal(t, expectedLoadBalancingPolicy.Policies[0].GetTypedExtensionConfig().GetTypedConfig().String(), dynamicXdsCluster.LoadBalancingPolicy.Policies[0].GetTypedExtensionConfig().GetTypedConfig().String())
 			}
@@ -160,12 +166,14 @@ func TestCheckZoneAwareRouting(t *testing.T) {
 	}
 }
 
-func getExpectedClusterLbPolicies(policy clusterv3.Cluster_LbPolicy, lb *ir.LoadBalancer) *clusterv3.LoadBalancingPolicy {
+func getExpectedClusterLbPolicies(zoneRouting *ir.ZoneAwareRouting, policy clusterv3.Cluster_LbPolicy, lb *ir.LoadBalancer) *clusterv3.LoadBalancingPolicy {
 	localityLbConfig := &commonv3.LocalityLbConfig{
 		LocalityConfigSpecifier: &commonv3.LocalityLbConfig_ZoneAwareLbConfig_{
 			ZoneAwareLbConfig: &commonv3.LocalityLbConfig_ZoneAwareLbConfig{
-				MinClusterSize:             wrapperspb.UInt64(1),
-				ForceLocalityDirectRouting: true,
+				MinClusterSize: wrapperspb.UInt64(1),
+				ForceLocalZone: &commonv3.LocalityLbConfig_ZoneAwareLbConfig_ForceLocalZone{
+					MinSize: wrapperspb.UInt32(uint32(zoneRouting.MinSize)),
+				},
 			},
 		},
 	}
diff --git a/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing-weighted-clusters.yaml b/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing-weighted-clusters.yaml
index 4e1138342..fb9c0c6fc 100644
--- a/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing-weighted-clusters.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing-weighted-clusters.yaml
@@ -19,7 +19,8 @@ http:
           zone: us-east-1b
         name: "first-route-dest/backend/0"
         weight: 30
-        zoneAwareRoutingEnabled: true
+        zoneAwareRouting:
+          minSize: 1
       - endpoints:
         - host: "3.4.5.6"
           port: 9090
@@ -29,4 +30,3 @@ http:
           zone: us-east-1d
         name: "first-route-dest/backend/1"
         weight: 70
-        zoneAwareRoutingEnabled: false
diff --git a/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing.yaml b/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing.yaml
index 1500304bb..37d103aed 100644
--- a/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/http-route-zonal-routing.yaml
@@ -19,4 +19,5 @@ http:
           zone: us-east-1b
         name: "first-route-dest/backend/0"
         weight: 30
-        zoneAwareRoutingEnabled: true
+        zoneAwareRouting:
+          minSize: 1
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing-weighted-clusters.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing-weighted-clusters.clusters.yaml
index 4ff3f3693..7494b14bc 100644
--- a/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing-weighted-clusters.clusters.yaml
+++ b/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing-weighted-clusters.clusters.yaml
@@ -19,7 +19,8 @@
           '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest
           localityLbConfig:
             zoneAwareLbConfig:
-              forceLocalityDirectRouting: true
+              forceLocalZone:
+                minSize: 1
               minClusterSize: "1"
   name: first-route-dest/backend/0
   perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing.clusters.yaml
index 055b68450..546f93e8e 100644
--- a/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing.clusters.yaml
+++ b/internal/xds/translator/testdata/out/xds-ir/http-route-zonal-routing.clusters.yaml
@@ -19,7 +19,8 @@
           '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest
           localityLbConfig:
             zoneAwareLbConfig:
-              forceLocalityDirectRouting: true
+              forceLocalZone:
+                minSize: 1
               minClusterSize: "1"
   name: first-route-dest
   perConnectionBufferLimitBytes: 32768
diff --git a/test/e2e/testdata/zone-aware-routing.yaml b/test/e2e/testdata/zone-aware-routing.yaml
index df91966fb..63a2414fc 100644
--- a/test/e2e/testdata/zone-aware-routing.yaml
+++ b/test/e2e/testdata/zone-aware-routing.yaml
@@ -6,7 +6,7 @@ metadata:
   labels:
     app: zone-aware-backend
 spec:
-  replicas: 1
+  replicas: 3
   selector:
     matchLabels:
       app: zone-aware-backend