LoadBalancer Service
A LoadBalancer Service is a Service of
type: LoadBalancerthat asks the cloud-controller-manager to provision an external load balancer in front of the Service’s NodePort (or, in newer modes, directly in front of the backing Pod IPs). It is the canonical mechanism for exposing a workload to the internet on managed Kubernetes — EKS, GKE, AKS, OKE, DOKS — and the mechanism every “expose this thing” tutorial on the internet ends up touching. Mechanically, atype: LoadBalancerService is a NodePort plus an asynchronous side effect: when youkubectl applythe manifest, the apiserver allocates the ClusterIP and the NodePort synchronously, but the actual cloud LB is provisioned by the cloud-controller-manager’s service controller in the background, which eventually writes the LB’s public endpoint intostatus.loadBalancer.ingress[](kubernetes.io — type=LoadBalancer). The contract is loose: the spec field istype: LoadBalancer, the implementation is delegated entirely to the cloud, and the per-cloud knobs are exposed via annotations —service.beta.kubernetes.io/aws-load-balancer-*on EKS,networking.gke.io/*on GKE,service.beta.kubernetes.io/azure-load-balancer-*on AKS. KEP-1959 addedspec.loadBalancerClass(KEP-1959) — GA in K8s 1.24 — so multiple LB implementations can coexist in one cluster (the in-tree cloud LB controller alongside AWS Load Balancer Controller, MetalLB, etc.). Cost reality: each LoadBalancer Service typically provisions its own cloud LB, billed per-hour plus per-GB, which makes “twenty microservices each exposed via type=LoadBalancer” the canonical financial-disaster pattern in managed K8s. The mitigations are Ingress (one LB serves many Services) and Gateway API (the modern successor).
Mental Model
flowchart TB USER[Manifest<br/>type: LoadBalancer<br/>+ annotations] APISERVER[(kube-apiserver)] CCM[cloud-controller-manager<br/>service controller] CLOUD[Cloud LB API<br/>AWS ELBv2 / GCLB / Azure LB] LB[Provisioned LB<br/>public DNS or static IP] subgraph "Worker nodes" N1[Node 1<br/>:31234 → kube-proxy DNAT] N2[Node 2<br/>:31234 → kube-proxy DNAT] N3[Node 3<br/>:31234 → kube-proxy DNAT] end PODS[Backing Pods<br/>app=foo, port 8080] USER -- "kubectl apply" --> APISERVER APISERVER -- "1. allocate ClusterIP<br/>2. allocate NodePort 31234<br/>3. expose status as empty" --> APISERVER APISERVER -- "watch Service<br/>type=LoadBalancer" --> CCM CCM -- "translate annotations" --> CLOUD CLOUD -- "provision" --> LB CCM -- "patch status.loadBalancer.ingress" --> APISERVER EXT[Internet client] -- "DNS → LB endpoint" --> LB LB -- "forward instance-mode: :31234 on every node" --> N1 LB -.-> N2 LB -.-> N3 LB == "IP target mode: directly to Pod IPs" ==> PODS N1 -- "kube-proxy DNAT" --> PODS
What the diagram shows. Provisioning is a two-phase reconciliation: the apiserver does the synchronous part (allocate ClusterIP + NodePort, leaving status.loadBalancer.ingress empty), and the cloud-controller-manager’s service controller does the asynchronous part (talk to the cloud’s LB API, wait for the LB to come up, patch the status). The cloud LB itself can target the workload in one of two modes: instance mode, where it round-robins among node NodePorts (the original, default behaviour), or IP target mode, where it talks directly to Pod IPs and skips kube-proxy entirely (newer, controller-mediated — AWS NLB-IP, GCP container-native LB). The insight to extract: “LoadBalancer” is not a single mechanism; it’s a pluggable contract. The same Service spec can produce wildly different runtime topologies depending on which controller is reconciling it and which annotations are set. Two clusters in the same cloud can have very different LB behaviour from “identical” manifests.
Mechanical Walk-through
The provisioning sequence
kubectl apply -f svc.yamlwrites the Service to etcd.- The apiserver validates
type: LoadBalancer, allocates a free ClusterIP, allocates a free NodePort (unlessspec.allocateLoadBalancerNodePorts: false), and writes the object back. - The cloud-controller-manager’s service controller sees the new Service via watch. It reads the cluster’s nodes (the LB’s target set will be derived from them), the Service’s annotations (cloud-specific knobs), and translates everything into a cloud-LB API call.
- The cloud creates the LB. This is typically the slow step — 30 seconds to several minutes depending on cloud and LB type.
- The cloud-controller-manager patches
status.loadBalancer.ingress[]with the LB’s public endpoint — either a hostname (AWS ELB/ALB return DNS names) or a static IP (GCP, Azure can return either). - The cloud-controller-manager also keeps the LB’s target set in sync as nodes join/leave the cluster — re-running the reconcile loop on Node events. This is the part you most easily forget exists.
When you kubectl get svc -w, the row for the new Service initially shows EXTERNAL-IP: <pending>; once step 5 completes, the hostname or IP appears.
loadBalancerClass — multiple LB implementations per cluster
Before KEP-1959, a cluster could have exactly one LB implementation — the cloud’s in-tree provider (or whatever you’d configured the cloud-controller-manager to handle). KEP-1959 added spec.loadBalancerClass, GA in K8s 1.24 (KEP-1959), so a cluster can have several LB controllers and route each Service to the appropriate one:
loadBalancerClass: ""(or unset) → handled by the cluster’s default LB controller (typically the in-tree cloud provider).loadBalancerClass: service.k8s.aws/nlb→ handled by AWS Load Balancer Controller (NLB mode).loadBalancerClass: example.com/internal→ handled by a custom controller you wrote.
The matching controller watches Services whose loadBalancerClass it owns; other controllers ignore them. This is essential when running, e.g., a managed cluster with both the cloud provider’s default LB controller and an additional controller that handles a different LB type (MetalLB for internal, AWS LB Controller for external NLBs). loadBalancerClass is immutable after creation and, like type, cannot be added to or removed from an existing Service.
On EKS specifically, service.k8s.aws/nlb is the class the AWS Load Balancer Controller claims (supported since controller v2.4.0 for Kubernetes v1.22+). From controller v2.5.0 onward the controller installs a mutating webhook that stamps spec.loadBalancerClass: service.k8s.aws/nlb onto every new type: LoadBalancer Service automatically, making the controller the default LB provider for the cluster — so on a v2.5+ install you may see the class appear even when you didn’t set it (AWS LB Controller NLB guide). The default class string itself is configurable via the controller’s --load-balancer-class flag.
Per-cloud annotations — the real knobs
Most LB customisation happens via annotations, not Service spec fields. Each cloud’s LB controller defines its own annotation vocabulary; they are not portable across clouds. Examples:
AWS Load Balancer Controller (aws-load-balancer-controller annotations):
metadata:
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "external" # use AWS LB Controller, not in-tree
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip" # talk to Pod IPs directly
service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing" # vs "internal"
service.beta.kubernetes.io/aws-load-balancer-ssl-cert: "arn:aws:acm:..."
service.beta.kubernetes.io/aws-load-balancer-subnets: "subnet-a,subnet-b"
service.beta.kubernetes.io/aws-load-balancer-attributes: "deletion_protection.enabled=true"The type: "external" value is the most important one: it tells the in-tree cloud provider to ignore the Service and lets AWS Load Balancer Controller reconcile it instead. Combined with nlb-target-type: "ip", traffic goes directly to Pod IPs and skips NodePort entirely.
GKE uses a mix of annotations and the CRDs BackendConfig / FrontendConfig (GKE — LoadBalancer Services):
metadata:
annotations:
cloud.google.com/neg: '{"ingress": true}' # container-native LB (Pod-IP target)
cloud.google.com/backend-config: '{"default": "my-backend-config"}'
networking.gke.io/load-balancer-type: "Internal" # vs ExternalThe cloud.google.com/neg annotation triggers Network Endpoint Group (NEG)-based routing — GKE’s equivalent of AWS NLB-IP target mode.
AKS uses annotations on the standard SKU LB (AKS — public load balancer):
metadata:
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
service.beta.kubernetes.io/azure-load-balancer-resource-group: "rg-aks"Annotation contracts evolve faster than Kubernetes itself, so the values matter as much as the keys. As of the AWS Load Balancer Controller “latest” docs (v2.x, checked May 2026), service.beta.kubernetes.io/aws-load-balancer-type: "external" is the current value that hands the Service to the controller; the older "nlb-ip" value is documented as deprecated and kept only for backwards compatibility (per the AWS LB Controller annotations reference). aws-load-balancer-nlb-target-type accepts instance (route via NodePort) or ip (route to Pod IPs), defaulting to instance. Still: always check the per-cloud controller’s current docs before pasting these into a production manifest, because the vocabulary genuinely shifts across major versions.
Instance mode vs IP target mode
Instance mode is the original behaviour: the cloud LB targets every node by IP, on the Service’s NodePort. Traffic arrives at a node, hits kube-proxy, gets DNATed to a Pod. externalTrafficPolicy: Cluster is the default, with SNAT loss-of-client-IP; externalTrafficPolicy: Local is opt-in for preserving client IPs at the cost of uneven load. See NodePort Service for the gory detail.
IP target mode is the newer pattern: the LB targets Pod IPs directly. No NodePort, no kube-proxy, no SNAT loss-of-client-IP. The trade-off is that the LB must understand the Pod CIDR and reach Pod IPs — which it does on AWS (because VPC CNI puts Pods on routable secondary ENI IPs), on GCP (because GKE Network Endpoint Groups feed Pod IPs to the LB), and on Azure (CNI-based, Pod IPs on the VNet). Overlay-based CNIs (Calico VXLAN, Flannel) don’t usually support IP target mode because the LB can’t route to the overlay IPs.
IP target mode also means spec.allocateLoadBalancerNodePorts: false is appropriate — there’s nothing for the NodePort to do, so skipping its allocation saves a port and reduces kube-proxy rule churn.
Source IP preservation
In instance mode with externalTrafficPolicy: Cluster, the backend sees the node IP as source. With Local, it sees the client IP (at the cost of possible load imbalance). In IP target mode, the backend always sees the LB’s IP unless the LB supports source-IP preservation natively — AWS NLB does (TCP and UDP), AWS ALB does at the L7 layer via X-Forwarded-For, GCP NEG-based LBs preserve client IP, Azure LB does. So the recipe for true client-IP preservation in 2026 is usually “NLB-IP mode on AWS, NEG on GCP, or externalTrafficPolicy: Local with topology-aware Pod spread on overlay-CNI clusters.”
status.loadBalancer.ingress.ipMode — VIP vs Proxy
A subtler source-IP and traffic-path concern is the ipMode field on each status.loadBalancer.ingress[] entry, added because of a long-standing kube-proxy short-circuit problem. When a Pod inside the cluster connects to its own Service’s external LB IP, kube-proxy historically intercepted that traffic and routed it straight to a backend (a “VIP” assumption), bypassing the cloud LB entirely — which breaks any LB that performs TLS termination, PROXY protocol injection, or source-IP rewriting, because the in-cluster caller never actually traverses the LB. The ipMode field lets the cloud controller declare intent: VIP (the default and historical behaviour — kube-proxy may short-circuit to a backend) or Proxy (kube-proxy must send the packet to the LB IP and let the load balancer handle delivery). ipMode may be set only when ingress.ip is also set (it is meaningless for hostname-only LB endpoints like classic AWS ELB DNS names). The feature (KEP-1860) went alpha in Kubernetes 1.29, beta in 1.30, and GA in 1.32 (Kubernetes 1.29 Load Balancer IP Mode blog; feature-gates reference). If you run a cloud LB that must see in-cluster traffic (PROXY protocol, WAF, mTLS at the LB), confirm the cloud controller sets ipMode: Proxy; otherwise intra-cluster calls to the external IP silently skip the LB.
Bare metal: MetalLB and kube-vip
Managed clouds have a service controller built into their cloud-controller-manager. Bare-metal clusters don’t. Two projects fill the gap:
- MetalLB (metallb.universe.tf) — a controller plus a speaker DaemonSet that announce Service IPs via BGP (real load balancing across nodes) or L2 ARP/NDP (one node owns the IP at a time, fail-over rather than load balancing). Functionally equivalent to
type: LoadBalancerfrom the Service user’s perspective. - kube-vip — similar story; can also handle the control plane VIP for HA kubeadm setups.
Both work alongside kube-proxy: they get traffic to a node, kube-proxy then DNATs to a Pod. They are not eBPF replacements, they are LB controllers.
Configuration / API Surface
apiVersion: v1
kind: Service
metadata:
name: payments-public
namespace: prod
annotations:
# AWS LB Controller: external NLB targeting Pod IPs
service.beta.kubernetes.io/aws-load-balancer-type: "external"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
service.beta.kubernetes.io/aws-load-balancer-ssl-cert: "arn:aws:acm:..."
spec:
type: LoadBalancer
loadBalancerClass: "service.k8s.aws/nlb" # opt into AWS LB Controller (post-1.24 GA)
externalTrafficPolicy: Local # preserve client IP (works regardless of target mode
# on AWS NLB-IP because there's no SNAT step)
allocateLoadBalancerNodePorts: false # NLB-IP doesn't need NodePort
selector:
app: payments
ports:
- name: https
protocol: TCP
port: 443
targetPort: 8443
- name: http
protocol: TCP
port: 80
targetPort: 8080
loadBalancerSourceRanges: # optional firewall rules at the LB level
- 203.0.113.0/24
status:
loadBalancer:
ingress:
- hostname: a1b2c3d4e5f6-1234567890.us-east-1.elb.amazonaws.com
# OR
# ip: 35.123.45.67Important fields:
loadBalancerClass— GA since K8s 1.24. Routes the Service to a specific LB controller. Leave unset to use the cluster default.loadBalancerSourceRanges— CIDR allow-list applied at the cloud LB layer (not kube-proxy). Honoured by most cloud LBs; on AWS LB Controller it becomes a security-group rule. Don’t confuse with NetworkPolicy.loadBalancerIP— deprecated since K8s 1.24 in favour of cloud-specific annotations. Used to be a way to pin a static IP; cloud-specific annotations are now the portable mechanism.allocateLoadBalancerNodePorts—trueby default (allocates a NodePort even if not needed). Set tofalsefor IP target mode LBs.status.loadBalancer.ingress— written by the cloud controller. Returns eitherhostname(AWS, because ELB DNS names are not stable IPs) orip(GCP, Azure). Code that consumes this status must handle both. Each entry may also carryipMode(VIPorProxy, GA since K8s 1.32, settable only alongsideip) — see the source-IP section above for whyProxymatters when the LB does TLS termination or PROXY-protocol.
Failure Modes
EXTERNAL-IP: <pending> forever. The cloud controller can’t provision the LB. Causes: insufficient cloud IAM permissions (the cloud-controller-manager or AWS LB Controller’s IAM role lacks elasticloadbalancing:CreateLoadBalancer), the cloud’s LB quota exhausted, a missing required annotation (e.g. AWS LB Controller refuses to create NLB-IP without aws-load-balancer-type: external). Diagnose: kubectl describe svc <name> for events; logs of kube-controller-manager / cloud-controller-manager / aws-load-balancer-controller.
Leaked cloud LBs. Changing service.beta.kubernetes.io/aws-load-balancer-type after creation leaves the old LB live (because the old controller no longer reconciles the Service) and creates a new one (because the new controller does). You get two LBs billed per hour for the same Service. AWS LB Controller’s docs explicitly warn: “This annotation should not be modified after service creation. … If you modify the annotation after service creation you will end up with leaked AWS load balancer resources.” Always delete-then-recreate when changing LB type.
externalTrafficPolicy: Local with no local backends on the LB-chosen node. Same trap as bare NodePort, made worse because cloud LB health checks can lag the actual Pod-readiness state. The ProxyTerminatingEndpoints feature — which lets kube-proxy fall back to terminating-but-still-serving endpoints under externalTrafficPolicy: Local so a draining Pod keeps receiving traffic during graceful shutdown — was beta and default-on since K8s 1.26 and went GA in 1.28 (feature-gates reference). It smooths the terminating case; the no-backend-yet case (just-scaled-up new node) is still a window of dropped traffic until the LB health check passes.
Cost surprises. Each type: LoadBalancer provisions a separate cloud LB (25/month minimum each on AWS, similar on GCP/Azure). Twenty microservices each exposed as LoadBalancer is hundreds of dollars per month before any traffic. The “free” kubectl expose command becomes very expensive at scale. Mitigation: Ingress (one LB for many Services) or Gateway API.
Stale status.loadBalancer.ingress after cluster restore. A Velero-restored cluster has Services with the old cluster’s LB hostnames in status.loadBalancer.ingress, which is wrong — the new cluster’s LBs are different. Fix: re-reconcile by changing a benign field on the Service to force the cloud-controller-manager to update the status. Some Velero plugins handle this automatically.
Cross-AZ data charges. Instance-mode LBs cross-zone-load-balance by default, which sounds nice but means a request from a client in AZ-a hits a node in AZ-b and is forwarded to a Pod in AZ-c — two cross-AZ hops, both billed. Mitigation: service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "false" on AWS NLB (then balance ends up uneven if AZ Pod counts differ), or use IP target mode with topology-aware routing.
loadBalancerSourceRanges ignored. Not all cloud LBs honour this field; AWS NLB does (translates to security-group rules), AWS ALB does, GCP does for some LB types, Azure depends on SKU. If the field is silently ignored and you assumed your LB was firewalled, you’ve shipped a public endpoint when you thought it was restricted. Verify by trying to connect from an outside-range IP.
Health checks vs liveness probes. The cloud LB has its own health check, configured separately (or via annotations). A backend Pod with a passing readiness probe but a failing LB health check (because the LB is hitting a different port or path) appears “healthy” inside the cluster but receives no LB traffic. Symptom: LoadBalancer Service shows endpoints, but the LB has zero healthy targets. Always wire the LB health check to the same endpoint as the Pod’s readiness probe.
Alternatives and When to Choose Them
- Ingress / Gateway API — when you have multiple HTTP/gRPC services to expose. One LB sits in front of an Ingress controller, which dispatches to many ClusterIP Services by host/path. Pays for itself in cloud-LB cost reduction after about three Services on most clouds.
- AWS Load Balancer Controller in ALB mode for HTTP — even for a single Service, ALB has L7 features (path routing, OIDC integration, WAF) that NLB doesn’t.
kind: Ingressplus the ALB controller is usually preferable totype: LoadBalancerfor HTTP services on EKS. type: NodePortplus an external HAProxy / nginx — on-prem clusters with an existing hardware LB. Functional, but requires manual LB config; MetalLB or kube-vip is usually a better automated answer.- MetalLB / kube-vip on bare metal — implement
type: LoadBalancersemantics where there’s no cloud provider. BGP mode for real load balancing; L2 mode for fail-over. hostNetwork: truewith an ingress controller deployed as a DaemonSet — historic GKE/AKS pattern for L7 routing without a cloud LB. Rare in modern clusters.- Service mesh + east-west traffic — for in-cluster traffic, a service mesh (Istio, Linkerd) handles L7 routing without any LoadBalancer Services at all. LoadBalancer is only for ingress — getting traffic into the cluster from outside.
Production Notes
- Always pair
type: LoadBalancerwith cost monitoring. The Kubecost / OpenCost dashboards specifically track per-Service LB cost. Spotify and Shopify engineering blogs have both documented the “spiral of LBs” pattern, where every team independently exposes a Service and the cluster ends up with hundreds. - Default to Ingress / Gateway API for HTTP. Reserve
type: LoadBalancerfor non-HTTP protocols (gRPC at the edge, TCP/UDP services like databases-as-a-service, MQTT brokers) where an L7 Ingress isn’t a fit. - Pin the cloud LB by annotation, not by
loadBalancerIP.loadBalancerIPis deprecated since K8s 1.24; cloud-specific annotations (e.g.service.beta.kubernetes.io/aws-load-balancer-eip-allocations) are the supported way to attach a known address. The deprecation hasn’t yet been removed but production code shouldn’t add new uses. - Don’t share LBs across teams without a contract. A single LoadBalancer Service has one set of annotations, one Service-CIDR allocation, one health-check endpoint, one ACL. Sharing it across teams’ workloads creates change-control conflicts. Use Ingress with hostname-based routing to give each team its own logical surface backed by a shared LB.
- EKS-specific: install AWS Load Balancer Controller. The in-tree
servicecontroller on AWS only supports CLB (classic) and NLB-instance modes; ALB and NLB-IP require the out-of-tree controller. The KEP-1959loadBalancerClassfield is what makes the coexistence safe. - GKE container-native LB (NEG) has been the recommended pattern for years; legacy “ingress through NodePort” routes are deprecated. NEG-based LBs are the GCP equivalent of AWS NLB-IP target mode and skip kube-proxy.
- Annotation churn. AWS LB Controller went through
aws-load-balancer-type: "nlb-ip"→"external"plusaws-load-balancer-nlb-target-typebetween v2.2 and v2.4. Treat any annotation example older than ~12 months as suspect; always validate against the current controller’s docs. - Pre-create the cloud LB and use a selectorless Service. For migration scenarios where the LB exists first (e.g. you cut DNS over to a new LB before the cluster exists), you can create the LB out-of-band and then create a selectorless Service with no
type(or useloadBalancerClassset to a class no controller claims). This decouples the LB lifecycle from the Service lifecycle but loses the automated update on Pod changes.
See Also
- Service (Kubernetes) — the umbrella resource note
- NodePort Service — the layer underneath instance-mode LoadBalancer
- ClusterIP Service — the innermost layer
- ExternalName Service — DNS alias; unrelated to LoadBalancer
- cloud-controller-manager — the controller that talks to the cloud LB API
- Ingress / Ingress Controller — L7 alternative; one LB for many Services
- Gateway API — modern successor to Ingress with richer multi-tenancy
- kube-proxy — handles instance-mode traffic at the node level
- Topology Aware Routing — zone-local routing; pairs with
externalTrafficPolicy - Amazon EKS, Google GKE, Azure AKS — the managed-K8s notes
- Pod Lifecycle — terminating-endpoints behaviour during rolling updates
- Kubernetes MOC — parent MOC