Random ClusterIPs on Purpose: The Allocator That Fights eBPF Hash Collisions
Both ClusterIP allocators pick a random starting offset and scan forward, and both split the service CIDR so dynamic allocation stays in the upper subrange, because sequential ClusterIPs collide in eBPF datapaths.
TL;DR: Every ClusterIP allocator in Kubernetes picks a random starting offset and scans forward until it finds a free address, instead of handing out 10.96.0.1, 10.96.0.2, 10.96.0.3 in order. Sequential ClusterIPs are pathological for eBPF datapaths like Cilium, which hash on the 5-tuple: consecutive ClusterIPs land in the same hash bucket and create hot spots. Both allocators also split the service CIDR into a small lower subrange for statically requested IPs and an upper subrange for dynamic ones, using the formula
min(max(16, cidrSize/16), 256).
A ClusterIP looks like an ordinary IP address, and that is exactly the problem. It is not a routable address; it is a key into a load balancer’s service map, and the key space is structured in a way that datapaths dislike.
If you allocate 10.96.0.1, 10.96.0.2, 10.96.0.3, … then a datapath that hashes on the 5-tuple will produce correlated hashes for them - consecutive values differ in the low bits, and most hash functions mix poorly with arithmetic progressions. The result is a small number of hot buckets in the service map and uneven load across it. Randomising the allocation makes the hash distribution uniform for free.
Two allocators, one idea
Kubernetes has two ClusterIP allocators and they are not interchangeable at the object level, but they agree on the algorithm:
- The legacy bitmap allocator (
pkg/registry/core/service/ipallocator/bitmap.go) keeps abig.Intbitmap of the CIDR in memory, and persists a snapshot of it in thekubernetes.io/service-cluster-ip-rangeConfigMap. - The IPAddress-object allocator (
pkg/registry/core/service/ipallocator/ipallocator.go) stores onenetworking.k8s.io/v1 IPAddressobject per allocated IP and uses an informer as its cache.
The legacy one has a hard ceiling on IPv6, because it keeps a bitmap:
// pkg/registry/core/service/ipallocator/bitmap.go:68
if netutils.IsIPv6CIDR(cidr) {
family = api.IPv6Protocol
// Limit the max size, since the allocator keeps a bitmap of that size.
if max > 65536 {
max = 65536
}
} else {65536 entries is nothing for an IPv6 service CIDR, which is why the new allocator exists. The new one has a different constraint - the CIDR must be at least a /64:
// pkg/registry/core/service/ipallocator/ipallocator.go:87
if prefix.Addr().Is6() && prefix.Bits() < 64 {
return nil, fmt.Errorf("shortest allowed prefix length for service CIDR is 64, got %d", prefix.Bits())
}The subrange split
KEP-3070 introduced a split of the service CIDR into two subranges, and both allocators implement it. The lower subrange is where statically requested IPs are expected to live; the upper subrange is where dynamic allocation happens. The split point is computed by a single function:
// pkg/registry/core/service/ipallocator/bitmap.go:352
// calculateRangeOffset estimates the offset used on the range for statically allocation based on
// the following formula `min(max($min, cidrSize/$step), $max)`, described as ~never less than
// $min or more than $max, with a graduated step function between them~. The function returns 0
// if any of the parameters is invalid.
func calculateRangeOffset(cidr *net.IPNet) int {
// default values for min(max($min, cidrSize/$step), $max)
const (
min = 16
max = 256
step = 16
)
cidrSize := netutils.RangeSize(cidr)
// available addresses are always less than the cidr size
// A /28 CIDR returns 16 addresses, but 2 of them, the network
// and broadcast addresses are not available.
if cidrSize <= min {
return 0
}
offset := cidrSize / step
if offset < min {
return min
}
if offset > max {
return max
}
return int(offset)
}So the reserved lower subrange is min(max(16, cidrSize/16), 256) addresses.
For the default 10.96.0.0/12 service CIDR - 1 048 574 usable addresses -
cidrSize/16 is 65536, which is above the maximum of 256, so the offset is
256. The lower 256 addresses of the service CIDR are reserved for static
requests; dynamic allocation starts at 10.96.1.0 and above.
For a small CIDR like a /24 (254 usable addresses), 254/16 = 15, which is
below the minimum of 16, so the offset is 16. For a /28 (14 usable
addresses after excluding network and broadcast) the function returns 0 - there
is no room to split, so there is no split.
The new allocator computes the same offset and turns it into an address boundary:
// pkg/registry/core/service/ipallocator/ipallocator.go:113
// KEP-3070: Reserve Service IP Ranges For Dynamic and Static IP Allocation
// calculate the subrange offset
rangeOffset := calculateRangeOffset(cidr)
offsetAddress, err := addOffsetAddress(ipFirst, uint64(rangeOffset))
if err != nil {
return nil, err
}The Allocator struct then carries both boundaries explicitly:
// pkg/registry/core/service/ipallocator/ipallocator.go:49
type Allocator struct {
cidr *net.IPNet
prefix netip.Prefix
firstAddress netip.Addr // first IP address within the range
offsetAddress netip.Addr // IP address that delimits the upper and lower subranges
lastAddress netip.Addr // last IP address within the range
family api.IPFamily // family is the IP family of this range
rangeOffset int // subdivides the assigned IP range to prefer dynamic allocation from the upper range
size uint64 // cap the total number of IPs available to maxInt64One caveat worth stating plainly: this is a soft reservation in the new
allocator. allocateService - the static path - only checks that the
requested IP is inside [firstAddress, lastAddress], not that it is in the
lower subrange. The lower subrange is kept free by not allocating from it
dynamically, not by forbidding static requests there.
The legacy random scan
The legacy allocator’s randomisation lives in the bit-strategy object rather
than in the allocator itself. randomScanStrategyWithOffset picks a random
start and scans forward:
// pkg/registry/core/service/allocator/bitmap.go:72
func NewAllocationMapWithOffset(max int, rangeSpec string, offset int) *AllocationBitmap {
a := AllocationBitmap{
strategy: randomScanStrategyWithOffset{
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
offset: offset,
},
allocated: big.NewInt(0),
count: 0,The upper subrange is tried first, and the lower one only as a fallback:
// pkg/registry/core/service/allocator/bitmap.go:238
func (rss randomScanStrategyWithOffset) AllocateBit(allocated *big.Int, max, count int) (int, bool) {
if count >= max {
return 0, false
}
// size of the upper subrange, prioritized for random allocation
subrangeMax := max - rss.offset
// try to get a value from the upper range [rss.reserved, max]
start := rss.rand.Intn(subrangeMax)
for i := 0; i < subrangeMax; i++ {
at := rss.offset + ((start + i) % subrangeMax)
if allocated.Bit(at) == 0 {
return at, true
}
}
start = rss.rand.Intn(rss.offset)
// subrange full, try to get the value from the first block before giving up.
for i := 0; i < rss.offset; i++ {
at := (start + i) % rss.offset
if allocated.Bit(at) == 0 {
return at, true
}
}
return 0, false
}The rand is seeded per allocator instance from the wall clock:
// pkg/registry/core/service/allocator/bitmap.go:238
func (rss randomScanStrategyWithOffset) AllocateBit(allocated *big.Int, max, count int) (int, bool) {
if count >= max {
return 0, false
}
// size of the upper subrange, prioritized for random allocation
subrangeMax := max - rss.offset
// try to get a value from the upper range [rss.reserved, max]
start := rss.rand.Intn(subrangeMax)
for i := 0; i < subrangeMax; i++ {
at := rss.offset + ((start + i) % subrangeMax)
if allocated.Bit(at) == 0 {
return at, true
}
}
start = rss.rand.Intn(rss.offset)
// subrange full, try to get the value from the first block before giving up.
for i := 0; i < rss.offset; i++ {
at := (start + i) % rss.offset
if allocated.Bit(at) == 0 {
return at, true
}
}
return 0, false
}Note the loop structure: the scan is at := offset + ((start + i) % subrangeMax),
i.e. it wraps around within the upper subrange rather than running off the
end of the range. And the fallback into the lower subrange uses a second
independent random start, so a nearly-full range does not degenerate into
allocating the low addresses in order.
The IPAddress random scan
The new allocator does the same thing with netip.Addr and an explicit
iterator instead of a bitmap. The random offset is chosen in three branches,
because the range size is a uint64 and rand.Int63n panics on a
non-positive argument:
// pkg/registry/core/service/ipallocator/ipallocator.go:248
// rand.Int63n panics for n <= 0 so we need to avoid problems when
// converting from uint64 to int64
rangeSize := a.size - uint64(a.rangeOffset)
var offset uint64
switch {
case rangeSize >= math.MaxInt64:
offset = rand.Uint64()
// a.offsetAddress + offset should not overflow a 64 bit CIDR.
if math.MaxUint64-offset < uint64(a.rangeOffset) {
offset -= uint64(a.rangeOffset)
}
case rangeSize == 0:
return net.IP{}, ErrFull
default:
offset = uint64(rand.Int63n(int64(rangeSize)))
}
iterator := ipIterator(a.offsetAddress, a.lastAddress, offset)The iterator starts at offsetAddress + offset and wraps within
[offsetAddress, lastAddress]:
// pkg/registry/core/service/ipallocator/ipallocator.go:293
func ipIterator(first netip.Addr, last netip.Addr, offset uint64) func() netip.Addr {
// There are no modulo operations for IP addresses
modulo := func(addr netip.Addr) netip.Addr {
if addr.Compare(last) == 1 {
return first
}
return addr
}
next := func(addr netip.Addr) netip.Addr {
return modulo(addr.Next())
}And when the upper subrange is exhausted, the lower one is tried, again with a fresh random start:
// pkg/registry/core/service/ipallocator/ipallocator.go:270
// if the upper range is full, try to allocate from the lower range
if errors.Is(err, ErrFull) && a.rangeOffset != 0 {
offset = uint64(rand.Intn(a.rangeOffset))
iterator = ipIterator(a.firstAddress, a.offsetAddress.Prev(), offset)
ip, err = a.allocateFromRange(iterator, svc)The mid-air collision
The new allocator’s cache is an informer, so it can be stale. The check for “is this IP free?” is a cache lookup, and the actual allocation is an API write - a classic time-of-check-to-time-of-use window. The code handles it explicitly:
// pkg/registry/core/service/ipallocator/ipallocator.go:346
// address is not present on the cache, try to allocate it
err = a.createIPAddress(name, svc, "dynamic")
// an error can happen if there is a TOCTOU race and the IP address
// was allocated by another instance, in that case, swallow the error
// and try with the next IP address, but abort on other errors.
// Ref: https://issues.k8s.io/135333
if err != nil {
if errors.Is(err, ErrAllocated) {
klog.Infof("mid-air collision trying to allocate IP %s that already exists, retrying ...", name)
continue
}
klog.Infof("can not create IPAddress %s: %v", name, err)
return nil, err
}ErrAllocated is what createIPAddress returns when the API server answers
AlreadyExists - another apiserver replica (or another allocator instance)
got there first. Swallowing it and continuing the scan is correct: the
address really is taken, and the loop simply moves to the next candidate.
The scan itself is a linear walk over the range with a cache lookup per candidate, and the source is honest that this could be better:
// pkg/registry/core/service/ipallocator/ipallocator.go:326
// allocateFromRange allocates an empty IP address from the range of
// IPs between the first and last address (both included), starting
// from the start address.
// TODO: this is a linear search, it can be optimized.
func (a *Allocator) allocateFromRange(iterator func() netip.Addr, svc *api.Service) (net.IP, error) {The edges of the range
Two addresses are never allocatable, and both allocators agree on which:
// pkg/registry/core/service/ipallocator/ipallocator.go:563
func hostsPerNetwork(subnet *net.IPNet) uint64 {
ones, bits := subnet.Mask.Size()
// this checks that we are not overflowing an int64
if bits-ones >= 64 {
return math.MaxUint64
}
max := uint64(1) << uint(bits-ones)
// Don't use the network's ".0" address,
if max == 0 {
return 0
}
max--
if netutils.IsIPv4CIDR(subnet) {
// Don't use the IPv4 network's broadcast address
if max == 0 {
return 0
}
max--
}
return max
}IPv4 loses two addresses (network .0 and broadcast .255), IPv6 loses one
(the all-zeros address, since IPv6 has no broadcast). The legacy bitmap
allocator does the same thing with two explicit max-- calls, and adds the
detail that the network address is bumped by one rather than merely excluded
(pkg/registry/core/service/ipallocator/bitmap.go:81).
// pkg/registry/core/service/ipallocator/bitmap.go:81
// Don't use the network's ".0" address, but don't just Allocate() it - we
// don't ever want to be able to release it.
base.Add(base, big.NewInt(1))
max--Trade-offs
What randomisation buys. ClusterIPs that do not form an arithmetic progression, so datapaths that hash on them get a uniform bucket distribution. It also removes a class of information leak: with sequential allocation, the creation order of services is recoverable from their ClusterIPs.
What it costs.
- A linear scan per allocation.
allocateFromRangewalks candidates until it finds one that is free, and the legacy allocator does the same over its bitmap. The TODO in the source acknowledges it. - Worse locality for humans.
kubectl get svc -o wideno longer shows services in creation order, and there is no way to tell which ClusterIP came first. - Two allocators with different ceilings. The legacy one caps IPv6 at
65536 entries; the new one requires a /64 or longer but has no practical
upper bound. Which one you get depends on whether the cluster uses
ServiceCIDRobjects andIPAddressobjects. - Randomness is per allocator instance, not per cluster. With multiple
service CIDRs, the
MetaAllocatorwalks them in map order and does not weight by capacity - there is an explicit TODO about it:
// pkg/registry/core/service/ipallocator/cidrallocator.go:351
// TODO(aojea) add strategy to return a random allocator but
// taking into consideration the number of addresses of each allocator.
// Per example, if we have allocator A and B with 256 and 1024 possible
// addresses each, the chances to get B has to be 4 times the chances to
// get A so we can spread the load of IPs randomly.
// However, we need to validate the best strategy before going to Beta.Wrapping up
The one thing to remember: both ClusterIP allocators start from a random
offset and scan forward, and both split the service CIDR with
calculateRangeOffset - min(max(16, cidrSize/16), 256) - so dynamic
allocation lives in the upper subrange and the lower 256 addresses are left
for statically requested IPs. The legacy allocator caps IPv6 at 65536
entries; the IPAddress allocator requires a /64 or longer.
The part people get wrong: assuming clusterIP: None and manually
picked IPs play by the same rules as dynamically allocated ones. The
subrange split is a soft reservation in the new allocator - a statically
requested IP is accepted from anywhere inside the CIDR, and the lower
subrange is kept clear only because dynamic allocation never picks from it.
What to go do about it: check what your cluster is actually using with
kubectl get servicecidr and kubectl get ipaddress - if the IPAddress
objects exist, you are on the new allocator; if they do not, you are on the
legacy bitmap one, which is the one with the 65536-entry IPv6 ceiling. To
see the split in your own CIDR, compute
min(max(16, cidrSize/16), 256) for your serviceSubnet and check that the
first dynamically allocated ClusterIP is above that boundary. And if you run
Cilium or another eBPF datapath, watch the per-service bucket counts in the
datapath’s own metrics - the whole point of the randomisation is that those
counts should be flat rather than skewed towards the low ClusterIPs.