Kubernetes Service: LoadBalancer
Provision a cloud load balancer for a Kubernetes Service. Annotations, externalTrafficPolicy, cost at scale, EXTERNAL-IP pending, and MetalLB on bare metal.
A LoadBalancer Service asks your cloud provider to create a real load balancer pointing at your cluster, and reports its address back in the Service object.
It is the simplest path to a public endpoint, and its cost model is the thing to understand before you use it widely.
It layers on NodePort, which layers on ClusterIP
Creating a LoadBalancer Service gives you all three:
- A ClusterIP for internal traffic.
- A node port on every node.
- A cloud load balancer distributing traffic across those node ports.
apiVersion: v1
kind: Service
metadata:
name: payments-api
spec:
type: LoadBalancer
selector:
app: payments-api
ports:
- port: 80
targetPort: 8080
kubectl get service payments-api
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
payments-api LoadBalancer 10.96.41.203 a1b2c3.us-east-1.elb.amazonaws.com 80:31847/TCP 2m
Provisioning takes a minute or two. The 31847 is the node port underneath. You can ignore it, but it explains what the load balancer is actually targeting.
The cost problem
This is one cloud load balancer per Service. Each has its own hourly charge, its own IP, and its own certificate and DNS record to manage. Ten public services means ten load balancers and ten sets of everything.
That is the entire argument for Ingress: one load balancer, routing to many Services by hostname and path, with TLS terminated centrally.
| LoadBalancer Service | Ingress | |
|---|---|---|
| Load balancers | One per Service | One for everything |
| Routing | Port only | Host, path, header |
| TLS | Per Service, manual | Centralised, cert-manager |
| Protocols | TCP, UDP, TLS | HTTP and HTTPS |
| Cost at ten services | Ten load balancers | One |
Use LoadBalancer for non-HTTP traffic, a database, a game server, a gRPC endpoint needing its own address, and for the single load balancer that fronts your Ingress controller. Use Ingress for everything HTTP.
Annotations do the real configuration
The Service spec is deliberately minimal, so provider-specific behaviour is configured through annotations:
metadata:
annotations:
# AWS: NLB instead of the legacy classic load balancer
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
# Internal only, no public IP
service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"
service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
# GCP: internal load balancer
networking.gke.io/load-balancer-type: "Internal"
# Azure: internal, on a specific subnet
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
These are not portable. An AWS annotation on a GKE cluster is ignored silently, which is worth remembering when copying manifests between environments.
The internal scheme is the one people most often forget. Without it, a Service intended for private traffic gets a public address, and an internal API is suddenly on the internet.
Restricting who can connect
spec:
loadBalancerSourceRanges:
- 203.0.113.0/24
- 198.51.100.14/32
This programs the cloud firewall to accept only those sources. Support varies by provider, but where available it is far better than relying on application-level checks.
externalTrafficPolicy
The same trade-off as NodePort, and here it usually matters more because you are handling real external clients.
spec:
externalTrafficPolicy: Local
Cluster, the default, source-NATs traffic so your application sees a node IP instead of the client's. Local preserves the client IP and removes a hop, and the cloud load balancer's health checks naturally stop sending traffic to nodes with no matching Pod, which is why Local is much safer on a LoadBalancer Service than on a bare NodePort.
If you need genuine client IPs for rate limiting, allowlisting or audit logs, set Local. Spread your Pods across nodes with a topology spread constraint so the distribution stays even.
EXTERNAL-IP stuck at pending
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
payments-api LoadBalancer 10.96.41.203 <pending> 80:31847/TCP 12m
Nothing is fulfilling the request. The causes, in order of likelihood:
No cloud provider. On kind, minikube or bare metal there is no controller to create a load balancer, so it stays pending forever. Install MetalLB, or use kubectl port-forward locally. On minikube, minikube tunnel fakes it.
Subnet tagging on AWS. The controller discovers where to place a load balancer from subnet tags. Public subnets need kubernetes.io/role/elb=1 and private ones kubernetes.io/role/internal-elb=1. Without them it cannot decide and gives up.
Missing permissions. The cloud controller manager's IAM role cannot create load balancers.
Quota exhausted. Cloud accounts cap load balancers per region, and busy clusters hit it.
kubectl describe service payments-api # events usually name the reason
kubectl logs -n kube-system -l component=cloud-controller-manager --tail=50
Keeping the address stable
A load balancer's address changes if the Service is deleted and recreated, which breaks any DNS record pointing at it.
Two mitigations. Request a static IP where the provider supports it:
spec:
loadBalancerIP: 203.0.113.42 # must be pre-allocated
Or run ExternalDNS, which watches Services and Ingresses and updates Route 53, Cloud DNS or Cloudflare automatically. That is the more robust answer, since it survives recreation rather than trying to prevent it.
Quick reference
| Task | Command |
|---|---|
| Create | kubectl expose deployment <n> --type=LoadBalancer --port=80 --target-port=8080 |
| Watch provisioning | kubectl get svc <n> -w |
| The address | kubectl get svc <n> -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' |
| Why it is pending | kubectl describe svc <n> |
| Controller logs | kubectl logs -n kube-system -l component=cloud-controller-manager |
| Local access instead | kubectl port-forward service/<n> 8080:80 |
Key takeaways
- One cloud load balancer per Service, each with its own cost, IP and certificate.
- Use Ingress for HTTP; reserve LoadBalancer for non-HTTP and for the Ingress controller itself.
- Annotations carry the real configuration, and they are provider-specific and silently ignored elsewhere.
- Set the
internalscheme for private services, or they get a public address. externalTrafficPolicy: Localpreserves the client IP and is safer here than on plain NodePort.<pending>means no fulfilling controller: no cloud provider, missing subnet tags, missing permissions, or quota.- Use ExternalDNS rather than hoping the address never changes.
Check your understanding
Pending on a local cluster
A LoadBalancer Service on a kind cluster has shown EXTERNAL-IP as pending for twenty minutes. What is happening?
Twelve services, twelve bills
A team exposes twelve HTTP microservices, each with its own LoadBalancer Service, and the cloud bill shows twelve load balancers. What is the appropriate fix?
Frequently Asked Questions
What is a LoadBalancer Service?
It is a Service type that asks the cloud provider to create an external load balancer routing traffic into your cluster, then reports its address in the Service's EXTERNAL-IP field. It includes everything NodePort and ClusterIP provide, so internal access continues to work unchanged. It is the simplest way to get a stable public endpoint for a Service, and it provisions real chargeable cloud infrastructure each time you create one.
Why is my LoadBalancer Service stuck at pending?
Nothing is fulfilling the request. On kind, minikube or bare metal there is no cloud controller at all, so it will never resolve. Install MetalLB, run minikube tunnel, or use kubectl port-forward for local access. On a cloud cluster, check the Service events with kubectl describe svc and the cloud controller manager logs; the usual causes are missing subnet role tags on AWS, insufficient IAM permissions, or having hit the account's load balancer quota for that region.
What is the difference between LoadBalancer and Ingress?
A LoadBalancer Service creates one cloud load balancer per Service and routes on port alone. An Ingress uses a single load balancer, fronting an Ingress controller that routes HTTP traffic to many Services by hostname and path and terminates TLS centrally. For more than one or two public HTTP services, Ingress is substantially cheaper and easier to manage. LoadBalancer remains the right choice for non-HTTP protocols and for exposing the Ingress controller itself.
How do I make a LoadBalancer internal rather than public?
Through a provider-specific annotation: service.beta.kubernetes.io/aws-load-balancer-scheme: "internal" on AWS, networking.gke.io/load-balancer-type: "Internal" on GCP, and service.beta.kubernetes.io/azure-load-balancer-internal: "true" on Azure. Omitting it gives you a public address, which is a genuinely common way to expose an internal API to the internet by accident. These annotations are not portable and are ignored without warning on a different provider.
Why does my application see node IPs instead of client IPs?
externalTrafficPolicy defaults to Cluster, which lets any node forward traffic to a Pod on another node and source-NATs the packet in the process. Set externalTrafficPolicy: Local to keep traffic on the receiving node and preserve the client address. On a LoadBalancer Service this is relatively safe, because the cloud load balancer health-checks each node and stops sending traffic to those without a matching Pod. The same setting on a bare NodePort has no such protection.
Can I keep the same IP address if the Service is recreated?
Some providers allow requesting a pre-allocated static address via spec.loadBalancerIP, though support and syntax vary and several clouds have moved to annotations instead. The more durable approach is ExternalDNS, which watches Services and Ingresses and updates your DNS provider automatically whenever an address changes. That way recreation is handled rather than avoided, which also covers cluster rebuilds and failovers.