Skip to content

Commit 3f946f4

Browse files
committed
[datapath] increase netlink receive buffer for neighbor subscription
Signed-off-by: Jared Ledvina <jared.ledvina@datadoghq.com>
1 parent 67dfc5a commit 3f946f4

7 files changed

Lines changed: 226 additions & 11 deletions

File tree

Documentation/cmdref/cilium-agent.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Documentation/cmdref/cilium-agent_hive.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Documentation/cmdref/cilium-agent_hive_dot-graph.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/datapath/linux/devices_controller.go

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ package linux
77

88
import (
99
"context"
10+
"errors"
1011
"fmt"
1112
"log/slog"
1213
"net"
1314
"net/netip"
1415
"slices"
1516
"strings"
17+
"sync/atomic"
1618

1719
"github.com/cilium/hive/cell"
1820
"github.com/cilium/statedb"
@@ -66,6 +68,8 @@ func (c DevicesConfig) Flags(flags *pflag.FlagSet) {
6668
flags.StringSlice(option.Devices, []string{}, "List of devices facing cluster/external network (used for BPF NodePort, BPF masquerading and host firewall); supports '+' as wildcard in device name, e.g. 'eth+'; support '!' to exclude devices, e.g. '!eth+' excludes any device with prefix 'eth'. Note '!' says nothing about which ones to include. A device must match other criteria to be selected; The filters are matched in order and whatever matched first wins.")
6769

6870
flags.Bool(option.ForceDeviceDetection, false, "Forces the auto-detection of devices, even if specific devices are explicitly listed")
71+
72+
flags.Int(option.NeighborNetlinkBufferSize, defaults.NeighborNetlinkBufferSize, "Size (in bytes) of the netlink socket receive buffer used to subscribe to neighbor updates; a larger buffer reduces the chance of ENOBUFS errors and subscription restarts under high neighbor churn")
6973
}
7074

7175
var (
@@ -92,7 +96,8 @@ type DevicesConfig struct {
9296
Devices []string
9397
// ForceDeviceDetection forces the auto-detection of devices,
9498
// even if user-specific devices are explicitly listed.
95-
ForceDeviceDetection bool
99+
ForceDeviceDetection bool
100+
NeighborNetlinkBufferSize int
96101
}
97102

98103
type devicesControllerParams struct {
@@ -141,7 +146,7 @@ func newDevicesController(lc cell.Lifecycle, p devicesControllerParams) (*device
141146
func (dc *devicesController) Start(startCtx cell.HookContext) error {
142147
if dc.params.NetlinkFuncs == nil {
143148
var err error
144-
dc.params.NetlinkFuncs, err = makeNetlinkFuncs()
149+
dc.params.NetlinkFuncs, err = makeNetlinkFuncs(dc.log, dc.params.Config.NeighborNetlinkBufferSize)
145150
if err != nil {
146151
return err
147152
}
@@ -741,9 +746,57 @@ type netlinkFuncs struct {
741746
NeighList func(linkIndex, family int) ([]netlink.Neigh, error)
742747
}
743748

749+
// neighSubscribeOptions normalizes negative sizes to 0: the underlying library
750+
// skips setsockopt only when the size is exactly 0, so a negative size would
751+
// otherwise reach setsockopt(SO_RCVBUF) and be silently clamped by the kernel.
752+
func neighSubscribeOptions(receiveBufferSize int, ns *vns.NsHandle, errorCallback func(error)) netlink.NeighSubscribeOptions {
753+
if receiveBufferSize < 0 {
754+
receiveBufferSize = 0
755+
}
756+
return netlink.NeighSubscribeOptions{
757+
ListExisting: false,
758+
ErrorCallback: errorCallback,
759+
Namespace: ns,
760+
ReceiveBufferSize: receiveBufferSize,
761+
ReceiveBufferForceSize: receiveBufferSize > 0,
762+
}
763+
}
764+
765+
// subscribeNeighWithBufferFallback forces the receive buffer via
766+
// SO_RCVBUFFORCE, which needs CAP_NET_ADMIN and otherwise fails with EPERM. On
767+
// EPERM it falls back once to the kernel default and latches forceFailed so
768+
// later restarts skip forcing: the vendored library orphans its netlink socket
769+
// on the setsockopt-failure path, so re-attempting every restart would leak
770+
// sockets.
771+
func subscribeNeighWithBufferFallback(
772+
subscribe func(netlink.NeighSubscribeOptions) error,
773+
bufferSize int,
774+
ns *vns.NsHandle,
775+
errorCallback func(error),
776+
forceFailed *atomic.Bool,
777+
log *slog.Logger,
778+
) error {
779+
forceBufferSize := bufferSize
780+
if forceFailed.Load() {
781+
forceBufferSize = 0
782+
}
783+
784+
err := subscribe(neighSubscribeOptions(forceBufferSize, ns, errorCallback))
785+
if err == nil || forceBufferSize <= 0 || !errors.Is(err, unix.EPERM) {
786+
return err
787+
}
788+
789+
forceFailed.Store(true)
790+
log.Warn("Failed to subscribe to neighbor updates with a forced netlink receive buffer (the agent likely lacks CAP_NET_ADMIN, which SO_RCVBUFFORCE requires); retrying without forcing it. Neighbor updates may be dropped more often under high neighbor churn.",
791+
logfields.Error, err,
792+
logfields.BufferSize, bufferSize,
793+
)
794+
return subscribe(neighSubscribeOptions(0, ns, errorCallback))
795+
}
796+
744797
// makeNetlinkFuncs returns a *netlinkFuncs containing netlink accessors to the
745798
// network namespace of the calling goroutine's OS thread.
746-
func makeNetlinkFuncs() (*netlinkFuncs, error) {
799+
func makeNetlinkFuncs(log *slog.Logger, neighborReceiveBufferSize int) (*netlinkFuncs, error) {
747800
netlinkHandle, err := safenetlink.NewHandle(&safenetlink.HandleConfig{NLFamilies: []int{unix.NETLINK_ROUTE}})
748801
if err != nil {
749802
return nil, fmt.Errorf("creating netlink handle: %w", err)
@@ -754,6 +807,9 @@ func makeNetlinkFuncs() (*netlinkFuncs, error) {
754807
return nil, fmt.Errorf("getting current netns: %w", err)
755808
}
756809

810+
// Latches after an EPERM so restarts stop re-attempting the forced buffer.
811+
var neighForceBufferFailed atomic.Bool
812+
757813
return &netlinkFuncs{
758814
RouteSubscribe: func(ch chan<- netlink.RouteUpdate, done <-chan struct{}, errorCallback func(error)) error {
759815
h := vns.NsHandle(cur.FD())
@@ -784,12 +840,11 @@ func makeNetlinkFuncs() (*netlinkFuncs, error) {
784840
},
785841
NeighSubscribe: func(ch chan<- netlink.NeighUpdate, done <-chan struct{}, errorCallback func(error)) error {
786842
h := vns.NsHandle(cur.FD())
787-
return safenetlink.NeighSubscribeWithOptions(ch, done,
788-
netlink.NeighSubscribeOptions{
789-
ListExisting: false,
790-
ErrorCallback: errorCallback,
791-
Namespace: &h,
792-
})
843+
return subscribeNeighWithBufferFallback(
844+
func(opts netlink.NeighSubscribeOptions) error {
845+
return safenetlink.NeighSubscribeWithOptions(ch, done, opts)
846+
},
847+
neighborReceiveBufferSize, &h, errorCallback, &neighForceBufferFailed, log)
793848
},
794849
LinkList: func() ([]netlink.Link, error) {
795850
return safenetlink.WithRetryResult(func() ([]netlink.Link, error) {

pkg/datapath/linux/devices_controller_test.go

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import (
3131
"golang.org/x/sys/unix"
3232

3333
"github.com/cilium/cilium/pkg/datapath/tables"
34+
"github.com/cilium/cilium/pkg/defaults"
3435
"github.com/cilium/cilium/pkg/hive"
36+
"github.com/cilium/cilium/pkg/option"
3537
"github.com/cilium/cilium/pkg/testutils"
3638
)
3739

@@ -62,9 +64,9 @@ func TestPrivilegedDevicesControllerScript(t *testing.T) {
6264

6365
h := hive.New(
6466
DevicesControllerCell,
65-
cell.Provide(func() (*netlinkFuncs, error) {
67+
cell.Provide(func(log *slog.Logger) (*netlinkFuncs, error) {
6668
// Provide the normal netlink interface, restricted to the test network namespace.
67-
return makeNetlinkFuncs()
69+
return makeNetlinkFuncs(log, defaults.NeighborNetlinkBufferSize)
6870
}),
6971
)
7072

@@ -97,6 +99,153 @@ func TestPrivilegedDevicesControllerScript(t *testing.T) {
9799
"testdata/device-*.txtar")
98100
}
99101

102+
func TestNeighSubscribeOptions(t *testing.T) {
103+
errorCallback := func(error) {}
104+
ns := netns.None()
105+
106+
t.Run("configured size is applied and forced", func(t *testing.T) {
107+
opts := neighSubscribeOptions(defaults.NeighborNetlinkBufferSize, &ns, errorCallback)
108+
assert.Equal(t, defaults.NeighborNetlinkBufferSize, opts.ReceiveBufferSize)
109+
assert.True(t, opts.ReceiveBufferForceSize, "buffer size should be forced past net.core.rmem_max")
110+
assert.Same(t, &ns, opts.Namespace)
111+
assert.False(t, opts.ListExisting)
112+
})
113+
114+
t.Run("zero size leaves kernel default in place", func(t *testing.T) {
115+
opts := neighSubscribeOptions(0, &ns, errorCallback)
116+
assert.Equal(t, 0, opts.ReceiveBufferSize)
117+
assert.False(t, opts.ReceiveBufferForceSize)
118+
})
119+
120+
t.Run("negative size is normalized to kernel default", func(t *testing.T) {
121+
opts := neighSubscribeOptions(-1, &ns, errorCallback)
122+
assert.Equal(t, 0, opts.ReceiveBufferSize)
123+
assert.False(t, opts.ReceiveBufferForceSize)
124+
})
125+
}
126+
127+
func TestSubscribeNeighWithBufferFallback(t *testing.T) {
128+
log := hivetest.Logger(t)
129+
ns := netns.None()
130+
cb := func(error) {}
131+
132+
type call struct {
133+
size int
134+
force bool
135+
}
136+
137+
newFake := func(errs ...error) (*[]call, func(netlink.NeighSubscribeOptions) error) {
138+
calls := &[]call{}
139+
i := 0
140+
return calls, func(opts netlink.NeighSubscribeOptions) error {
141+
*calls = append(*calls, call{opts.ReceiveBufferSize, opts.ReceiveBufferForceSize})
142+
var err error
143+
if i < len(errs) {
144+
err = errs[i]
145+
}
146+
i++
147+
return err
148+
}
149+
}
150+
151+
t.Run("success on forced attempt does not latch", func(t *testing.T) {
152+
var flag atomic.Bool
153+
calls, sub := newFake(nil)
154+
require.NoError(t, subscribeNeighWithBufferFallback(sub, 4096, &ns, cb, &flag, log))
155+
require.Len(t, *calls, 1)
156+
assert.Equal(t, call{4096, true}, (*calls)[0])
157+
assert.False(t, flag.Load())
158+
})
159+
160+
t.Run("EPERM falls back to kernel default and latches", func(t *testing.T) {
161+
var flag atomic.Bool
162+
calls, sub := newFake(unix.EPERM, nil)
163+
require.NoError(t, subscribeNeighWithBufferFallback(sub, 4096, &ns, cb, &flag, log))
164+
require.Len(t, *calls, 2)
165+
assert.Equal(t, call{4096, true}, (*calls)[0])
166+
assert.Equal(t, call{0, false}, (*calls)[1], "fallback must not force the buffer")
167+
assert.True(t, flag.Load(), "EPERM should latch so future restarts skip forcing")
168+
})
169+
170+
t.Run("latched flag skips forcing without falling back", func(t *testing.T) {
171+
var flag atomic.Bool
172+
flag.Store(true)
173+
calls, sub := newFake(nil)
174+
require.NoError(t, subscribeNeighWithBufferFallback(sub, 4096, &ns, cb, &flag, log))
175+
require.Len(t, *calls, 1)
176+
assert.Equal(t, call{0, false}, (*calls)[0])
177+
})
178+
179+
t.Run("non-EPERM error propagates without fallback or latch", func(t *testing.T) {
180+
var flag atomic.Bool
181+
wantErr := errors.New("socket setup failed")
182+
calls, sub := newFake(wantErr)
183+
err := subscribeNeighWithBufferFallback(sub, 4096, &ns, cb, &flag, log)
184+
require.ErrorIs(t, err, wantErr)
185+
require.Len(t, *calls, 1, "must not fall back on a non-EPERM error")
186+
assert.False(t, flag.Load(), "must not latch on a transient/unrelated error")
187+
})
188+
189+
t.Run("fallback error propagates and still latches", func(t *testing.T) {
190+
var flag atomic.Bool
191+
fallbackErr := errors.New("still broken")
192+
calls, sub := newFake(unix.EPERM, fallbackErr)
193+
err := subscribeNeighWithBufferFallback(sub, 4096, &ns, cb, &flag, log)
194+
require.ErrorIs(t, err, fallbackErr)
195+
require.Len(t, *calls, 2)
196+
assert.True(t, flag.Load())
197+
})
198+
199+
t.Run("latch persists across restarts: force once, then never again", func(t *testing.T) {
200+
var flag atomic.Bool
201+
calls, sub := newFake(unix.EPERM, nil, nil)
202+
203+
require.NoError(t, subscribeNeighWithBufferFallback(sub, 4096, &ns, cb, &flag, log))
204+
require.NoError(t, subscribeNeighWithBufferFallback(sub, 4096, &ns, cb, &flag, log))
205+
206+
require.Len(t, *calls, 3, "first call forces+falls back (2), second skips forcing (1)")
207+
assert.Equal(t, call{4096, true}, (*calls)[0])
208+
assert.Equal(t, call{0, false}, (*calls)[1])
209+
assert.Equal(t, call{0, false}, (*calls)[2], "second restart must not re-attempt forcing")
210+
})
211+
212+
t.Run("disabled buffer returns the error without forcing or latching", func(t *testing.T) {
213+
var flag atomic.Bool
214+
wantErr := unix.EPERM // even EPERM must not latch when forcing wasn't requested
215+
calls, sub := newFake(wantErr)
216+
err := subscribeNeighWithBufferFallback(sub, 0, &ns, cb, &flag, log)
217+
require.ErrorIs(t, err, wantErr)
218+
require.Len(t, *calls, 1)
219+
assert.Equal(t, call{0, false}, (*calls)[0])
220+
assert.False(t, flag.Load())
221+
})
222+
}
223+
224+
func TestNeighborNetlinkBufferSize_Config(t *testing.T) {
225+
newConfig := func(t *testing.T, args ...string) DevicesConfig {
226+
var got DevicesConfig
227+
h := hive.New(
228+
cell.Config(DevicesConfig{}),
229+
cell.Invoke(func(cfg DevicesConfig) { got = cfg }),
230+
)
231+
flags := pflag.NewFlagSet("", pflag.ContinueOnError)
232+
h.RegisterFlags(flags)
233+
require.NoError(t, flags.Parse(args))
234+
require.NoError(t, h.Populate(hivetest.Logger(t)))
235+
return got
236+
}
237+
238+
t.Run("default is applied", func(t *testing.T) {
239+
cfg := newConfig(t)
240+
assert.Equal(t, defaults.NeighborNetlinkBufferSize, cfg.NeighborNetlinkBufferSize)
241+
})
242+
243+
t.Run("flag overrides default", func(t *testing.T) {
244+
cfg := newConfig(t, "--"+option.NeighborNetlinkBufferSize+"=1024")
245+
assert.Equal(t, 1024, cfg.NeighborNetlinkBufferSize)
246+
})
247+
}
248+
100249
func TestDevicesController_Restarts(t *testing.T) {
101250
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
102251
defer cancel()

pkg/defaults/defaults.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const (
2020
)
2121

2222
const (
23+
// Kernel default overflows under high neighbor churn, causing
24+
// ENOBUFS-triggered subscription restarts; 4MiB absorbs bursts.
25+
NeighborNetlinkBufferSize = 4 << 20 // 4MiB
26+
2327
// ClusterHealthPort is the default value for option.ClusterHealthPort
2428
ClusterHealthPort = 4240
2529

pkg/option/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ const (
112112
// Forces the auto-detection of devices, even if specific devices are explicitly listed
113113
ForceDeviceDetection = "force-device-detection"
114114

115+
// NeighborNetlinkBufferSize sets the size in bytes of the netlink socket
116+
// receive buffer used by the devices controller's neighbor subscription
117+
NeighborNetlinkBufferSize = "neighbor-netlink-buffer-size"
118+
115119
// DirectRoutingDevice is the name of a device used to connect nodes in
116120
// direct routing mode (only required by BPF NodePort)
117121
DirectRoutingDevice = "direct-routing-device"

0 commit comments

Comments
 (0)