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

client on LAN192.168.1.240:80who-has 192.168.1.240? (ARP)worker-1 192.168.1.11:31080 NodePortowns VIP 240web-7d4f-akube-proxy / eBPF → any ready podworker-2 192.168.1.12:31080 NodePortweb-7d4f-bkube-proxy / eBPF → any ready podworker-3 192.168.1.13:31080 NodePortno web pod herekube-proxy / eBPF → any ready pod
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/TCP
service.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 L2MetalLB BGPCilium L2 / BGP
How the IP is foundARP (IPv4) / NDP (IPv6) reply from one node/32 route advertised by every eligible nodesame two modes, built into the CNI
Spreads across nodesno — one node per IPyes, router ECMPL2: no · BGP: yes
Failoverseconds (memberlist)BGP hold timer, or BFDL2: Lease expiry · BGP: hold/BFD
Needsflat L2 segmentBGP-capable routerCilium; 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: privileged
Cilium · 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: DoesNotExist
Not 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:
node→node hopclient203.0.113.7LB / routerall nodes eligibleworker-1192.168.1.11SNAT → 192.168.1.11worker-2192.168.1.12SNAT → 192.168.1.12worker-3192.168.1.13SNAT → 192.168.1.13web-a · 0src: —web-b · 0src: —web-c · 0src: —
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: 32411
Local + 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 provider

Which controller implements gateways (Cilium, Envoy Gateway…). Cluster-scoped.

Gateway
cluster operator

Listeners: ports, hostnames, TLS certs, which namespaces may attach routes. Gets the LoadBalancer IP.

HTTPRoute
app developer

Host/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 /
IngressGateway API
Ownershipone object mixes infra + app concernsGatewayClass / Gateway / Route split by role
Traffic split, header matchcontroller-specific annotationsbuilt in: weight, headers, query params, method
ProtocolsHTTP(S) onlyHTTP, gRPC (GRPCRoute); TLS/TCP/UDP routes in experimental channel
Cross-namespacenoallowedRoutes + ReferenceGrant
Portabilityannotations differ per controllersame YAML across conformant implementations
Implementationsingress-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: Local keeps 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.