mirror of
https://github.com/slackhq/nebula.git
synced 2026-08-16 09:06:59 +02:00
ecn: CE-mark on decap when the receive queue runs deep (nebula-as-AQM)
The tunnel's real bottleneck queue - the UDP receive buffer feeding the decrypt loop - is invisible to every kernel AQM, so under overload it regulates ECN-capable flows with tail-drop loss like it's 1993. Sample SK_MEMINFO once per recvmmsg batch (tunnels.ecn_mark_threshold, fraction of rcvbuf, 0=off) and treat depth beyond the threshold as an outer CE: the existing RFC 6040 fold then CE-marks ECT inner packets and senders back off without loss.
This commit is contained in:
@@ -425,6 +425,13 @@ logging:
|
|||||||
# the route half of this setting to take effect.
|
# the route half of this setting to take effect.
|
||||||
#ecn: true
|
#ecn: true
|
||||||
|
|
||||||
|
# EXPERIMENTAL, Linux only. ecn_mark_threshold turns nebula into the AQM for its own receive queue — the one
|
||||||
|
# congested hop on a tunnel path that no kernel AQM can see. When the UDP receive queue's depth exceeds this
|
||||||
|
# fraction of the receive buffer (see listen.read_buffer), decapsulated ECT packets are CE-marked so ECN-capable
|
||||||
|
# senders back off before the queue overflows and regulates by tail-drop (loss) instead. 0 disables (default).
|
||||||
|
# Requires `ecn: true` end to end. Sampled once per receive batch. Reloadable.
|
||||||
|
#ecn_mark_threshold: 0.05
|
||||||
|
|
||||||
# Nebula security group configuration
|
# Nebula security group configuration
|
||||||
firewall:
|
firewall:
|
||||||
# Action to take when a packet is not allowed by the firewall rules.
|
# Action to take when a packet is not allowed by the firewall rules.
|
||||||
|
|||||||
+9
-1
@@ -573,7 +573,15 @@ func (f *Interface) handleOutsideMessagePacket(hostinfo *HostInfo, out []byte, s
|
|||||||
// underlay into the inner header before firewall + TUN write. Other
|
// underlay into the inner header before firewall + TUN write. Other
|
||||||
// outer codepoints are advisory only — we keep the inner unchanged.
|
// outer codepoints are advisory only — we keep the inner unchanged.
|
||||||
if f.ecnEnabled.Load() {
|
if f.ecnEnabled.Load() {
|
||||||
applyOuterECN(out, meta.OuterECN, hostinfo, f.l)
|
outerECN := meta.OuterECN
|
||||||
|
if meta.QueueCongested {
|
||||||
|
// nebula-as-AQM: our own receive queue is the congested hop on
|
||||||
|
// this path and no kernel AQM can see it. Depth beyond the
|
||||||
|
// marking threshold is treated as CE so ECT senders back off
|
||||||
|
// before the queue regulates by tail-drop instead.
|
||||||
|
outerECN = ecnCE
|
||||||
|
}
|
||||||
|
applyOuterECN(out, outerECN, hostinfo, f.l)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := newPacket(out, true, fwPacket)
|
err := newPacket(out, true, fwPacket)
|
||||||
|
|||||||
+6
-4
@@ -10,11 +10,13 @@ import (
|
|||||||
_ "net/http/pprof" // registers pprof handlers on http.DefaultServeMux
|
_ "net/http/pprof" // registers pprof handlers on http.DefaultServeMux
|
||||||
)
|
)
|
||||||
|
|
||||||
// startPprofServer serves net/http/pprof on :6060 for the life of ctx. It is
|
// startPprofServer serves net/http/pprof on localhost:6060 for the life of
|
||||||
// only compiled into debug builds (`-tags debug`, `make debug`), so a debug
|
// ctx. It is only compiled into debug builds (`-tags debug`, `make debug`),
|
||||||
// build announces itself with the Info line below.
|
// so a debug build announces itself with the Info line below. Loopback only:
|
||||||
|
// a wildcard bind would expose profiles (peer addresses, config-derived
|
||||||
|
// state) to anything that can reach the host, the overlay included.
|
||||||
func startPprofServer(ctx context.Context, l *slog.Logger) {
|
func startPprofServer(ctx context.Context, l *slog.Logger) {
|
||||||
server := &http.Server{Addr: ":6060", Handler: nil}
|
server := &http.Server{Addr: "localhost:6060", Handler: nil}
|
||||||
l.Info("Starting pprof debug server (debug build)", "addr", server.Addr)
|
l.Info("Starting pprof debug server (debug build)", "addr", server.Addr)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ const MaxWriteBatch = 128
|
|||||||
// supply on every packet.
|
// supply on every packet.
|
||||||
type RxMeta struct {
|
type RxMeta struct {
|
||||||
OuterECN byte
|
OuterECN byte
|
||||||
|
// QueueCongested is set when the receiving socket's kernel queue depth
|
||||||
|
// exceeded the configured AQM marking threshold (tunnels.ecn_mark_threshold)
|
||||||
|
// when this batch was pulled. The decap path treats it like an outer CE
|
||||||
|
// mark on ECT inner packets — nebula acting as the AQM for the one queue
|
||||||
|
// on the tunnel path no kernel AQM can see. Backends without queue
|
||||||
|
// introspection leave it false.
|
||||||
|
QueueCongested bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type EncReader func(
|
type EncReader func(
|
||||||
|
|||||||
+61
-1
@@ -8,8 +8,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -43,6 +45,13 @@ type StdConn struct {
|
|||||||
// each arriving datagram as a per-slot cmsg, and ListenOut passes
|
// each arriving datagram as a per-slot cmsg, and ListenOut passes
|
||||||
// the parsed value to the EncReader callback for RFC 6040 combine.
|
// the parsed value to the EncReader callback for RFC 6040 combine.
|
||||||
ecnRecvSupported bool
|
ecnRecvSupported bool
|
||||||
|
|
||||||
|
// ecnMarkThreshold holds tunnels.ecn_mark_threshold as float64 bits: the
|
||||||
|
// fraction of the socket receive buffer above which listenOutBatch flags
|
||||||
|
// the batch QueueCongested (decap then CE-marks ECT inner packets). Zero
|
||||||
|
// disables sampling entirely. Atomic because ReloadConfig may update it
|
||||||
|
// while the reader runs.
|
||||||
|
ecnMarkThreshold atomic.Uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListener(l *slog.Logger, ip netip.Addr, port int, multi bool, batch int) (Conn, error) {
|
func NewListener(l *slog.Logger, ip netip.Addr, port int, multi bool, batch int) (Conn, error) {
|
||||||
@@ -320,6 +329,24 @@ func (u *StdConn) ListenOut(r EncReader, flush func()) error {
|
|||||||
setMsgControllen(&msgs[i].Hdr, cmsgSpace)
|
setMsgControllen(&msgs[i].Hdr, cmsgSpace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AQM sample: one getsockopt per recvmmsg batch (skipped entirely at
|
||||||
|
// threshold 0). Sampled BEFORE the read: a single recvmmsg can drain
|
||||||
|
// more than the whole receive buffer (64 GRO superpackets ≈ 4MB), so
|
||||||
|
// post-read residue is ~always zero; the pre-read depth is the
|
||||||
|
// backlog that accumulated while the previous batch was processed —
|
||||||
|
// the actual standing-queue signal. Depth beyond the configured
|
||||||
|
// fraction of the receive buffer flags every packet in the batch so
|
||||||
|
// decap CE-marks ECT inner packets: the ECN substitute for the
|
||||||
|
// tail-drop this queue otherwise regulates with.
|
||||||
|
congested := false
|
||||||
|
if frac := math.Float64frombits(u.ecnMarkThreshold.Load()); frac > 0 {
|
||||||
|
var mi [unix.SK_MEMINFO_VARS]uint32
|
||||||
|
if err := u.getMemInfo(&mi); err == nil {
|
||||||
|
congested = float64(mi[unix.SK_MEMINFO_RMEM_ALLOC]) >= frac*float64(mi[unix.SK_MEMINFO_RCVBUF])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
n, err := u.recvmmsg(msgs)
|
n, err := u.recvmmsg(msgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, unix.EINTR) {
|
if errors.Is(err, unix.EINTR) {
|
||||||
@@ -340,7 +367,7 @@ func (u *StdConn) ListenOut(r EncReader, flush func()) error {
|
|||||||
segSize, outerECN = parseRecvCmsg(&msgs[i].Hdr, u.groSupported, u.ecnRecvSupported)
|
segSize, outerECN = parseRecvCmsg(&msgs[i].Hdr, u.groSupported, u.ecnRecvSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
deliverSegments(r, from, payload, segSize, RxMeta{OuterECN: outerECN})
|
deliverSegments(r, from, payload, segSize, RxMeta{OuterECN: outerECN, QueueCongested: congested})
|
||||||
}
|
}
|
||||||
|
|
||||||
flush()
|
flush()
|
||||||
@@ -495,6 +522,8 @@ func writeSockaddr(buf []byte, addr netip.AddrPort, isV4 bool) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *StdConn) ReloadConfig(c *config.C) {
|
func (u *StdConn) ReloadConfig(c *config.C) {
|
||||||
|
u.reloadECNMarkThreshold(c)
|
||||||
|
|
||||||
b := c.GetInt("listen.read_buffer", 0)
|
b := c.GetInt("listen.read_buffer", 0)
|
||||||
if b > 0 {
|
if b > 0 {
|
||||||
if err := u.SetRecvBuffer(b); err == nil {
|
if err := u.SetRecvBuffer(b); err == nil {
|
||||||
@@ -536,6 +565,37 @@ func (u *StdConn) ReloadConfig(c *config.C) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reloadECNMarkThreshold parses tunnels.ecn_mark_threshold: the fraction
|
||||||
|
// (0..1] of the receive buffer above which decap CE-marks ECT inner packets.
|
||||||
|
// 0 (the default) disables the AQM sampling. Reloadable.
|
||||||
|
func (u *StdConn) reloadECNMarkThreshold(c *config.C) {
|
||||||
|
var frac float64
|
||||||
|
switch v := c.Get("tunnels.ecn_mark_threshold").(type) {
|
||||||
|
case nil:
|
||||||
|
case float64:
|
||||||
|
frac = v
|
||||||
|
case int:
|
||||||
|
frac = float64(v)
|
||||||
|
case string:
|
||||||
|
f, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
u.l.Warn("tunnels.ecn_mark_threshold is not a number; disabling", "value", v)
|
||||||
|
} else {
|
||||||
|
frac = f
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
u.l.Warn("tunnels.ecn_mark_threshold is not a number; disabling", "value", v)
|
||||||
|
}
|
||||||
|
if frac < 0 || frac > 1 {
|
||||||
|
u.l.Warn("tunnels.ecn_mark_threshold must be within [0, 1]; disabling", "value", frac)
|
||||||
|
frac = 0
|
||||||
|
}
|
||||||
|
old := math.Float64frombits(u.ecnMarkThreshold.Swap(math.Float64bits(frac)))
|
||||||
|
if old != frac {
|
||||||
|
u.l.Info("tunnels.ecn_mark_threshold set", "fraction", frac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (u *StdConn) getMemInfo(meminfo *[unix.SK_MEMINFO_VARS]uint32) error {
|
func (u *StdConn) getMemInfo(meminfo *[unix.SK_MEMINFO_VARS]uint32) error {
|
||||||
var vallen uint32 = 4 * unix.SK_MEMINFO_VARS
|
var vallen uint32 = 4 * unix.SK_MEMINFO_VARS
|
||||||
_, _, err := unix.Syscall6(unix.SYS_GETSOCKOPT, uintptr(u.sysFd), uintptr(unix.SOL_SOCKET), uintptr(unix.SO_MEMINFO), uintptr(unsafe.Pointer(meminfo)), uintptr(unsafe.Pointer(&vallen)), 0)
|
_, _, err := unix.Syscall6(unix.SYS_GETSOCKOPT, uintptr(u.sysFd), uintptr(unix.SOL_SOCKET), uintptr(unix.SO_MEMINFO), uintptr(unsafe.Pointer(meminfo)), uintptr(unsafe.Pointer(&vallen)), 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user