MetalLB as TCP/UDP Load Balancer for RKE2
On this page

This guide sets up MetalLB in Layer 2 mode so that Services of type: LoadBalancer on a bare-metal or on-prem RKE2 cluster get reachable IP addresses.
For ingress on RKE2 (Traefik by default from v1.36), see the Ingress on RKE2 guide.
Overview
What is MetalLB?
MetalLB is a load balancer implementation for bare-metal Kubernetes clusters. It assigns addresses from pools you define to Services of type: LoadBalancer and announces them on the network, either with ARP/NDP (Layer 2 mode) or with BGP.
Key Features
- Layer 2 announcement: No router configuration needed; works on any flat network.
- TCP and UDP Services: Any Service of
type: LoadBalancer. - Failover: If the node announcing an address fails, another node takes over.
Layer 2 Limitations
From the MetalLB Layer 2 concepts page:
- All traffic for one Service IP arrives at a single elected node, so a Service’s ingress bandwidth is limited to one node’s bandwidth.
- Failover depends on clients updating their ARP caches. Modern operating systems switch within seconds, but some clients take longer.
- Layer 2 mode is a failover mechanism, not a load balancer. kube-proxy spreads the traffic to pods after it reaches the elected node.
What MetalLB Does Not Do
MetalLB only serves Services of type: LoadBalancer. It does not provide the RKE2 control plane VIP (the fixed registration address for ports 9345 and 6443). Use kube-vip, DNS, or an external load balancer for that.
For more details, visit the MetalLB documentation.
Setup Instructions
Step 1: Install MetalLB
The current release is v0.16.1 (installation guide). For Layer 2 only, the native manifest is enough:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.16.1/config/manifests/metallb-native.yamlOr install with Helm:
helm repo add metallb https://metallb.github.io/metallb
helm install metallb metallb/metallb \
--namespace metallb-system \
--create-namespace \
--version 0.16.1The Helm chart defaults to the FRR-K8s backend, which the MetalLB docs recommend if you will use BGP. The older FRR mode is deprecated.
If you changed kube-proxy to IPVS mode, enable strictARP: true in the kube-proxy configuration, as the installation guide requires.
Verify the pods:
kubectl get pods -n metallb-systemStep 2: Configure an Address Pool
Since v0.13, MetalLB is configured with custom resources. The old config ConfigMap with address-pools is no longer read, so configurations copied from older guides silently do nothing.
Define a pool and advertise it in Layer 2 mode (configuration guide):
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default
namespace: metallb-system
spec:
addresses:
- 192.168.1.240-192.168.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- defaultApply it:
kubectl apply -f metallb-pool.yamlThe addresses must be on the node subnet and outside any DHCP scope. MetalLB does not obtain addresses from a DHCP server; earlier versions of this page showed a “DHCP pool” that MetalLB has never supported. If you need DHCP-assigned Service addresses, kube-vip offers that.
Step 3: Test MetalLB
Create a Service with LoadBalancer Type:
yamlapiVersion: v1 kind: Service metadata: name: nginx-service namespace: default spec: selector: app: nginx ports: - protocol: TCP port: 80 targetPort: 80 type: LoadBalancerValidate the Assigned IP:
bashkubectl get svc nginx-serviceExpected output:
plaintextNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE nginx-service LoadBalancer 10.43.12.34 192.168.1.240 80:30001/TCP 2mCheck which node announces it:
bashkubectl describe svc nginx-serviceThe events show the speaker node that announced the address.
With the default externalTrafficPolicy: Cluster, the client source IP is hidden behind the announcing node. With Local, pods see the real client IP, but only pods on the announcing node receive traffic (usage guide).
Deploying MetalLB via ArgoCD
Install the chart with Argo CD, and keep the pool and advertisement in Git as a second Application (or in the same repository path) so they sync after the CRDs exist:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: metallb
namespace: argocd
spec:
destination:
namespace: metallb-system
server: https://kubernetes.default.svc
project: load-balancers
source:
repoURL: https://metallb.github.io/metallb
chart: metallb
targetRevision: 0.16.1
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=trueDedicated Pool for Ingress
Create a separate pool for the ingress controller so its address never changes, with autoAssign: false so other Services cannot take addresses from it:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: ingress
namespace: metallb-system
spec:
addresses:
- 192.168.1.200-192.168.1.210
autoAssign: false
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: ingress
namespace: metallb-system
spec:
ipAddressPools:
- ingressOn RKE2, Traefik’s Service is ClusterIP by default (Traefik listens on host ports 80 and 443 on every node). To put it behind a MetalLB address, change the Service type with a HelmChartConfig and request the pool and a fixed IP through MetalLB’s annotations:
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-traefik
namespace: kube-system
spec:
valuesContent: |-
service:
spec:
type: LoadBalancer
annotations:
metallb.io/address-pool: ingress
metallb.io/loadBalancerIPs: 192.168.1.200metallb.io/loadBalancerIPs is preferred over spec.loadBalancerIP, which is planned for deprecation in the Kubernetes API.
Integration with RKE2
- MetalLB gives RKE2 Services real addresses on bare metal; it does not replace the control plane VIP.
- RKE2 has its own ServiceLB (klipper-lb), off by default and enabled with
enable-servicelb. Do not run ServiceLB and MetalLB together. - Do not run MetalLB and kube-vip Service advertisement on the same cluster. Pick one.
- For ingress, see the Ingress on RKE2 guide.