Simple Load Balancer Tutorial with k3d
Local Kubernetes clusters are useful when an application needs more than a single container. They provide a realistic environment for testing Deployments, Services, routing, health checks, and failure recovery without requiring a remote cluster.
k3d runs K3s nodes as containers. In this tutorial, we will disable the ingress controller bundled with K3s and use Envoy Gateway to manage Envoy as the application-facing data plane. Instead of maintaining a large static envoy.yaml, we will describe the desired routing with the Kubernetes Gateway API.
Why Envoy Gateway?
Envoy Proxy is highly configurable, but its native configuration format reflects that power. Even a small listener, route, cluster, health check, and access-log setup can require a substantial amount of YAML. That is appropriate when we need low-level control, but it is unnecessary boilerplate for a Kubernetes routing example.
Envoy Gateway provides a Kubernetes-native control plane for Envoy. We declare a GatewayClass, a Gateway, and an HTTPRoute; Envoy Gateway validates these resources and translates them into the detailed configuration consumed by Envoy. The generated Envoy configuration still exists, but it is owned and continuously reconciled by the controller rather than copied into a ConfigMap by hand.
The request path in this tutorial is:
localhost:8080
│
▼
k3d port mapping
│
▼
K3s ServiceLB
│
▼
Envoy managed by Envoy Gateway
│
▼
Kubernetes Service
│
├── backend pod
├── backend pod
└── backend pod
There are several components involved, but their responsibilities are distinct:
- k3d exposes port 80 from the local cluster as port 8080 on the host.
- K3s ServiceLB makes Envoy Gateway’s
LoadBalancerService reachable on the cluster nodes. - Envoy terminates the HTTP connection, evaluates the route, and forwards the request.
- The Kubernetes Service and its EndpointSlices represent the healthy backend pods.
This is more representative of a modern Kubernetes setup than embedding a complete static Envoy configuration in an application manifest.
Prerequisites
The tutorial requires:
Confirm that the tools are available:
docker version
k3d version
kubectl version --client
helm version
The commands below pin Envoy Gateway to v1.8.2 so that the example remains reproducible. Before using a different version, check the Envoy Gateway compatibility matrix for the supported Kubernetes, Gateway API, and Envoy versions.
1. Create a Cluster Without Traefik
Create a cluster named envoy-gateway-demo with one K3s server and three agents:
k3d cluster create envoy-gateway-demo \
--servers 1 \
--agents 3 \
--k3s-arg="--disable=traefik@server:0" \
--port "8080:80@loadbalancer" \
--wait
The --disable=traefik argument prevents K3s from installing its bundled Traefik ingress controller. We do not need two ingress implementations competing for the same purpose, and leaving port 80 free allows K3s ServiceLB to expose Envoy cleanly.
The port mapping publishes port 80 of k3d’s load-balancer container as localhost:8080. This is the normal k3d entry point for a Service exposed inside the cluster.
k3d updates the kubeconfig and selects the new context. Verify the cluster:
kubectl cluster-info
kubectl get nodes
kubectl get deployment --namespace kube-system
The node list should contain one server and three agents, all reporting Ready. The deployment list should not contain Traefik.
If a cluster with the same name already exists, delete that cluster specifically before recreating it:
k3d cluster delete envoy-gateway-demo
Avoid deleting every local cluster as part of a tutorial setup. Other clusters may contain unrelated work.
2. Install Envoy Gateway
Install Envoy Gateway from its official OCI Helm chart:
helm install eg \
oci://docker.io/envoyproxy/gateway-helm \
--version v1.8.2 \
--namespace envoy-gateway-system \
--create-namespace
Wait until the controller is available:
kubectl wait \
--namespace envoy-gateway-system \
--for=condition=Available \
deployment/envoy-gateway \
--timeout=5m
The chart installs the Envoy Gateway controller together with the required Envoy Gateway and Gateway API custom resource definitions. On a shared or production cluster, CRD ownership and upgrades should be handled deliberately rather than treated as an incidental part of an application deployment.
At this point, only the control plane is running. Envoy Gateway creates an Envoy data plane after we define a Gateway.
3. Define the Application and Route
Create a file named envoy-gateway-demo.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: envoy-demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: envoy-demo
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: backend
containers:
- name: backend
image: registry.k8s.io/e2e-test-images/agnhost:2.53
command:
- /agnhost
args:
- netexec
- --http-port=8080
ports:
- name: http
containerPort: 8080
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 1
periodSeconds: 3
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: envoy-demo
spec:
type: ClusterIP
selector:
app: backend
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: envoy-gateway-demo
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: demo
namespace: envoy-demo
spec:
gatewayClassName: envoy-gateway-demo
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: backend
namespace: envoy-demo
spec:
parentRefs:
- name: demo
sectionName: http
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: backend
port: 8080
Apply the resources:
kubectl apply --filename envoy-gateway-demo.yaml
Wait for the backend Deployment:
kubectl wait \
--namespace envoy-demo \
--for=condition=Available \
deployment/backend \
--timeout=2m
Then inspect the Gateway API status:
kubectl get gatewayclass envoy-gateway-demo
kubectl get gateway,httproute --namespace envoy-demo
The GatewayClass should report Accepted, and the Gateway should eventually report that it has been programmed. Envoy Gateway creates and configures the corresponding Envoy Deployment and LoadBalancer Service in envoy-gateway-system.
You can see those generated resources with:
kubectl get deployment,service \
--namespace envoy-gateway-system \
--selector gateway.envoyproxy.io/owning-gateway-name=demo
The generated names are intentionally not hard-coded in the tutorial. They are implementation details owned by Envoy Gateway.
4. Understand the Gateway API Resources
The manifest separates concerns that would otherwise be mixed into one static proxy configuration:
GatewayClassselects Envoy Gateway as the controller responsible for this class of gateways.Gatewayrequests an HTTP listener on port 80. It represents the infrastructure entry point.HTTPRouteattaches to that listener and forwards matching requests to the backend Service.Serviceprovides a stable Kubernetes abstraction over the ready backend pods.
The allowedRoutes setting restricts this listener to routes from the same namespace. That is a useful default because it prevents unrelated namespaces from attaching routes without an explicit decision.
No hostname is specified in this example, so the route accepts any HTTP Host header. Production routes should usually declare hostnames and use TLS listeners with certificates appropriate for those names.
The important difference from a hand-written Envoy deployment is ownership. We own the Kubernetes intent. Envoy Gateway owns the generated listeners, clusters, endpoint discovery, and data-plane lifecycle. If pods or endpoints change, the controller updates Envoy without requiring us to rebuild a static ConfigMap.
5. Send Requests Through Envoy
Call the backend through the host port:
curl http://localhost:8080/hostname
The response contains the name of the backend pod that handled the request. Send several independent requests:
for request in $(seq 1 12); do
curl --silent http://localhost:8080/hostname
printf '\n'
done
The output should contain multiple pod names, demonstrating that requests reach more than one replica. Do not expect an exact repeating sequence: connection reuse, endpoint readiness, retries, and load-balancing policy can all influence the observed order.
Confirm that three backend pods are available:
kubectl get pods \
--namespace envoy-demo \
--selector app=backend \
--output=wide
The topology spread constraint asks Kubernetes to distribute the replicas across nodes where possible. It is not required for HTTP load balancing, but it makes the local topology more representative of a multi-node deployment.
6. Inspect Routing and Troubleshoot Failures
Gateway API status conditions are the first place to look when traffic does not flow:
kubectl describe gateway demo --namespace envoy-demo
kubectl describe httproute backend --namespace envoy-demo
Useful conditions include:
Accepted, which indicates that the relevant controller accepts the resource.Programmed, which indicates that the requested data-plane configuration was applied.ResolvedRefs, which indicates that referenced objects such as the backend Service could be resolved.
Next, verify the backend Service and its endpoints:
kubectl get service,endpointslice --namespace envoy-demo
If the route is valid but requests still fail, inspect the Envoy Gateway controller:
kubectl logs \
--namespace envoy-gateway-system \
deployment/envoy-gateway
Also verify that the generated Envoy Service has an external address or published ports:
kubectl get service \
--namespace envoy-gateway-system \
--selector gateway.envoyproxy.io/owning-gateway-name=demo \
--output=wide
On k3d, the displayed address is less important than the complete path through the k3d port mapping and K3s ServiceLB. curl http://localhost:8080/hostname is the final end-to-end check.
7. Observe Failure Recovery
Delete one backend pod and watch the Deployment restore the desired replica count:
BACKEND_POD=$(kubectl get pods \
--namespace envoy-demo \
--selector app=backend \
--output=jsonpath='{.items[0].metadata.name}')
kubectl delete pod "$BACKEND_POD" --namespace envoy-demo
kubectl get pods \
--namespace envoy-demo \
--selector app=backend \
--watch
Press Ctrl-C after the replacement pod becomes ready, then repeat the request loop. Kubernetes updates the Service’s EndpointSlices as pod readiness changes, and Envoy Gateway propagates the relevant endpoint state to Envoy.
This is the value of using Kubernetes-native discovery rather than a static list of pod IP addresses: the routing configuration follows the declared application state.
What This Example Does Not Cover
This setup is intentionally local and minimal. A production design also needs decisions about:
- TLS termination and certificate lifecycle
- authentication and authorization
- timeouts, retries, circuit breakers, and rate limits
- access logs, metrics, traces, and alerting
- high availability and disruption budgets
- network policies and namespace delegation
- resource sizing and upgrade strategy
- the infrastructure-specific implementation of
LoadBalancerServices
Envoy Gateway supports policies and extensions for many of these concerns, but enabling features without a clear operational requirement usually makes a tutorial less useful. Start with a working request path, then add policy deliberately.
Clean Up
Delete the cluster when it is no longer needed:
k3d cluster delete envoy-gateway-demo
Because Envoy Gateway and the application live inside that cluster, deleting the cluster removes all resources created by the tutorial.
Conclusion
The earlier static Envoy configuration was not unusually long because Envoy was doing something mysterious. It was long because native Envoy configuration explicitly describes details that a Kubernetes control plane can derive and manage.
Envoy Gateway moves that responsibility to a controller and lets us express the routing model with standard Gateway API resources. The result is shorter application-owned configuration, clearer responsibility boundaries, Kubernetes-native status reporting, and a data plane that is reconciled as the cluster changes.
For a standalone proxy, a hand-written envoy.yaml may still be the right tool. For Kubernetes ingress and application routing, Envoy Gateway is usually the more maintainable abstraction.