Skip to main content

Command Palette

Search for a command to run...

Production-Ready on GKE: Istio Service Mesh with Automatic HTTPS, mTLS and Real-Time Observability

Updated
21 min read
Production-Ready on GKE: Istio Service Mesh with Automatic HTTPS, mTLS and Real-Time Observability

If you have ever tried to secure microservices communication, manage TLS certificates, and get meaningful visibility into your traffic all at the same time you know how quickly it becomes overwhelming. In this article, we are going to solve all three in one cohesive setup.

We will walk you through building a production-grade service mesh on Google Kubernetes Engine (GKE) from the ground up. Starting with provisioning our cloud infrastructure using Terraform, we will create a public GKE Standard cluster complete with a dedicated VPC, fine-grained firewall rules, and a least-privilege service account for our nodes. No clicking around in the GCP console everything is reproducible and version-controlled.

Once our cluster is up, we will install Istio using Helm the most flexible and production recommended approach deploying the control plane (Istiod) and a dedicated Ingress Gateway to handle all inbound traffic into our mesh. We will then deploy the Istio Bookinfo sample application, a microservices based bookstore made up of four independent services, to demonstrate everything working end to end.

Security is at the core of everything we build here. We will use Cert-Manager to automatically provision and renew TLS certificates from Let's Encrypt, giving our application real HTTPS at a custom domain no self-signed certificates, no manual renewals. Istio handles mutual TLS (mTLS) between every service inside the mesh automatically, meaning all internal service-to-service communication is encrypted and verified without a single line of application code change.

Finally, we will layer on a full observability stack using the kube-prometheus-stack for metrics and dashboards in Grafana, Kiali for real time service mesh topology and traffic visualization, and Jaeger for distributed tracing all exposed securely through our Istio Ingress Gateway on their own subdomains.

By the end of this article you will have a fully working, secure and observable service mesh running on GKE that you can use as a foundation for your own production workloads.

  secondary_ip_range {
    range_name    = "pods"
    ip_cidr_range = var.pods_cidr
  }

  secondary_ip_range {
    range_name    = "services"
    ip_cidr_range = var.services_cidr
  }

  private_ip_google_access = true
}

GKE requires two secondary IP ranges on the subnet one for pods and one for services. This is because GKE uses VPC native networking, where every pod gets a real IP address from the VPC rather than a NATed address, which means Kubernetes needs a dedicated CIDR block carved out specifically for pods separate from the nodes, and another for the cluster's internal service IPs.

The private_ip_google_access = true flag allows our nodes, which have no public IP addresses, to still reach Google APIs and services like Container Registry and Cloud Logging over Google's internal network without needing to route traffic through the internet.

Since our GKE nodes have no public IP addresses, they have no direct route to the internet. This is great for security but creates a practical problem our nodes need to pull container images from registries like Docker Hub or Google Container Registry, download packages, and reach external APIs. Without a way out, every pod would fail to start because the container runtime cannot pull its image.

Cloud NAT solves this by acting as a gateway that translates outbound traffic from our private nodes to a public IP address, allowing them to reach the internet for outbound requests while remaining completely unreachable from the internet themselves no inbound connections can be initiated to our nodes.

The Cloud Router is the underlying GCP component that Cloud NAT sits on top of, handling the routing logic at the VPC level. We set nat_ip_allocate_option = "AUTO_ONLY" so GCP automatically manages the external IP addresses used for translation rather than us having to pre-allocate static IPs, and source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" ensures all traffic from both our node and pod CIDRs can route outbound through NAT. Logging is enabled for errors only so we have visibility into any NAT failures without being flooded with logs from every outbound connection.

resource "google_compute_router_nat" "nat" {
  name                               = "${var.cluster_name}-nat"
  router                             = google_compute_router.router.name
  region                             = var.region
  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  log_config {
    enable = true
    filter = "ERRORS_ONLY"
  }
}

Rather than using the default Compute Engine service account that GCP assigns to nodes which carries far more permissions than a Kubernetes node ever needs we create a dedicated service account and grant it only the specific roles it requires to function. This is the principle of least privilege in practice.

The logging and monitoring roles allow our nodes to ship logs and metrics to Google Cloud Logging and Cloud Monitoring, giving us visibility into what is running on the cluster. The stackdriver.resourceMetadata.writer role allows the node to annotate its own metadata in Stackdriver so that metrics and logs are correctly associated with the right resources in the GCP console.

The storage.objectViewer and artifactregistry.reader roles are what allow the container runtime on each node to pull images from Google Container Registry and Artifact Registry respectively without these two roles our pods would fail to start because the node cannot authenticate to pull the container images.

If a node is ever compromised, this tight set of permissions means an attacker gains almost nothing at the GCP level they cannot create resources, modify infrastructure, access other services, or escalate privileges beyond what this narrow set of roles allows.

resource "google_project_iam_member" "node_sa_roles" {
  for_each = toset([
    "roles/logging.logWriter",
    "roles/monitoring.metricWriter",
    "roles/monitoring.viewer",
    "roles/stackdriver.resourceMetadata.writer",
    "roles/storage.objectViewer",
    "roles/artifactregistry.reader",
  ])

Our cluster is configured with a deliberate split between node privacy and master accessibility. The nodes themselves have enable_private_nodes = true meaning every worker node in the cluster gets only a private IP address from our subnet they are completely invisible and unreachable from the public internet. This is the right default for any production workload since your application pods should never be directly accessible at the node level.

  private_cluster_config {
    enable_private_nodes    = true   # nodes have no public IPs
    enable_private_endpoint = false  # master endpoint public
    master_ipv4_cidr_block  = var.master_cidr
  }

  master_authorized_networks_config {
    dynamic "cidr_blocks" {
      for_each = var.authorized_networks
      content {
        cidr_block   = cidr_blocks.value.cidr_block
        display_name = cidr_blocks.value.display_name
      }
    }
  }

The master endpoint however has enable_private_endpoint = false which keeps it publicly accessible. This is a conscious tradeoff in a fully private cluster the master is only reachable from within the VPC, which means your CI/CD pipelines, local kubectl commands and any tooling that talks to the Kubernetes API would need to be inside the VPC or connected via VPN.

For most teams that adds significant operational overhead. Instead we keep the master endpoint public but lock it down using master_authorized_networks_config, which acts as an IP allowlist on the Kubernetes API server. Only the CIDR blocks defined in your authorized_networks variable can reach the master endpoint everyone else gets a connection refused at the network level before they even see a TLS handshake.

The master itself lives in a dedicated /28 CIDR block defined by master_ipv4_cidr_block, which is a GCP-managed network that is peered into your VPC and kept completely separate from your node and pod ranges.

Workload Identity is one of the most important security features we enable on the cluster. Without it, applications running inside the cluster that need to call GCP APIs such as reading from Cloud Storage or publishing to Pub/Sub would typically need a GCP service account key file mounted into the pod as a secret.

Key files are a significant security risk; they do not expire, they can be copied, and if leaked they provide persistent access to your GCP project.

Workload Identity eliminates this entirely by allowing a Kubernetes service account to impersonate a GCP service account directly, using short-lived tokens that are automatically rotated. The workload_pool value of ${var.project_id}.svc.id.goog is the identity pool that GCP uses to map Kubernetes service accounts in your cluster to their corresponding GCP service accounts, with no key files involved at any point.

  workload_identity_config {
    workload_pool = "${var.project_id}.svc.id.goog"
  }

The http_load_balancing addon is kept enabled because GKE's HTTP load balancing integration is what allows our Istio Ingress Gateway's LoadBalancer service to provision a real GCP external load balancer automatically when we install it.

When Istio creates a Kubernetes service of type LoadBalancer, GKE detects it and provisions a GCP TCP load balancer, assigns it an external IP address, and routes traffic from that IP into our ingress gateway pods. Without this addon that external IP would never be assigned and our Istio Ingress Gateway would have no way to receive traffic from the internet.

 addons_config {
    http_load_balancing {
      disabled = false
    }

Securing the Cluster with Firewall Rules

With our infrastructure in place, we need to be deliberate about what traffic is allowed in and out of the cluster. GKE does not automatically open the ports that Istio needs, so we define all firewall rules explicitly in Terraform rather than relying on GCP defaults.

The most critical rule is allowing the GKE master to reach our nodes on port 15017. This is Istio's mutating webhook port every time a new pod is created in a namespace with istio-injection=enabled, the Kubernetes API server calls this webhook on the node to inject the Envoy sidecar container into the pod spec before it starts. If this port is blocked, sidecar injection silently fails and your pods run without Envoy, completely outside the mesh.

  allow {
    protocol = "tcp"
    ports    = ["8443", "10250", "15017"]
  }

For the Istio control plane we open ports 15010 and 15012 which Istiod uses to push configuration routing rules, certificates, and service discovery down to every Envoy sidecar in the cluster. Without these ports open the sidecars cannot receive configuration from Istiod and the mesh cannot function.

 allow {
    protocol = "tcp"
    ports    = ["15010", "15012", "15014", "15017"]
  }

On the data plane we open the Envoy sidecar ports 15001 and 15006 for outbound and inbound traffic interception respectively, along with 15021 for health checks and 15020 for Prometheus metrics scraping.


  allow {
    protocol = "tcp"
    ports    = ["15001", "15006", "15008", "15020", "15021", "15090"]
  }

For external traffic we open ports 80 and 443 from 0.0.0.0/0 to allow internet traffic into the Istio Ingress Gateway, and we add the GCP health check source ranges so the load balancer can probe the gateway backends. Finally we allow all traffic within the pod and node CIDRs to flow freely between workloads, which is what enables mTLS communication inside the mesh.

  allow {
    protocol = "tcp"
    ports    = ["80", "443"]
  }

Installing Istio, Cert-Manager, the Monitoring Stack and Bookinfo Using Helm

With our GKE cluster provisioned and all firewall rules in place, we can now install everything we need onto the cluster. The first step is to configure kubectl to talk to our new cluster:

gcloud container clusters get-credentials bookinfo-istio \
  --zone us-central1-a --project YOUR_PROJECT_ID

Installing Istio

Istio is installed in three steps using Helm — the base CRDs first, then the control plane, and finally the ingress gateway into its own dedicated namespace:

helm repo add istio https://istio-release.storage.googleapis.com/charts

helm repo update

kubectl create namespace istio-system

helm install istio-base istio/base \
  -n istio-system --version 1.29.2

helm install istiod istio/istiod \
  -n istio-system --version 1.29.2 --wait

kubectl create namespace istio-ingress
kubectl label namespace istio-ingress istio-injection=enabled

helm install istio-ingressgateway istio/gateway \
  -n istio-ingress --version 1.29.2 --wait

The ingress gateway is deployed into its own istio-ingress namespace rather than istio-system this is the recommended approach as it isolates the data plane from the control plane and allows you to manage and scale the gateway independently.

Installing Cert-Manager

Cert-Manager handles automatic TLS certificate provisioning and renewal from Let's Encrypt:

helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.17.0 \
  --set crds.enabled=true

Once installed we create a ClusterIssuer that tells cert-manager to use Let's Encrypt as the certificate authority, and then a Certificate resource for our domain. Cert-manager takes care of the HTTP01 challenge, obtains the certificate, and stores it as a Kubernetes secret that Istio's ingress gateway reads directly to terminate TLS.

Installing the Monitoring Stack

The observability stack is made up of three components Prometheus and Grafana via kube-prometheus-stack, Kiali for mesh topology, and Jaeger for distributed tracing:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add kiali https://kiali.org/helm-charts
helm repo add jaegertracing https://jaegertracing.github.io/helm-charts
helm repo update

kubectl create namespace monitoring

helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \
  --set grafana.adminPassword=admin \
  --set "grafana.grafana\.ini.server.domain=grafana.klaudmazoezi.top" \
  --set "grafana.grafana\.ini.server.root_url=https://grafana.klaudmazoezi.top" \
  --wait

helm install kiali-server kiali/kiali-server \
  --namespace istio-system \
  --set auth.strategy=anonymous \
  --set external_services.prometheus.url="http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090" \
  --set external_services.grafana.url="http://kube-prometheus-stack-grafana.monitoring.svc.cluster.local:80" \
  --wait

helm install jaeger jaegertracing/jaeger \
  --namespace istio-system \
  --set provisionDataStore.cassandra=false \
  --set allInOne.enabled=true \
  --set storage.type=memory \
  --set agent.enabled=false \
  --set collector.enabled=false \
  --set query.enabled=false \
  --wait

Each tool is then exposed on its own subdomain through the Istio Ingress Gateway using a dedicated certificate from Let's Encrypt and a VirtualService, the same pattern we will use for the Bookinfo app itself:

Deploying the Bookinfo Application

With the mesh running we label the default namespace to enable automatic Envoy sidecar injection, then deploy the Bookinfo app directly from the official Istio GitHub repository:

kubectl label namespace default istio-injection=enabled

kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.29/samples/bookinfo/platform/kube/bookinfo.yaml

Before applying the official Bookinfo gateway manifest, it is important to verify that the selector in the Gateway resource matches the labels on your Istio ingress gateway pod. The official manifest uses istio: ingressgateway as the selector run the following to confirm your pod has that label:

kubectl get pods -n istio-ingress --show-labels

You should see istio=ingressgateway in the labels column. If it matches, go ahead and apply:

kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.29/samples/bookinfo/networking/bookinfo-gateway.yaml

kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.29/samples/bookinfo/networking/destination-rule-all.yaml

One more thing to be aware of the official bookinfo-gateway.yaml uses port 8080 not port 80 for the HTTP server. This is intentional as Istio's ingress gateway listens internally on 8080 which the LoadBalancer maps to external port 80. Also note that the Gateway and VirtualService resources should live in the same namespace as your ingress gateway pod (istio-ingress) and the VirtualService destination host should use the fully qualified service name productpage.default.svc.cluster.local since it is routing across namespaces.

Once the pods are running every pod should show 2/2 in the READY column, meaning the application container and the Envoy sidecar are both running:

kubectl get pods -n default

Creating TLS Certificates with Cert-Manager

Before creating our Gateways and VirtualServices, we need to provision TLS certificates for all our domains. Cert-Manager handles this automatically by talking to Let's Encrypt on our behalf we just need to define what we want and it takes care of the rest.

ClusterIssuer — Let's Encrypt Production

The ClusterIssuer is the bridge between cert-manager and Let's Encrypt. It is a cluster-wide resource meaning it can issue certificates for any namespace in the cluster. We use the production Let's Encrypt server which issues fully trusted certificates recognised by all browsers:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - http01:
          ingress:
            class: istio

Certificates

With the ClusterIssuer in place we create a Certificate resource for each domain. Cert-Manager reads these, performs the HTTP01 challenge with Let's Encrypt to verify domain ownership, and stores the resulting certificate as a Kubernetes secret in the istio-ingress namespace exactly where our Gateway resources will look for them:

Bookinfo Certificate

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: bookinfo-cert
  namespace: istio-ingress
spec:
  secretName: bookinfo-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - klaudmazoezi.top

Grafana Certificate

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: grafana-cert
  namespace: istio-ingress
spec:
  secretName: grafana-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - grafana.klaudmazoezi.top

Kiali Certificate

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: kiali-cert
  namespace: istio-ingress
spec:
  secretName: kiali-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - kiali.klaudmazoezi.top

Jaeger Certificate

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: jaeger-cert
  namespace: istio-ingress
spec:
  secretName: jaeger-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - jaeger.klaudmazoezi.top

After applying all four Certificate resources, wait for all of them to show READY = True before moving on to creating the Gateways and VirtualServices, the Gateway will fail to serve HTTPS if the secret does not exist yet:

kubectl get certificate -n istio-ingress -w

Exposing Services Through the Istio Ingress Gateway

With all our services running inside the cluster, we need to expose them to the outside world through the Istio Ingress Gateway. We do this using two Gateway resources — one for the Bookinfo app and one for all the observability tools and a VirtualService for each service.

Important: Before applying any of the Gateway and VirtualService manifests below, make sure you have already created all the necessary DNS records in your GCP Cloud DNS zone. Every subdomain must have an A record pointing to your Istio Ingress Gateway external IP address — in our case your.loadbalancer.ip.address — before cert-manager attempts to issue the certificates. If the DNS records are not in place when the Certificate resources are created, the Let's Encrypt HTTP01 challenge will fail because it cannot resolve the domain to verify ownership, and your Gateway will have no TLS secret to serve on port 8443 resulting in a connection refused. For our setup we created the following A records:

Bookinfo Gateway

apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
  name: bookinfo-gateway
  namespace: istio-ingress
spec:
  selector:
    istio: ingressgateway
  servers:
    - port:
        number: 8080
        name: http
        protocol: HTTP
      hosts:
        - "klaudmazoezi.top"
      tls:
        httpsRedirect: true
    - port:
        number: 8443
        name: https
        protocol: HTTPS
      hosts:
        - "klaudmazoezi.top"
      tls:
        mode: SIMPLE
        credentialName: bookinfo-tls

Observability Gateway

apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
  name: observability-gateway
  namespace: istio-ingress
spec:
  selector:
    istio: ingressgateway
  servers:
    - port:
        number: 8080
        name: http-observability
        protocol: HTTP
      hosts:
        - "grafana.klaudmazoezi.top"
        - "kiali.klaudmazoezi.top"
        - "jaeger.klaudmazoezi.top"
      tls:
        httpsRedirect: true
    - port:
        number: 8443
        name: https-grafana
        protocol: HTTPS
      hosts:
        - "grafana.klaudmazoezi.top"
      tls:
        mode: SIMPLE
        credentialName: grafana-tls
    - port:
        number: 8443
        name: https-kiali
        protocol: HTTPS
      hosts:
        - "kiali.klaudmazoezi.top"
      tls:
        mode: SIMPLE
        credentialName: kiali-tls
    - port:
        number: 8443
        name: https-jaeger
        protocol: HTTPS
      hosts:
        - "jaeger.klaudmazoezi.top"
      tls:
        mode: SIMPLE
        credentialName: jaeger-tls

VirtualService — Bookinfo

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: bookinfo
  namespace: istio-ingress
spec:
  hosts:
    - "klaudmazoezi.top"
  gateways:
    - bookinfo-gateway
  http:
    - match:
        - uri:
            exact: /productpage
        - uri:
            prefix: /static
        - uri:
            exact: /login
        - uri:
            exact: /logout
        - uri:
            prefix: /api/v1/products
      route:
        - destination:
            host: productpage.default.svc.cluster.local
            port:
              number: 9080

VirtualService — Grafana

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: grafana
  namespace: istio-ingress
spec:
  hosts:
    - "grafana.klaudmazoezi.top"
  gateways:
    - observability-gateway
  http:
    - route:
        - destination:
            host: kube-prometheus-stack-grafana.monitoring.svc.cluster.local
            port:
              number: 80

VirtualService — Kiali

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: kiali
  namespace: istio-ingress
spec:
  hosts:
    - "kiali.klaudmazoezi.top"
  gateways:
    - observability-gateway
  http:
    - route:
        - destination:
            host: kiali.istio-system.svc.cluster.local
            port:
              number: 20001

VirtualService — Jaeger

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: jaeger
  namespace: istio-ingress
spec:
  hosts:
    - "jaeger.klaudmazoezi.top"
  gateways:
    - observability-gateway
  http:
    - route:
        - destination:
            host: jaeger.istio-system.svc.cluster.local
            port:
              number: 16686

Second, make sure your istio-ingressgateway service has the correct targetPort mapping port 80 must target 8080 and port 443 must target 8443 on the pod. Ensure your service looks like this:

apiVersion: v1
kind: Service
metadata:
  name: istio-ingressgateway
  namespace: istio-ingress
spec:
  type: LoadBalancer
  selector:
    app: istio-ingressgateway
    istio: ingressgateway
  ports:
    - name: status-port
      port: 15021
      targetPort: 15021
      protocol: TCP
    - name: http2
      port: 80
      targetPort: 8080
      protocol: TCP
    - name: https
      port: 443
      targetPort: 8443
      protocol: TCP

You can verify your current mapping by running:

kubectl get svc istio-ingressgateway -n istio-ingress -o yaml | grep -A 15 "ports:"

If targetPort shows 80 and 443 instead of 8080 and 8443, the LoadBalancer will forward traffic to the wrong ports on the gateway pod and every request will get a connection refused.

- port:
    number: 8443
    name: https-grafana
    protocol: HTTPS
- port:
    number: 8080
    name: http-observability
    protocol: HTTP

Each server entry in the Gateway resource defines a listener on the ingress gateway pod. The http-observability listener on port 8080 catches all plain HTTP traffic coming in for our observability subdomains and immediately redirects it to HTTPS thanks to httpsRedirect: true, so if anyone visits http://grafana.klaudmazoezi.top they are automatically sent to https://grafana.klaudmazoezi.top without ever seeing the site over plain HTTP.

The https-grafana, https-kiali and https-jaeger listeners on port 8443 are where the actual HTTPS traffic is handled. Each one is configured with mode: SIMPLE which means the ingress gateway terminates TLS at the edge using the certificate stored in the corresponding Kubernetes secret grafana-tls, kiali-tls and jaeger-tls that cert-manager provisioned from Let's Encrypt.

After TLS termination the gateway forwards the decrypted traffic internally to the correct VirtualService which routes it to the right backend service. This means TLS is handled entirely at the gateway level and the backend services like Grafana and Kiali receive plain HTTP internally, keeping the internal communication simple while the external facing traffic remains fully encrypted.

Configuring Istio Distributed Tracing with Jaeger

With Jaeger installed, there is one additional step required to connect Istio to it. By default Istiod does not know where to send trace spans we need to tell it the address of our Jaeger instance and enable tracing across the mesh.

We do this by patching the istio configmap in the istio-system namespace directly:

kubectl patch configmap istio -n istio-system --type merge -p '{
  "data": {
    "mesh": "defaultConfig:\n  tracing:\n    zipkin:\n      address: jaeger.istio-system.svc.cluster.local:9411\n    sampling: 100\nenableTracing: true\noutboundTrafficPolicy:\n  mode: ALLOW_ANY"
  }
}'

A few things to note here. Jaeger supports the Zipkin protocol on port 9411 which is why we use the Zipkin address format Istio's tracing configuration speaks Zipkin by default and Jaeger is fully compatible with it. The sampling value of 100 means every single request is traced, which is ideal for a demo environment. In production you would drop this to something like 1 or 5 to avoid the overhead of tracing every request at scale.

After patching the configmap, restart all the Bookinfo pods so their Envoy sidecars pick up the new proxy configuration:

kubectl rollout restart deployment -n default
kubectl rollout status deployment -n default

Then generate some traffic to populate the traces:

for i in $(seq 1 50); do
  curl -s "https://klaudmazoezi.top/productpage" > /dev/null
done

Open https://jaeger.klaudmazoezi.top, select productpage from the Service dropdown and click Find Traces. You will now see the full distributed trace across all four Bookinfo microservices showing exactly how long each service took to respond and the complete call chain from productpage through reviews and ratings down to details.

Conclusion

What we have built in this article is far more than a sample application running on Kubernetes. Starting from an empty GCP project we provisioned a production grade GKE cluster using Terraform with a properly segmented VPC, private nodes, a least-privilege service account, Cloud NAT for outbound connectivity, and a carefully considered set of firewall rules that open only the ports Istio needs to function correctly.

On top of that infrastructure we layered a full Istio service mesh installed via Helm, giving us automatic mutual TLS between every microservice in the cluster, a dedicated Ingress Gateway to handle all inbound traffic, and powerful traffic management primitives that we can use to control how requests flow between services without touching a single line of application code.

We secured all external traffic with real trusted TLS certificates issued automatically by Cert-Manager from Let's Encrypt, configured to renew themselves before they expire. Every subdomain the Bookinfo app, Grafana, Kiali and Jaeger is served over HTTPS with no manual certificate management involved.

The observability stack gives us full visibility into the mesh at every level. Prometheus scrapes metrics from every Envoy sidecar in the cluster, Grafana visualises them through the official Istio dashboards, Kiali shows us a real-time topology of how our services are communicating with each other, and Jaeger gives us end-to-end distributed traces so we can follow a single request as it travels through productpage, reviews, ratings and details.

The Bookinfo application itself is intentionally simple but that is the point. It demonstrates that Istio adds security, observability and traffic control to your services without requiring any changes to the application code itself. The same setup you have built here can be applied to any microservices application running on GKE.

From here there is a lot more you can explore with Istio. Traffic shifting lets you gradually roll out a new version of a service by sending a percentage of traffic to it. Fault injection lets you simulate failures and latency in your mesh to test how your application behaves under degraded conditions. Circuit breaking protects your services from cascading failures by cutting off traffic to an unhealthy service automatically. All of these are just a few more Istio resources away no application changes required.