Andrew Mercer
on this page
  • https://metallb.universe.tf
  • https://github.com/kubernetes/ingress-nginx/blob/main/docs/deploy/baremetal.md#a-pure-software-solution-metallb

Concepts

MetalLB gives bare-metal Kubernetes clusters a working LoadBalancer service type — something cloud providers give you for free via their own load balancers, but that a self-hosted cluster has no equivalent for out of the box. Without MetalLB (or something like it), a LoadBalancer service on bare metal just sits in <pending> forever.

IP address allocation

  • https://metallb.universe.tf/concepts/#address-allocation

MetalLB needs a pool of IP addresses on your local network to hand out to LoadBalancer services. These addresses come from your own network range — MetalLB doesn't create IPs, it just claims and advertises ones you tell it about, so they need to be addresses your router/network isn't already handing out via DHCP to other devices.

Layer 2 Mode

  • https://metallb.universe.tf/concepts/layer2

In Layer 2 mode, one node at a time takes ownership of a given service IP and answers ARP requests for it directly — there's no real load-balancing across nodes at the network level (all traffic for that IP goes to whichever node currently owns it); failover to another node happens if the owning node goes down. This is simpler to set up than BGP mode but doesn't spread traffic across multiple nodes for a single IP the way BGP mode can.

Requirements

  • https://metallb.universe.tf/#requirements

Verify requirements before installing — in particular, check network addon compatibility: some CNI plugins (Calico in particular — see the Troubleshooting doc) need extra configuration alongside MetalLB to avoid interface conflicts.

  • I'll use 10.0.0.100-200 for IPs to start

Installation

  • https://metallb.universe.tf/installation

Update the kube-proxy configmap

This step is only required if kube-proxy is running in IPVS mode. Since Kubernetes v1.14.2, IPVS mode needs strict ARP enabled, or MetalLB's Layer 2 speaker won't be able to properly claim ARP responses for its addresses. (If you're using kube-router as the service proxy, it enables strict ARP by default and this step isn't needed.)

kubectl edit configmap -n kube-system kube-proxy

Set the following under the ipvs section:

apiVersion: kubeproxy.config.k8s.io/v1alpha1
kind: KubeProxyConfiguration
mode: "ipvs"
ipvs:
  strictARP: true

To automate this instead of hand-editing (safe to run — the sed pattern only flips false to true and does nothing if it's already true):

# preview the change first
kubectl get configmap kube-proxy -n kube-system -o yaml | \
sed -e "s/strictARP: false/strictARP: true/" | \
kubectl diff -f - -n kube-system

# apply for real
kubectl get configmap kube-proxy -n kube-system -o yaml | \
sed -e "s/strictARP: false/strictARP: true/" | \
kubectl apply -f - -n kube-system

Create the metallb namespace

kubectl create namespace metallb

(Upstream MetalLB's own manifests default to a metallb-system namespace — using plain metallb here is a fine custom choice, just worth remembering if you ever cross-reference upstream docs/manifests that assume metallb-system.)

or use a manifest file:

cat << EOF > metallb_ns.yaml
kind: Namespace
apiVersion: v1
metadata:
  name: metallb
  labels:
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged
EOF
kubectl apply -f metallb_ns.yaml

Label

The privileged Pod Security Admission labels are required because the MetalLB speaker pod needs elevated network permissions (it manipulates ARP/interfaces directly) that the default restricted or baseline PSA levels would block.

If you didn't add labels to the metallb namespace at creation:

Test:

kubectl label --dry-run=server --overwrite ns metallb \
    pod-security.kubernetes.io/enforce=privileged \
    pod-security.kubernetes.io/audit=privileged \
    pod-security.kubernetes.io/warn=privileged

Run for real:

kubectl label --overwrite ns metallb \
    pod-security.kubernetes.io/enforce=privileged \
    pod-security.kubernetes.io/audit=privileged \
    pod-security.kubernetes.io/warn=privileged

See also: https://kubernetes.io/docs/tasks/configure-pod-container/enforce-standards-namespace-labels

Install metallb

helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm -n metallb install metallb metallb/metallb

(Added helm repo update — without it, a repo added previously but not refreshed can silently install an older chart version than what's actually available.)

By default, the current Helm chart deploys MetalLB's FRR-K8s BGP backend alongside the speaker/controller even for pure Layer 2 use — this is normal and doesn't require BGP to actually be configured or used.

Confirm everything came up:

kubectl get pods -n metallb

You should see one controller deployment pod and one speaker pod per node (it's deployed as a DaemonSet).

MetalLB Configuration

Configure address pool

ipaddresspool_simple.yml

  • https://github.com/metallb/metallb/blob/main/configsamples/ipaddresspool_simple.yml
cat << EOF > metallb_config.yml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: example
  namespace: metallb
spec:
  addresses:
  - 10.0.0.100-10.0.0.200
EOF

Layer 2 configuration

  • https://metallb.universe.tf/configuration/#layer-2-configuration

An IPAddressPool alone doesn't do anything by itself — it just defines a pool of claimable addresses. You also need an L2Advertisement resource to tell MetalLB how to announce those addresses (via Layer 2/ARP, in this case) and which pool(s) it applies to.

Append to the metallb_config.yml file:

apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: example
  namespace: metallb

Full file:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: example
  namespace: metallb
spec:
  addresses:
  - 10.0.0.100-10.0.0.200
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: example
  namespace: metallb
cat << EOF > metallb.yml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: example
  namespace: metallb
spec:
  addresses:
  - 10.0.0.100-10.0.0.200
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: example
  namespace: metallb
EOF
kubectl apply -f metallb.yml

Verify

Create a test LoadBalancer service and confirm it gets an EXTERNAL-IP from the pool instead of staying <pending>:

kubectl expose deployment <some-deployment> --type=LoadBalancer --name=test-lb --port=80
kubectl get svc test-lb

If it stays <pending>, check the controller's logs (kubectl logs -n metallb deploy/metallb-controller) — a common cause is the L2Advertisement not referencing the right pool, or the pool's range already being fully allocated.

See Troubleshooting for the Calico interface conflict and other common post-install issues.