The Problem: EXTERNAL-IP Stuck on Pending
Create a Deployment and expose it as a LoadBalancer on a Kind cluster:
kubectl create deployment nginx --image=nginx
kubectl expose deployment nginx \
--type=LoadBalancer \
--port=80Check the Service:
kubectl get svcNAME TYPE EXTERNAL-IP
nginx-service LoadBalancer <pending>
EXTERNAL-IP never resolves. This isn't a bug — it's a missing piece of infrastructure.
On a managed cluster (EKS, GKE, AKS), creating a LoadBalancer Service doesn't provision the load balancer itself. Kubernetes hands the request to a cloud controller manager, which calls out to the cloud provider's API:
Service (type: LoadBalancer)
↓
Cloud Controller Manager
↓
Cloud Load Balancer (AWS ELB, GCP LB, Azure LB)
↓
Public IP
AWS provisions an ELB. That ELB gets a public IP. Kubernetes just waits for the answer and writes it into EXTERNAL-IP.
Kind runs entirely inside Docker, on your laptop or a bare EC2 box — there is no cloud controller manager, and no cloud API to call. Docker can't create an AWS ELB. With nothing to fulfill the request, the field sits on <pending> forever.
What Is MetalLB?
MetalLB is a load balancer implementation for bare-metal and local Kubernetes clusters. It plugs the exact gap described above: it watches for LoadBalancer Services that have no external IP and assigns one from a pool you configure, then makes sure traffic to that IP actually reaches the Service.
Think of it as playing the same role a hotel reception desk plays for arriving guests. Without reception, a guest has no way to get a room — they just wait. Reception takes the request and hands back a room number from the hotel's available inventory. MetalLB does the same thing for Services: it takes the pending request and hands back an IP from its own inventory, the address pool.
It doesn't replace Kubernetes Services — it works alongside them, filling in the one piece (external IP provisioning) that a cloud provider would normally supply.
How MetalLB Works
MetalLB ships two components, both running as pods in the metallb-system namespace:
- controller — watches for
LoadBalancerServices and assigns each one an IP from the configured pool - speaker — runs as a DaemonSet on every node; once an IP is assigned, a speaker announces it on the local network
This lesson uses MetalLB's Layer 2 (L2) mode, the simplest and most common setup for local clusters. In L2 mode, one node's speaker takes responsibility for an assigned IP and answers ARP requests for it, the same way a machine on a LAN answers "who has this IP?" When traffic for that IP arrives, the receiving node forwards it into the cluster and kube-proxy routes it to a healthy pod, the same as any other Service traffic. There's no BGP router or external hardware involved — just ARP on the local Docker network.
Installing MetalLB
Apply the official manifest:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.9/config/manifests/metallb-native.yamlConfirm the namespace and pods came up:
kubectl get ns
kubectl get pods -n metallb-systemYou should see a metallb-system namespace with a controller pod and one speaker pod per node, all Running.
Configuring an IP Address Pool
MetalLB needs to know which IPs it's allowed to hand out. Define an IPAddressPool:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 172.18.255.200-172.18.255.250Save this as ip-pool.yaml and apply it:
kubectl apply -f ip-pool.yamlThe address range has to fall inside the Docker network your Kind cluster actually runs on — MetalLB can only hand out IPs that are reachable on that network, and speakers can only answer ARP for addresses within it. Check the real range before picking a pool:
docker network inspect kindPick a slice of addresses from that subnet that isn't already in use by cluster nodes, and don't guess a range from a different Docker network or a previous cluster — it'll assign IPs that nothing on the host can actually route to.
Advertising the Pool: L2Advertisement
Defining a pool tells MetalLB which IPs exist. It also needs to be told how to advertise them — in this case, over L2/ARP:
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-systemSave as l2.yaml and apply:
kubectl apply -f l2.yamlWithout an L2Advertisement, MetalLB will still assign IPs from the pool, but nothing announces them on the network — so they'd never be reachable.
Creating a LoadBalancer Service
With the pool and advertisement in place, expose the Deployment again:
kubectl expose deployment nginx \
--type=LoadBalancer \
--port=80Check the Service:
kubectl get svcNAME TYPE EXTERNAL-IP PORT(S)
nginx LoadBalancer 172.18.255.200 80:xxxxx/TCP
EXTERNAL-IP is now populated — MetalLB's controller picked the next free address from default-pool and a speaker started announcing it.
Request Flow
Browser
↓
172.18.255.200 (MetalLB-assigned IP, announced via ARP)
↓
MetalLB speaker (on the node that owns the IP)
↓
LoadBalancer Service
↓
Deployment → ReplicaSet → Pods
Compared to the cloud path from earlier, the shape is identical — an external IP in front of a Service — but everything left of the Service is self-managed instead of provided by a cloud API.
Cloud Load Balancers vs. MetalLB
| AWS (Cloud Provider) | Kind + MetalLB | |
|---|---|---|
| Who provisions the IP | AWS Cloud Controller → ELB | MetalLB controller from a configured pool |
| IP reachability | Public IP, internet-accessible | Local/Docker network IP |
| Advertisement mechanism | Managed by AWS | ARP (L2 mode) via MetalLB speaker |
| Management | Fully managed by the cloud provider | Self-managed by you |
| Concept | Same — external IP in front of a Service | Same — external IP in front of a Service |
Same contract for anything consuming the Service; different implementation underneath.
Verifying the Setup
kubectl get svc
kubectl describe svc nginxdescribe shows the assigned LoadBalancer Ingress IP along with the events MetalLB emitted while assigning it — useful for confirming which pool the IP came from and which node's speaker is announcing it if something isn't reachable.
Summary
LoadBalancerServices stay<pending>on Kind because there's no cloud controller manager to provision a real load balancer.- MetalLB fills that gap for bare-metal and local clusters by assigning IPs from a pool you define and advertising them on the local network.
- Two CRDs drive it:
IPAddressPool(which IPs exist) andL2Advertisement(how they're announced). - The IP pool must live inside the cluster's actual Docker network, or the assigned addresses won't be reachable from the host.