Skip to content

LoadBalancer

A LoadBalancerClass configures how Lace assigns and advertises external VIPs for LoadBalancer Services. A Service opts in by setting spec.loadBalancerClass to lace-cni.io/<metadata.name>; the class then decides which address pool the VIP comes from and how it is advertised to the outside network.

Only class names carrying the lace-cni.io/ prefix are handled by Lace. A Service whose loadBalancerClass has the prefix but matches no class is reported as an error rather than silently left to another implementation.

A Service that sets no loadBalancerClass at all is adopted by the default class, if there is one.

A Service carries the standard service.kubernetes.io/load-balancer-cleanup finalizer for as long as Lace advertises a VIP for it, so deleting it completes only once the address is released. A Service Lace advertises nothing for never receives one.

apiVersion: lace-cni.io/v1alpha1
kind: LoadBalancerClass
metadata:
name: public
spec:
mode: l2
ipv4Pools:
- cidr: 198.51.100.0/24
- start: 203.0.113.10
end: 203.0.113.20
ipv6Pools:
- cidr: 2001:db8:f00d::/64

A Service selects the class above with loadBalancerClass: lace-cni.io/public.

Where Lace is the LoadBalancer implementation of a cluster, one class can be marked as the cluster default, so a type: LoadBalancer Service that names no class at all just works.

apiVersion: lace-cni.io/v1alpha1
kind: LoadBalancerClass
metadata:
name: public
annotations:
loadbalancer.lace-cni.io/default-class: "true"
  • Exactly one class may carry the marker. With more than one there is no default and nothing is adopted; with none, a Service that names no class is left to another implementation.
  • spec.loadBalancerClass wins wherever it is set: the default adopts only Services that name no class, never another implementation’s.
  • Adoption is sticky. An adopted Service keeps its class and its VIP when the default later moves to another class or is dropped.
  • Kubernetes rejects filling in spec.loadBalancerClass after the fact, so the resolved class is instead recorded on the Service as the loadbalancer.lace-cni.io/class-name annotation. It is informational only — editing it rebinds nothing.

Each class carries an ipv4Pools and/or ipv6Pools list; at least one must be non-empty. Each entry is either a cidr block or an inclusive startend range. Entries are tried in order, falling through to the next when one is exhausted.

A Service gets one VIP per address family it is configured for, each allocated from the matching family’s pools. The families a Service can have are its own IP-family configuration, further constrained to the families the class actually provides a pool for.

spec.mode selects how an allocated VIP is advertised to the surrounding network. Only l2 is implemented; l3 is reserved for a future BGP-based advertisement.

In L2 mode each allocated VIP is tracked by an L2Advertisement — one per VIP — which records the VIP, the owning Service, and the elected node. The VIP is attracted to a single node that answers for it on the local segment:

  • Exactly one node is elected to advertise each VIP. The L2Advertisement doubles as the election lock: the winning node claims it and heartbeats its liveness, and a stale claim is taken over by another reachable node.
  • The elected node answers ARP (IPv4) and NDP (IPv6) for the VIP, and on acquisition sends a gratuitous ARP / unsolicited Neighbor Advertisement so switches and peers re-point the VIP to it immediately.
  • Only a node that can actually reach the VIP’s subnet is a candidate; a node with no route to the VIP never claims it.

The election only decides where external traffic enters the cluster. Routing the VIP onward to the Service’s endpoints is not tied to the elected node: every node programs the VIP into its data plane and load-balances it across the Service’s endpoints wherever they run. So the entry node forwards to a backing pod on any node, and a takeover changes only the ingress point, not reachability.

  • spec.ipv4Pools, spec.ipv6Pools — the address pools VIPs are allocated from; at least one must be non-empty. Entries are tried in order, falling through to the next when one is exhausted. Each entry is either:
    • cidr — a CIDR block, or
    • start and end — an inclusive address range.
  • spec.mode — the advertisement mechanism. Only l2 is implemented (the default); l3 is reserved for a future BGP-based advertisement.
  • Lifecycle — created and owned by the controller: one per LoadBalancer Service per required IP family, under a deterministic name. The controller allocates its VIP from the class pool and removes it when the family is no longer needed or the Service is deleted.
  • Contents — the allocated VIP, the owning Service, the class it was drawn from, and its IP family; plus the elected node and a liveness timestamp it heartbeats.
  • Use — nodes elect a single owner per advertisement through its status and answer for the VIP on the segment, as described under L2.

A Service can ask for specific addresses with the loadbalancer.lace-cni.io/loadBalancerIPs annotation: a comma-separated list of at most one IPv4 and one IPv6 address. Each must fall within the class’s pools, and is claimed exactly if free.

apiVersion: v1
kind: Service
metadata:
name: web
annotations:
loadbalancer.lace-cni.io/loadBalancerIPs: "198.51.100.7,2001:db8:f00d::7"
spec:
type: LoadBalancer
loadBalancerClass: lace-cni.io/public
# ...

A requested address that is already taken or outside every pool is reported as an error on the Service instead of being silently replaced.