Repository navigation
FP-5: dump/record/netns caps, socket-count gate, reconcile limits, per-owner quota - #301
Merged
Merged
Conversation
…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
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
- 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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)inuse+tw) through an fd opened in the namespace next to its netlink socket.pread, or with-ioUringbatches of-ioUringSockstatBatchSize(64) perio_uring_enterwithIOSQE_ASYNCand-ioUringSockstatWorkers(4) io-wq threads; procfs reads can't be non-blocking (io_uring/rw.c:39-52).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).max(sport, dport)over the namespace's ownip_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).-maxRecordsPerNetns.Reconcile limits and per-owner quota
-reconcileTimeout(1s) bounds the/procscan; a timed-out scan deletes nothing. One scan takes in at most 4 ×-maxNetnsnew 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.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}.0turns each off; all startup-only (preserveCaps). RLIMIT_NOFILE warning at startup (4 fds per namespace: socket, origNs snapshot, sockstat, port range).Tradeoffs to review
/procorder can delay discovery of new namespaces (tracked ones are unaffected).Kernel analysis:
docs/kernel/inet-diag-all-netns.md— every per-namespace dump walks the host-wide TCP table (tcp_diag.c:325,net_eqskips 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 onlyAF_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 throughxtcpnl'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;Setpreserving the caps; env overrides.Benchmarks (24-core host):
BenchmarkNetlinkerDump, 8 interleaved runs): geomean −1.5%, no row slower. A field first added above the pools shifted their cache lines and cost +5–10% at 4–8 decoders; moved to the struct tail.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-lifecyclepasses 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.sstakes 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).owner_quotadrops counted), another owner's namespace still added; no reconcile timeouts; 141 threads, 46 MB RSS.sockfillnow disables Go's default keepalive: ~33k probes/s at 500k connections reset idle ones during full dumps (test tool only).🤖 Generated with Claude Code