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 start–end 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 node advertising it. The VIP is attracted to a single node that answers for it on the local segment:

  • Exactly one node advertises each VIP, decided by acquiring the advertisement’s ownership Lease. The holder renews it well before it expires, so a live holder is never taken over from; a lease that stops being renewed is taken over by another candidate.
  • The advertising 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.
  • A node is a candidate only if it matches the class’s node selector and has a route to the VIP’s subnet. A holder that stops being a candidate releases the lease so another node can take over.

Which node holds the VIP only decides where external traffic enters the cluster. Routing the VIP onward to the Service’s endpoints is not tied to it: 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.nodeSelector on the class restricts candidacy to nodes whose labels match, for a cluster where only some nodes are attached to the network the VIPs live on. An empty selector — the default — selects every node.

apiVersion: lace-cni.io/v1alpha1
kind: LoadBalancerClass
metadata:
name: public
spec:
mode: l2
nodeSelector:
matchLabels:
node-role.kubernetes.io/edge: ""
ipv4Pools:
- cidr: 198.51.100.0/24

The selector is copied onto each L2Advertisement when the advertisement is created. Editing it afterwards does not move VIPs that are already advertised; it applies to advertisements created from then on.

  • 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.
  • spec.nodeSelector — restricts which nodes may advertise this class’s VIPs. Empty (the default) selects every node.
  • 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, its IP family, and the class’s node selector as copied at creation; plus, informationally, the node currently advertising it and where its lease lives.
  • Use — nodes read it to decide whether they are a candidate for the VIP and to answer for it on the segment, as described under L2.
  • Lifecycle — a coordination.k8s.io Lease in the release namespace, one per L2Advertisement under a deterministic name. Created by the first node that takes the VIP, owned by its advertisement and so removed with it. Nodes never release it on shutdown, so a failed node’s VIP moves after the lease expires.
  • Contents — the standard Lease fields: the holding node, when it acquired and last renewed, and the duration after which the lease may be taken over.
  • Use — it is the authority on which node advertises a VIP; the advertisement’s status only mirrors it.

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.

Prefer expressing this as reachability: declare the permitted prefixes as external Networks, and let only those Networks reach the Service with a ServiceRoutingPolicy. Everything else is unreachable because nothing grants it a path, which is the same model the rest of Lace uses and the only one that also covers the ClusterIP and node ports.

spec.loadBalancerSourceRanges exists for workloads that already set it. It limits a Service’s ingress VIPs to the listed CIDRs, and is a second filter inside the policy above — it can only narrow what a ServiceRoutingPolicy already admits, never widen it. A Service carrying ranges but no policy admitting the source is unreachable regardless.

A source outside the ranges gets no answer at all: the VIP goes dark rather than refusing, so a disallowed client waits out its own timeout and learns nothing about whether the VIP exists. lace node trace flow reports the drop as source-range-deny.

  • Only the ingress VIPs are restricted. The Service’s ClusterIPs and node ports stay reachable on their own terms, matching kube-proxy — which is why ranges alone are a weaker boundary than a routing policy.
  • Ranges apply to their own address family. A dual-stack Service given only IPv4 CIDRs leaves its IPv6 VIP unrestricted.
  • A range covering the whole family, such as 0.0.0.0/0, means the same as setting no ranges at all.
  • Traffic a node originates is restricted like any other, and is never inside the ranges an operator would write, so a node cannot reach a restricted VIP of its own accord.
  • Established flows are unaffected until they expire; a change applies to new connections.