09
Services, LB & Gateway
A Service is a stable name and virtual IP in front of a churning set of pods. Each Service type adds one more way in: ClusterIP for in-cluster callers, NodePort on every node, LoadBalancer for a real external IP. In a cloud that last step is magic; on Talos running on Proxmox or bare metal you provide the magic, then put HTTP routing on top with the Gateway API.
ClusterIP → NodePort → LoadBalancer
The Service ladder
Each type is a superset of the previous: a LoadBalancer Service still gets a ClusterIP and (by default) a NodePort. Switch types, then pick how the external IP gets announced and kill nodes to watch failover. Note that the pods never change — only how traffic reaches a node does.
Service types & LoadBalancer implementations
tip: kill the VIP owner
worker-1 is the only node answering ARP for 192.168.1.240. All traffic for this Service enters through that one node, then kube-proxy / eBPF spreads it to pods. Simple, works on any flat L2 network, but no real load balancing between nodes: one node's NIC is the ceiling.
$ kubectl get svc web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
web LoadBalancer 10.96.12.34 192.168.1.240 80:31080/TCPservice.yaml
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: LoadBalancer # ClusterIP ⊂ NodePort ⊂ LoadBalancer
selector:
app: web
ports:
- port: 80 # ClusterIP port
targetPort: 8080 # containerPort
# nodePort: 31080 # auto-picked from 30000-32767 if omitted
externalTrafficPolicy: Cluster # or Local (see below)
# loadBalancerClass: io.cilium/l2-announcer # pick an implementation explicitly| MetalLB L2 | MetalLB BGP | Cilium L2 / BGP | |
|---|---|---|---|
| How the IP is found | ARP (IPv4) / NDP (IPv6) reply from one node | /32 route advertised by every eligible node | same two modes, built into the CNI |
| Spreads across nodes | no — one node per IP | yes, router ECMP | L2: no · BGP: yes |
| Failover | seconds (memberlist) | BGP hold timer, or BFD | L2: Lease expiry · BGP: hold/BFD |
| Needs | flat L2 segment | BGP-capable router | Cilium; L2 needs kube-proxy replacement |
MetalLB · pool + L2 announcement
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: lan-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.240-192.168.1.250 # outside your DHCP range!
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement # or BGPAdvertisement + BGPPeer for BGP mode
metadata:
name: lan
namespace: metallb-system
spec:
ipAddressPools: [lan-pool]Talos · Pod Security for MetalLB
# Talos enforces the "baseline" Pod Security Standard by default.
# MetalLB's speaker needs host networking + NET_RAW, so label its namespace:
apiVersion: v1
kind: Namespace
metadata:
name: metallb-system
labels:
pod-security.kubernetes.io/enforce: privilegedCilium · LB IPAM + L2 announcements
# Helm values: l2announcements.enabled=true, kubeProxyReplacement=true
apiVersion: cilium.io/v2 # v2alpha1 on older Cilium releases
kind: CiliumLoadBalancerIPPool
metadata:
name: lan-pool
spec:
blocks:
- start: 192.168.1.240
stop: 192.168.1.250
---
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
name: lan
spec:
loadBalancerIPs: true
interfaces: ["^eth[0-9]+", "^ens[0-9]+"]
nodeSelector:
matchExpressions:
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExistNot the same as Talos' control-plane VIP
Talos can float a shared IP between control-plane nodes (machine.network.interfaces[].vip) for the Kubernetes API. That VIP is managed by Talos via etcd election and is only for :6443; it does nothing for your Services. Use MetalLB or Cilium for those, and keep both IP ranges out of your DHCP pool.where did my client IP go?
externalTrafficPolicy: Cluster vs Local
Once a packet reaches a node, kube-proxy still has to pick a pod. With
Cluster it can pick a pod on another node, which costs a hop and an SNAT. With Local it only uses local pods and tells the load balancer which nodes to avoid. Fire a burst and compare what the pods see.Packet path simulator
externalTrafficPolicy:
requests
0
extra hops
—
client IP seen
—
web-c share
—
Cluster (default): even spread, lost source IP
Any node accepts the connection and kube-proxy picks any ready endpoint cluster-wide, so ~2/3 of requests landing on the "wrong" node take a second hop. To make the reply come back through the same node, kube-proxy SNATs external traffic to the node IP — your app logs, rate limiters and allow-lists see 192.168.1.x, never 203.0.113.7. (Cilium's eBPF datapath in DSR mode can avoid this; standard kube-proxy cannot.)preserve the client IP
spec:
type: LoadBalancer
externalTrafficPolicy: Local
# allocated automatically; kube-proxy serves GET /healthz here:
# 200 + localEndpoints count, or 503 when this node has none
healthCheckNodePort: 32411Local + MetalLB L2 / Cilium L2
With Local, L2 announcers only elect a node that actually runs a ready endpoint, otherwise the VIP would lead to a node that drops traffic. When that pod moves, the VIP moves too — expect a short blip during rollouts. For HTTP you usually don't need Local on every app: put it on the Gateway's Service and pass the client IP onward in X-Forwarded-For.L7 routing
Ingress → Gateway API
An L4 LoadBalancer gives you one IP per Service. For HTTP you want one IP and route by host and path. That was Ingress: a single resource where the app team, the TLS certificates and controller-specific annotations all collided. The Gateway API (
gateway.networking.k8s.io/v1, GA since 2023) splits it by role and makes traffic splitting, header matching and cross-namespace attachment first-class instead of annotations.ingress-nginx is retired
In November 2025 Kubernetes SIG Network and the Security Response Committee announced the retirement of the community kubernetes/ingress-nginx controller, with best-effort maintenance only until March 2026. After that there are no further releases, bug fixes or security patches. Existing installs keep running, but you are on your own. The Ingress API itself is not removed (it's frozen); the recommended path for new work is the Gateway API. (F5's separate NGINX Ingress Controller is a different project.)GatewayClass
infra providerWhich controller implements gateways (Cilium, Envoy Gateway…). Cluster-scoped.
Gateway
cluster operatorListeners: ports, hostnames, TLS certs, which namespaces may attach routes. Gets the LoadBalancer IP.
HTTPRoute
app developerHost/path/header matches, filters, weighted backendRefs. Lives in the app's own namespace.
Request router · Gateway → HTTPRoute → weighted backends
1 · Gateway listener
http · HTTP:80 · *.example.com
https · HTTPS:443 · *.example.com
tls: Terminate · wildcard-cert
2 · HTTPRoute rules (most specific wins)
HTTPRoute/http-redirectsectionName: http
PathPrefix /not attached to this listener
HTTPRoute/shopsectionName: httpsshop.example.com
PathPrefix /api + x-canary=always✕ header
PathPrefix /api✓ selected
PathPrefix /matches, less specific
HTTPRoute/docssectionName: httpsdocs.example.com
PathPrefix /✕ hostname
3 · backendRefs
Service/api-v190%
Service/api-v210%
Forwarded to a backend chosen by weight.
responses by backend0 sent
api-v1
0
api-v2
0
shop-web
0
docs-web
0
301 redirect
0
404
0
gateway.yaml (cluster operator)
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
namespace: infra
spec:
gatewayClassName: cilium # or eg (Envoy Gateway), istio, traefik, nginx…
listeners:
- name: http
protocol: HTTP
port: 80
hostname: "*.example.com"
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs: [{ name: wildcard-cert }]
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels: { gateway-access: "true" }httproute.yaml (app team) · 90/10 canary
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: shop # app team's namespace
spec:
parentRefs:
- name: public
namespace: infra
sectionName: https
hostnames: ["shop.example.com"]
rules:
- matches:
- path: { type: PathPrefix, value: /api }
headers: [{ name: x-canary, value: always }]
backendRefs: [{ name: api-v2, port: 8080 }]
- matches:
- path: { type: PathPrefix, value: /api }
backendRefs:
- { name: api-v1, port: 8080, weight: 90 }
- { name: api-v2, port: 8080, weight: 10 }
- backendRefs: [{ name: shop-web, port: 80 }] # default match: PathPrefix /| Ingress | Gateway API | |
|---|---|---|
| Ownership | one object mixes infra + app concerns | GatewayClass / Gateway / Route split by role |
| Traffic split, header match | controller-specific annotations | built in: weight, headers, query params, method |
| Protocols | HTTP(S) only | HTTP, gRPC (GRPCRoute); TLS/TCP/UDP routes in experimental channel |
| Cross-namespace | no | allowedRoutes + ReferenceGrant |
| Portability | annotations differ per controller | same YAML across conformant implementations |
| Implementations | ingress-nginx (retired), Traefik, HAProxy, cloud controllers… | Cilium, Envoy Gateway, Istio, Traefik, NGINX Gateway Fabric, kgateway… |
On Talos + Cilium
If Cilium is your CNI with kube-proxy replacement, enabling its Gateway API support (gatewayAPI.enabled=true, after installing the Gateway API CRDs) gives you a cilium GatewayClass with no extra proxy Deployment: each Gateway becomes a LoadBalancer Service handed out by Cilium LB IPAM and announced via L2 or BGP — the whole stack on this page in one component.- why it mattersA LoadBalancer Service is just a request. On Proxmox or bare metal something must answer it — MetalLB or Cilium LB IPAM — or EXTERNAL-IP stays
<pending>. - why it mattersL2 mode means one node carries each IP and failover takes seconds; BGP gives ECMP across nodes but needs a router that speaks it.
externalTrafficPolicy: Localkeeps the client IP at the cost of uneven spread. - why it mattersRoute HTTP with the Gateway API: operators own Gateways and certificates, app teams own HTTPRoutes, and canaries are a
weight, not an annotation. ingress-nginx is retired; plan the migration.