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.

Why this, alongside Ingress? A hosting service doesn't only route HTTP hostnames. Sometimes a workload needs its own external IP on any TCP port (a TCP app, a database, or the ingress controller itself). That's a 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?"

type: ClusterIP  ·  reachable: inside cluster only
stable virtual IP — the default (Lesson 02). Invisible from outside.
type: NodePort  ·  reachable: <nodeIP>:30000–32767
opens a high port on every node — crude, but needs no addon.
type: LoadBalancer  ·  reachable: a dedicated EXTERNAL-IP
asks the cluster for its own external IP — clean, but someone must fulfill the request.
The "aha" that explains MetalLB In the cloud, asking for a 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 onlyL4 — any TCP/UDP
IPs one IP, many hostnamesone 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

1

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
2

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
How layer-2 mode works MetalLB's default L2 mode picks one node to "own" each external IP and answers ARP requests for it on the local network — so to the rest of the LAN the IP looks like just another host. No router config, no BGP.
3

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.

4

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
Platform reality — read this Whether the IP is reachable from other machines depends on the host:
  • 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?

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?

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?

Interleaving check: ClusterIP is internal-only — which is exactly the limitation NodePort and LoadBalancer climb past.

Primary sources — read these next

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.

Ask your teacher
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?"