The story: an estate runs its mesh on Google Anthos Service Mesh, managed, with the control plane off-cluster. It works, but the control plane is not theirs. The Istio version is chosen for them, the CRD versions the API server serves are older than a current Istio expects, and an in-place upgrade to ambient is not on the table. The way out is not an upgrade at all: build a new cluster with a mesh you control, keep ASM running untouched, and drain across.
On the name. Google has rebranded Anthos Service Mesh as Cloud Service Mesh. Everything
in the cluster still says ASM, which is what this lab uses: the revision is asm-managed, the
config map is env-asm-managed, and the tag on these clusters is 1.20.8-asm.88.
Read ASM and Cloud Service Mesh as the same product throughout.
That is a longer road than a label flip, and it has more places to trip. This lab walks all of it: the target cluster, the sidecar-first install, the ingress, the certificate authority, the ambient switch, the per-namespace migrations, and the bridge that lets a service in the new cluster call a service in the old one while both are live. Every number below came off a real run on GKE.
In ambient there is no sidecar. ztunnel does L4 for every enrolled pod, meaning mTLS, L4 authorization and TCP telemetry. Everything the sidecar did at L7 moves to a waypoint. A namespace with only L4 concerns needs no waypoint. A namespace with any L7 policy needs one before it is enrolled. That single decision drives the whole migration, and getting its ordering wrong is the most expensive mistake in this lab.
Why this is a migration and not an upgrade
Two things make this urgent, and both come from Google's own published positions.
The version underneath is already off the supported list. The clusters in this lab report
1.20.8-asm.88. Google's version page now lists 1.27, 1.28 and 1.29 as the supported in-cluster
lines, most recently 1.29.5-asm.12 released on 15 July 2026, and puts the whole 1.20 line under
unsupported in-cluster versions. Anything on 1.20 is several lines behind the floor, and Google sets the pace at which
that gap widens.
The provisioning path many estates were onboarded through is gone. Provisioning managed
Cloud Service Mesh with asmcli on Google Cloud clusters was deprecated on 22 August 2024 and
support for it ended in February 2025. Existing deployments were not switched off, and clusters outside
Google Cloud carry on with asmcli as before, but the onboarding route is closed and the
supported paths are gcloud and the console.
Add the thing this lab is actually about. ASM does not offer you ambient, the control plane that decides your Istio version belongs to Google, and its API server already serves older CRD versions than a current Istio expects, which you can see in the table further down. There is no in-place step from here to ambient. So the move is sideways: a new cluster, a mesh you own, and a drain.
Sources: Cloud Service Mesh versions, Provision managed Cloud Service Mesh with asmcli. Both read on 10 August 2026.
Two moves, not one
This is the thing to get straight before any of the commands make sense, because a migration off ASM is two separate changes and people run them together and then cannot tell which one broke.
| The move | What changes | Where it happens |
|---|---|---|
| 1. Change cluster ASM cluster to the new cluster |
The workload is deployed on the new cluster and its callers are repointed. The ASM copy is drained and scaled to zero. The mesh is still sidecars at this point if you want it to be. | Deploy on the new cluster, repoint callers, drain the old. The east-west bridge only exists to cover the window where a service has moved and some callers have not. |
| 2. Change dataplane sidecar to ambient |
The namespace swaps istio-injection for istio.io/dataplane-mode=ambient, the
pods roll without sidecars, and any L7 policy moves to a waypoint. |
Entirely inside one cluster. Nothing outside it is aware this happened. |
They are independent, and you can run them in either order. This lab does cluster first, dataplane second: the workloads are deployed onto the new cluster in sidecar mode, proven there against the same tests they pass on ASM, and only then migrated to ambient. That order is deliberate. If you flip the dataplane on a cluster where the app has never run, a failure has two possible causes and you get to guess which. Doing it this way, each step has one variable.
It also means the two clusters have very different workloads. Steps 1 to 11 below all happen on the new cluster. The ASM cluster is touched in exactly one step, and only if you need the bridge.
The environment
Two clusters already run ASM and are never modified at the control-plane level: no version change, no mesh-wide policy change, no fleet reconfiguration. Everything this lab builds lands on new clusters. That boundary keeps the exercise repeatable, and it is the boundary you want on a real migration too: experiments stay off the clusters carrying production traffic.
There is a fourth cluster in this lab that is not in the diagram, because it is not part of the architecture: a plain community Istio cluster, identical in every other respect, used purely as a control. The migration runs twice, once on each, and the two results are compared at the end. That comparison is what settles the mixed-fleet interop question.
Do not register the new cluster into the fleet
Check the fleet's default member config before you create anything:
bashthe trap worth five minutes of checking
gcloud container fleet mesh describe --project "$PROJECT"
If fleetDefaultMemberConfig.mesh.management reads MANAGEMENT_AUTOMATIC, then
registering the new cluster into that fleet makes Google start managing a mesh on it too. You would install
Solo Istio onto a cluster that Google is simultaneously handing an ASM install, which is the exact CRD collision
the whole migration exists to escape, self-inflicted on the new cluster instead of inherited on the old one.
Simplest answer: do not register the target cluster into the fleet at all. Fleet registration is opt-in later
if you specifically want fleet-scoped observability.
What is configured where
Start with what you can skip. If you can move a service and everything that calls it in the same window, you need nothing on the ASM side at all. Move the namespace, repoint its callers, done. The east-west bridge below exists for one situation: a service has moved and some of its callers have not, so a call has to cross while both clusters are live. Plenty of estates have that situation and some do not, so treat the whole left-hand column as conditional.
When you do need it, the ASM side gets one namespace holding an east-west gateway and one
ServiceEntry per crossing call. No remote secret, no injection change, no mesh-wide policy
edit, nothing that touches a running sidecar, and nothing at all at the control-plane level.
Two load balancers, doing different jobs. The east-west pair are internal LoadBalancers, one per cluster, reachable only inside the VPC, carrying migration-window traffic between the two meshes. The north-south ingress on the new cluster is a separate, external LoadBalancer, and it is what you eventually point real client traffic at. Keep them apart. Putting east-west traffic on an external LB is the hairpin problem described above, and putting client traffic on the internal one means nobody outside the VPC can reach you.
Why an Istio gateway on the ASM side and agentgateway on the new one
The two sides get different gateways, and not for consistency's sake. On the new cluster
there is no reason to run a second gateway stack: Enterprise agentgateway is already installed as the
ingress, so the east-west listener is just another Gateway on the same
enterprise-agentgateway class, with the internal-LB annotation set through Gateway API's own
spec.infrastructure.annotations and an HTTPRoute per exposed service. One gateway
product, one control plane, two listeners.
On the ASM cluster it has to be an Istio gateway, for two reasons. First, that cluster has
no Gateway API CRDs at all: kubectl get gatewayclass
answers the server doesn't have a resource type "gatewayclass", so a Gateway-API gateway cannot
even be expressed there without installing CRDs and a controller. Second, and more to the point, an Istio
gateway is programmed by the control plane that is already running. Its logs read
connected to upstream XDS server: meshconfig.googleapis.com:443, which is ASM's own managed
control plane configuring it. Nothing new gets installed into the cluster you promised not to touch, and
that is also precisely why the bridge needs no remote secret: the gateway talks to its own local control
plane, exactly as every sidecar on that cluster already does.
What GKE requires
Three of the things that cost time on the first pass are decided by GKE, not by Istio. Each one fails in a way that points at the wrong culprit, so they are worth having in front of you before you start.
-
The Gateway API CRDs are a separate install, and they go first. Waypoints are Gateway API
Gateways and the Enterprise agentgateway ingress is a GatewayClass, so both fail without them. GKE does not
ship them by default in this configuration. Install
gateway-apiv1.5.1standard-install.yamlbefore the first Helm chart, on every cluster. Make it a fixed first step. -
global.platform=gkeon thecniandztunnelcharts. Without it the CNI install fails withinsufficient quota to match these scopes: [{PriorityClass In [system-node-critical …]}], and there is noResourceQuotaobject anywhere that explains it, because it is GKE itself restricting those priority classes to its own managed namespaces. Moving the chart intokube-systemgets past that and straight into a second failure,open /host/opt/cni/bin/istio-cni.tmp…: read-only file system, because the default CNI binary path does not match Container-Optimized OS. The flag fixes both at once: it enables the chart's bundled GKE platform profile, which creates the quota GKE wants and detects the right CNI path, and it lets the chart stay inistio-systemwhere you wanted it. - East-west traffic wants an internal LoadBalancer. A pod calling another cluster's external LoadBalancer IP from inside the same VPC can silently time out: GCP's legacy target-pool Network Load Balancers have a hairpin limitation for exactly that shape of traffic. Internal LBs do not, and for traffic that should never leave the VPC they are the right choice anyway. Set the annotation on the first apply. Annotating an already-provisioning external LB afterwards does not cleanly switch it, because the finalizer stays with the external-LB controller, and you end up deleting and recreating the Service.
Where ASM's older CRD versions bite
The ASM control plane in this environment serves Istio 1.20.8. Ask its API server what it actually serves and the picture is uneven:
| API group | Versions served on the ASM cluster | Consequence |
|---|---|---|
networking.istio.ioVirtualService, DestinationRule, ServiceEntry, Gateway, Sidecar, WorkloadEntry |
v1alpha3, v1beta1. No v1. |
Manifests written for a current Istio fail to apply until the apiVersion is rewritten. |
security.istio.ioAuthorizationPolicy |
v1 is served. |
Works unchanged. The groups do not all lag equally, so check each one. |
security.istio.ioPeerAuthentication, RequestAuthentication |
v1beta1. |
Applies fail, and so does istioctl x describe pod, which assumes v1. |
telemetry.istio.io, extensions.istio.io |
v1alpha1. |
Same rewrite needed for Telemetry and WasmPlugin resources. |
This is also why the new cluster's control plane must not be pointed at the ASM cluster's Kubernetes API.
A remote secret makes istiod watch the other cluster's registry, and on this skew that watch fails outright:
failed to list *v1.WorkloadEntry: the server could not find the requested resource. Worse, while
a broken remote secret is installed, istiod's readiness probe fails and stays failed. A single bad
remote cluster takes the whole control plane's readiness down, not just discovery for that one cluster. Check
what your API server serves before you write a manifest:
bashwhat does this cluster actually serve
for c in virtualservices destinationrules serviceentries peerauthentications authorizationpolicies; do
printf '%-24s %s\n' "$c" \
"$(kubectl get crd ${c}.networking.istio.io ${c}.security.istio.io \
-o jsonpath='{.items[*].spec.versions[*].name}' 2>/dev/null)"
doneThe order that matters
Note. A namespace with an L7 AuthorizationPolicy needs a waypoint before it
is enrolled, and the old selector-based policy has to be converted to a targetRefs policy that
the waypoint enforces. The conversion itself is mechanical:
yamlselector to targetRefs, the whole transform
# before, enforced by the pod's own sidecar
selector:
matchLabels: { app: catalog }
# after, enforced on the waypoint
targetRefs:
- kind: Service
group: ""
name: catalogThe timing of the delete is the part that catches people. The instinct, and what most runbooks say, is to delete it last so the rule is never absent. Put a continuous load generator across the whole sequence instead of sampling after each step and that instinct turns out to be wrong, expensively:
| Order | Result across the cutover, 25 qps, 4 connections |
|---|---|
| Delete the old selector policy last, after the pods are ambient | Code 200 : 7341 (97.9 %), Code 503 : 159 (2.1 %). A 6.5 second window of total failure, ending at the instant the policy is deleted. 163 sockets used where 4 would be perfect. |
| Delete the old selector policy before the namespace is enrolled | Code 200 : 7500 (100.0 %), zero error cases, 4 sockets. And DELETE still returns 403 at every single gate. |
The reason is that a selector-based policy attaches to the workload, so once the workload is ambient
it is ztunnel that has to evaluate it. ztunnel is L4. It cannot evaluate an HTTP method rule, so it fails
closed, and the waypoint in front returns
503 URX,UC upstream_reset_before_response_started{connection_termination} for every request
until you get to the delete. I confirmed it in isolation instead of inferring it from the migration: with
the workload already fully ambient and stable at 100%, re-applying that one selector policy produced 995
errors out of 2248 requests in the 42 seconds it was present, and removing it restored 100% immediately. A
plain rolling restart of the same pods with no policy change at all scored 3748 (100.0 %), so
the pod roll is not the cause.
The fix is to move one step earlier. The waypoint is already enforcing the converted policy from the moment
it is programmed and the namespace carries istio.io/use-waypoint, so deleting the old one before
enrolment leaves no gap on the Solo distribution. The waypoint's own access log shows it serving requests
ten seconds before the first ambient pod existed, while every catalog pod still had a sidecar, and
DELETE returned 403 throughout.
DELETE starts returning 200 to a caller that still has a sidecar, because on
community images a sidecar caller does not route through the destination's waypoint. The waypoint's policy is
live and correct; that caller just never reaches it. On the Solo distribution the same caller is routed
through the waypoint, so the rule applies to it and DELETE stays 403.
What gets deployed, and what each piece is for
| Namespace | What is in it | What it proves |
|---|---|---|
petstore | catalog v1 and v2 behind one Service, a DestinationRule with subsets plus a VirtualService canary, and a method-based AuthorizationPolicy | The L7 case. Needs a waypoint before enrolment, and its policy needs converting. |
petstore-data | Redis over plain TCP with an identity-based L4 AuthorizationPolicy | The L4 case. No waypoint at all, and the policy does not change. |
petstore-legacy | checkout, a curl client, and fortio, the load generator | The estate that never migrates. It is the mixed-fleet caller and the measuring instrument at the same time. |
pki-check | One sidecar pod issued by the Vault-backed CA | The RSA baseline whose certificate must not change while everything else does. |
cross-cluster-test | A curl client with DNS capture on and a ServiceEntry | The migration-window bridge back to ASM. |
Keeping fortio and checkout in a namespace that never migrates is deliberate. The
measurement comes from outside whatever is being changed, and there is always a genuine sidecar caller in
the fleet to test policy against. Verify that it really is one, with the full pod JSON, and
read both container lists:
bashthe only sidecar check worth trusting
kubectl -n petstore-legacy get pod "$POD" -o json | python3 -c "
import json,sys
p=json.load(sys.stdin)
print('containers :', [c['name'] for c in p['spec']['containers']])
print('initContainers:', [(c['name'], c.get('restartPolicy')) for c in p['spec'].get('initContainers',[])])
for c in p['status'].get('containerStatuses',[]) + p['status'].get('initContainerStatuses',[]):
print(' ', c['name'], 'ready=', c.get('ready'))
"
On the 1.30.3 line, both the Solo distribution and community Istio inject istio-proxy as a
Kubernetes native sidecar: an init container with restartPolicy: Always, whose
live status lives in status.initContainerStatuses. A check that only reads
spec.containers and status.containerStatuses reports no sidecar at all and sends you
chasing an injection bug that is not there. jsonpath range expressions and
-o custom-columns both produced that false negative on earlier passes. The full-JSON version
above never has.
The mesh CA, if yours is RSA-only
Most estates that have run a mesh for years have an external CA and an RSA policy. Sidecars send RSA CSRs, so nothing has ever had to care. ztunnel only generates ECDSA P-256 keys. The first time a pod is enrolled into ambient, the CA is asked to sign something it has never been asked for, and if the signing role is locked to RSA it says no:
textthe rejection, from the CertificateRequest's own status
Ready = False | Failed | Vault failed to sign certificate: failed to sign certificate by vault:
Error making API request.
URL: POST http://vault.vault.svc:8200/v1/pki_int/sign/istio-ca
Code: 400. Errors:
* role requires keys of type rsa
The fix is one vault write to set key_type=any on that role, and it pays to be
precise with whoever owns the PKI about its scope. It relaxes a single signing path and leaves the rest of
the CA alone. ECDSA P-256 is a strength upgrade over RSA-2048. And the RSA workloads keep getting RSA
certificates from the same chain, which you can show by capturing a sidecar's leaf certificate serial before
and after.
key_type=ec while a sidecar is still running. Nothing fails at the
moment you do it. Existing certificates keep working until they expire, so what you have set up is a
mesh-wide sidecar outage on a one hour timer. any is the migration posture, and it stays
any until the last sidecar is gone.
One more ordering rule in this area, because it cost a run: after pointing an already running istiod
at a new CA, restart it immediately. The MutatingWebhookConfiguration's caBundle
does not refresh from the Helm upgrade alone, so injection breaks with
x509: certificate signed by unknown authority the next time a pod starts. A cold install never
hits this, which is exactly why it is missing from most install guides.
Which parts of this apply to you
Not all of it will. Four of the twelve build steps are conditional, and skipping the ones you do not need is the difference between a week and an afternoon.
| Part | Skip it when | Cost if you need it |
|---|---|---|
| Vault, cert-manager and istio-csr (step 5, step 8) | The mesh CA is istiod's built-in one. Most greenfield clusters, and plenty of mature ones. | Two steps, and the signing role has to allow EC before anything is enrolled. |
| The east-west bridge (step 12) | Every service moves together with its callers, so no call crosses while both clusters are live. | One gateway per cluster and one ServiceEntry per cluster pair. |
| The community-Istio control cluster (step 11) | Always, unless you specifically want the interop comparison reproduced in your own environment. | A second cluster, built once, that runs no production traffic. |
| Waypoints (step 10) | A namespace has no L7 policy or routing. Those namespaces are two labels and a rolling restart. | One Gateway per namespace, plus the selector to targetRefs conversion. |
The unconditional core is smaller than the page length suggests: build the cluster, install the mesh in sidecar mode, deploy and prove the workloads, turn on ambient, then migrate namespace by namespace. On this run the per-namespace work was the fast part. An L4 namespace took 3 seconds of wall clock to cut over, the full waypoint-first L7 sequence took about 40 seconds of actual operations, and a rollback took 17 seconds. The load windows in the steps below are long because they are there to catch failures, not because the work is slow.
Run the migration
Every step carries a badge saying which cluster it runs on. Almost all of them are the new cluster: steps 1 to 11 build it, migrate it and prove north-south, step 12 is the community-Istio control if you want the comparison, step 13 is the only one that applies anything to the ASM cluster, and step 14 is the rollback you should practise before you need it. If your services and their callers can move together, you never run step 13 at all.
Set these once. Everything below assumes them, and every command should carry an explicit
--context so a step never lands on the wrong cluster.
bashenvironment
export PROJECT=your-project-id
export REGION=us-central1
export TARGET=ambient-target # the Solo Enterprise cluster
export CONTROL=oss-istio-ab # the community Istio control cluster
export SOURCE=fleet-cluster-1 # the existing ASM cluster
export USE_GKE_GCLOUD_AUTH_PLUGIN=True
# your Solo Istio and agentgateway licence keys
export SOLO_ISTIO_LICENSE_KEY=...
export AGENTGATEWAY_LICENSE_KEY=...Create the target cluster
A regular GKE cluster on a version inside Istio 1.30's supported range, on the same VPC as the source clusters so the east-west bridge later has somewhere to go, and deliberately not fleet-registered.
bashcreate
gcloud container clusters create "$TARGET" \
--project "$PROJECT" --region "$REGION" --node-locations "${REGION}-a" \
--cluster-version 1.34.9-gke.1065000 \
--machine-type e2-standard-4 --num-nodes 3 \
--network default --subnetwork default
gcloud container clusters get-credentials "$TARGET" --region "$REGION" --project "$PROJECT"a RUNNING cluster in about 5 to 10 minutes, and no fleet membership.
Install the Gateway API CRDs, before any Helm chart
Fixed first step. Waypoints and the agentgateway GatewayClass both need these.
bashgateway-api v1.5.1
kubectl apply --server-side -f \
https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
kubectl get crd gateways.gateway.networking.k8s.io \
-o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}{"\n"}'v1.5.1.
Install Solo Enterprise Istio in sidecar mode
Sidecar first, on purpose. The target cluster has to be able to run the workloads exactly as they run today before anything about the dataplane changes, otherwise a later failure has two possible causes instead of one. No operator is needed for this path.
bashbase + istiod
helm install istio-base oci://us-docker.pkg.dev/soloio-img/istio-helm/base \
--version 1.30.3-solo -n istio-system --create-namespace --wait
helm install istiod oci://us-docker.pkg.dev/soloio-img/istio-helm/istiod \
--version 1.30.3-solo -n istio-system \
--set global.hub=us-docker.pkg.dev/soloio-img/istio \
--set global.tag=1.30.3-solo \
--set license.value="$SOLO_ISTIO_LICENSE_KEY" \
--set meshConfig.accessLogFile=/dev/stdout \
--wait
kubectl -n istio-system get podsistiod 1/1 Running.
Install the Enterprise agentgateway ingress
The ingress for the new cluster. Two things that are easy to get wrong: the CRDs chart is separate, and the
licence value path is licensing.licenseKey, not a flat licenseKey.
bashagentgateway v2026.5.1
helm install agentgateway-crds \
oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway-crds \
--version v2026.5.1 -n agentgateway-system --create-namespace --wait
helm install agentgateway \
oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway \
--version v2026.5.1 -n agentgateway-system \
--set licensing.licenseKey="$AGENTGATEWAY_LICENSE_KEY" \
--set clusterName="$TARGET" \
--wait
kubectl get gatewayclassan enterprise-agentgateway GatewayClass, and a Gateway you create against it reaching PROGRAMMED: True.
Put the mesh CA on Vault, with an RSA-only signing role
An external CA with an RSA policy, which is what most estates of this age actually run. Vault in dev mode is fine for the lab; the role's shape is what matters.
bashcert-manager, Vault, istio-csr
helm install cert-manager jetstack/cert-manager -n cert-manager \
--create-namespace --version v1.21.1 --set crds.enabled=true --wait
helm install vault hashicorp/vault -n vault --create-namespace \
--version 0.34.0 --set "server.dev.enabled=true" --wait
# RSA-4096 root, RSA-4096 intermediate, a signing role locked to RSA-2048,
# and the Kubernetes auth role the cert-manager Issuer uses
./yaml/60-pki/25-vault-bootstrap.sh
# istio-csr's trust anchor is the Vault root certificate
kubectl -n vault exec vault-0 -- sh -c \
'VAULT_TOKEN=root VAULT_ADDR=http://127.0.0.1:8200 vault read -field=certificate pki/cert/ca' > ca.pem
kubectl -n cert-manager create secret generic istio-root-ca --from-file=ca.pem=ca.pem
kubectl apply -f yaml/60-pki/20-vault-issuer.yaml
helm install cert-manager-istio-csr jetstack/cert-manager-istio-csr \
-n cert-manager --version v0.16.0 -f yaml/60-pki/10-istio-csr-values.yaml --wait
# point istiod at the new CA, then restart it. The restart is not optional.
helm upgrade istiod oci://us-docker.pkg.dev/soloio-img/istio-helm/istiod \
--version 1.30.3-solo -n istio-system --reuse-values \
--set global.caAddress=cert-manager-istio-csr.cert-manager.svc:443 \
--set pilot.env.ENABLE_CA_SERVER=false --wait
kubectl -n istio-system rollout restart deploy/istioda sidecar pod's real leaf certificate showing Issuer: CN=Lab Intermediate CA and Public Key Algorithm: rsaEncryption. Read the certificate, not the config.
bashverify from the leaf, not the config
istioctl -n pki-check proxy-config secret probe -o json \
| jq -r '.dynamicActiveSecrets[] | select(.name=="default")
| .secret.tlsCertificate.certificateChain.inlineBytes' \
| base64 -d | openssl x509 -noout -serial -issuer -text | head -20Deploy the workloads in sidecar mode and take the baseline
Deploy the catalog, then prove it works before you change anything. A deployment that rolls out successfully has told you nothing about whether the policies do their job.
bashdeploy
kubectl apply -f yaml/20-policies-sidecar/00-peerauth-strict.yaml
kubectl apply -f yaml/10-apps-sidecar/
kubectl apply -f yaml/20-policies-sidecar/
for ns in petstore petstore-data petstore-legacy; do
kubectl -n $ns wait --for=condition=Available deploy --all --timeout=180s
donebashthe four gates, run at every step from here on
CHECKOUT=$(kubectl -n petstore-legacy get pod -l app=checkout -o jsonpath='{.items[0].metadata.name}')
FORTIO=$(kubectl -n petstore-legacy get pod -l app=fortio -o jsonpath='{.items[0].metadata.name}')
# 1. L7: allowed method and denied method
kubectl -n petstore-legacy exec $CHECKOUT -c checkout -- \
curl -s -o /dev/null -w 'GET %{http_code}\n' http://catalog.petstore/
kubectl -n petstore-legacy exec $CHECKOUT -c checkout -- \
curl -s -o /dev/null -w 'DELETE %{http_code}\n' -X DELETE http://catalog.petstore/
# 2. L4: the allowed principal
kubectl -n petstore logs deploy/data-client -c client --tail=3
# 3. L4 negative: a principal that is not allowed
kubectl -n petstore-legacy exec $FORTIO -c fortio -- \
fortio load -c 1 -n 1 -timeout 5s -quiet tcp://redis.petstore-data:6379
# 4. the zero-downtime gate
kubectl -n petstore-legacy exec $FORTIO -c fortio -- \
fortio load -c 4 -qps 25 -t 15s -quiet http://catalog.petstore/GET 200, DELETE 403, a PONG stream, tcp short read : 1 (100.0 %) with 0 bytes received on the negative, and Code 200 : 372 (100.0 %).
Make the mesh bilingual
Upgrade istiod to the ambient profile and add the two ambient charts. Nothing migrates here. Every pod keeps its sidecar, ztunnel comes up with nothing enrolled, and the load generator should not notice.
bashambient control plane, with the GKE platform flag from the start
helm upgrade istiod oci://us-docker.pkg.dev/soloio-img/istio-helm/istiod \
--version 1.30.3-solo -n istio-system --reuse-values \
--set profile=ambient --set istio_cni.enabled=true --wait
helm install istio-cni oci://us-docker.pkg.dev/soloio-img/istio-helm/cni \
--version 1.30.3-solo -n istio-system \
--set profile=ambient \
--set global.hub=us-docker.pkg.dev/soloio-img/istio \
--set global.tag=1.30.3-solo \
--set global.platform=gke \
--wait
helm install ztunnel oci://us-docker.pkg.dev/soloio-img/istio-helm/ztunnel \
--version 1.30.3-solo -n istio-system \
--set profile=ambient \
--set hub=us-docker.pkg.dev/soloio-img/istio \
--set tag=1.30.3-solo \
--set global.platform=gke \
--set env.LOG_FORMAT=json \
--waitistio-cni-node and ztunnel Ready on every node on the first attempt, no quota error, no crash loop, and the load generator still at 100%.
Open the signing role to EC before you enrol anything
One vault write, and it has to land before the first pod is enrolled into ambient. Skip it and
the first workload you migrate is the one that finds out.
bashthe whole change
./yaml/60-pki/30-vault-role.sh anykey_type reads any. The RSA sidecar in pki-check keeps the certificate it already had: same serial, istio-proxy still at 0 restarts. Nothing else moves, because nothing is enrolled yet.
Migrate the L4-only namespace
petstore-data has no L7 policy, so it needs no waypoint. Two labels and a rolling restart, with
load running across the whole thing.
bashthe whole L4 migration
# start the load first, in another terminal
kubectl -n petstore-legacy exec $FORTIO -c fortio -- \
fortio load -c 4 -qps 25 -t 120s -quiet http://catalog.petstore/
kubectl label ns petstore-data istio.io/dataplane-mode=ambient istio-injection- --overwrite
kubectl -n petstore-data rollout restart deploy/redis
kubectl -n petstore-data rollout status deploy/redisthe redis pod comes back with a single container and no istio-proxy in either container list, the PONG stream never breaks, the non-allowed principal is still refused with tcp short read and 0 bytes, and the load generator reads Code 200 : 3000 (100.0 %) across the cut.
Migrate the L7 namespace, waypoint first, in this order
Seven steps, and the order decides the outcome. Keep load running across all of it and check
GET and DELETE between every step, not just at the end. Sampling only at the end
is what hid the outage on the previous pass.
bashthe corrected sequence
# 1. the waypoint
kubectl apply -f yaml/30-waypoints/petstore-waypoint.yaml
# 2. never enrol before this returns
kubectl -n petstore wait --for=condition=Programmed gateway/waypoint --timeout=180s
# 3. the converted policy, on the waypoint
kubectl apply -f yaml/40-policies-waypoint/10-catalog-l7-authz-targetref.yaml
# 4. route service traffic through the waypoint
kubectl label ns petstore istio.io/use-waypoint=waypoint --overwrite
# 5. remove the old selector policy BEFORE enrolment, not after
kubectl -n petstore delete authorizationpolicy catalog-get-only
# 6. enrol
kubectl label ns petstore istio.io/dataplane-mode=ambient istio-injection- --overwrite
# 7. shed the sidecars
kubectl -n petstore rollout restart deploy/catalog-v1 deploy/catalog-v2 deploy/data-clientGET 200 and DELETE 403 at every gate including step 5, catalog pods with an empty initContainers list at the end, and Code 200 : 7500 (100.0 %) with zero error cases and 4 sockets across the entire sequence.
Put client traffic through the ingress, and check your policy survived it
Everything so far has been tested from inside the mesh. North-south is where an ambient migration leaves a hole that in-cluster testing cannot see, so test it explicitly before you point real users at this cluster.
bashroute the app through the ingress
kubectl apply -f yaml/70-northsouth/10-catalog-ingress.yaml
IP=$(kubectl -n agentgateway-system get gateway agw-ingress \
-o jsonpath='{.status.addresses[0].value}')
curl -s -o /dev/null -w 'GET %{http_code}\n' -H 'Host: petstore.example.com' http://$IP:8080/
curl -s -o /dev/null -w 'DELETE %{http_code}\n' -X DELETE -H 'Host: petstore.example.com' http://$IP:8080/GET 200 and DELETE 403. If DELETE comes back 200, the istio.io/ingress-use-waypoint label is missing from the Service and every request from outside the cluster is skipping your L7 policy.
HTTPRoute sends the gateway straight at the backend pods, which is fine until those pods are
ambient behind a waypoint, at which point north-south traffic bypasses the waypoint and its
AuthorizationPolicy along with it. Measured here on the same service, one label apart:
without istio.io/ingress-use-waypoint, DELETE returned 200 and the
waypoint's access log recorded zero requests; with it, DELETE returned
403 and the waypoint logged them. Every in-mesh caller is enforced correctly in both cases, so
this is invisible unless you deliberately test from outside. If you also lock the pods down at L4 so only
the waypoint's identity may reach them, set this label first, or the ingress starts getting denied.
Run the identical migration on community Istio and compare
Same manifests, same order, upstream charts, no licence. The distribution is the only variable.
bashthe A/B
kubectl --context "$CONTROL" -n petstore-legacy exec $CHECKOUT -c checkout -- \
curl -s -o /dev/null -w 'community DELETE %{http_code}\n' -X DELETE http://catalog.petstore/
kubectl --context "$TARGET" -n petstore-legacy exec $CHECKOUT -c checkout -- \
curl -s -o /dev/null -w 'Solo DELETE %{http_code}\n' -X DELETE http://catalog.petstore/community DELETE 200 and Solo DELETE 403, from a caller that is genuinely still on a sidecar on both clusters. Check that with the full-JSON sidecar check above before you believe either number.
Bridge back to ASM for the migration window
While the migration is in flight, some calls have to cross. Each side exposes what the other needs, and
each calling side declares one ServiceEntry per call. The gateway differs per side for the
reasons above.
bashnew cluster: reuse the agentgateway that is already there
# the gateway must be in the mesh to reach ambient workloads,
# and needs a PERMISSIVE carve-out because its own inbound is plaintext
kubectl label ns agentgateway-system istio.io/dataplane-mode=ambient --overwrite
# Gateway + HTTPRoute + PeerAuthentication, all three in one file
kubectl apply -f yaml/50-crosscluster/40-agentgateway-eastwest.yaml
kubectl -n agentgateway-system get svc agw-eastwest \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}{"\n"}'bashASM cluster: Istio's own gateway chart, programmed by ASM's control plane
helm install istio-eastwestgateway istio/gateway -n istio-gateways --create-namespace \
-f yaml/50-crosscluster/10-eastwest-gateway-values.yaml --wait
kubectl apply -f yaml/50-crosscluster/20-exposing-side.yaml
kubectl -n istio-gateways get svc istio-eastwestgateway \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}{"\n"}'basheach calling side: one ServiceEntry per cluster pair
# hosts is a list, so this is ONE object per cluster pair, not one per call.
# Adding a service to the bridge later is a one-line edit here plus an
# HTTPRoute on the exposing side.
kubectl apply -f yaml/50-crosscluster/30-calling-side.yaml
Do not try to shorten that to a wildcard. hosts: ["*.crosscluster.svc"] with
resolution: STATIC does not resolve at all under DNS auto-allocation, and curl exits 6 with no
useful error. Listing the hostnames also gives you a better failure mode: a name you have declared but not
yet routed on the far side returns a 404 from the gateway rather than hanging.
ten consecutive clean calls in each direction, including a call from the ASM cluster that reaches a service on the new cluster which has already migrated to ambient behind its waypoint.
503 upstream call failed: SendRequest: connection error: Connection reset by peer (os error 104)
and ztunnel logs explicitly denied by: istio-system/istio_converted_static_strict. Label the
namespace ambient to fix that, and the gateway's own inbound is now subject to STRICT as well.
Traffic from a LoadBalancer is plaintext by definition, so the north-south ingress stops answering
completely and curl returns 000. Both are fixed by one
PeerAuthentication: PERMISSIVE scoped to the gateway namespace, which is the standard carve-out
for any ingress under STRICT and changes nothing east of the gateway, where it re-originates over HBONE
under its own identity.
catalog VirtualService weights 100% to subset v1 and that is exactly what happens: 20 calls, 20 v1 responses, on both distributions. The same service reached across the bridge
came back 8 v1 and 12 v2, because the east-west gateway's own VirtualService is the routing authority for
that path and it routes straight to the Service. If a canary weight matters during the migration window,
replicate it on the gateway's VirtualService as well, or cross-cluster callers quietly get the unweighted
split.
Roll a namespace back to sidecars
Practise this before you need it. The order is the forward sequence reversed, and it matters for the same reason: the workload must leave ambient before the selector-based L7 policy comes back, or ztunnel is holding an L7 rule it cannot evaluate.
bashrollback, in order
# 1. injection back on, out of ambient. Leave use-waypoint and the
# targetRefs policy in place so enforcement never lapses.
kubectl label ns petstore istio-injection=enabled istio.io/dataplane-mode- --overwrite
# 2. roll the pods, sidecars come back
kubectl -n petstore rollout restart deploy/catalog-v1 deploy/catalog-v2 deploy/data-client
# 3. now the selector policy can safely return
kubectl apply -f yaml/20-policies-sidecar/30-l7-authz-catalog.yaml
# 4. and only now remove the waypoint
kubectl label ns petstore istio.io/use-waypoint-
kubectl -n petstore delete authorizationpolicy catalog-get-only-waypoint
kubectl -n petstore delete gateway waypointGET 200 and DELETE 403 at every step, pods back with istio-proxy in initContainers, and about 17 seconds of wall clock. Measured Code 200 : 4994 (99.9 %) with 6 503s. Put the policy back first instead and it is 4976 (99.5 %) with 24, the same fail-closed trap mirrored.
http-echo, which has no graceful shutdown at all. Real
services should carry a preStop hook and a PodDisruptionBudget, which is what closes that last
0.1% on any rolling restart, ambient or not.
What this run actually measured
| Gate | Result |
|---|---|
| Baseline, sidecar mode, both clusters | GET 200 / DELETE 403, PONG, Code 200 : 372 (100.0 %) |
| L4 namespace migrated under load | Code 200 : 3000 (100.0 %), redis single-container, negative case still denied at L4 |
| L7 waypoint-first, delete old policy last | Code 200 : 7341 (97.9 %), Code 503 : 159 (2.1 %), one 6.5 second window of total failure |
| L7 waypoint-first, delete old policy before enrolment | Code 200 : 7500 (100.0 %), zero error cases, 4 sockets, DELETE 403 held at every gate |
| Control: plain pod roll in the ambient end state | Code 200 : 3748 (100.0 %), confirming the pod roll is not what caused the 503s |
| Control: selector L7 policy re-applied to an ambient workload | 995 errors in 2248 requests over the 42 seconds it was present, 100% again the moment it was removed |
| Interop A/B, never-migrated sidecar caller | Solo DELETE 403, community DELETE 200, identical scenario both sides |
| CA transition | rejection reproduced verbatim, self-heal in about 45 seconds, RSA sidecar serial unchanged and istio-proxy at 0 restarts throughout |
| Cross-cluster bridge, Istio east-west gateway both sides | 10/10 each direction, including into an already-ambient service behind its waypoint |
| Cross-cluster bridge, agentgateway serving east-west on the new cluster | 10/10 from the ASM cluster into the same already-ambient service, once the gateway namespace was ambient-enrolled and given a PERMISSIVE carve-out |
North-south without ingress-use-waypoint | GET 200 / DELETE 200, waypoint access log recorded zero requests: the L7 policy did not apply to ingress traffic |
North-south with ingress-use-waypoint | GET 200 / DELETE 403, waypoint logged both requests |
| Rollback to sidecars, pods out of ambient first | Code 200 : 4994 (99.9 %), 6 503s, DELETE 403 held throughout, 17 seconds |
| Rollback with the selector policy restored first | Code 200 : 4976 (99.5 %), 24 503s, the forward trap mirrored |
| One ServiceEntry listing several hostnames | Works. A declared hostname with no route on the far side returns 404 from the gateway, so it fails visibly |
Wildcard *.crosscluster.svc ServiceEntry | Does not work with DNS auto-allocation: the client cannot resolve the name, curl exits 6 |
Field notes
- Measure across the cut, not after it. A 15 second fortio run after each step scores 100% and tells you nothing about the seconds in between. The 2.1% failure that exposed the ordering bug only showed up because one load generator ran across the whole sequence. Point samples cannot tell you a cutover was clean.
-
Read the waypoint's access log, not just the status code.
503 URX,UC upstream_reset_before_response_started{connection_termination}with the upstream address attached is what pointed away from the plausible theory (rolling restart drain). Two control experiments then finished the wrong theory off. - A selector policy follows the workload; a targetRefs policy follows the Service. That is where the ordering rule comes from. Once a workload is ambient, its selector-based policies are ztunnel's problem, and ztunnel cannot do L7. Audit for L7 selector policies before you enrol a namespace.
-
Check for
PeerAuthentication mode: DISABLEcarve-outs before you enrol.STRICTandPERMISSIVEboth carry over cleanly, but there is no ambient equivalent ofDISABLE: in ambient the transport is HBONE and it is always mTLS. Resolve the carve-out first. You can prove the carve-out is real with a plaintext request from a genuinely non-mesh pod, which is the only check that answers the question. Config dumps do not:istioctl x describe podfails outright against ASM's older control plane, andproxy-config listenercomes back empty for both aDISABLEworkload and aSTRICTone, because modern sidecars consolidate inbound traffic through one virtual listener. -
In-mesh tests cannot see the north-south hole. Every caller inside the mesh is enforced
correctly whether or not
istio.io/ingress-use-waypointis set, so a migration can pass every test you wrote and still be serving unenforced traffic to the internet. One curl from outside catches it. Add it to the per-namespace gate rather than trusting the in-cluster result. -
Use the gateway you already run, where you can. The new cluster does not need a second
gateway stack for east-west: the Enterprise agentgateway serving north-south takes another
Gatewayon the same class, with the internal-LB annotation viaspec.infrastructure.annotationsso the Service provisions internal on the first apply. The ASM side is the exception, and for a concrete reason: no Gateway API CRDs, and an Istio gateway there is configured by the managed control plane that is already running, so nothing new gets installed. - A gateway is not a remote cluster. The east-west design works because a gateway talks to its own local control plane over xDS, the same relationship every sidecar already has. Nothing in it watches the other cluster's Kubernetes API, so the CRD skew that breaks remote secrets never applies.
-
The identity tradeoff on the bridge. This terminates and re-originates
mTLS at the gateway rather than carrying the original caller's identity end to end. The identity-preserving
alternative,
AUTO_PASSTHROUGHSNI routing, only kicks in when the control plane has no known direct address for an endpoint, which is precisely how automatic remote-secret discovery forces traffic through a gateway. A hand-writtenServiceEntryalways carries a real address, so it connects directly and the gateway-redirect path never activates. That makes the plain HTTP pattern the right tool for a static, migration-window reference, but the identity tradeoff is one to agree up front.
Migration checklist
- Before you build anything
- Read the fleet's
fleetDefaultMemberConfig.mesh.management. If it isMANAGEMENT_AUTOMATIC, keep the target cluster out of the fleet. - List the CRD versions the ASM cluster's API server actually serves, per group. Do not assume they all lag equally.
- Confirm no pod CIDR overlap across every cluster that will talk to another.
- Find every
PeerAuthenticationwithmode: DISABLEand decide what happens to it, because ambient has no equivalent. - Find every L7
AuthorizationPolicythat usesselector. Each one needs converting totargetRefsand deleting in the right order. - Per new cluster, in order
- Gateway API CRDs first, before any Helm chart.
- Mesh control plane in sidecar mode, and prove the workloads run on it before changing the dataplane.
- The real ingress, first time. Note that the agentgateway licence value path is
licensing.licenseKey. - CA work next if the mesh CA is external, and restart
istiodimmediately after pointing it at a new CA. global.platform=gkeoncniandztunnel, from the first attempt.- Open the CA signing role to
key_type=anybefore the first pod is enrolled into ambient. - Per namespace
- Attach a load generator before the change and read it after. A namespace is done when its pass rate is 100%, or when every failure is explained and bounded.
- No L7 policy: two labels and a rolling restart. No waypoint.
- Any L7 policy: waypoint,
Programmed: True, converted policy,use-waypoint, delete the old selector policy, enrol, roll. - Verify the workload lost its sidecar by reading both
containersandinitContainersin the full pod JSON. - Re-run the negative test as well as the happy path. A policy that stops denying is a worse outcome than one that stops allowing.
- Test the denied case from outside the cluster, not only from a pod. A waypoint's L7 policy does not apply to ingress traffic until the Service carries
istio.io/ingress-use-waypoint, and in-mesh tests pass either way. - Confirm enrolling this namespace disturbed nothing in the others: no unexpected restarts anywhere else.
- Drain, then decommission
- Bring the service up on the new cluster and prove it there before any client is moved: same GET and DELETE results, same load-generator pass rate, same negative test as the ASM-side baseline.
- Move clients team by team, not in one cut. A namespace is drained when the ASM-side load generator reads zero and the new cluster's reads the full volume, with no gap and no double-count in between.
- Watch both sides through each move. Two load generators, one per cluster, both running across the cutover.
- Scale the ASM-side Deployments to zero and leave them there for a full business cycle before deleting anything. A scaled-to-zero Deployment is a rollback that takes seconds; a deleted namespace is a redeploy.
- While they sit at zero, confirm nothing still calls them: no traffic in the ASM-side access logs, no DNS lookups for the old hostnames, and no
ServiceEntryanywhere still pointing at them. - Check what else referenced that service before you delete it: CI jobs, cron workloads, dashboards, alerts and any hard-coded cluster-local hostname in another team's config.
- Delete the app namespaces on the ASM side only once the scaled-to-zero period has passed cleanly.
- Remove the migration scaffolding last and in this order: the
ServiceEntryresources on each calling side, then theGatewayandVirtualService, then the east-west gateway release and its internal LoadBalancer. Removing the gateway first breaks any call you missed, and breaks it silently. - Confirm the internal LoadBalancers are actually gone from the project. A deleted Service with a stuck finalizer leaves the forwarding rule and the bill behind.
- Before you call it done
- Check the access-log shape. ztunnel's JSON L4 log is structurally different from an Envoy sidecar log, so any SIEM rule written against the old format silently stops matching.
- Re-check any canary or subset weighting for callers that arrive over the east-west bridge. They do not get the in-mesh VirtualService.
- Keep the signing role on
anyuntil the last sidecar in the estate is gone. - Point north-south client traffic at the new cluster's external ingress and retire the old one only after the drain is complete.
Why the Enterprise distribution, on this specific migration
-
The mixed period is the whole migration, and that is where the interop matters. A large
estate is mixed for months. On community images a caller that still has a sidecar does not route through the
destination's waypoint, so the waypoint's L7 policy does not apply to it, which was measured directly here:
DELETE 200on community,DELETE 403on Solo, same caller, same manifests. Every namespace that migrates before its callers do has that hole until the callers follow. -
L7 telemetry without a waypoint. The L4-only namespace in this lab loses request-level
visibility on community images the moment it is enrolled, because ztunnel is L4. The Solo ztunnel emits
HTTP-level telemetry with no waypoint at all, so a namespace like
petstore-datakeeps its request metrics after migrating. - Sharper identity at L4. Upstream ztunnel authorises on the ServiceAccount identity, so two workloads sharing a ServiceAccount are indistinguishable to an L4 policy. The Solo images add workload claims: pod-level attributes carried in the certificate that ztunnel can authorise on with no waypoint. Certificates as workload identity in ambient runs that end to end.
- A support window that matches how long migrations take. Community minors leave support quickly. Solo maintains an N-4 patched window with CVE backports, so the version you started the migration on is still patched when the last team finishes.
See also
- Sidecar to Ambient Upgrade: the same migration on one kind cluster, installed by the Gloo Operator, with the subset canary moved to an HTTPRoute and a single-label rollback.
- Sidecar to Ambient on OSS Istio: the fully upstream version, with one cluster-wide waypoint instead of one per namespace.
- Vault + istio-csr: sidecars on RSA, ambient on EC: the CA transition in this lab, in depth, on kind.
- Certificates as workload identity in ambient: the L4 identity model the Redis policy here relies on.
Versions
Built and verified on both editions:
v1.5.11.30.31.34.9-gke.10650000.34.0v1.21.1v0.16.0v2026.5.1v1.5.11.34.9-gke.10650001.30.3-solo0.34.0v1.21.1v0.16.0