Cilium Cilium

Advertise Service IPs with BGP

No cloud load balancer? Cilium can announce Service IPs straight to your physical network over BGP instead.

15 min Advanced

A cloud provider’s LoadBalancer Service type works because the cloud has an API to provision an external load balancer on demand. On bare metal, or any environment without that API, a LoadBalancer Service just sits in Pending forever unless something else fills that role. Cilium’s BGP control plane is one way to fill it: routing a Service’s IP straight into your physical network by speaking BGP directly from cluster nodes to upstream routers. This guide covers the four resources that configure it, and how to verify a BGP session is actually established from both sides.

When BGP is the right tool

BGP peering from cluster nodes to upstream routers is a real networking integration, it requires coordination with whoever manages the physical (or virtual) network, router configuration on their side matching your peer configuration on the Kubernetes side, and genuine operational familiarity with BGP concepts like autonomous systems and route advertisement. This is the right tool for an on-premises or bare-metal cluster that needs Service IPs to actually be routable from the rest of your network, the same way a cloud LoadBalancer would be. For simpler needs, Cilium’s L2 announcements feature (advertising a Service’s IP via ARP/NDP on the local network segment, no routing protocol involved) is a lighter-weight alternative worth considering first if your cluster lives on a single network segment and doesn’t need Layer 3 routing between segments or data centers.

The four resources

Four CiliumBGP* custom resources work together:

  • CiliumLoadBalancerIPPool defines a range of IP addresses Cilium can assign to LoadBalancer Services.
  • CiliumBGPAdvertisement defines what gets advertised over BGP, in this case, Service IPs drawn from that pool.
  • CiliumBGPPeerConfig defines how to speak BGP: timers, graceful restart behavior, address families.
  • CiliumBGPClusterConfig ties it together: which nodes participate (via a label selector), their local AS number, and which upstream peers (by IP and AS number) they connect to.

Define the IP pool

apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
  name: apps-pool
spec:
  allowFirstLastIPs: "No"
  blocks:
    - cidr: "10.10.211.0/24"

allowFirstLastIPs: "No" excludes the network and broadcast addresses (.0 and .255) from the assignable range, a common convention carried over from traditional subnet allocation. Adjust the CIDR to whatever range your network team has actually allocated for this purpose; it needs to be real, routable address space from their perspective, not an arbitrary private range you picked without coordinating.

Define what gets advertised

apiVersion: cilium.io/v2alpha1
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-advertisements
  labels:
    advertise: bgp
spec:
  advertisements:
    - advertisementType: Service
      service:
        addresses:
          - LoadBalancerIP
      selector:
        matchExpressions:
          - key: io.kubernetes.service.namespace
            operator: Exists

This advertises the LoadBalancerIP of matching Services. The labels field here (advertise: bgp) is what the peer config below references to tie the two together, not a label on the Services themselves.

Define peer behavior

apiVersion: cilium.io/v2alpha1
kind: CiliumBGPPeerConfig
metadata:
  name: bgp-peer-config
spec:
  timers:
    holdTimeSeconds: 9
    keepAliveTimeSeconds: 3
  gracefulRestart:
    enabled: true
    restartTimeSeconds: 15
  families:
    - afi: ipv4
      safi: unicast
      advertisements:
        matchLabels:
          advertise: bgp

The families.advertisements.matchLabels here is what connects this peer configuration to the advertisement resource defined above, by matching its label.

Tie it together: cluster config and node selection

apiVersion: cilium.io/v2alpha1
kind: CiliumBGPClusterConfig
metadata:
  name: bgp-cluster-config
spec:
  nodeSelector:
    matchLabels:
      role: bgp-node
  bgpInstances:
    - name: instance-1
      localASN: 65011
      peers:
        - name: leaf-switch-1a
          peerASN: 65002
          peerAddress: "10.10.210.2"
          peerConfigRef:
            name: bgp-peer-config
        - name: leaf-switch-1b
          peerASN: 65002
          peerAddress: "10.10.210.3"
          peerConfigRef:
            name: bgp-peer-config

nodeSelector is what limits BGP peering to a deliberate subset of nodes, those labeled role: bgp-node, rather than every node in the cluster trying to peer with upstream routers:

kubectl label node worker-4 worker-5 worker-6 role=bgp-node

A reasonable range is two to eight peering nodes, enough to avoid a single point of failure, without requiring your network team to configure and maintain BGP sessions against every node in a cluster that might have dozens or hundreds. How many makes sense depends on your cluster’s physical layout, spread across multiple racks or rows generally argues for peering from nodes in each, rather than concentrating all BGP sessions in one location.

Apply all four resources, then check from the Kubernetes side:

kubectl apply -f bgp-pool.yaml -f bgp-advertisement.yaml -f bgp-peer-config.yaml -f bgp-cluster-config.yaml

Verify the session actually came up

From a node’s perspective, using Cilium’s own debug CLI inside the Cilium Pod running on a BGP-labeled node:

kubectl -n kube-system exec <cilium-pod-on-bgp-node> -- cilium-dbg bgp peers

Look for a session state of established. Anything else, idle, connect, active, means the session hasn’t actually come up, and the most common causes are an AS number mismatch between the two sides, an unreachable peer address, or the upstream router simply not yet configured to expect this peer at all; confirm the peer configuration on the network side matches what you’ve declared here exactly before assuming the Kubernetes-side configuration is wrong.

Confirm an address is actually being advertised

Create a LoadBalancer Service that draws from the pool:

kubectl get svc -o wide

Its EXTERNAL-IP should be an address from the pool’s CIDR. From the upstream router (or from whatever sits beyond it, a firewall, a core router), check that this address shows up as a learned /32 route via BGP, advertised from the peering nodes’ addresses. Seeing that specific /32 appear, and successfully reaching the Service through it, is the real end-to-end confirmation that IP pool allocation, BGP advertisement, and the physical network’s own routing are all correctly wired together, not just that the Kubernetes-side resources applied without error.

Popular Guides