Lesson 03 · Serving
Expose with a LoadBalancer (MetalLB)
The other front door: hand your nginx Pods a real external
IP with a type: LoadBalancer Service — no Ingress involved — and make it
work on a local cluster using MetalLB.
LoadBalancer Service — and on a cluster with no cloud behind it,
MetalLB is what makes that possible.
The mental model: the Service-type ladder
One object — the Service — has three
types. They're a ladder of "how far out is this reachable?"
stable virtual IP — the default (Lesson 02). Invisible from outside.
opens a high port on every node — crude, but needs no addon.
asks the cluster for its own external IP — clean, but someone must fulfill the request.
LoadBalancer makes AWS/GCP provision a real load
balancer. On a local / bare-metal cluster nobody is listening
for that request — so the Service's EXTERNAL-IP sits at
<pending> forever. MetalLB is the missing fulfiller:
it watches for LoadBalancer Services and hands each one an IP from a pool you define.
LoadBalancer vs Ingress — when to reach for which
| Ingress (Lesson 02) | LoadBalancer (this lesson) | |
|---|---|---|
| Layer | L7 — HTTP/S only | L4 — any TCP/UDP |
| IPs | one IP, many hostnames | one IP per Service |
| Reach for it when | hosting many websites on 80/443 | a workload needs its own IP/port |
Do it — on the machine that talks to your cluster
See the problem first: an unfulfilled LoadBalancer
Before installing MetalLB, expose nginx as a LoadBalancer and watch it hang — this is the lesson's whole point made visible.
$ kubectl create deployment web --image=nginx:1.27 --replicas=2
$ kubectl expose deployment web --type=LoadBalancer --port=80
$ kubectl get svc web
# EXTERNAL-IP shows <pending> — nobody is fulfilling the request
Enable MetalLB with an IP pool on the node's network
The pool must be free addresses on the same subnet as the VM, so the IPs
are routable. Find the VM's IP, then pick an unused range in that /24.
$ multipass list # e.g. VM IP 192.168.64.5 → subnet 192.168.64.0/24
$ microk8s enable metallb:192.168.64.240-192.168.64.250
$ kubectl get pods -n metallb-system # a "controller" + a "speaker" per node
Watch the IP get assigned
$ kubectl get svc web -w # -w = watch; EXTERNAL-IP flips to e.g. 192.168.64.240
$ kubectl describe svc web # events show MetalLB allocating the address
The <pending> is gone — MetalLB fulfilled the request from your pool.
You declared "I want an external IP"; a controller made it real. Same reconciliation
loop as everything else.
Reach it — and verify the right way
The guaranteed-correct test runs inside the VM (where L2/ARP always works):
$ multipass shell microk8s-vm
vm$ curl http://192.168.64.240/ # the nginx welcome page
- Linux on a real LAN: the EXTERNAL-IP is reachable from any machine on that network. This is MetalLB's intended home.
- Multipass on macOS: Canonical documents that MetalLB "does not work under Multipass on macOS" — the host filters the L2/ARP traffic, so the macOS browser often can't reach the IP even though it's assigned. Verify from inside the VM (above); for host-browser access on a Mac, Ingress (Lesson 02) is the reliable path.
Declarative version — for your real manifests
The imperative expose was for speed. This is the YAML you'd actually commit
(optionally pin a specific IP from the pool with the annotation):
apiVersion: v1
kind: Service
metadata:
name: web
annotations:
metallb.universe.tf/loadBalancerIPs: 192.168.64.240 # optional: request a fixed IP
spec:
type: LoadBalancer
selector: { app: web }
ports: [ { port: 80, targetPort: 80 } ]
Why does a LoadBalancer Service sit at EXTERNAL-IP <pending> locally?
- No provider assigns an address
- The backing pods are unready
- The node port is blocked
- The cluster dns is disabled
With no cloud behind the cluster, nothing fulfills the LoadBalancer request — until MetalLB does. The Pods are fine; the address is what's missing.
What is MetalLB's actual job in the cluster?
- Assigns and advertises external IPs
- Terminates the TLS per service
- Routes the HTTP by hostname
- Schedules the pods across nodes
It allocates an IP from your pool to each LoadBalancer Service and advertises it (ARP in L2 mode). It does no HTTP routing — that's Ingress's job.
From Lesson 02: a default ClusterIP Service is reachable from where?
- Only inside the cluster network
- Any machine on the internet
- Only the control plane nodes
- Every browser via the ingress
Interleaving check: ClusterIP is internal-only — which is exactly the limitation NodePort and LoadBalancer climb past.
MetalLB — Concepts (official) for L2 vs BGP, and microk8s MetalLB addon (official) for the enable syntax + the Multipass-on-macOS caveat. Background: Kubernetes — Service types.
Good questions now: "Is my cluster machine Linux or multipass-on-macOS?" (it changes what Step 4 does for you — tell me and I'll tailor it), "What's the difference between L2 and BGP mode?", "Could I give the ingress controller a LoadBalancer IP and get the best of both?"