Skip to content

FP-5: dump/record/netns caps, socket-count gate, reconcile limits, per-owner quota - #301

Merged
randomizedcoder merged 20 commits into
mainfrom
sec/fp5-dump-netns-caps
Nov 14, 2026
Merged

randomizedcoder merged 20 commits into
mainfrom
sec/fp5-dump-netns-caps

Conversation

@randomizedcoder

@randomizedcoder randomizedcoder commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

FP-5 of the focused hardening plan (#291), Phase F2: what one workload can cost the collector is bounded and counted.

Per-namespace caps

  • -maxDumpBytes (default 256 MiB, MAX_DUMP_BYTES): bytes of one namespace's dump per poll. Packets past the cap are drained undecoded (handOff, shared by both receivers); NLMSG_DONE still ends the dump.
  • -maxRecordsPerNetns (default 262144, MAX_RECORDS_PER_NETNS): records past the cap are dropped before decode. A per-packet budget from the receiver's existing header walk, so decoders compare a local counter, no shared atomics.
  • -maxNetns (default 4096, MAX_NETNS): a tracked namespace keeps its slot until it dies; later ones wait first come, first served. Pid-rank eviction (630b117) was backed out: pids wrap, so a tenant could evict monitored workloads.

Socket-count gate (-maxRecordsPerPoll, default 1,048,576)

  • Before each poll, read every namespace's sockstat (TCP inuse + tw) through an fd opened in the namespace next to its netlink socket. pread, or with -ioUring batches of -ioUringSockstatBatchSize (64) per io_uring_enter with IOSQE_ASYNC and -ioUringSockstatWorkers (4) io-wq threads; procfs reads can't be non-blocking (io_uring/rw.c:39-52).
  • Over the budget, a max-min fair share degrades only heavy namespaces: first drop TIME_WAIT/SYN_RECV from idiag_states, then dump one of K port shards per poll (rotated by the gate's own poll count) (inet_diag bytecode, audited; no CAP_NET_ADMIN needed).
  • Shard key is max(sport, dport) over the namespace's own ip_local_port_range (read through a per-namespace fd), so a busy listener spreads over every shard; 36-byte bytecode. The earlier (sport∈R) OR (dport∈R) put every accepted connection in one shard (82a598b).
  • Sockstat can't see request sockets (SYN_RECV): a SYN flood is still bounded by -maxRecordsPerNetns.

Reconcile limits and per-owner quota

  • -reconcileTimeout (1s) bounds the /proc scan; a timed-out scan deletes nothing. One scan takes in at most 4 × -maxNetns new namespaces but keeps reporting tracked and waiting ones, so deletes stay correct. -maxNetnsAddsPerReconcile (256).
  • -maxNetnsPerOwner (64). Owner = cgroup path cut at the runtime's component, accepted only where runtimes put it; user.slice → the user's slice; else the first two components. Over-quota owners get no queue place.
  • Names and owners (cgroup reads) only for namespaces being added; owners oldest waiter first, ≥ 64 per reconcile.

Metrics: xtcp_dump_dropped_total{reason}, xtcp_netns_dropped_total{reason="max_netns"|"owner_quota"|"add_rate"}, xtcp_poll_sockets, xtcp_poll_degraded_total{level}, xtcp_reconcile_incomplete_total{reason}. 0 turns each off; all startup-only (preserveCaps). RLIMIT_NOFILE warning at startup (4 fds per namespace: socket, origNs snapshot, sockstat, port range).

Tradeoffs to review

  • A timed-out scan deletes nothing: dead namespaces keep slots while scans keep timing out (needs ~10⁵+ live pids). Deleting on "last-seen pid left" instead lets churn during a flood evict live monitored namespaces. TODO: age out after N timed-out reconciles.
  • The scan cap is owner-agnostic: a flood earlier in /proc order can delay discovery of new namespaces (tracked ones are unaffected).
  • Runtimes that don't name cgroups by container id: owner is the first two path components, so a process there can mint owners.

Kernel analysis: docs/kernel/inet-diag-all-netns.md — every per-namespace dump walks the host-wide TCP table (tcp_diag.c:325, net_eq skips at :350/:416/:501); where an all-namespace dump would go (Linux 7.3.0 @ 46fec1c3029b).

Also found: giouring v1.1.2 RegisterIOWQMaxWorkers([]uint) passes 64-bit values where the kernel reads __u32[2]; worked around here, fork fix TODO. xtcp2 requests only AF_INET, so IPv6 sockets are never dumped (TODO, own PR).

Tests (table-driven, P/N/B/C): sockstat parser and both readers on real fds (batch sizes, bad fds, out-of-order CQEs); EnqueueReadAt/IOWQ; fair share and plans with simulated 500k counts; shard bytecode over 500k pairs through xtcpnl's kernel model; the poll path end to end with the captured request run through that model; scan limits, owners (spoofed sub-cgroups), reconcile limits, quota, queue bound; caps through both receivers; Set preserving the caps; env overrides.

Benchmarks (24-core host):

Review fixes (109ff10): shard rotation counted poller events, not polls, so it could pin one shard; fd check missed the origNs fd. The thread-churn test (pre-existing on main) hit its 150-thread cap at GOMAXPROCS ≥ 64; now bounded and pinned.

VM: microvm-x86_64-lifecycle passes at 9af699d and 109ff10 (20 checks, drops=0, namespace add/delete, docker and anonymous namespaces).

Socket-scale VM (microvm-x86_64-socket-scale, 82a598b): OVERALL_PASS.

  • Shared walk: a small namespace's ss takes 37 ms beside an empty neighbour, 143 ms beside 500k sockets on the shared table, 54 ms when the neighbour has its own (tcp_child_ehash_entries).
  • Gate on (500k-socket namespace, budget 200k): sampled on 31 of 31 polls, 0 records dropped, RSS 51 → 48 MB, 15 threads. Gate off: 31 of 31 polls hit the per-namespace cap.
  • SYN flood: sockstat misses SYN_RECV; the per-namespace cap holds.
  • Namespace flood: one owner gets exactly 64 tracked (owner_quota drops counted), another owner's namespace still added; no reconcile timeouts; 141 threads, 46 MB RSS.
  • sockfill now disables Go's default keepalive: ~33k probes/s at 500k connections reset idle ones during full dumps (test tool only).

🤖 Generated with Claude Code

randomizedcoder and others added 2 commits November 12, 2026 19:28
…ces tracked

FP-5 (C1, C2) of the focused hardening plan. What one workload can cost
the collector is bounded and counted.

- -maxDumpBytes (256 MiB): packets of one namespace's dump past the cap
  are drained undecoded. Both receivers go through handOff, and the
  packet is still numbered, so NLMSG_DONE ends the dump and the done
  signal is unaffected.
- -maxRecordsPerNetns (262144): the receiver counts each packet's
  inet_diag records in the header walk that finds NLMSG_DONE (packetScan)
  and gives each packet a budget (keep); a packet with none left is
  drained. Decoders compare a local counter, with no shared atomics.
- -maxNetns (4096): reconcile admits new namespaces up to the cap, lowest
  pid first, xtcp2's own always (admitNamespaces).
- xtcp_dump_dropped_total{reason}, xtcp_netns_dropped_total, volume
  counters in xtcp_counts, and one log line per truncated dump.
- 0 turns a cap off. ConfigService.Set keeps the running caps
  (preserveCaps).
- XtcpConfig fields 25, 26 and 42; MAX_DUMP_BYTES, MAX_RECORDS_PER_NETNS
  and MAX_NETNS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
randomizedcoder added a commit that referenced this pull request Nov 13, 2026
randomizedcoder and others added 12 commits November 12, 2026 19:40
Admission was first come, first served across reconciles: a workload
that created thousands of namespaces could fill the cap, and every
later namespace went unmonitored. admitNamespaces now ranks tracked and
untracked live namespaces together (self first, then pid, then inode)
and keeps the first -maxNetns. A newer namespace stays out; an older
one, or a bind-mounted one (pid 0), displaces the highest-pid tracked
namespace. The ranking depends only on the live set, so the tracked set
converges instead of churning. trackedLive counts xtcp2's own slot even
when the scan misses it. Displaced namespaces count in
xtcp_netns_dropped_total; the docs say to alert on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Ranking by pid (630b117) let a tenant evict monitored workloads: pids
wrap, so one that burns through pids gets low ones, and a namespace's
scanned pid changes when its first process exits. Eviction by any key a
tenant can influence is worse than the problem it solved, so a tracked
namespace now keeps its slot until it dies. Namespaces past the cap wait
in nsWaiting, ordered by the reconcile that first saw them (a sequence
xtcp2 assigns), then pid; a freed slot goes to the longest waiter, and
a recreated namespace joins at the back. A burst that fills the cap
still keeps later namespaces out: that is counted in
xtcp_netns_dropped_total, which the docs say to alert on, and per-owner
quotas are the follow-up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…wner quota

-maxRecordsPerPoll, -ioUringSockstatBatchSize, -ioUringSockstatWorkers,
-reconcileTimeout, -maxNetnsPerOwner, -maxNetnsAddsPerReconcile (+ env).
Startup-only: preserveCaps carries them across ConfigService.Set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Positioned reads (OpReadAt, optional IOSQE_ASYNC) for batching per-netns
procfs reads. SetIOWQMaxWorkers writes the kernel's __u32[2] through a
uint32 view: giouring v1.1.2 passes []uint (64-bit), which the kernel
would misread (io_uring/register.c io_register_iowq_max_workers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Each namespace's sockstat fd is opened in the namespace next to its
netlink socket and re-read at offset 0 before every poll (pread, or an
io_uring batch of -ioUringSockstatBatchSize with IOSQE_ASYNC and capped
io-wq workers). Over the budget, a max-min fair share degrades only the
heavy namespaces: first drop TIME_WAIT/SYN_RECV, then sample one of K port
shards per poll with audited inet_diag bytecode. xtcp_poll_sockets,
xtcp_poll_degraded_total{level}; netns_dropped_total gains reason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… ephemeral side

- statHandle: the poller reads each sockstat fd under a read lock and the
  namespace teardown closes it under the write lock, so a poll never
  reads an fd number after close (and reuse).
- A sockstat ring whose submit/wait fails is never closed and its buffers
  stay reachable (io-wq reads may still write into them); EINTR retried.
- Shard key is the ephemeral side (sport if >= 32768, else dport) and the
  ranges split 32768-65535: an exact partition, and a busy listener's
  sockets spread over every shard instead of all landing in one.
- Planner, bytecode, request and end-to-end gate tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…and add rate

- Scanner.ScanLimited: -reconcileTimeout deadline (checked every 256
  pids) and a 4 x -maxNetns namespace cap, shared with the bind-mount
  pass. After an incomplete scan a tracked namespace is deleted only when
  confirmed gone (its pid left it / its bind mount went), so a flood can
  neither get monitored namespaces deleted nor keep dead ones' slots.
- -maxNetnsPerOwner: owner = outermost container id of the pid's cgroup
  (tenants can't leave it); bind-mount-only namespaces exempt; owners of
  waiting candidates cached; reads stop at the deadline.
- -maxNetnsAddsPerReconcile bounds adds per reconcile.
- Names (cgroup reads) resolved only for admitted namespaces.
- xtcp_reconcile_incomplete_total{reason}; netns_dropped_total{reason}.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…by runtime cgroup

- Past the scan cap the scan still walks every pid and reports tracked
  and waiting namespaces (ScanLimits.Keep); the cap only limits new ones.
  A capped scan therefore still deletes gone namespaces; only a timed-out
  scan deletes nothing, and its waiters keep their queue places.
- Owner is the cgroup v2 path cut at the runtime's container component
  (docker-/cri-containerd-/crio-/libpod-<id>.scope, <id> under docker or
  a kubepods/pod slice), else its first two components; not any 64-hex
  run, which a tenant could plant under a runtime without ids.
- An unreadable cgroup (pid exited) defers the candidate instead of
  filing it under the "" owner.
- Owners resolve oldest waiter first, at least 64 per reconcile even past
  the deadline, so a slow scan can't stop admission.
- RLIMIT_NOFILE startup warning; gate benchmarks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… timeout liveness; docs

- Owner: a runtime component counts only where runtimes put it
  (system.slice/machine.slice scope, /docker/<id>, under kubelet-only
  kubepods parents); everything under user.slice is the user's slice.
  A delegated cgroup naming a sub-cgroup like a container is one owner.
- Waiting queue capped at 4 x -maxNetns, so Keep can't defeat the scan cap.
- After a timed-out scan, an unreached tracked namespace is deleted when
  the pid it was last seen through has left it (pids refreshed each scan),
  so a flood that times out every scan can't hold dead namespaces' slots.
- Docs: socket-count gate, reconcile limits and owner quota; CHANGELOG;
  docs/kernel/inet-diag-all-netns.md (kernel analysis for an all-netns
  inet_diag dump, Linux 7.3.0 @ 46fec1c3029b).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ueue place

Deleting on a last-seen pid leaving lets ordinary churn during a flood
evict live monitored namespaces; keep the approved rule (a timed-out scan
deletes nothing) and document the slot cost. A namespace whose owner is
at quota isn't queued, so one owner can't fill the waiting queue or ride
Keep past the scan cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
nsOwners was added just above packetBufferPool and the record pools, shifting
the fields 4-8 concurrent decoders hit: +5-10% on NetlinkerDump at 4/8
netlinkers (both receivers, reproduced on a quiet host). Moved to the tail of
XTCP: back within noise of the #301 head (geomean -1.5%, 8 interleaved runs).

Lint: exhaustive switches, loop condition, misspelling, label constants,
unchecked errors in tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@randomizedcoder randomizedcoder changed the title feat(xtcp): FP-5 per-namespace dump, record and namespace caps FP-5: dump/record/netns caps, socket-count gate, reconcile limits, per-owner quota Nov 13, 2026
randomizedcoder and others added 6 commits November 13, 2026 15:36
- Shard rotation used pollingLoops, which advances on every poller event
  (ticks, poll requests, one netlinkerDone per namespace). A poll stepping
  it by a multiple of K dumped the same shard forever. The gate now counts
  its own polls. TestPollGateShardRotation fails on the old code.
- Each namespace goroutine keeps its origNs snapshot open for its life, so
  a tracked namespace holds 3 fds with the gate (2 without), not 2 (1).
  The RLIMIT_NOFILE warning now uses that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
TestNamespaceChurn_threadBoundedUnderRestoreFailure started 400 goroutines
that each lock an M and sleep. With GOMAXPROCS >= 64 they ran together and
passed the test's 150-thread cap (reproduced on main 9949ee6 at
GOMAXPROCS=64 and 256). Run at most 16 at once and pin GOMAXPROCS to 8;
leaked Ms still pile up over all 400 iterations. Worst delta over 100 runs
at GOMAXPROCS 1/64/256 is now 5-6 (was up to 74, or the cap).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
New microvm-x86_64-socket-scale (8 GiB, 4 vCPU) runs four phases on boot
and prints SOCKSCALE_* lines; the host runner exits 0 on OVERALL_PASS:

- walk: a small namespace's ss time as another grows to 500k sockets, on
  the shared TCP table and with tcp_child_ehash_entries
- gate: xtcp2 with -maxRecordsPerPoll 200k against a 500k-socket namespace
  (sampled, no drops, RSS steady), gate on vs off
- synflood: hping3 against a non-accepting listener; -maxRecordsPerNetns
  bounds the dump that sockstat can't see
- nsflood: a docker-<id> scope creates 2000 namespaces; the owner quota
  holds it at 64 and another owner's namespace is still admitted

tools/sockfill holds N loopback pairs (or a never-accepting listener) and
resets them on exit so no TIME_WAIT is left behind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The socket-scale VM showed sampled polls still hitting the record cap. The
shard key was sport if >= 32768, else dport, with shards splitting
32768..65535. In a namespace whose ip_local_port_range starts lower (the
VM's 10000-65000, a common tuning), clients below 32768 keyed on their
listener's port, so shard 0 held ~300k of 500k sockets, over the 262,144
per-namespace cap.

- key is max(sport, dport), the ephemeral side whichever end the socket
  is; bytecode: S_LE hi AND D_LE hi AND (S_GE lo OR D_GE lo)
- shards split the namespace's own ip_local_port_range, read through an
  fd opened in the namespace (a /proc/sys/net fd stays bound to the
  opener's namespace), only for sampled namespaces; the default range if
  it can't be read
- four fds per tracked namespace with the gate on (RLIMIT_NOFILE check)
- tests: parser, handle, partition and spread over default, wide, full
  and tiny ranges, including the VM's 250k loopback pairs
- socket-scale script: count tracked namespaces from the instance
  counters (xtcp_gauge refreshes once a minute), require exactly the
  64-namespace quota, print sockfill/ss/dmesg state with the gate lines
- kernel doc: the VM's shared-walk numbers; a per-namespace table shares
  lhash2/bhash2 (inet_pernet_hashinfo_alloc), so the all-namespace loop
  needs only each own ehash

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Go enables keepalive by default (15s idle, 15s interval, 9 probes). At
500k connections that is ~33k probes/s; probes lost while the host runs
a full dump reset idle connections to CLOSE, so the gate-off phase of the
socket-scale VM lost 211k sockets mid-measurement. TestFill now checks
SO_KEEPALIVE is off on both ends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
mainFlagsFieldCountCst: this branch's seven cap flags plus main's two
treatment flags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@randomizedcoder
randomizedcoder merged commit e360b6f into main Nov 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant