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>
113 lines
3.9 KiB
Go
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
|
|
}
|