One Rule Per Service: How nftables Replaced kube-proxy's Chain of Chains
The iptables proxier needs N chains and N rules per service; the nftables proxier emits one rule with a numgen random mod N map, so the per-service rule count is O(1).
TL;DR: In iptables mode, a service with N endpoints needs a
KUBE-SVC-XXXXchain plus NKUBE-SEP-XXXXchains, and the load balancing is emulated with a cascade of-m statistic --mode random --probabilityrules that only add up to a uniform distribution once you reach the bottom. In nftables mode, kube-proxy emits one rule per service:numgen random mod N map { 0 : ip.port, 1 : ip.port, ... }. The map is a kernel hash table, so the lookup is a hash, and the distribution is uniform by construction instead of by accumulation.
Every service in a Kubernetes cluster is a load balancer, and every load balancer has to answer the same question: given this packet, which backend? The iptables proxier answers it with a chain of chain of chain. The nftables proxier answers it with a hash map lookup and a uniform random number. The difference is not cosmetic - it is the difference between O(endpoints) rules per service and O(1). That matters because the number of services grows with the number of teams, the number of endpoints per service grows with the number of replicas, and every rule in the iptables layout costs a linear scan of the chain at packet time.
The chain of chains
The iptables proxier names five kinds of chain, and every service gets one of each:
// pkg/proxy/iptables/proxier.go:538
const (
servicePortPolicyClusterChainNamePrefix = "KUBE-SVC-"
servicePortPolicyLocalChainNamePrefix = "KUBE-SVL-"
serviceFirewallChainNamePrefix = "KUBE-FW-"
serviceExternalChainNamePrefix = "KUBE-EXT-"
servicePortEndpointChainNamePrefix = "KUBE-SEP-"
)KUBE-SVC- is the service’s dispatch chain. It contains one rule per
endpoint, and each rule jumps to that endpoint’s own KUBE-SEP- chain, which
holds the actual -j DNAT. The suffix is a 16-character base32 truncation of
a SHA-256 over the service port name and protocol, because iptables chain
names are limited to 28 characters.
Here is the entire load balancing logic of the iptables proxier:
// pkg/proxy/iptables/proxier.go:1452
// Now write loadbalancing rules.
numEndpoints := len(endpoints)
for i, ep := range endpoints {
epInfo, ok := ep.(*endpointInfo)
if !ok {
continue
}
comment := fmt.Sprintf(`"%s -> %s"`, svcPortNameString, epInfo.String())
args = append(args[:0], "-A", string(svcChain))
args = proxier.appendServiceCommentLocked(args, comment)
if i < (numEndpoints - 1) {
// Each rule is a probabilistic match.
args = append(args,
"-m", "statistic",
"--mode", "random",
"--probability", proxier.probability(numEndpoints-i))
}
// The final (or only if n == 1) rule is a guaranteed match.
natRules.Write(args, "-j", string(epInfo.ChainName))
}Read the probability values: for N endpoints, the first rule fires with probability 1/N, the second with probability 1/(N-1), the third with probability 1/(N-2), and the last one always fires. The unconditional probability of reaching the k-th rule is 1/N * 1/(N-1) * … which is uniform across the N endpoints - but only because the multiply-out happens implicitly as you fall through. Each of the N rules is a separate kernel matcher, and the packet walks the chain until one fires.
The verdict map
nftables has a first-class data type that iptables does not: a map, a
hash table whose values can be verdict-like objects (goto, drop, reject)
as well as data. kube-proxy declares several of them for dispatch, all in
the nat table setup (pkg/proxy/nftables/proxier.go:723):
// pkg/proxy/nftables/proxier.go:723
// Set up service dispatch
tx.Add(&knftables.Map{
Name: serviceIPsMap,
Type: ipvX_addr + " . inet_proto . inet_service : verdict",
Comment: ptr.To("ClusterIP, ExternalIP and LoadBalancer IP traffic"),
})
tx.Add(&knftables.Map{
Name: serviceNodePortsMap,
Type: "inet_proto . inet_service : verdict",
Comment: ptr.To("NodePort traffic"),
})The key is a concatenation of three fields: destination address, IP protocol,
and destination port. The value is a verdict - in practice a goto to a
per-service chain. So the whole dispatch table for every service in the
cluster is a single nftables map, and the rule that consults it is a single
line:
// pkg/proxy/nftables/proxier.go:761
tx.Add(&knftables.Rule{
Chain: servicesChain,
Rule: knftables.Concat(
ipX, "daddr", ".", "meta l4proto", ".", "th dport",
"vmap", "@", serviceIPsMap,
),
})vmap @service-ips is the verdict-map lookup. The packet’s daddr,
l4proto and th dport are concatenated into a key, the key is hashed, the
hash is looked up in a kernel hash table, and the value is a verdict the rule
engine applies - a goto to the per-service chain. One rule, one hash
lookup, for every service in the cluster. The per-service chain still exists
- nftables mode keeps one per service and per endpoint, because
externalTrafficPolicy: Localneeds somewhere to live - but it is reached through a map lookup and holds exactly one rule for the endpoints.
One rule per service
The dispatch from the service chain to the endpoints is where the O(N) -> O(1) collapse happens. Here is the whole thing:
// pkg/proxy/nftables/proxier.go:1963
tx.Add(&knftables.Rule{
Chain: svcChain,
Rule: knftables.Concat(
"meta l4proto", strings.ToLower(string(svcInfo.Protocol())),
"dnat", ipX, "addr . port to",
"numgen random mod", len(endpoints), "map",
"{", elements, "}",
),
})Read that left to right. meta l4proto tcp matches the protocol. dnat ip addr . port to says the destination is a concatenation of an IPv4 address
and a port. Then numgen random mod N map { 0 : 10.0.0.5.8080, 1 : 10.0.0.6.8080, ... }: the kernel generates a random number, takes it modulo
N, and uses the result as the index into an inline map whose values are the
endpoint address-and-port pairs.
That is a uniform random choice over N endpoints, computed in one expression.
No probability cascade, no fall-through, no per-endpoint chain jump. The
elements are built by iterating the endpoint list and emitting index : ip.port, separated by commas (pkg/proxy/nftables/proxier.go:1949).
Note that the map is inline in the rule, not a named nftables map object. That is a deliberate trade-off, and the code says so out loud - inserting elements into a named nftables map is a O(n) (or worse) operation in the kernel, so for the common case of exactly one endpoint, kube-proxy skips the map entirely:
// pkg/proxy/nftables/proxier.go:1929
// Special case for single endpoint services
// Inserting maps into nftables is a O(n) (or worse) operation, so we want to avoid it for single endpoint services.
if len(endpoints) == 1 {
ep := endpoints[0]
epInfo, ok := ep.(*endpointInfo)
if !ok {
return
}
tx.Add(&knftables.Rule{
Chain: svcChain,
Rule: knftables.Concat(
"meta l4proto", strings.ToLower(string(svcInfo.Protocol())),
"dnat to", net.JoinHostPort(epInfo.IP(), strconv.Itoa(epInfo.Port())),
),
})
return
}That single-endpoint fast path is the most common case in most clusters, and it produces literally one rule with no map, no numgen, and no random number generator at all.
Session affinity needs a different shape
ClientIP session affinity cannot use the DNAT map, because the pin has to be looked up before the DNAT decision, and the pin is keyed on the source address. So the affinity variant of the service chain uses a verdict map instead, whose values are chain jumps rather than addresses:
// pkg/proxy/nftables/proxier.go:1988
tx.Add(&knftables.Rule{
Chain: svcChain,
Rule: knftables.Concat(
ipX, "saddr", "@", epInfo.affinitySetName,
"goto", epInfo.chainName,
),
})
elements = append(elements,
strconv.Itoa(i), ":", "goto", epInfo.chainName,
)
if i != len(endpoints)-1 {
elements = append(elements, ",")
}
}
// Now write the vmap, for the case where no affinity rule matched
tx.Add(&knftables.Rule{
Chain: svcChain,
Rule: knftables.Concat(
"numgen random mod", len(endpoints), "vmap",
"{", elements, "}",
),
})So an affinity-enabled service has N affinity rules (one per endpoint, each
matching a source address against a dynamic, timeout-bearing set), plus one
numgen random mod N vmap fallback rule for clients with no pin. The vmap
values are goto verdicts pointing at the per-endpoint chain, and the
per-endpoint chain does the DNAT and updates the affinity set. The affinity
set itself is a dynamic nftables set with a timeout, created with the
DynamicFlag and TimeoutFlag set and a timeout equal to the service’s
sessionAffinityConfig.clientIP.timeoutSeconds. The DynamicFlag comment in
the source is worth repeating: the nftables docs say “dynamic” is only
needed for sets containing stateful objects such as counters, but on RHEL8
the set gets mutated to have it later, and the next tx.Add() of the set
fails because it looks like a flag change.
A service that loses its endpoints flips one element
The other thing the chain-of-chains layout made expensive was the negative case: a service with no endpoints. In iptables mode that means generating a reject rule per service and tearing it down later. In nftables mode there is a dedicated verdict map for exactly this, and the service’s fate is one map element:
// pkg/proxy/nftables/proxier.go:607
// Set up "no endpoints" drop/reject handling
tx.Add(&knftables.Map{
Name: noEndpointServicesMap,
Type: ipvX_addr + " . inet_proto . inet_service : verdict",
Comment: ptr.To("vmap to drop or reject packets to services with no endpoints"),
})
tx.Add(&knftables.Map{
Name: noEndpointNodePortsMap,
Type: "inet_proto . inet_service : verdict",
Comment: ptr.To("vmap to drop or reject packets to service nodeports with no endpoints"),
})The check itself is one rule, hooked into the filter table’s input, forward
and output chains for new connections - ip daddr . meta l4proto . th dport vmap @no-endpoint-services
(pkg/proxy/nftables/proxier.go:631).
And the element itself is written with the verdict the service deserves.
When a service has no endpoints at all, both internal and external traffic is
sent to a shared reject chain; when it has endpoints but none that are local
and the internal traffic policy is Local, internal traffic is dropped while
external traffic still works:
// pkg/proxy/nftables/proxier.go:1417
var internalTrafficFilterVerdict, externalTrafficFilterVerdict string
if !hasEndpoints {
// The service has no endpoints at all; hasInternalEndpoints and
// hasExternalEndpoints will also be false, and we will not
// generate any chains in the "nat" table for the service; only
// rules in the "filter" table rejecting incoming packets for
// the service's IPs.
internalTrafficFilterVerdict = fmt.Sprintf("goto %s", rejectChain)
externalTrafficFilterVerdict = fmt.Sprintf("goto %s", rejectChain)
} else {
if !hasInternalEndpoints {
// The internalTrafficPolicy is "Local" but there are no local
// endpoints. Traffic to the clusterIP will be dropped, but
// external traffic may still be accepted.
internalTrafficFilterVerdict = "drop"So a service that goes from three endpoints to zero does not cause a rule
synthesis or a rule teardown. It causes one map element to be deleted and one
map element to be inserted, with the verdict flipping from “goto the service
chain” to “goto reject-chain” - the element is written to no-endpoint-services
with the key <clusterIP> <protocol> <port> and the value being the verdict
(pkg/proxy/nftables/proxier.go:1449).
The ensureElem helper is what makes this cheap, and it encodes the only
slightly clever part of the design: it compares the desired value against
the value it last read out of the kernel, and only emits a delete+add when
the value actually changed
(pkg/proxy/nftables/proxier.go:1121):
// pkg/proxy/nftables/proxier.go:1121
if exists {
if existingValue != newValue {
// value is different, delete and re-add
tx.Delete(elem)
tx.Add(elem)
s.elements[newKey] = newValue
}
delete(s.leftoverKeys, newKey)
} else {
tx.Add(elem)
s.elements[newKey] = newValue
}The userspace localhost NodePort proxy
There is one thing nftables cannot do that iptables can, and it has a whole subsystem bolted on for it: NodePorts on localhost.
In iptables mode, kube-proxy turns on net/ipv4/conf/all/route_localnet, a
kernel hack that makes the loopback interface route non-local addresses.
That is what allows curl 127.0.0.1:30000 to reach a NodePort. nftables
mode does not use route_localnet, so when the node port addresses include
an explicit loopback address, kube-proxy falls back to a userspace TCP
proxy - the KubeProxyNFTablesLocalhostNodePorts feature gate plus
useLocalhostNodePortProxy(nodePortAddresses) at
pkg/proxy/nftables/proxier.go:293.
The proxy listens on 127.0.0.1:<nodePort> (or ::1) for TCP only, and
pipes bytes to a backend picked at random, with a five second dial timeout
(const dialTimeout = 5 * time.Second, pkg/proxy/localnodeportproxy/proxy.go:37):
// pkg/proxy/localnodeportproxy/proxy.go:223
func (l *nodePortListener) handleTCPConn(ctx context.Context, clientConn net.Conn) {
defer clientConn.Close() //nolint:errcheck
ep := l.pickEndpoint()
backendConn, err := net.DialTimeout("tcp", ep, dialTimeout)
if err != nil {
l.logger.V(4).Info("Failed to connect to backend", "err", err, "protocol", l.protocol, "port", l.port, "endpoint", ep)
return
}implemented as a sliding window - the timer is renewed on every connection - and the source comments are unusually honest about what that means:
// pkg/proxy/localnodeportproxy/proxy.go:268
// Session affinity is implemented as a sliding window (renewing the expiration
// timer on every connection). Note that since all localhost traffic shares the
// same source IP (127.0.0.1 or ::1), any active connection from localhost will
// keep this pin alive indefinitely for all local clients. This matches the
// behavior in standard iptables/nftables modes.So “ClientIP affinity” for localhost traffic is effectively “affinity to
whichever endpoint the first connection happened to pick”, and it never
expires as long as something on the node keeps connecting. That is not a
bug in the nftables port; it is a property of keying affinity on a source
address that every local client shares. The protocols the userspace proxy
cannot serve - UDP, SCTP - are rejected explicitly and counted in the
kubeproxy_nftables_localhost_nodeport_rejected_packets_total metric.
Trade-offs
What nftables buys. One rule per service instead of one per endpoint. A
single verdict-map lookup instead of a linear scan of a dispatch chain. A
uniform distribution computed in one expression instead of a cascade of
-m statistic rules. A service losing all its endpoints costs one map
element, not a rule synthesis.
What it costs.
- The inline map is rebuilt on every change.
numgen random mod N map {...}lives inside the rule, so changing the endpoint set means rewriting the rule. Named maps would let you patch an element, but the kernel’s map insertion is O(n) or worse, so the single-endpoint fast path avoids the map entirely and the multi-endpoint case accepts the rewrite. - Session affinity still needs per-endpoint chains. The DNAT map cannot express “look up the source pin first”, so the affinity path uses a verdict map and keeps N endpoint chains plus N affinity rules.
- The localhost NodePort path leaves the kernel datapath. In iptables mode
it is a
route_localnethack plus an iptables jump; in nftables mode it is a userspace TCP proxy with a 5 second dial timeout, which is a real latency and throughput regression for local clients. This is whyKubeProxyNFTablesLocalhostNodePortsis a separate gate. - Stale chains cannot be deleted immediately. With kernels before 6.2, if a map element points at a chain, deleting the map element does not make the kernel notice the chain is unreferenced for some time, so stale chains are flushed now and deleted later.
Wrapping up
The one thing to remember: the nftables proxier replaces the per-service
chain of N endpoint jumps with a single rule - numgen random mod N map { 0 : ip.port, ... } - and replaces the global dispatch chain with a verdict-map
lookup, ip daddr . meta l4proto . th dport vmap @service-ips. The
per-service rule count is O(1) in the number of endpoints.
The part people get wrong: assuming the whole nftables mode is O(1).
sessionAffinity: ClientIP still needs one rule and one chain per endpoint,
because the pin has to be resolved before the DNAT, and the verdict-map
fallback (numgen random mod N vmap) can only express “jump to a chain”, not
“jump to an address”. And the localhost NodePort path in nftables mode is a
userspace TCP proxy, not a kernel datapath, so it is measurably slower than
iptables mode’s route_localnet trick.
What to go do about it: run nft list map inet kube-proxy service-ips and
nft list ruleset on a node running nftables mode and count the rules in the
per-service chains - if you see more than one per service, you are looking at
an affinity service or at a stale chain pending deletion. To enable the
localhost NodePort path, turn on the KubeProxyNFTablesLocalhostNodePorts
feature gate (beta and on by default as of 1.38) and set
nodePortAddresses to include 127.0.0.1; the rejections for the protocols
the userspace proxy cannot serve are counted in
kubeproxy_nftables_localhost_nodeport_rejected_packets_total.