MetalLB helm with FRR sidecar
Process to deploy MetalLB via helm. I will be using BGP with my upstream router and enabling FRR for routing.
I prefer this option over installing the operator with FRR as it consumes far less resources and I can still edit the route-maps once deployed.
- When you run eBGP between your worker nodes (via MetalLB FRR) and a FortiGate, the FortiGate will, by default, re-advertise routes learned from one eBGP neighbour (e.g. Worker A) to other eBGP neighbours in the same AS (e.g. Worker B)
- Cisco and Check Point, on the other hand, do not do this by default. They follow a stricter interpretation of RFC 4271 section 9.1.3, which says an eBGP route should only be re-advertised to other eBGP peers unless filters or policies explicitly allow it, and even then, not when the peers are in the same remote AS
Fortigate's implementation of BGP is slightly different when compared with Cisco or Checkpoint.
Router BGP
On the upstream router enable BGP and configure as follows:
BGP global settings
| Setting | Value |
|---|---|
| Local AS | 64500 |
| Router ID | 192.168.1.254 |
AS path lists
| Name | Action | Expression |
|---|---|---|
| from-AS64502 | permit | _64502$ |
Route maps
| Name | Statement | Action | Match condition |
|---|---|---|---|
| block-readvertised-64502 | 1 | deny | AS path list: from-AS64502 |
| block-readvertised-64502 | 2 | permit | - |
Neighbors summary
| Neighbor IP | Remote AS | Interface | Update source | IPv4 active | Route map out | Soft reconfig | Capabilities |
|---|---|---|---|---|---|---|---|
| 192.168.1.207 | 64502 | LAN | LAN | Yes | block-readvertised-64502 | Enabled | graceful restart, route refresh |
Neighbor 192.168.1.207 details
| Field | Value |
|---|---|
| IP | 192.168.1.207 |
| Remote AS | 64502 |
| Interface | LAN |
| Update source | LAN |
| Activate IPv4 | Yes |
| Route map out | block-readvertised-64502 |
| Soft reconfiguration | Enabled |
| Capabilities | graceful restart, route refresh |
Helm install with values
- Create a values file specifying the path and server:
vi ~/manifests/metallb-system-metallb-values.yamlcrds:enabled: truespeaker:frr:enabled: truenodeSelector:metallb: enabled
- Install metallb using helm, referencing the values file:
helm upgrade --install metallb metallb/metallb \--namespace metallb-system --create-namespace \--values ~/manifests/metallb-system-metallb-values.yamlRelease "metallb" does not exist. Installing it now.NAME: metallbLAST DEPLOYED: Sun Oct 5 19:49:40 2025NAMESPACE: metallb-systemSTATUS: deployedREVISION: 1TEST SUITE: NoneNOTES:MetalLB is now running in the cluster.Now you can configure it via its CRs. Please refer to the metallb official docson how to use the CRs.
BGP peer configuration
- Prepare the BGP peer
vi metallb-bgp-peer.yamlapiVersion: metallb.io/v1beta2kind: BGPPeermetadata:name: edge-routernamespace: metallb-systemspec:myASN: 64502peerASN: 64500peerAddress: 192.168.1.254
- Apply the BGP peer configuration:
kubectl apply -f metallb-bgp-peer.yamlbgppeer.metallb.io/edge-router created
- Prepare the IP address pool
vi metallb-ip-pool.yamlapiVersion: metallb.io/v1beta1kind: IPAddressPoolmetadata:name: pool-10-70-1namespace: metallb-systemspec:addresses:- 10.70.1.0/24avoidBuggyIPs: true
- Apply the IP pool configuration
kubectl apply -f metallb-ip-pool.yamlipaddresspool.metallb.io/pool-10-70-1 created
- Prepare the BGP advertisment
vi metallb-bgp-adv.yamlapiVersion: metallb.io/v1beta1kind: BGPAdvertisementmetadata:name: adv-10-70-1namespace: metallb-systemspec:ipAddressPools:- pool-10-70-1aggregationLength: 24
- Apply the BGP advertisment configuration:
kubectl apply -f metallb-bgp-adv.yamlbgpadvertisement.metallb.io/adv-10-70-1 created
- Label the nodes that speak BGP. In this instance only
worker-gpu-1:kubectl label node worker-gpu-1 metallb=enabled
Test BGP
- Create an app based on Google's hello app and configure it to use the load balancer service:
vi hello-app-lb.yamlapiVersion: apps/v1kind: Deploymentmetadata:name: hello-appnamespace: devspec:selector:matchLabels:app: helloreplicas: 1template:metadata:labels:app: hellospec:containers:- name: helloimage: 'gcr.io/google-samples/hello-app:2.0'---apiVersion: v1kind: Servicemetadata:name: hello-servicenamespace: devlabels:app: hellospec:type: LoadBalancerselector:app: helloports:- port: 80targetPort: 8080protocol: TCP
- Apply the app:
kubectl apply -f hello-app-lb.yamldeployment.apps/hello-app createdservice/hello-service created
- Confirm an external IP has been allocated
kubectl get svc -n devNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEhello-service LoadBalancer 10.70.168.159 10.70.1.1 80:32497/TCP 7m57s
- Check using curl from both the master node and your workstation:
curl http://10.70.1.1Hello, world!Version: 2.0.0Hostname: hello-app-c98b499bb-t5w4p
- Open browser and navigate to: http://10.70.1.1
BGP verification (from k8s side)
- See which prefixes we advertise to the peer and what we receive
POD=$(kubectl -n metallb-system get pods -o name | grep speaker | head -1 | sed 's#pod/##')kubectl -n metallb-system exec -it "$POD" -c frr -- vtysh -c "show bgp summary"IPv4 Unicast Summary (VRF default):BGP router identifier 192.168.1.207, local AS number 64502 vrf-id 0BGP table version 1RIB entries 1, using 96 bytes of memoryPeers 1, using 13 KiB of memoryNeighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc192.168.1.254 4 64500 10 10 1 0 0 00:05:46 0 1 N/ATotal number of neighbors 1kubectl -n metallb-system exec -it "$POD" -c frr -- vtysh -c "show bgp ipv4 unicast neighbors 192.168.1.254 advertised-routes"BGP table version is 1, local router ID is 192.168.1.207, vrf id 0Default local pref 100, local AS 64502Status codes: s suppressed, d damped, h history, * valid, > best, = multipath,i internal, r RIB-failure, S Stale, R RemovedNexthop codes: @NNN nexthop's vrf id, < announce-nh-selfOrigin codes: i - IGP, e - EGP, ? - incompleteRPKI validation codes: V valid, I invalid, N Not foundNetwork Next Hop Metric LocPrf Weight Path*> 10.70.1.0/24 0.0.0.0 0 32768 ikubectl -n metallb-system exec -it "$POD" -c frr -- vtysh -c "show bgp ipv4 unicast neighbors 192.168.1.254 routes"kubectl -n metallb-system exec -it "$POD" -c frr -- vtysh -c "show running-config"Building configuration...Current configuration:!frr version 9.1_gitfrr defaults traditionalhostname worker-gpu-1log file /etc/frr/frr.log informationallog timestamp precision 3service integrated-vtysh-config!router bgp 64502no bgp ebgp-requires-policyno bgp default ipv4-unicastbgp graceful-restart preserve-fw-stateno bgp network import-checkneighbor 192.168.1.254 remote-as 64500!address-family ipv4 unicastnetwork 10.70.1.0/24neighbor 192.168.1.254 activateneighbor 192.168.1.254 route-map 192.168.1.254-in inneighbor 192.168.1.254 route-map 192.168.1.254-out outexit-address-familyexit!ip prefix-list 192.168.1.254-allowed-ipv4 seq 1 permit 10.70.1.0/24!ipv6 prefix-list 192.168.1.254-allowed-ipv6 seq 1 deny any!route-map 192.168.1.254-in deny 20exit!route-map 192.168.1.254-out permit 1match ip address prefix-list 192.168.1.254-allowed-ipv4exit!route-map 192.168.1.254-out permit 2match ipv6 address prefix-list 192.168.1.254-allowed-ipv6exit!end
Inbound routes are denied by default
Sidecar (speaker+FRR) mode: MetalLB’s CRDs don’t let you filter inbound routes.
MetalLB isn’t a transit router; it doesn’t use received prefixes for data-plane decisions. FRR will learn whatever the peer sends, but MetalLB ignores them for forwarding. You can tune session knobs (ASN, holdtime, BFD, srcAddress, node selectors), but not import policy.
- Persist via MetalLB CRDs
- Express everything in MetalLB’s own CRs (these are the metallb.io ones, not frrk8s.metallb.io):
- IPAddressPool: the LB ranges (incl. avoidBuggyIPs, autoAssign, etc.)
- BGPAdvertisement: what to advertise (communities, aggregation length, localpref, etc.)
- BGPPeer: who to peer with (peer address/ASN, my ASN, holdtime, srcAddress, BFD, node selectors)
- The speaker converts those CRs into FRR config on startup and on any change. That’s the supported persistence mechanism in sidecar mode
- Express everything in MetalLB’s own CRs (these are the metallb.io ones, not frrk8s.metallb.io):
- What you can’t persist in sidecar mode
- Arbitrary FRR statements (custom route-maps/prefix-lists beyond what MetalLB models)
- vtysh commands run by hand or “write” commands inside the container
With the FRR sidecar you can’t make manual vtysh edits persistent. The speaker regenerates FRR config from Kubernetes objects and overwrites whatever you hand-type; a pod restart wipes it too.
However, although they will not be persistent, it is possible to configure the inbound BGP route-map while operating in sidecar mode.
- Get the pod name
POD=$(kubectl -n metallb-system get pods -o name | grep speaker | head -1 | sed 's#pod/##')
- Then confirm by running a show command
kubectl -n metallb-system exec -it "$POD" -c frr -- vtysh -c "show bgp summary"IPv4 Unicast Summary (VRF default):BGP router identifier 192.168.1.207, local AS number 64502 vrf-id 0BGP table version 1RIB entries 1, using 96 bytes of memoryPeers 1, using 13 KiB of memoryNeighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc192.168.1.254 4 64500 10 10 1 0 0 00:05:46 0 1 N/A
- Connect to the command line of the frr process within the pod
kubectl -n metallb-system exec -it "$POD" -c frr -- vtyshHello, this is FRRouting (version 9.1_git).Copyright 1996-2005 Kunihiro Ishiguro, et al.
- Now you can execute commands, such as show running-config, very similar to a Cisco router:
show running-configBuilding configuration...Current configuration:!frr version 9.1_gitfrr defaults traditionalhostname worker-gpu-1log file /etc/frr/frr.log informationallog timestamp precision 3service integrated-vtysh-config!router bgp 64502no bgp ebgp-requires-policyno bgp default ipv4-unicastbgp graceful-restart preserve-fw-stateno bgp network import-checkneighbor 192.168.1.254 remote-as 64500!address-family ipv4 unicastnetwork 10.70.1.0/24neighbor 192.168.1.254 activateneighbor 192.168.1.254 route-map 192.168.1.254-in inneighbor 192.168.1.254 route-map 192.168.1.254-out outexit-address-familyexit!ip prefix-list 192.168.1.254-allowed-ipv4 seq 1 permit 10.70.1.0/24!ipv6 prefix-list 192.168.1.254-allowed-ipv6 seq 1 deny any!route-map 192.168.1.254-in deny 20exit!route-map 192.168.1.254-out permit 1match ip address prefix-list 192.168.1.254-allowed-ipv4exit!route-map 192.168.1.254-out permit 2match ipv6 address prefix-list 192.168.1.254-allowed-ipv6exit!end
- Enter configuration mode:
configure terminal
- Change the BGP configuration, by adding a prefix-list and a route-map entry. Also enable soft reconfiguration on the BGP peer and save the config:
worker-gpu-1(config)# ip prefix-list 192.168.1.254-allowed-ipv4-in seq 1 permit anyworker-gpu-1(config)# route-map 192.168.1.254-in permit 10worker-gpu-1(config-route-map)# match ip address prefix-list 192.168.1.254-allowed-ipv4-inworker-gpu-1(config-route-map)# exitworker-gpu-1(config)# router bgp 64502worker-gpu-1(config-router)# neighbor 192.168.1.254 soft-reconfiguration inboundworker-gpu-1(config-router)# endworker-gpu-1# writeNote: this version of vtysh never writes vtysh.confBuilding Configuration...Integrated configuration saved to /etc/frr/frr.conf[OK]
- Use a soft clear of the BGP session to make the changes take effect without resetting the peer:
worker-gpu-1# clear bgp ipv4 unicast 192.168.1.254 soft in
- Confirm that the peer is now receiving 11 routes:
show ip bgp sumNeighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc192.168.1.254 4 64500 226 200 12 0 0 03:11:35 11 1 N/A
- Check the routing table:
show ip routeCodes: K - kernel route, C - connected, S - static, R - RIP,O - OSPF, I - IS-IS, B - BGP, E - EIGRP, N - NHRP,T - Table, v - VNC, V - VNC-Direct, A - Babel, F - PBR,f - OpenFabric,> - selected route, * - FIB route, q - queued, r - rejected, b - backupt - trapped, o - offload failureB 0.0.0.0/0 [20/0] via 192.168.1.254, eno1, weight 1, 00:36:50K>* 0.0.0.0/0 [0/0] via 192.168.1.254, eno1, 03:46:39...
List all resources in the metallb-system namespace
- Get all resources
kubectl get all -n metallb-systemNAME READY STATUS RESTARTS AGEpod/metallb-controller-5754956df6-mdh6r 1/1 Running 0 5m10spod/metallb-speaker-zb997 4/4 Running 0 5m10sNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEservice/metallb-webhook-service ClusterIP 10.70.156.55 <none> 443/TCP 5m10sNAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGEdaemonset.apps/metallb-speaker 1 1 1 1 1 kubernetes.io/os=linux,metallb=enabled 5m10sNAME READY UP-TO-DATE AVAILABLE AGEdeployment.apps/metallb-controller 1/1 1 1 5m10sNAME DESIRED CURRENT READY AGEreplicaset.apps/metallb-controller-5754956df6 1 1 1 5m10s