Services and Ingress: how traffic reaches your app
Three hops from the internet to a running pod, and why the middle layer has to be there.
The problem: pods don’t sit still
A pod is not a stable target. It restarts, it scales up and down, and it gets a new IP every time. Point a client at a pod IP and the address goes stale the first time that pod dies.
So Kubernetes puts two layers in front of the pods. A Service and an Ingress. Each one has a single job, and the request passes through both before it reaches a pod.
The Service: a stable front door
A Service is one fixed address for a group of pods. It finds those pods by label, not by IP. You tell it to target everything labeled app=shop, and it keeps a live list of the matching pod IPs. A pod dies, it drops off the list. A new one starts, it joins. The caller never notices.
The address itself is the ClusterIP, and it does not change for the life of the Service. When traffic hits it, the Service load-balances across the healthy pods behind it. That is the whole point: pods are disposable, the Service IP is fixed.
A ClusterIP is only reachable from inside the cluster, which is how pods talk to each other. To take traffic from outside, you need something in front of it that speaks HTTP. That is the Ingress.
The Ingress: routing by host and path
A Service moves TCP. It does not read HTTP, so it cannot look at a hostname or a URL path and decide where a request should go. An Ingress can. It works at the HTTP layer, so one entry point can serve many apps by routing on host and path.
The catch is that an Ingress object does nothing on its own. It is just a set of rules written down. Something has to read those rules and enforce them, and that something is the Ingress controller: a real reverse proxy running in the cluster. On k3s that is Traefik, and it ships built in, so there is nothing to install.
Run it yourself
Two apps, two Services, one Ingress with two rules. One route is host only, the other adds a path, so you can watch both kinds of routing work.
Deploy the apps and put a Service in front of each. The shop app is plain nginx. The api app is whoami, which answers on any path and echoes the pod name, so routing is visible instead of silent.
kubectl create deployment shop --image=nginx --replicas=3 kubectl expose deployment shop --port=80 kubectl create deployment api --image=traefik/whoami --replicas=2 kubectl expose deployment api --port=80
Confirm each Service answers from inside the cluster. Reach it by name, and include the scheme, because busybox wget wants it.
kubectl run tmp --rm -it --image=busybox --restart=Never -- wget -qO- http://shop
Now the Ingress. It routes shop.local/ to the shop Service by host, and api.local/v1 to the api Service by host and path.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: routing-demo
spec:
rules:
- host: shop.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shop
port:
number: 80
- host: api.local
http:
paths:
- path: /v1
pathType: Prefix
backend:
service:
name: api
port:
number: 80Create the file first, then apply it and check that it registered.
kubectl apply -f ingress.yaml kubectl get ingress
kubectl get ingress until it fills in. That value is the node IP where Traefik listens, and it is what you curl, not a Service ClusterIP.Test the full path from outside. Replace the whole placeholder including the brackets, or bash reads the < as a redirection.
curl -H "Host: shop.local" http://<node-ip>/ # nginx page, host routing curl -H "Host: api.local" http://<node-ip>/v1 # whoami, host + path routing curl -H "Host: api.local" http://<node-ip>/ # 404, proves the path rule
That last line is the interesting one. The api host has only a /v1 rule, so the root matches nothing and Traefik returns a 404. Same host, different path, different outcome. That is path routing doing its job.
Tear it down when you are done.
kubectl delete -f ingress.yaml kubectl delete deployment shop api kubectl delete svc shop api
The recap
The Ingress picks the Service by host and path. The Service picks a healthy pod. Pods come and go, but the Service IP stays put, so nothing upstream has to track which pods are alive. That layering is the whole reason a request can survive a cluster that never stops shifting underneath it.
Next: namespaces and RBAC, keeping workloads and access fenced off