NodePort & LoadBalancer
Summary
- NodePort opens a port on every node; LoadBalancer provisions a cloud load balancer that fronts those NodePorts.
- Insight: LoadBalancer is NodePort plus a cloud controller - understanding NodePort explains where the traffic actually lands.
- Key Concepts: NodePort range, cloud controller manager, externalTrafficPolicy, SNAT vs source IP, per-Service LB cost.
- When to Use: LoadBalancer for production external entry; NodePort for on-prem, testing, or behind your own edge.
- Limitations: One cloud LB per Service gets expensive; NodePort exposes raw node ports and needs firewalling.
Recipe
Prefer LoadBalancer on managed clouds for a real external IP.
Use NodePort on bare metal or when an external load balancer already fronts the nodes.
# Cloud: get an external IP from the cloud provider
kubectl expose deployment web --type=LoadBalancer --port=80 --target-port=8080
# On-prem/testing: expose a high port on every node
kubectl expose deployment web --type=NodePort --port=80 --target-port=8080Consolidate many services behind one LB with Ingress or Gateway API to control cost.
Working Example
A production-style LoadBalancer Service that preserves the client IP.
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: web
ports:
- name: http
port: 80
targetPort: 8080Apply it and watch the cloud controller fill in the address.
kubectl get service web -wNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
web LoadBalancer 10.96.14.3 203.0.113.24 80:31240/TCPNotice the 31240 NodePort in the PORT(S) column: the cloud LB forwards to that port on the nodes, and kube-proxy takes it from there.
With externalTrafficPolicy: Local, the LB health checks only route to nodes running a Ready pod, preserving the client source IP.
Deep Dive
NodePort mechanics
A NodePort Service allocates a port from the configurable range (default 30000-32767) on every node.
Traffic to NodeIP:NodePort is DNATed by kube-proxy to a backend pod.
The port is open on all nodes whether or not they run a matching pod, so a fronting balancer can target any node.
LoadBalancer builds on NodePort
A type: LoadBalancer Service is a NodePort Service plus a request to the cloud controller manager.
The controller provisions a cloud L4 load balancer and points it at the nodes' NodePort.
The Service's EXTERNAL-IP is populated once the cloud reports the LB address.
On bare metal, a controller like MetalLB fills the same role.
externalTrafficPolicy and source IP
Cluster (the default) spreads to pods on any node, but cross-node hops require SNAT, which hides the client IP.
Local routes only to pods on the receiving node, preserving the client IP and removing a hop.
The trade-off with Local is uneven load if pods are unevenly spread across nodes.
Cloud LB health checks respect Local by only marking nodes with a Ready pod as healthy.
Annotations tune the cloud LB
Cloud-specific behavior (internal vs internet-facing, NLB vs classic, health check paths) is set through provider annotations on the Service.
These vary by cloud, so consult your provider's cloud controller docs rather than assuming portability.
LoadBalancerClass and multiple implementations
spec.loadBalancerClass lets one cluster run several LB implementations side by side.
You might route most Services through the cloud provider while sending a few through MetalLB or a specialized controller.
Leaving the field unset uses the default implementation the cloud controller registers.
This is how platform teams migrate between LB backends without touching every Service at once.
Health checks and the NodePort path
The cloud LB health-checks the NodePort, not the pod directly.
With Cluster policy the check hits any node, so a node with no local pod still passes by forwarding onward.
With Local policy the check only passes on nodes with a Ready pod, which is what preserves the client IP.
Aligning the LB health check with your readiness contract keeps these two views consistent.
Gotchas
Each type: LoadBalancer Service usually provisions its own cloud LB, and you pay per LB - dozens of them add up fast.
Consolidate with an Ingress controller or Gateway API so many hostnames share one external LB.
externalTrafficPolicy: Local drops traffic on nodes with no Ready pod, so ensure your scheduling spreads pods or the LB will show unhealthy nodes.
NodePort exposes a high port on every node's IP; lock it down with security groups or NetworkPolicy so it is not open to the world.
The default NodePort range is fixed cluster-wide; you cannot request an arbitrary low port like 80 without reconfiguring the API server.
Health check settings that differ from your readiness probe can cause the LB to route to pods your cluster considers unhealthy - keep them aligned.
Alternatives
Ingress controller: pick this for L7 HTTP routing across many hostnames behind a single LB.
Gateway API: pick this as the modern, role-oriented successor to Ingress for L7 and some L4 routing; verify controller support per cloud.
NodePort behind your own LB: pick this on-prem when a hardware or software balancer already fronts the nodes.
ClusterIP + port-forward: pick this only for debugging, never for real external traffic.
FAQs
Is LoadBalancer just NodePort with extra steps? Essentially yes. It allocates a NodePort and asks the cloud controller to provision an external LB pointing at it.
How do I keep the client's real IP?
Set externalTrafficPolicy: Local, which avoids cross-node SNAT at the cost of even distribution.
Why is my Service stuck on <pending> external IP?
No cloud controller is provisioning the LB - common on bare metal without MetalLB, or with missing cloud permissions.
How do I avoid paying for many load balancers? Put an Ingress or Gateway in front so multiple services share one external LB.
Can I choose the NodePort number?
You can set a value inside the configured range with nodePort:, but not an arbitrary low port without changing the API server flag.
Related
- Why Pods Need Services
- Services Basics
- Life of a Request
- ClusterIP & Headless
- EndpointSlices
- Services Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).