Containers

Install Istio on Kubernetes: mTLS, Canary and Traffic Routing

Istio adds three things to a Kubernetes cluster that the API server does not give you for free: encrypted service-to-service traffic without modifying any application, fine-grained traffic routing for canary and blue-green deploys, and end-to-end observability of every HTTP and gRPC call. The cost is an Envoy sidecar in every pod and a control plane (istiod) to push configuration to those sidecars.

Original content from computingforgeeks.com - post 167283

This guide installs Istio on a real Kubernetes 1.34 cluster, deploys a two-version sample app, enables strict mTLS between every pod, splits traffic 90/10 between v1 and v2 with a VirtualService, then verifies the split with 100 real curls and a Kiali topology graph. Every command and every output below is captured from the test cluster.

Tested April 2026 on Kubernetes 1.34.7 (kubeadm), Istio 1.29.2, podinfo 6.7.x as the sample workload, MetalLB providing LoadBalancer IPs, Cilium 1.19.3 as the CNI.

What Istio actually adds to your cluster

Istio is a service mesh, which is a vague term that means three concrete things in practice. First, an Envoy proxy is injected as a sidecar into every application pod. Application traffic flows through the sidecar instead of directly to the network, which lets the mesh terminate mTLS, enforce policy, and emit metrics without the application knowing. Second, a control plane (istiod) watches Kubernetes resources for traffic-routing CRDs (VirtualService, DestinationRule, Gateway) and pushes the resulting Envoy configuration to every sidecar over xDS. Third, an ingress gateway (a dedicated Envoy at the cluster edge) handles north-south traffic the same way sidecars handle east-west.

The two-line summary: every pod’s traffic is routed by an Envoy that knows what istiod tells it. Everything else is plumbing.

Istio competes most directly with Linkerd (simpler, faster, fewer features) and Cilium service mesh (no sidecars, runs in eBPF, paired with the Cilium CNI). Pick Istio when you need the full traffic-management vocabulary (per-header routing, fault injection, multi-cluster mesh) and can absorb the sidecar overhead. Pick Linkerd when simplicity matters more than feature surface. Pick Cilium mesh when you are already running Cilium and want to skip sidecars entirely.

Step 1: Set reusable shell variables

Three values repeat throughout the guide. Export them once at the top of the kubectl shell:

export ISTIO_VERSION="1.29.2" #https://github.com/istio/istio/releases
export DEMO_NS="demo"
export INGRESS_LB_IP="10.0.1.203"   # any free MetalLB IP

The ingress IP must come from a free range in the LoadBalancer pool. The MetalLB on Kubernetes guide covers IP pool setup; if you are on a managed cluster, your cloud provider’s LoadBalancer assigns it automatically.

Step 2: Install the istioctl CLI

The official downloader pulls the matching CLI for your OS and architecture, then installs it. Run on the workstation or control plane node:

curl -sL https://istio.io/downloadIstio | ISTIO_VERSION=${ISTIO_VERSION} sh -
sudo mv istio-${ISTIO_VERSION}/bin/istioctl /usr/local/bin/
istioctl version --remote=false

The output should match the version you downloaded:

client version: 1.29.2

The downloader leaves the rest of the Istio release (sample apps, addons, raw manifests) in istio-${ISTIO_VERSION}/. Keep that directory; you will use the addon manifests for Kiali and Jaeger in Step 8.

Step 3: Install Istio on the cluster (demo profile)

Istio ships several install profiles. The demo profile installs everything needed to follow this guide (istiod, ingress gateway, egress gateway). For production, the default profile is leaner; for ambient mode (no sidecars), use ambient:

istioctl install --set profile=demo -y

The install prints progress as it brings up each component. Wait for the success messages:

✔ Istiod installed 🧠
✔ Egress gateways installed 🛫
✔ Ingress gateways installed 🛬
✔ Installation complete

Verify the control plane and gateways are running in the istio-system namespace:

kubectl -n istio-system get pods
kubectl -n istio-system get svc istio-ingressgateway

The ingress gateway should pick up an external IP from MetalLB:

NAME                                    READY   STATUS    RESTARTS   AGE
istio-egressgateway-5999478b6d-9r65h    1/1     Running   0          15s
istio-ingressgateway-584467857d-h8gtc   1/1     Running   0          15s
istiod-6c4f4b85b7-7sn4b                 1/1     Running   0          29s

NAME                   TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)
istio-ingressgateway   LoadBalancer   10.98.50.247    10.0.1.203    15021:31920/TCP,80:32724/TCP,443:30614/TCP

Step 4: Create the demo namespace and enable sidecar injection

Sidecar injection is opt-in per namespace via a label. When a pod is created in a labelled namespace, the Istio admission webhook patches the pod spec to add the Envoy sidecar container:

kubectl create namespace ${DEMO_NS}
kubectl label namespace ${DEMO_NS} istio-injection=enabled
kubectl get namespace ${DEMO_NS} --show-labels

The istio-injection=enabled label is what triggers the webhook. Without it, pods in this namespace would run without sidecars and would not be part of the mesh.

Step 5: Deploy a two-version sample app

The article uses podinfo in two versions for the canary demo. Each version has its own Deployment with a distinct version label, plus a single Service that fronts both:

cat > podinfo-v1.yaml <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: podinfo-v1
  namespace: demo
  labels: {app: podinfo, version: v1}
spec:
  replicas: 1
  selector:
    matchLabels: {app: podinfo, version: v1}
  template:
    metadata:
      labels: {app: podinfo, version: v1}
    spec:
      containers:
        - name: podinfo
          image: ghcr.io/stefanprodan/podinfo:6.7.1
          ports: [{containerPort: 9898}]
YAML

cat > podinfo-v2.yaml <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: podinfo-v2
  namespace: demo
  labels: {app: podinfo, version: v2}
spec:
  replicas: 1
  selector:
    matchLabels: {app: podinfo, version: v2}
  template:
    metadata:
      labels: {app: podinfo, version: v2}
    spec:
      containers:
        - name: podinfo
          image: ghcr.io/stefanprodan/podinfo:6.7.0
          ports: [{containerPort: 9898}]
YAML

cat > podinfo-svc.yaml <<'YAML'
apiVersion: v1
kind: Service
metadata:
  name: podinfo
  namespace: demo
  labels: {app: podinfo}
spec:
  selector: {app: podinfo}   # selects BOTH v1 and v2 pods
  ports:
    - {name: http, port: 80, targetPort: 9898}
YAML

kubectl apply -f podinfo-v1.yaml -f podinfo-v2.yaml -f podinfo-svc.yaml

Two design notes that matter for the canary that comes next. The Service selector matches app: podinfo on both Deployments; without Istio, kube-proxy would round-robin between v1 and v2 pods (50/50 by default). With Istio, the DestinationRule and VirtualService take over routing decisions and the Service selector is just a member-list. The version label is what DestinationRule subsets reference.

Confirm both pods are Running with the Envoy sidecar attached. The READY column shows 2/2 when the sidecar is injected (one for the app container, one for Envoy):

kubectl -n ${DEMO_NS} get pods

Both pods come up with two containers each (the app plus the Envoy sidecar):

NAME                          READY   STATUS    RESTARTS   AGE
podinfo-v1-d57b7c9fd-7swjb    2/2     Running   0          47s
podinfo-v2-7dd4c48986-b985l   2/2     Running   0          17s

If READY shows 1/1, the sidecar was not injected. Check that the namespace has the istio-injection=enabled label and that the pod was created after the label was applied (existing pods do not retroactively get sidecars).

Step 6: Enable strict mTLS for the namespace

The Envoy sidecars in the mesh can speak mTLS to each other automatically, but they default to PERMISSIVE mode (accept both plain HTTP and mTLS) so the mesh works during gradual rollout. To require mTLS, apply a PeerAuthentication resource:

cat > peer-authentication.yaml <<'YAML'
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: demo
spec:
  mtls:
    mode: STRICT
YAML

kubectl apply -f peer-authentication.yaml
kubectl -n ${DEMO_NS} get peerauthentication

The output confirms STRICT mode is active for the namespace:

NAME      MODE     AGE
default   STRICT   3s

From this point, any pod that talks to a podinfo pod from outside the mesh (no sidecar) will be rejected at the Envoy layer. Pods inside the mesh negotiate mTLS automatically using identity certificates issued by istiod.

Step 7: Route 90/10 traffic with VirtualService and DestinationRule

Three resources work together. The Gateway attaches to the istio-ingressgateway and accepts HTTP on port 80. The DestinationRule defines named subsets (v1 and v2) of the podinfo Service based on pod labels. The VirtualService binds them: 90% of traffic for the gateway hostname goes to subset v1, 10% to subset v2.

cat > gateway.yaml <<'YAML'
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
  name: podinfo-gw
  namespace: demo
spec:
  selector:
    istio: ingressgateway
  servers:
    - port: {number: 80, name: http, protocol: HTTP}
      hosts: ["*"]
YAML

cat > destination-rule.yaml <<'YAML'
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: podinfo
  namespace: demo
spec:
  host: podinfo.demo.svc.cluster.local
  subsets:
    - {name: v1, labels: {version: v1}}
    - {name: v2, labels: {version: v2}}
YAML

cat > virtualservice-canary.yaml <<'YAML'
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: podinfo
  namespace: demo
spec:
  hosts: ["*"]
  gateways: ["podinfo-gw"]
  http:
    - route:
        - destination: {host: podinfo.demo.svc.cluster.local, subset: v1}
          weight: 90
        - destination: {host: podinfo.demo.svc.cluster.local, subset: v2}
          weight: 10
YAML

kubectl apply -f gateway.yaml -f destination-rule.yaml -f virtualservice-canary.yaml

Verify all three resources:

kubectl -n ${DEMO_NS} get gateway,virtualservice,destinationrule

All three CRDs should be present, with the VirtualService bound to the new Gateway:

NAME                                     AGE
gateway.networking.istio.io/podinfo-gw   3s

NAME                                         GATEWAYS         HOSTS   AGE
virtualservice.networking.istio.io/podinfo   ["podinfo-gw"]   ["*"]   3s

NAME                                          HOST                             AGE
destinationrule.networking.istio.io/podinfo   podinfo.demo.svc.cluster.local   3s

Now hit the ingress IP 100 times and count which version each request hit. The /version endpoint on podinfo returns the running version, which makes the split easy to verify:

for i in $(seq 1 100); do
  curl -s "http://${INGRESS_LB_IP}/version" | jq -r .version
done | sort | uniq -c

Captured from the test cluster: 91 hits to v1 (which is podinfo 6.7.1) and 9 hits to v2 (which is podinfo 6.7.0). Within statistical noise, this is the 90/10 split the VirtualService asked for:

     91 6.7.1
      9 6.7.0

To shift more traffic to v2, edit the VirtualService and change the weights. To roll back, set v1 to 100 and v2 to 0. To do a canary by header (only requests with x-canary: true go to v2), use a match clause in the VirtualService instead of weights.

Step 8: Install Kiali for the topology graph

Istio ships several addons in the release tarball you downloaded in Step 2. The most useful for production is Kiali, which gives you a real-time topology graph of every mesh request. Install it (plus Prometheus and Jaeger that Kiali depends on) from the samples folder:

cd istio-${ISTIO_VERSION}
kubectl apply -f samples/addons/prometheus.yaml
kubectl apply -f samples/addons/kiali.yaml
kubectl apply -f samples/addons/jaeger.yaml
kubectl -n istio-system rollout status deploy kiali

Expose the Kiali Service so a browser can reach it. In a homelab, change to LoadBalancer and let MetalLB hand out an IP:

kubectl -n istio-system patch svc kiali \
  -p '{"spec":{"type":"LoadBalancer","loadBalancerIP":"10.0.1.204"}}'
kubectl -n istio-system get svc kiali

In production, put Kiali behind an Ingress with the Gateway API or NGINX Ingress and put it behind your SSO of choice. Then open the Kiali graph view filtered to the demo namespace:

open "http://10.0.1.204:20001/kiali/console/graph/namespaces/?namespaces=demo"

The graph shows the istio-ingressgateway pushing traffic into the podinfo Service which fans out to v1 and v2, with the actual percentage split labelled on each edge. Live RPS, success rate, and error rate are visible on the right panel:

Kiali topology graph showing istio-ingressgateway routing 91 percent of podinfo traffic to v1 and 9 percent to v2

This view is the operator’s primary debug tool. When something goes wrong (red edges, high latency, error spikes), this graph tells you which pair of services is the source. Clicking any node opens the workload detail with metrics, traces, and Envoy config dumps.

Step 9: Debug Envoy config with istioctl

The first command to learn for any Istio issue is istioctl analyze. It runs config validation across the cluster and reports issues:

istioctl analyze -n ${DEMO_NS}

On a clean install with the manifests from this guide, the analyzer reports no issues:

✔ No validation issues found when analyzing namespace: demo.

When you do hit issues (DestinationRule for a non-existent Service, VirtualService routing to a subset that does not exist, sidecar not injected), this command flags them with the exact resource and the fix.

The next command for traffic-routing problems is istioctl proxy-config cluster <pod>.<ns>, which dumps the Envoy cluster config that the sidecar received from istiod. Inspect what destinations a podinfo pod knows about:

istioctl proxy-config cluster $(kubectl -n ${DEMO_NS} \
  get pod -l app=podinfo,version=v1 -o jsonpath="{.items[0].metadata.name}").${DEMO_NS} \
  | grep podinfo

The Envoy in the v1 pod knows about three clusters for the podinfo Service: an unsubsetted one and one per DestinationRule subset:

podinfo.demo.svc.cluster.local    80    -    outbound    EDS    podinfo.demo
podinfo.demo.svc.cluster.local    80    v1   outbound    EDS    podinfo.demo
podinfo.demo.svc.cluster.local    80    v2   outbound    EDS    podinfo.demo

Three clusters: one for the unsubsetted Service (used when no DestinationRule subset matches) and one for each named subset from the DestinationRule. The Envoy uses these to make routing decisions whenever the sidecar’s local app talks to podinfo.

Other useful proxy-config subcommands:

  • istioctl proxy-config listeners <pod>.<ns>: every port the sidecar listens on and what filter chain runs on each.
  • istioctl proxy-config routes <pod>.<ns>: every HTTP route the sidecar knows. This is where you confirm a VirtualService landed.
  • istioctl proxy-config endpoints <pod>.<ns>: actual pod IPs the sidecar will load-balance to. Confirms EDS is delivering the right backends.
  • istioctl x precheck: pre-upgrade health check; run before bumping Istio versions.

The most common failures and their fixes

The four issues that account for most Istio support tickets, with the exact symptom and the fix.

Sidecar not injected (READY 1/1 instead of 2/2)

The namespace label is missing or was applied after the pod was created. Fix:

kubectl label namespace ${DEMO_NS} istio-injection=enabled --overwrite
kubectl -n ${DEMO_NS} rollout restart deploy

UPSTREAM_RESET / connection refused after enabling mTLS

A client outside the mesh is trying to reach a pod inside it. PeerAuthentication strict requires both endpoints to have sidecars. Either move the client into a labelled namespace or change the PeerAuthentication mode to PERMISSIVE during the migration.

VirtualService weights are not respected

The DestinationRule subsets do not match labels on actual pods. Run:

kubectl -n ${DEMO_NS} get pods -l version=v2
istioctl proxy-config endpoints $(kubectl -n ${DEMO_NS} \
  get pod -l app=podinfo,version=v1 -o jsonpath="{.items[0].metadata.name}").${DEMO_NS} \
  | grep podinfo

Both should return at least one pod for each subset. Empty output means the labels in the DestinationRule do not match the labels on the pods.

503 NR (no route) at the ingress gateway

The Gateway hostname does not match what the client sent. Either change the VirtualService hosts to ["*"] for testing, or set a real hostname and pass -H "Host: example.com" on the curl. istioctl proxy-config routes on the ingress gateway pod is the source of truth for what hostnames are matched.

Performance and footprint

Each Envoy sidecar adds memory (~40MB per pod) and a small CPU overhead (1-3% on light traffic, more under load). On the test cluster, measured request latency through Envoy added about 1-2ms p50 and 5-8ms p99 over plain Kubernetes Service routing. The istiod control plane uses ~150MB regardless of cluster size for clusters under ~200 services; beyond that it grows with the number of distinct services and routes.

The bigger operational cost is mental overhead. Every traffic problem now has two layers (Service plus Envoy), every TLS issue has a PeerAuthentication and a DestinationRule mode, every observability question routes through Kiali. For a team with 10 services, this is overkill; for a team with 100 services, it is the only way to maintain sanity. The decision usually comes when manual kubectl exec debugging stops scaling.

Failure drill: what happens when istiod goes down

One question every team asks before adopting Istio: what is the blast radius if the control plane crashes? The answer is reassuring. Existing Envoy sidecars keep their last-known config in memory and continue routing traffic; only new pods cannot get Envoy configuration until istiod recovers. Verify on the test cluster:

kubectl -n istio-system scale deploy istiod --replicas=0
sleep 10
# Existing pods should still serve traffic
curl -s "http://${INGRESS_LB_IP}/version" | jq .

The curl still returns the podinfo response because the ingress-gateway Envoy and the podinfo sidecars all have their last-known routing config cached. Restore istiod and new pods can join the mesh again:

kubectl -n istio-system scale deploy istiod --replicas=1
kubectl -n istio-system rollout status deploy istiod

The lesson: istiod is recoverable, the data plane is resilient, and a control-plane outage degrades capacity (no new pods join the mesh) but does not stop traffic. In production, run istiod with at least 2 replicas and a PodDisruptionBudget so node maintenance never takes both at once.

Cleaning up

Remove the demo workloads and Istio itself when you are done with the lab:

kubectl delete ns ${DEMO_NS}
istioctl uninstall --purge -y
kubectl delete ns istio-system

Service mesh is one layer of cluster networking; the foundation is the CNI. The Cilium CNI guide covers eBPF-based pod networking and NetworkPolicies. For ingress traffic specifically, the Gateway API migration guide explains the new ingress standard that Istio, Cilium, and most service meshes have started supporting natively. Production observability beyond Kiali ties into the Prometheus and Grafana on Kubernetes stack, and the Kubernetes RBAC guide covers who can edit VirtualServices and DestinationRules in production multi-tenant clusters.

Related Articles

Containers Configure Static IPv4 Address in OpenShift 4.x CoreOS Servers Kubernetes How To Install Minikube on Ubuntu 24.04|22.04|20.04 Containers Run Kubernetes Cluster on Rocky Linux using k3s Containers Run Nextcloud on Docker Containers using Podman

Leave a Comment

Press ESC to close