Istio End to End: Install, Canary, Lock Down, Monitor

Most Istio tutorials stop after istioctl install and a green check. That leaves you with an encrypted cluster, a pile of CRDs whose names give you no hint which does what, and no idea how to ship a canary or refuse a caller. This guide goes all the way through: install, one canary, one authorization policy, one failing dependency survived, and two layers of monitoring.

Then it does something the Linkerd version of this guide could not. It converts the finished mesh to ambient mode and re-runs all four scenarios, because three of them silently stop working and nothing tells you. Budget about ninety minutes on a kind cluster. Every command below was run against Istio 1.30.3 on Kubernetes 1.35, and every number is measured rather than quoted.

Before you start

  • A Kubernetes cluster you can install CRDs into. Two nodes is plenty.
  • kubectl and helm already on your path.
  • An app with at least two services. Mine is a backend serving one line of text in two versions, a go-httpbin deployment for producing failures on demand, and a curl pod as the client.

The istioctl CLI is the one tool you probably do not have:

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.30.3 sh -
export PATH=$PWD/istio-1.30.3/bin:$PATH
istioctl version --remote=false

Pin the version. Without ISTIO_VERSION the installer resolves the latest release by scraping the GitHub releases page, so the same command run two months apart gives you two different builds. I read the script to confirm that rather than assuming it.

One decision to make before anything else, because retrofitting it is annoying: give every workload its own ServiceAccount. All three major meshes derive workload identity from it. Leave everything on default and “allow the client” quietly means “allow anything in this namespace”, which is not a policy.

Here is the shape of the app, trimmed to the parts that matter. Deploy it unmeshed first and enroll it in Step 2, so you can see what the mesh changes:

apiVersion: v1
kind: ServiceAccount
metadata: { name: backend, namespace: shop }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: backend-v1, namespace: shop }
spec:
  selector:
    matchLabels: { app: backend, version: v1 }
  template:
    metadata:
      labels: { app: backend, version: v1 }
    spec:
      serviceAccountName: backend
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          readinessProbe:
            httpGet: { path: /, port: 80 }
---
apiVersion: v1
kind: Service
metadata: { name: backend, namespace: shop }
spec:
  selector: { app: backend }        # both versions, on purpose
  ports: [{ name: http, port: 80, targetPort: 80 }]

backend-v2 is the same with version: v2 and a different response body. The client is a curl image sleeping forever, with its own ServiceAccount. For Step 7 you also want something that fails on demand: mccutchen/go-httpbin serves /delay/5 and /status/200,503 and saves you writing one.

Step 1 - Install Gateway API first, because nothing will tell you to

Linkerd refuses to install without the Gateway API CRDs and prints the exact command to fix it. Istio does not. istioctl x precheck passed cleanly on an empty cluster with no Gateway API CRDs anywhere:

✔ No issues found when checking the cluster. Istio is safe to install or upgrade!

You still need them, for the canary in Step 4 and the ingress in Step 6. Take the version from Istio, not from the Gateway API project. The current Gateway API release is v1.6.1, but Istio 1.30.3’s own docs pin v1.5.1, and mixing those is a bad way to spend an afternoon:

kubectl apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
istioctl install --set profile=minimal -y

I used minimal rather than default deliberately. The default profile also installs istio-ingressgateway, a legacy component that Gateway API replaces: gateways get provisioned per Gateway resource in Step 6, so that pod sits there doing nothing. Later, when I installed the ambient profile over the top, Istio pruned it on its own, which is a reasonable second opinion.

The check worth running is not a pod count:

kubectl get gatewayclass
NAME           CONTROLLER                    ACCEPTED
istio          istio.io/gateway-controller   True

Step 2 - Enroll your app

Injection is a namespace label plus a restart. Nothing rewrites running pods:

kubectl label namespace shop istio-injection=enabled
kubectl -n shop rollout restart deploy

Pods come back 2/2. One thing that surprised me on Kubernetes 1.35: the proxy is a native sidecar, so it is an initContainer, not a regular container.

kubectl -n shop get pod -l app=client -o jsonpath='{.items[0].spec.initContainers[*].name}'
istio-init istio-proxy

If you have scripts grepping .spec.containers for istio-proxy, they now find nothing while the mesh works fine.

Confirm the identities are distinct, because everything in Step 5 depends on it:

istioctl proxy-config secret <pod>.shop -o json | ...  # extract the cert, read the SAN
URI:spiffe://cluster.local/ns/shop/sa/client
URI:spiffe://cluster.local/ns/shop/sa/backend
URI:spiffe://cluster.local/ns/shop/sa/intruder

Three workloads, three identities. If that reads sa/default for every pod, go back and fix your ServiceAccounts now. Note the shape, because policy drops the spiffe:// scheme and writes cluster.local/ns/shop/sa/client.

Step 3 - Get metrics early, which on Istio means installing Prometheus

This is the sharpest difference from Linkerd, and it is worth knowing before you plan your afternoon. linkerd viz install gives you golden metrics per workload in one command. Istio ships no equivalent. What you get is the raw Envoy endpoint on every pod:

kubectl -n shop exec <pod> -c istio-proxy -- curl -s localhost:15020/stats/prometheus

That is 614 metric lines per pod, unaggregated. The mesh’s own layer is one useful command, and it reports config convergence rather than traffic:

istioctl proxy-status
NAME                     CLUSTER      ISTIOD                    VERSION  SUBSCRIBED TYPES
backend-v1-...shop       Kubernetes   istiod-68fdb6dd64-kbbgm   1.30.3   4 (CDS,LDS,EDS,RDS)
client-...shop           Kubernetes   istiod-68fdb6dd64-kbbgm   1.30.3   4 (CDS,LDS,EDS,RDS)

Use it whenever a config change seems not to have landed. For actual numbers, skip to Step 8 and install Prometheus now if you want graphs while you work through the rest.

The mTLS number lies, and it lies differently than Linkerd’s does

Worth catching on your first day rather than your first incident. Here are the same 32 requests, read from the two proxies that saw them:

reporter connection_security_policy count
source unknown 32
destination mutual_tls 32

The sending proxy does not record what security policy was used. So a panel built across both reporters undercounts, badly. On the finished lab under load:

query reading
mutual_tls / all, no reporter filter 64.93%
the same, scoped to reporter="destination" 99.90%

Thirty-five points of nothing. Scope every security panel to reporter="destination". Linkerd has the same trap for a different reason, and wants direction="outbound" instead.

Prove the encryption instead of trusting it

A dashboard reading 99.9% is a claim about a label. I ran tcpdump on the node instead, with a marker string in a header, and looked for it in the clear.

Run the positive control first. A capture that never started reports zero markers, and zero markers is also what success looks like. Istio’s default PERMISSIVE mode makes the control easy to arrange: an unmeshed pod can talk plaintext straight to the backend pod IP and be served.

run packets marker in clear plaintext GET
unmeshed caller, direct to pod IP 54 5 10
through the mesh, 40 requests 114 0 0

My first meshed run captured only 6 packets, because the backend Service balances across two pods and the capture watches one pod IP. Six packets and zero markers is indistinguishable from a broken capture, so I repeated it at 40 requests to get comparable volume before believing the zero.

Absence of the marker is weak evidence on its own, so I also looked for positive proof: 98 TLS 1.3 application-data records (17 03 03) with ciphertext payloads. That is the mesh doing its job, visible in the bytes.

Where each object attaches

Four features, and the object names give you no hint which is which. Worse than Linkerd here, because two different APIs compete for the same attachment point.

Where each Istio configuration object attachesA request travels from a client pod, through a Kubernetes Service, to two backend pods. Gateway API HTTPRoute and Istio VirtualService both attach at the Service and are alternatives to each other: HTTPRoute carries canary weights and timeouts, VirtualService carries retries. A DestinationRule attaches to the same upstream host and controls circuit breaking through outlier detection. PeerAuthentication and AuthorizationPolicy select the backend pods and control who is allowed to call them.Two APIs want the same Serviceclient podits own identityService: backendthe address clients callbackend v1port 80backend v2port 80HTTPRouteweights, timeoutsVirtualServiceretries, timeoutspick one, not bothDestinationRulecircuit breaking (outlier detection)PeerAuthentication+ AuthorizationPolicyselector: app=backendREAD IT THIS WAYTraffic management has two APIs competingfor one Service. Gateway API is portable buthas no retries, so retries pull you back.Circuit breaking is a third object again,and policy selects pods, not routes.

The part that catches people: HTTPRoute and VirtualService both attach to the Service and do overlapping jobs. You pick one. Gateway API is portable and has no retries, so the moment you need retries it pulls you back to VirtualService.

Step 4 - Ship a canary

Istio has its own traffic splitting object and every tutorial reaches for it. It does not need to. Istio implements GAMMA, so the portable Gateway API object works, and I used the same file the Linkerd guide used.

HTTPRoute backendRefs point at Services, not label subsets, so each version needs its own Service. The original backend Service stays as the address clients call and becomes the route’s parent:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: backend-canary, namespace: shop }
spec:
  parentRefs:
    - { name: backend, kind: Service, group: "", port: 80 }
  rules:
    - backendRefs:
        - { name: backend-v1, port: 80, weight: 90 }
        - { name: backend-v2, port: 80, weight: 10 }

That group: "" is load bearing and cost me a debugging round. Gateway API spells the core API group as the empty string. I wrote group: core, which Linkerd tolerates, and Istio did not:

parentRefs[0].group route status measured split, 200 requests
core parents: [] 100 / 100, plain round-robin
"" Accepted=True 173 / 27

There is no error and no event anywhere. The route simply is not claimed by any controller, and the Service carries on doing what it always did. Always read the route status back:

kubectl -n shop get httproute backend-canary -o yaml | sed -n '/^status:/,$p'

Now the split itself. Weighted routing is statistical, not a quota, and small samples mislead:

weights requests measured
90 / 10 200 173 / 27 (86.5 / 13.5)
90 / 10 1000 899 / 101 (89.9 / 10.1)
50 / 50 500 241 / 259
0 / 100 500 0 / 500

At 200 requests it looks 3.5 points off and you start doubting the config. At 1000 it lands where you asked. Weight 0 is a hard zero, so that is your rollback.

Step 5 - Refuse an unauthorized caller

Three objects, and the middle one is the one people skip.

PeerAuthentication decides whether a caller may speak at all. AuthorizationPolicy decides who, having spoken, gets through. Getting only the first is the common half-configured state: every connection encrypted, every workload still able to call every other workload.

apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: { name: shop-strict, namespace: shop }
spec:
  mtls: { mode: STRICT }
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata: { name: deny-all, namespace: shop }
spec: {}                      # empty spec = allow nothing, namespace wide
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata: { name: backend-allow-client, namespace: shop }
spec:
  selector:
    matchLabels: { app: backend }
  action: ALLOW
  rules:
    - from:
        - source:
            principals: ['cluster.local/ns/shop/sa/client']

Istio’s default is allow-all, so without deny-all the third object grants nothing that was not already permitted and proves nothing.

The control that makes this a real test is the intruder: a second meshed pod in the same namespace with a valid certificate and its own identity. The only thing that can stop it is the policy.

caller before after
client (allowed principal) 200 200
intruder 200 403 RBAC: access denied
backend readiness probe Ready Ready

Do not test this with a single curl. I did, and got a 200 with a body of RBAC: access denied, which is nonsense until you realise the two halves of the command straddled the policy landing. Polling once a second from the intruder while kubectl apply ran, twice:

run first 403 settled got through after apply
A +1s +10s, flapping 200/403 in between 8 of 55
B +3s +3s, clean 10 of 60

Run A flipped back to 200 twice after the first denial. I had a tidy theory about per-pod config convergence, tested it by logging which backend version served each request, and run B refused to reproduce the flapping at all. So I have no mechanism to offer you, only the operational rule: policy takes seconds to land, sometimes unevenly, so verify it from a loop.

One more consequence that will bite in Step 7: deny-all covers workloads you were not thinking about. With it in force, client -> flaky returned 403 and the entire resilience scenario was unreachable until flaky got its own ALLOW rule.

The trap: port 15020 walks straight through all of this

Istio does not exempt your probe path the way Linkerd does. It rewrites the probe to a different port entirely:

readinessProbe.httpGet: { path: /app-health/nginx/readyz, port: 15020 }

That is genuinely better in one respect. Kubelet traffic never enters istio_requests_total, so it cannot skew your success rate the way it does on Linkerd.

It costs you something else. With both STRICT mTLS and deny-all in force, I curled the backend pod from a hostNetwork pod with no mesh identity whatsoever:

target on the backend pod IP result
:80, the app port connection refused
:15020/app-health/nginx/readyz 200, and the full application body
:15020/stats/prometheus 200, 283,941 bytes of Envoy metrics
:15020/quitquitquit 403

The dangerous admin endpoints are gated. The health and stats ports are open by design, because kubelet cannot present a mesh certificate and Prometheus has to scrape something. That design decision is why the scrape job in Step 8 needs no credentials, and it is also a cleartext path to your probe endpoint and a full map of your mesh, for anything that can reach the pod network.

Two fixes, neither of them a mesh policy. Probe a path that returns nothing worth having, such as an empty /healthz, so the leak is worthless. And reach for NetworkPolicy for port 15020, because deny-all in the mesh does not cover a path the mesh is not in.

Step 6 - Let traffic in from outside

No third-party controller needed, which is the main saving over the Linkerd version of this step. Istio registered the istio GatewayClass at install, so applying a Gateway makes istiod provision the data plane for it.

The Gateway is infrastructure:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: shop, namespace: istio-ingress }
spec:
  gatewayClassName: istio
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      hostname: shop.example.com
      allowedRoutes:
        namespaces: { from: All }

The HTTPRoute is application config and lives with the app, parented to that Gateway. Applying the Gateway gets you three objects you did not write:

deployment.apps/shop-istio   1/1
service/shop-istio           LoadBalancer
serviceaccount/shop-istio

The identity is named after the Gateway plus its class, so cluster.local/ns/istio-ingress/sa/shop-istio. Rename the Gateway and the policy you are about to write silently stops matching.

On kind there is no LoadBalancer, so the Service sat <pending> and the Gateway reported PROGRAMMED=False. kubectl port-forward is the quick way through. I also installed MetalLB to check the real path, and it is worth one warning if you are on Windows: MetalLB assigned 172.22.255.200 and the Gateway went PROGRAMMED=True, but that address answered only from inside the cluster network and returned nothing from my host, because the kind network lives inside the WSL2 VM. A real LoadBalancer IP you cannot route to is more confusing than no LoadBalancer at all.

Now send a request from outside and watch Steps 5 and 6 collide:

from outside, through the Gateway result
with the policy from Step 5 in force 403
after adding the Gateway’s identity 200, reached backend v1
a request with the wrong Host 404, refused by the Gateway
intruder inside the cluster, same moment 403

That 403 is correct behaviour and worth sitting with. Your gateway is not special. It is another workload with another identity, and a deny-by-default namespace does not know it is an ingress:

principals:
  - 'cluster.local/ns/shop/sa/client'
  - 'cluster.local/ns/istio-ingress/sa/shop-istio'

Keep the 404 row separate in your head. Hostname matching is the Gateway’s job and runs before any mesh policy, so a bad Host never reaches a proxy that could evaluate identity.

Step 7 - Survive a bad dependency

Three mechanisms, three different objects, and only one of them is portable:

concern object
timeout Gateway API HTTPRoute, spec.rules[].timeouts.request
retry Istio VirtualService, spec.http[].retries
circuit breaking Istio DestinationRule, trafficPolicy.outlierDetection

Gateway API’s standard channel has no retry field at all. I checked the installed CRD rather than the docs: a rule accepts backendRefs, filters, matches, name, timeouts and nothing else. So retries mean the experimental channel or an Istio CRD. And since VirtualService and HTTPRoute both want to own the same Service, adding retries in practice means moving the timeout across too:

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: flaky-resilience, namespace: shop }
spec:
  hosts: [flaky]
  http:
    - timeout: 2s
      retries: { attempts: 3, retryOn: 5xx, perTryTimeout: 1s }
      route:
        - destination: { host: flaky, port: { number: 8080 } }

Keep perTryTimeout inside the outer timeout or the outer one fires first and you never see a retry happen.

measurement before after
/delay/5 with a 2s request timeout 200 in 4.87s 504 in 1.95s
/status/200,503 x30, retry 3 on 5xx 14/30 succeeded 30/30 succeeded

Circuit breaking does not do what you expect

This is where I have to correct the mental model the Linkerd guide leaves you with. Same idea, same thresholds, completely different outcome.

outlierDetection:
  consecutive5xxErrors: 3
  baseEjectionTime: 10s
  maxEjectionPercent: 100

After 30 consecutive 503s, all three flaky endpoints were ejected. I confirmed it two ways, because I did not believe the first one:

istioctl proxy-config endpoint ...   OUTLIER CHECK: FAILED   (all 3)
envoy /clusters                      health_flags::/failed_outlier_check (all 3)

And five healthy requests sent inside that same window all returned 200. Linkerd’s breaker refuses healthy requests while the penalty lasts. Istio’s ejected every endpoint and kept serving through them anyway.

The reason is Envoy’s healthy panic threshold, which defaults to 50%: once fewer than half the hosts are healthy, Envoy stops trusting the health data and load balances across everything. Ejecting 3 of 3 puts you at zero. I could not read the lb_healthy_panic counter to prove it directly, because Istio’s default stats filter drops outbound cluster stats, so treat that as the documented explanation for a measured behaviour rather than a second measurement.

The practical takeaway does not depend on the mechanism: outlier detection sheds load when a minority of your endpoints are bad. It is not a stop-everything breaker. Unless you run ambient, where it is. That is Step 9.

Step 8 - Monitoring you can keep

kube-prometheus-stack, trimmed to Prometheus and Grafana. The Istio-specific part is smaller than Linkerd’s, because Istio’s injector writes the scrape annotations for you:

prometheus.io/scrape = true
prometheus.io/path   = /stats/prometheus
prometheus.io/port   = 15020

So the scrape job is the generic annotation-driven one. Which is exactly the problem, and every Istio tutorial gets this wrong. Those annotations are a general convention, not an Istio one. My cluster had MetalLB in it from Step 6, which advertises the same scrape=true on port 9120:

scrape job targets up down
scrape=true only 15 12 3, MetalLB, HTTP 400
plus prometheus.io/port == 15020 11 11 0

One extra relabel rule:

- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
  action: keep
  regex: '15020'

Pinning the port also correctly drops istiod, which annotates 15014 and deserves its own job. One gotcha on top: additionalScrapeConfigs lands in a Secret that Prometheus mounts, and helm upgrade alone was not enough. The running instance served the old config until I restarted the pod.

Your success rate panel counts denials as successes

Istio records an RBAC denial as an ordinary response_code="403". So the usual “success is anything that is not a 5xx” definition scores every intruder you correctly blocked as a success:

definition reading on the same data
non-5xx 90.60%
2xx only 69.22%

Twenty-one points, entirely from traffic the mesh was refusing on purpose. Define success as 2xx and put denials in their own panel, because a success-rate number can only ever hide them.

Grafana dashboard with seven panels against the app from this guide. mTLS share of pod-to-pod traffic reads 99.9 percent in green, throughput 3.85 requests per second, and 5xx rate 0.29 per second in orange. A request rate panel shows backend-v1 at about 2.9 requests per second against backend-v2 at 0.38, the ninety ten canary. A success rate panel using a 2xx only definition shows backend-v1 at 73.8 percent, backend-v2 at 77 percent and flaky at 47.6 percent. A latency panel shows p50 near 0.28 milliseconds and p95 and p99 near 0.5 milliseconds. An authorization denials panel names loadgen-intruder as the top denied caller at about 0.7 requests per second.
The canary is the gap between backend-v1 and backend-v2. Note the backend success rates sitting near 75 percent rather than 100: that is the intruder being refused, which is why the denials panel exists rather than being folded into success rate.

Step 9 - Now convert it to ambient and watch half of it stop working

Ambient mode replaces per-pod sidecars with a per-node ztunnel for L4, plus an optional waypoint proxy for L7. I converted this same cluster in place, because that is the migration you would actually face:

istioctl install --set profile=ambient -y
kubectl label namespace shop istio-injection-
kubectl label namespace shop istio.io/dataplane-mode=ambient
kubectl -n shop rollout restart deploy

Pods come back 1/1. Every workload shows up in istioctl ztunnel-config workload with protocol HBONE and, importantly, WAYPOINT: None.

Then I re-ran all four scenarios without changing a single config object:

scenario sidecar ambient, no waypoint
canary 90/10 899/101 28/32, route ignored
lockdown, intruder 403 connection refused
ingress, right / wrong Host 200 / 404 200 / 404
/delay/5 with 2s timeout 504 in 1.95s 200 in 4.86s, ignored
/status/200,503 x30 with retry 30/30 17/30, ignored

Nothing errored or warned, and every object still reported Accepted=True. Three of the four scenarios just quietly went away, because they are L7 and there is no L7 proxy left in the path.

Two details worth pulling out. Authorization still works, because ztunnel enforces identity at L4, but the intruder is now refused by a dropped connection rather than a 403, so anything alerting on 403 rate goes quiet. And load balancing moves from per-request to per-connection:

method result
60 requests, separate connections 28 / 32
60 requests, one reused connection 60 / 0

A keepalive client now pins to one pod for the life of the connection.

Adding a waypoint breaks everything, and the obvious fix opens the door

istioctl waypoint apply -n shop --enroll-namespace

Immediately after, every request returned 503 upstream connect error. The waypoint could not reach the backend, because deny-all did not name it. The natural fix is to add the waypoint’s identity to the allow rule. That gave me:

client   -> 200
intruder -> 200

Wide open. With a waypoint in the path, the backend’s ztunnel evaluates the waypoint’s identity, not the caller’s. Allowing the waypoint allows everything that can reach the waypoint. The policy still exists, still names one principal, and enforces nothing.

The fix is two policies at two layers, not one policy with two principals:

# L4, on the workload: only the waypoint may connect directly.
spec:
  selector:
    matchLabels: { app: backend }
  rules:
    - from: [{ source: { principals: ['cluster.local/ns/shop/sa/waypoint'] } }]
---
# L7, on the waypoint: only the client may call. targetRefs, not selector.
spec:
  targetRefs:
    - { kind: Gateway, group: gateway.networking.k8s.io, name: waypoint }
  rules:
    - from: [{ source: { principals: ['cluster.local/ns/shop/sa/client'] } }]

That gets you client 200, intruder 403 again, and the 403 is a real HTTP response from the waypoint rather than a reset.

You also have to name the ingress gateway in the L4 policy. A waypoint is a property of the destination namespace and callers reach it because their own ztunnel redirects them there. The Gateway lives outside the ambient namespace, so it has no ztunnel and connects to the pod directly. Leaving it out produced 503s for every external request while internal traffic worked perfectly, which is a miserable place to start debugging.

With the waypoint in place and policy split correctly, the L7 scenarios come back:

measurement sidecar ambient + waypoint
canary 90/10, 200 requests 173/27 180/20
/delay/5 with 2s timeout 504 in 1.95s 504 in 1.9465s
lockdown, client / intruder 200 / 403 200 / 403

Circuit breaking starts working, and your dashboard goes blank

Circuit breaking starts working. The same DestinationRule that did nothing under sidecars opens the circuit under a waypoint:

step sidecar ambient + waypoint
control 200 200 200 200 200 200
after 30 consecutive 503s 200 200 200 200 200 503 503 503 503 503
after the penalty window never opened 200 at t+10s

Ambient behaves the way the Linkerd guide would lead you to expect. Be aware of the cost: while the circuit was open the waypoint returned 503 in 1.9ms for every request to that Service, healthy ones included.

Your dashboard goes blank. Two silent breakages at once. The scrape annotations came from the sidecar injector, and there is no injector any more, so the job fell from 11 targets to 4: the waypoint, both ztunnels, and the gateway. Every application pod left monitoring.

And the reporter label changes. Running Step 8’s own panel queries, unchanged:

panel query ambient result
reporter="destination" mTLS NO DATA
reporter="destination" throughput NO DATA
the same with reporter="waypoint" 100.00% and 6.34 req/s

The correction that made the sidecar dashboard honest is the exact thing that blanks it after conversion. If your dashboard needs to survive a migration, match both: reporter=~"destination|waypoint".

L4 telemetry keeps flowing the whole time. istio_tcp_connections_opened_total read 6.41/s from ztunnel while every HTTP panel was empty. Watch only HTTP and you will conclude the mesh is dead.

Where to go next

The single thing to carry out of this guide: on Istio, the object existing is not the same as the object doing anything. A route with the wrong group reports no status and silently round-robins. A policy that names a waypoint enforces nothing. An L7 config on ambient without a waypoint is inert. A circuit breaker can eject every endpoint and keep serving. None of those print an error, and four of them cost me a debugging round in this lab.

So the one next action is not a feature. Go and read the status back on every routing object you already have in production:

kubectl get httproute -A -o custom-columns=\
'NS:.metadata.namespace,NAME:.metadata.name,PARENTS:.status.parents[*].conditions[?(@.type=="Accepted")].status'

Any row that comes back empty is a config you believe is running and is not.