Files
moby/daemon/libnetwork/service_nftables_linux.go
Cory Snider 01573380f9 libnetwork: add nftables load-balancer impl
Add a native nftables implementation of service load balancing which
achieves feature parity with the iptables firewall mode. This is
accomplished using a static nftables ruleset with dynamic data. NAT and
DSR modes are both supported. The backend server for each flow is chosen
randomly rather than round-robin like the iptables+IPVS implementation.

As the load balancer endpoint only sees one side of DSR flows we cannot
use netfilter's conntrack subsystem. And IPVS does not play well with
nftables -- nftables is missing an analogue for the `ipvs` xtables
matcher. DSR flows are made sticky by implementing one-sided connection
tracking directly in the nftables ruleset using maps that are updated in
the packet path.

A network's load-balancer tables have exactly one writer: each
loadBalancer records the data plane it wants, and the complete element set
is composed from every recorded contribution. nat-publish-port is an
interval map keyed on `l4proto . dport . numgen`, so the bucket space for
a published port has to be partitioned by a single owner -- and a service
whose published-port set changes has a separate binding, hence a separate
loadBalancer, per generation until the rolling update drains. Two of them
partitioning a shared port independently would emit overlapping intervals,
which nftables rejects. Composing them instead partitions the port once
over every backend that serves it, so a shared published port is
load-balanced across both generations. IPVS cannot do that: one fwMark
cannot select a different backend set per port.

Signed-off-by: Cory Snider <csnider@mirantis.com>
2026-11-03 20:59:49 -05:00

113 lines
3.9 KiB
Go

package libnetwork
import (
"context"
"fmt"
"net"
"strconv"
"strings"
"github.com/moby/moby/v2/daemon/libnetwork/internal/nftables"
)
func (sb *Sandbox) addRedirectRulesNFTables(ctx context.Context, eIP *net.IPNet, ingressPorts []*PortConfig) error {
if sb.osSbox == nil {
return nil
}
// The iptables implementation skips programming the rules to drop
// packets addressed to eIP on unpublished ports when the list of
// ingress ports is empty. It's a very strange behavior, but it's been
// like that for a decade, so we are replicating it here for
// consistency.
if len(ingressPorts) == 0 {
return nil
}
const (
publishedPortsMap = "published-ports"
ingressIPsSet = "ingress-ips"
)
var tm nftables.Modifier
ingressIP := eIP.IP.String()
tm.Create(nftables.SetElement{
SetName: ingressIPsSet,
Element: ingressIP,
})
for _, p := range ingressPorts {
// No comment: PortConfig.Name is an optional, unvalidated string straight
// from the service spec, and validateComment rejects quotes and newlines.
// A rejected element fails the whole table, which would leave the task with
// neither its port redirect nor the input/output filtering below - open
// rather than closed. The key already identifies the element completely,
// and addRedirectRulesIPTables ignores Name too.
tm.Create(nftables.MapElement{
MapName: publishedPortsMap,
Key: fmt.Sprintf("%s . %s . %d", ingressIP, strings.ToLower(p.Protocol.String()), p.PublishedPort),
Value: strconv.FormatUint(uint64(p.TargetPort), 10),
})
}
err := sb.osSbox.ApplyNFTable(ctx, nftables.IPv4, "docker-container-ingress", func(initIngress *nftables.Modifier) error {
// Map of ingress-IP . publishPort -> targetPort.
// Packets with a destination address of ingress-IP and a
// destination port in this map are redirected to the target
// port.
initIngress.Create(nftables.Map{
Name: publishedPortsMap,
ElementType: nftables.IPv4Addr.Concat(nftables.InetProto).Concat(nftables.InetService).MapTo(nftables.InetService),
})
initIngress.Create(nftables.Set{
Name: ingressIPsSet,
ElementType: nftables.IPv4Addr,
})
nftables.BaseChain{
Name: "prerouting",
ChainType: nftables.BaseChainTypeNAT,
Hook: nftables.BaseChainHookPrerouting,
Priority: nftables.BaseChainPriorityDstNAT,
Policy: nftables.BaseChainPolicyAccept,
}.Builder().
Rule("meta l4proto { tcp, udp, sctp } redirect to ip daddr . meta l4proto . th dport map @" + publishedPortsMap).
Create(initIngress)
nftables.BaseChain{
Name: "input",
ChainType: nftables.BaseChainTypeFilter,
Hook: nftables.BaseChainHookInput,
Priority: nftables.BaseChainPriorityFilter,
Policy: nftables.BaseChainPolicyAccept,
}.Builder().
Rule("ip daddr != @"+ingressIPsSet, "counter accept").
Rule("icmp type { destination-unreachable, time-exceeded } counter accept").
// Only allow incoming connections to exposed ports
// from the ingress network.
Rule("ct state { established, related } counter accept").
Rule("ct status dnat counter accept").
Rule("counter reject").
Create(initIngress)
nftables.BaseChain{
Name: "output",
ChainType: nftables.BaseChainTypeFilter,
Hook: nftables.BaseChainHookOutput,
Priority: nftables.BaseChainPriorityFilter,
Policy: nftables.BaseChainPolicyAccept,
}.Builder().
Rule("ip saddr != @"+ingressIPsSet, "counter accept").
Rule("icmp type { destination-unreachable, time-exceeded } counter accept").
// Only allow outgoing replies for incoming connections
// to be transmitted over the ingress network.
Rule("ct status dnat ct state { established, related } counter accept").
Rule("counter reject").
Create(initIngress)
return nil
}, tm)
if err != nil {
return fmt.Errorf("failed to add redirect rules to nftables ingress table: %v", err)
}
return nil
}