Istio’s VirtualService doesn’t actually route traffic; it defines intent for how traffic should be routed, and the underlying Envoy proxies make it happen.
Let’s say you have a service my-app running across multiple pods, and you want to send 90% of your traffic to version v1 and 10% to version v2. Here’s how you’d express that with a VirtualService:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: my-app-vs
spec:
hosts:
- my-app.default.svc.cluster.local # The service name Istio watches
http:
- route:
- destination:
host: my-app.default.svc.cluster.local
subset: v1 # Reference to a subset defined in a DestinationRule
weight: 90
- destination:
host: my-app.default.svc.cluster.local
subset: v2 # Reference to another subset
weight: 10
This VirtualService is paired with a DestinationRule that defines those v1 and v2 subsets, typically based on labels applied to the pods running different versions of your application.
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: my-app-dr
spec:
host: my-app.default.svc.cluster.local
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
When a request hits the Istio ingress gateway (or any Istio-enabled service), the Envoy proxy intercepts it. It looks at the VirtualService associated with the destination host (my-app.default.svc.cluster.local in this case). The VirtualService tells Envoy: "For HTTP requests, if the host header matches my-app.default.svc.cluster.local, then send 90% of the traffic to the pods labeled version: v1 (which we’ve called v1 in the DestinationRule) and 10% to the pods labeled version: v2 (called v2)." Envoy then uses its internal load balancing algorithms to distribute the traffic according to these weights.
The real power comes when you add more sophisticated routing rules. You can match on request headers, URIs, methods, and more. For example, to send all requests with a User-Agent header containing "beta-tester" to v2 permanently:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: my-app-vs
spec:
hosts:
- my-app.default.svc.cluster.local
http:
- match:
- headers:
user-agent:
regex: ".*beta-tester.*"
route:
- destination:
host: my-app.default.svc.cluster.local
subset: v2
- route: # This is the default route if the above match doesn't hit
- destination:
host: my-app.default.svc.cluster.local
subset: v1
weight: 90
- destination:
host: my-app.default.svc.cluster.local
subset: v2
weight: 10
In this modified VirtualService, Envoy first checks if the User-Agent header matches the regex. If it does, the request is routed to v2 without considering the weights. If it doesn’t match, Envoy then falls back to the second route block, applying the 90/10 split. This is how you implement phased rollouts, canary releases, or A/B testing with Istio.
The VirtualService itself doesn’t know where the pods are; it only knows about logical destinations (hosts and subsets). The DestinationRule is what tells Envoy how to find the actual endpoints for those subsets (e.g., by selecting pods with specific labels) and what load balancing policies to apply at the endpoint level.
The VirtualService acts as a layer of abstraction, decoupling your routing logic from the underlying service discovery and endpoint management. This allows you to change routing rules dynamically without redeploying your application code or even restarting Envoy proxies in many cases. The Istio control plane (Istiod) watches for changes to VirtualService and DestinationRule objects and pushes the updated configuration to the Envoy sidecars.
It’s crucial to understand that the host field in VirtualService and DestinationRule refers to the Kubernetes Service name, not the actual application name or deployment name. Istio uses the Kubernetes Service as the primary entry point for its routing configuration. When you define hosts: - my-app.default.svc.cluster.local, you’re telling Istio that any traffic destined for the Kubernetes Service named my-app in the default namespace should be subject to the rules defined in this VirtualService.
A common point of confusion is the order of rules. In an http block within a VirtualService, rules are evaluated in the order they appear. The first rule that matches the incoming request’s criteria (headers, method, URI, etc.) is applied, and processing stops for that request. If no specific match criteria are met, the last route block (which typically defines the default traffic split) is used. This sequential evaluation is key to implementing complex routing scenarios.
The weight in a route block is not an absolute guarantee. It’s a probabilistic distribution. For a single request, there’s no guarantee it will land on v1 90% of the time. However, over a large number of requests, the distribution will converge to the specified weights. This is a fundamental aspect of how weighted routing works in distributed systems.
The subset in the VirtualService route definition points to a name defined in the subsets section of the associated DestinationRule. The DestinationRule is where you specify the labels that Envoy should use to select the actual pods belonging to that subset. Without a corresponding DestinationRule (or if the DestinationRule doesn’t have a matching subset), the VirtualService rule referencing that subset will not be able to route traffic.
When you’re debugging VirtualService rules, always check both the VirtualService and its corresponding DestinationRule. A common mistake is to have a VirtualService rule pointing to a subset that isn’t defined in the DestinationRule, or where the labels in the DestinationRule don’t match any running pods.
The next thing you’ll want to dive into is Gateways and how they interact with VirtualServices to control external access to your services.