Skip to content

perf/fix(network): cut policy apply latency and harden TAP create under density - #1318

Merged
zhouxianping merged 1 commit into
TencentCloud:masterfrom
FakeLearne:refactor-network
Aug 19, 2026
Merged

perf/fix(network): cut policy apply latency and harden TAP create under density#1318
zhouxianping merged 1 commit into
TencentCloud:masterfrom
FakeLearne:refactor-network

Conversation

@FakeLearne

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to the network-agent → Cubelet embed refactor (#1285). This PR tightens create-path latency when network policy is enabled, and hardens TAP allocation under high-concurrency density load.

  • CubeVS policy hot path: cache HashOfMaps inner maps and avoid per-call outer Lookups that trigger synchronize_rcu (tens of ms). Delete policy maps on TAP destroy, and limit the DNS reaper to live ifindices so create latency with policy stays near the no-policy baseline.
  • TAP create under density: retry dump-style netlink reads on ErrDumpInterrupted / EINTR, and stop doing a full LinkList on every pool-miss create — cleanupConflictingTap only needs a by-name lookup. This stops EnsureNetwork failing with "results may be incomplete or inconsistent" under concurrent create pressure.
  • Cleanup: remove an unused Cubelet helper left over from the above changes.
  • cube-bench: extend network-policy / warmup coverage so the latency and density paths are easier to exercise in CI/local benches.

// 12 CIDRs + 12 domains (incl. 2 wildcards) — medium allowlist for create-path
// CubeVS allow_out_v2 / dns_allow map updates. Hosts are stable fakes; the
// bench does not require them to resolve or be reachable.
return []string{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "rules" payload doesn't match its own test (and the README/PR description), so go test ./examples/cube-bench fails on this branch.

rulesAllowOut() returns only "1.1.1.1/32" — every other CIDR/domain is commented out — and rulesL7Rules() returns a single rule whose Action field is commented out. So networkFingerprint("rules") = {AllowOut:1, Rules:1, InjectRules:0}.

But TestBuildCreateRequestBodyRulesShape (main_test.go:156) hard-asserts:

if fp.AllowOut != 24 || fp.Rules != 6 || fp.InjectRules != 2 {
    t.Fatalf("unexpected fingerprint: %+v", fp)
}

1 != 24 → the test fails. The README and the PR description also promise "~24 allowOut + 6 L7 rules (2 with inject)".

Two options:

  • Uncomment the intended entries (24 allowOut / 6 rules / 2 inject) so the implementation matches the documented intent, or
  • if the reduced payload is deliberate, update the test assertion and README to the actual 1/1/0 fingerprint.

Note that with Action commented out, the single allow_llm_chat rule serializes as "action":{"allow":false} — a deny, with no inject — so the bench would not be exercising the allow-with-inject path described in the README either.

}
}
}
go func(keep map[uint32]struct{}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOCTOU: the keep snapshot is taken here, but the GC goroutine runs asynchronously and overlaps warmupTapPoolBackground and on-demand EnsureNetwork creates.

Every TAP created after this snapshot (pool warmup fills, pool-miss creates) has an ifindex that is not in keep. gcStaleOuterKeys iterates the HashOfMaps and deletes any key whose ifindex is absent — so a freshly warmed/created TAP's deny_out/allow_out/dns_allow outer keys can be deleted by this background GC while the TAP is still live. The comment at line 71-73 acknowledges "Warmup only adds new ifindexes later" but the GC is exactly when those late keys are at risk.

The concrete impact: a Ready pool TAP whose deny_out outer key was removed by the GC can be assigned to a sandbox that never receives the private-range deny defaults — the normal (non-replace) applyNetPolicy path does not re-add alwaysDeniedSandboxEntries (only effectiveDenyOutEntriesForReplace does). The sandbox would then be missing deny rules for 10.0.0.0/8, 169.254.0.0/16, etc.

The window is largest exactly in the pathological case this PR targets (thousands of stale inners → slow Iterate/Delete → long overlap with warmup). Consider computing keep inside the goroutine after warmup completes, or skipping the GC when the tap listing / keep-set is known-incomplete.

if err != nil {
return nil, err
}
if err := outerMap.Put(&ifindex, created); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrency hazard in the create path: two goroutines that both miss the cache for the same (mapName, ifindex) and both reach newInner() can leave the cache and the outer map pointing at different inner maps.

Interleaving:

  • G1: LookupErrKeyNotExist, G2: LookupErrKeyNotExist
  • G1: created1 = newInner(); outer.Put(ifindex, created1)
  • G2: created2 = newInner(); outer.Put(ifindex, created2) → overwrites G1's entry in the outer map
  • G1: LoadOrStore(key, created1) → stores, returns created1
  • G2: LoadOrStore(key, created2)loaded=true, closes created2, returns created1

End state: the process-wide cache holds created1, while the kernel's HashOfMaps entry (what the eBPF programs see) points to created2. Every subsequent policy write through the cached created1 is invisible to the dataplane, and the divergence persists until releaseCachedInner (TAP destroy). The pre-refactor code re-read the outer map on each call, so it self-healed; the cache removes that.

Worth serializing creation per (mapName, ifindex) (e.g., a per-key mutex around the lookup→create→put), or re-reading the outer value after a LoadOrStore miss and reconciling which inner is actually live in the outer map.

@cubesandboxbot

cubesandboxbot Bot commented Aug 11, 2026

Copy link
Copy Markdown

AI-Generated Code Review — PR #1318: perf/fix(network): cut policy apply latency and harden TAP create under density

This is an automated review produced by an AI code reviewer. It is a static analysis of the diff against the base branch; it has not been run against the PR head tree, and no tests were executed.

Overall: This is a well-structured performance PR. The core idea — caching HashOfMaps inner-map FDs process-wide in policyInnerMaps to avoid per-call outer.Lookup (which triggers synchronize_rcu on HashOfMaps) — is sound for Cubelet's single-writer-per-ifindex lifecycle. The WithDumpRetry wrapper, the startup stale-policy GC, and the destroy-path policy cleanup are sensible and come with real unit tests. The issues below are mostly about test isolation, an unguarded create race, and a couple of claims in comments that are stronger than the code guarantees.


1. Medium — Process-global inner-map cache leaks across tests; clearPolicyInnerCacheForTest is dead code

policyInnerMaps is package-global and keyed by (mapName, ifindex). clearPolicyInnerCacheForTest (inner_cache.go) is defined but never called anywhere. The migration tests reuse ifindex = 42 in six tests and 43 in another, all with the same map names (MapNameDNSAllowV2 / MapNameAllowOutV3):

migration_test.go:102  ifindex := uint32(42)
migration_test.go:159  ifindex := uint32(43)
migration_test.go:220  ifindex := uint32(42)
migration_test.go:271  ifindex := uint32(42)
migration_test.go:417  ifindex := uint32(42)
migration_test.go:469  ifindex := uint32(42)
migration_test.go:542  ifindex := uint32(42)

Every test creates a fresh outer map (newDNSAllowOuterMap / pinned outer). With the old lookupInnerMap(outerMap, ifindex), the inner was resolved by outerMap.Lookup + NewMapFromID, so each test was fully isolated. After this PR, lookupInnerMap / acquireInnerMap return the cached inner FD for (name, 42) from whichever test ran first — an inner that belongs to a different, already-closed outer. The defer dest.Close() removals in migration_test.go mean nothing resets this.

The writes and the subsequent dest.Lookup assertions both go through the same stale cached FD, so the tests still "pass," but they are now validating a detached map that the outer under test never references — the assertions are vacuous, and the real migrateAllowOutInnerMap/migrateDNSAllowInnerMap behavior on a fresh outer is no longer exercised. This also makes the tests order-dependent and will silently corrupt any future test that reuses an ifindex.

Recommendation: wire t.Cleanup(clearPolicyInnerCacheForTest) into the cubevs tests that touch the cache (or clear it in TestMain), and/or include the outer map's ID in the cache key so a cache entry can never be served for a different outer.


2. Low/Medium — acquireInnerMap create race can diverge cache from datapath

In the create path:

created, err := newInner()
if err := outerMap.Put(&ifindex, created); err != nil { ... }
actual, loaded := policyInnerMaps.LoadOrStore(key, created)
if loaded { _ = created.Close(); return actual.(*ebpf.Map), nil }
return created, nil

If two goroutines both miss the cache and both observe ErrKeyNotExist from outerMap.Lookup before either Puts, then G1 Puts I1 and wins LoadOrStore, while G2 Puts I2 (overwriting the outer reference) then loses LoadOrStore and closes I2. Result: the cache returns I1, but the outer — and therefore the kernel datapath — references I2, whose only userspace FD was just closed. Policy applied through the cached I1 is invisible to the datapath, and I2 is orphaned (no default deny, no allow-out) until something recreates it.

The comment on line 34-36 asserts Cubelet's TAP lifecycle prevents concurrent ownership of one ifindex, which is plausible — but the DNS reaper now runs concurrently on a ticker, and nothing in acquireInnerMap itself enforces the invariant. Since the failure mode is silent policy divergence, consider documenting the exact guarantee (which callers may create for a given key) or using a per-key singleflight. At minimum, a test that drives two concurrent creators for the same key would pin down the expected behavior.


3. Low — DNS reaper fast path loses coverage for metadata-less ifindices

The new fast path iterates ifindex_to_mvmmeta and only reaps ifindices present there. Any allow_out_v3 outer key whose ifindex no longer has metadata (invariant violation — e.g. a partially-cleaned TAP) is never visited in the fast path, and the fallback (reapDNSLearnedPoliciesFromAllowOutOuter) only triggers when the metadata map fails to load, not when a specific key is missing. The old code iterated the allow_out_v3 outer directly and reaped every ifindex with an outer key. So when the metadata⇔allow_out invariant is violated, expired DNS-learned entries persist until the next startup stale-GC. The new fast path also has no direct unit test (the kept reapDNSLearnedPoliciesForInnerMap is exercised, but not the mvmmeta iteration or the fallback trigger).


4. Info — cleanupConflictingTap "RTM_GETLINK, not LinkList" claim needs verification against netlink v1.3.1

The controller comment claims "Lookup is by deterministic tap name (RTM_GETLINK), not LinkList," and the PR description credits GetByName with "stop doing a full LinkList on every pool-miss create." However, in vishvananda/netlink v1.3.1 (per Cubelet/go.mod), LinkByName is implemented via LinkList() (a full dump) plus a linear search — it is not a single RTM_GETLINK. Note the PR's own tap_device.go comment labels netlinkLinkByName a "dump-style read" that needs ErrDumpInterrupted retry, which is consistent with the dump-based implementation and contradicts the startup_recover.go claim.

If that's the case, swapping tapAdapter.List() for tapAdapter.GetByName() does not avoid the dump; the real (and valuable) fix for density is the WithDumpRetry on interruption. Worth confirming against the vendored netlink source and, if the claim is wrong, tightening the comment / PR description so the perf win is attributed to the retry rather than an avoided dump.


5. Info — destroyTap policy cleanup is best-effort, comment is stronger than the guarantee

destroyTap calls cleanupDestroyedTapPolicyMaps(ifIdx) after deletePersistentTapByName/netlinkLinkDel, but cleanup first re-checks netlinkLinkByIndex and bails if the lookup returns nil (netdev not yet unregistered) or a transient error. Both branches leave the outer policy keys in place until the next startup GC. The comment above destroyTap says "destroyed ifindexes cannot accumulate stale … inners," which is optimistic; the behavior is "leaked until next startup GC in some races," which is acceptable given runStaleNetPolicyMapGC, but the comment should match it.


Nits

  • startup_recover.go runStaleNetPolicyMapGC doc says it deletes "allow_out_v2 / deny_out / dns_allow outer keys," but the implementation iterates MapNameAllowOutV3 / MapNameDenyOut / MapNameDNSAllowV2. allow_out_v2 is the legacy map name; the comment should say allow_out_v3.

What looks good

  • flushInnerEntries[K, V] collect-then-delete is a real bug fix (cilium/ebpf LPM iterators can skip entries deleted mid-iteration), and all key/value types used are fixed-size structs so the by-value copy is safe.
  • WithDumpRetry is correctly limited to read-only dump paths; mutating calls are left unwrapped, and the new tests cover interrupt-retry, exhaustion, and pass-through of other errors.
  • GCStaleNetPolicyMaps / DeleteTAPDevicePolicyMaps correctly separate "keep outer keys for Ready-pool reuse" from "delete on destroy," and the stillPresent/onConflict guards are conservative.
  • destroyTap policy-cleanup tests (TestDestroyTapSkipsPolicyCleanupOnTransientLookupError, TestDestroyTapCleansPolicyWhenLinkAlreadyGone, TestDestroyTapPolicyCleanupFailureDoesNotFailDestroy) cover the important branches.
  • The cleanupConflictingTap GetByName + isTapNotFound change is strictly better regardless of the dump question, because isTapNotFound routes the not-found case to a cheap early return and WithDumpRetry makes the whole thing robust to interruption.

Findings 1–3 are posted as inline comments on the corresponding lines; 4–5 and the nit are summarized here only.

}
}
}
go func(keep map[uint32]struct{}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keep set is snapshotted once at the end of recover(), but GC runs in this background goroutine and is deliberately slow ("pathologically slow" when thousands of stale inners leak). warmupTapPoolBackground() starts right after recover() returns, and on-demand creates begin once the API server is up — so TAPs created after the snapshot but before GC finishes iterating are not in keep. gcStaleOuterKeys will then delete their allow_out_v2/deny_out/dns_allow outer keys and close their cached inners while the TAP is live.

This is security-relevant: the datapath is allow_out_v2 > deny_out > default allow (CubeNet/src/session.h), and applyNetPolicy with replace=false skips the deny_out block entirely when the plan has no deny entries. An allow-internet sandbox relies on the pool-stage default-deny entries (alwaysDeniedSandboxEntries) installed in deny_out; with that outer key gone, traffic to 10.0.0.0/8, 169.254.0.0/16, etc. silently becomes allowed.

Suggest rebuilding keep inside the goroutine right before iterating (ideally under s.mu), or re-verifying at delete time that the ifindex is still absent from the live TAP set / pool.

// After the netdev is gone it deletes HashOfMaps outer policy keys so destroyed
// ifindexes cannot accumulate stale allow_out_v2 / deny_out / dns_allow inners.
func destroyTap(ifIdx int) error {
link, err := netlinkLinkByIndex(ifIdx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two related problems in the new destroyTap policy cleanup:

  1. This branch treats any netlinkLinkByIndex error (transient ErrDumpInterrupted after retries, EPERM, ENODEV…) as "netdev already gone" and deletes the TAP's policy maps — and discards the cleanup error (_ =). If the error was transient and the device is still up, its policy is silently dropped (datapath falls back to default allow). Consider deleting policy only when the link is confirmed absent (netlink.LinkNotFoundError).

  2. Asymmetry with the success path below: there, a DeleteTAPDevicePolicyMaps failure now fails the entire destroy, so a netdev that was successfully destroyed is reported as a destroy error — and the cached inners were already released. Under a degraded/missing-BPF condition every Destroy would start failing. Prefer best-effort policy cleanup that does not change the destroy result, or at least keep the two branches consistent.

if err != nil {
return nil, err
}
if err := outerMap.Put(&ifindex, created); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The create-and-Put critical section is unsynchronized per key. Two concurrent acquireInnerMap calls for the same (mapName, ifindex) can both miss the cache, both create inners, and both outerMap.Put — the outer ends up referencing one inner while LoadOrStore keeps the other in the cache (closing the loser). Subsequent cache hits then write policy to an inner the outer no longer references (silent policy loss, since the datapath looks up the outer), and the superseded kernel inner leaks.

Separately, a Lookup result cached after a concurrent releaseCachedInner/outer-delete leaves a stale FD in the cache; a later TAP reusing the ifindex will hit the stale inner and write policy to a map not installed in the outer. With the DNS reaper and the new async GC now running concurrently with the create/apply paths, these races are reachable in practice. Consider guarding with per-key locks, and/or re-validating on cache hit that the outer still references the cached inner.


func buildCreateRequestBody(template string, hostMount string) ([]byte, error) {
func buildCreateRequestBody(template string, hostMount string, networkPolicy string) ([]byte, error) {
reqBody := createRequest{TemplateID: template}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compile break: one buildCreateRequestBody call site was not updated. This signature change (2→3 args) updated the callers in main.go and two of the three call sites in runner_test.go, but runner_test.go:13 (TestRunWarmupCompletesBeforeBenchmark) still calls buildCreateRequestBody("tpl-warmup", "") with the old arity. The examples/cube-bench test package will not compile (go vet / go test ./... fails), which breaks CI. Fix: buildCreateRequestBody("tpl-warmup", "", networkPolicyNone).

newInner func() (*ebpf.Map, error),
) (*ebpf.Map, error) {
key := policyInnerKey{mapName: mapName, ifindex: ifindex}
if cached, ok := policyInnerMaps.Load(key); ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FD-cache race: an unsynchronized Load can hand out an FD that a concurrent destroy/GC just closed, and the release→delete window can orphan a cached inner.

Two problems with the cache lifetime:

  1. This fast-path Load is not covered by lockPolicyInnerKey. releaseCachedInner (LoadAndDeleteClose) can run between the Load here and the caller's first inner.Update/Iterate, so the caller uses a closed FD and gets EBADF on the create/apply hot path — the exact path this PR is trying to harden.

  2. More seriously, the cache can be left pointing at an orphaned inner. In DeleteTAPDevicePolicyMaps / gcStaleOuterKeys, releaseCachedInner and outer.Delete are separate steps and the per-key lock is released before outer.Delete (and loadPinnedMap runs in between). A concurrent acquireInnerMap can take the lock in that window, outer.Lookup the still-present key, and LoadOrStore the about-to-be-deleted inner back into the cache; the subsequent outer.Delete then orphans it. Any later TAP that reuses that ifindex gets the orphaned inner from the cache, and its allow/deny policy updates are written to a map the outer (dataplane) no longer references — silent policy non-enforcement.

Consider making the destroy path hold the per-key lock across releaseCachedInner + outer.Delete (e.g., a combined delete-and-release under one lock acquisition), and/or re-validating the outer key on the fast path.

func (s *NetworkController) scheduleStaleNetPolicyMapGC() {
go func() {
keep := s.buildStaleNetPolicyKeepSet()
deleted, err := s.cubevsAdapter.GCStaleNetPolicyMaps(keep, netPolicyIfindexStillPresent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOCTOU: startup GC can wipe a freshly-created TAP's default-deny policy.

gcStaleOuterKeys re-checks stillPresent(ifindex) and then calls deleteCachedInnerAndOuter a few instructions later; the check and the delete are not atomic. If warmup / an on-demand create reuses that ifindex between the re-check and outer.Delete, this GC deletes the HashOfMaps outer keys for a TAP that just had InstallTAPDefaultDenyPolicy applied. applyNetPolicyWithMode with replace=false (normal AddTAPDevice path) only populates plan.denyOutEntries; for an allow_internet_access=true sandbox that's empty and the always-denied private-range/metadata entries are not re-installed, so the sandbox would serve egress without the denylist until the next Cubelet restart. The window is microscopic and startup-only, but the failure mode is a silently-missing security boundary.

The comment on lines 83-86 acknowledges the window; consider closing it rather than just shrinking it — e.g. re-run stillPresent after the delete and restore on conflict, serialize GC against warmup/on-demand create, or defer GC until after warmup with a fresh keep-set.

Comment thread CubeNet/cubevs/netpolicy.go Outdated
continue
}
if err := deleteCachedInnerAndOuter(outer, mapName, ifindex); err != nil {
return deleted, fmt.Errorf("delete stale %s[%d]: %w", mapName, ifindex, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gcStaleOuterKeys aborts the whole sweep on the first per-key delete error.

GCStaleNetPolicyMaps already accumulates errors per outer map, but this inner loop returns immediately on the first failed deleteCachedInnerAndOuter. Because this GC only runs once at Cubelet startup, a single transient error (e.g. EBUSY from a concurrently-running reaper, or ENOMEM) leaves every remaining stale key undeleted until the next restart — which is exactly the leak this PR is trying to drain. Suggest errs = append(errs, fmt.Errorf(...)); continue and errors.Join(errs...) at the end, mirroring the caller.

//
// Callers must not use the returned map across TAP destroy / stale-outer GC for
// the same ifindex; Cubelet serializes those against apply for a given TAP.
func acquireInnerMap(outerMap *ebpf.Map, ifindex uint32, mapName string,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design note: unbounded FD cache with no invalidation hook, and no direct unit tests.

Two things worth addressing before this lands:

  1. FD budget. Every {mapName, ifindex} now pins one open inner-map FD for the life of the TAP (3 maps × active TAPs), and the DNS reaper's newInner=nil acquires additionally warm the cache for any active ifindex that was created before this cache existed. On a large host this adds a steady ~3×-active-TAP-count to Cubelet's FD usage with no eviction other than TAP destroy / startup GC. The Lookup-then-cache path (line 56) makes even read-only consumers pin FDs. A bounded cache (or at least a documented FD-accounting expectation) would make the tradeoff explicit.

  2. No reload invalidation. The cache is process-global and keyed only by {mapName, ifindex} — if the pinned outer maps are ever recreated (a future BPF reload path), acquireInnerMap would keep handing out FDs to maps no longer in the dataplane. The current codebase has no reload path so this is latent, but a generation counter or a clearPolicyInnerCache hook would make it safe.

Also: this is the highest-risk new file in the PR (per-key locking + FD ownership invariants) but has no direct unit tests — the invariants are only exercised indirectly through netpolicy/dnspolicy tests. A focused test for the lock-ordering / cache-vs-outer consistency would be valuable.

Comment thread CubeNet/cubevs/dns_reaper.go Outdated
// flushed, so restricting to ifindex_to_mvmmeta matches Active (and in-flight
// Cleaning) sandboxes without iterating every HashOfMaps outer key.
func reapDNSLearnedPolicies(now uint64) {
meta, err := loadPinnedMap(MapNameIfindexToMVMMetadata)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DNS reaper now fails closed on ifindex_to_mvmmeta load — stale DNS-learned allow rules can accumulate.

Previously reapDNSLearnedPolicies reaped allow_out_v2 independently; the metadata map wasn't involved. Now a failure to load ifindex_to_mvmmeta (transient pin-path issue, ENOENT after an upgrade, permissions) makes reapDNSLearnedPolicies return early, so no expired DNS-learned entries are removed for that tick — and for every subsequent tick while the failure persists, since each tick reloads the map. The leaked entries are exactly the DNS-learned allow rules this reaper is supposed to expire. Consider warning and continuing (falling back to iterating allow_out_v2) instead of failing closed, or at least make the metadata load failure a per-tick warn so it's visible.

Comment thread CubeNet/cubevs/netpolicy.go Outdated
@@ -563,22 +622,17 @@ func isValidDNSDomainName(domain string) bool {
// populateInnerMap inserts pre-parsed deny_out entries into the inner LPM trie
// map for the specified ifindex.
func populateInnerMap(outerMap *ebpf.Map, ifindex uint32, entries []denyOutPolicyEntry) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: populateInnerMap / populateAllowOutInnerMap wrappers are no longer called.

After this PR refactored applyNetPolicyWithMode and InstallTAPDefaultDenyPolicy to call populateDenyOutInner / populateAllowOutInner directly with the cached inner, neither populateInnerMap (here) nor populateAllowOutInnerMap (below) has any remaining callers in the package. These are the same class of unused helper this PR removes on the Cubelet side ("remove an unused Cubelet helper left over"). Worth deleting to avoid maintaining two code paths for the same populate operation — one of which still does the old acquire-then-populate dance.

@chenhengqi chenhengqi self-assigned this Aug 11, 2026
return fmt.Errorf("failed to open allow_out_v2 inner map: %w", err)
}
defer inner.Close()
return reapDNSLearnedPoliciesForInner(inner, now)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrency hazard: the DNS reaper uses a cached inner map that a concurrent TAP destroy / stale-GC can Close out from under it.

acquireInnerMap returns the process-wide cached *ebpf.Map and releases the per-key lock before the caller uses it. Here the reaper then iterates and deletes entries on that map via reapDNSLearnedPoliciesForInner with no lock held. Meanwhile destroyTapDeleteTAPDevicePolicyMapsdeleteCachedInnerAndOuter takes the same per-key lock and Close()s that exact FD. The reaper runs on a 5s ticker goroutine (StartSessionReaper/doReap) and is not serialized against destroy for a given ifindex — the comment in acquireInnerMap ("Callers must not use the returned map across TAP destroy / stale-outer GC for the same ifindex") does not protect this caller.

Failure modes: an iteration on a closed FD returns EBADF (iter.Err() is logged and that tick's reap for the ifindex is silently lost), or worse — if the freed FD number is reused by another map creation in the window, the reaper's inner.Delete targets an unrelated map and can corrupt a live sandbox's allow_out policy.

Suggestion: hold the per-key lock for the duration of the inner iteration (the destroy path already takes the same lock, so it would wait out the reap before closing), or defer the Close in deleteCachedInnerAndOuter until no reader can be in flight.

// churn and surfaces as netlink ErrDumpInterrupted under density load.
func (s *NetworkController) cleanupConflictingTap(ip net.IP) error {
taps, err := s.tapAdapter.List()
tap, err := s.tapAdapter.GetByName(tapName(ip.String()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "not LinkList, RTM_GETLINK by name" claim is incorrect — this still performs a full link-table dump.

realTapDeviceAdapter.GetByNamegetTapByNamenetlinkLinkByNamenetlink.LinkByName. In vishvananda/netlink (v1.3.1 here), LinkByName is implemented as LinkList() (a full RTM_GETLINK dump) followed by a linear name scan — there is no single-link RTM_GETLINK-by-name lookup. So switching from tapAdapter.List() to GetByName(tapName(...)) does not stop doing a full LinkList on every pool-miss create, and the latency win claimed in the comment above ("Lookup is by deterministic tap name (RTM_GETLINK), not LinkList. A full dump of every host interface ... races with concurrent TAP churn") is not realized by this change. What does fix the ErrDumpInterrupted/EINTR failure is the new WithDumpRetry wrapper.

Worth correcting the comment and re-measuring the create-path delta so the benchmark result isn't attributed to the wrong optimization. (Minor behavior delta: getTapByName now returns a hard "%s is not tap" error for a same-named non-TAP device, where the old List()-based path silently skipped it.)

// (pre-shard or downgrade-written) files first, then each shard in name order.
// Invalid names are ignored so unrelated files in the state directory do not
// break runtime startup.
// Scan returns every valid state file under hash shards in deterministic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unrelated behavioral change that contradicts the code's own downgrade-compatibility story — please verify the "never released" claim.

Removing migrateFlatStateFiles and the root-level flat scan changes startup recovery semantics. The removed migration's doc comment said it existed to pick up "files written by a downgraded binary," and persistedState.MarshalJSON in this same file still explicitly preserves cubevsContext "while preserving downgrade/read compatibility during the transition." If a downgraded build ever wrote root-level *.json state files, this change silently orphans them on the next upgrade — those sandboxes would fail to recover (their records are neither migrated nor scanned).

The "Flat (unsharded) layout was never released" assertion is a claim about release history that a reviewer can't verify from this diff, and it sits in a PR titled "perf/fix(network)". If the claim is not airtight, this should either stay behind a migration or be called out explicitly in the PR body as an intentional compat break.

Comment thread CubeNet/cubevs/dns_reaper.go
newInner func() (*ebpf.Map, error),
) (*ebpf.Map, error) {
key := policyInnerKey{mapName: mapName, ifindex: ifindex}
if cached, ok := policyInnerMaps.Load(key); ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — stale-cache TOCTOU: a reused ifindex can silently lose all allow/deny policy.

The cache-hit path returns the cached inner without verifying the outer HashOfMaps still maps this ifindex to it. The DNS reaper (dns_reaper.go fast path) is a new background reader that populates this cache every 5s via acquireInnerMap(allowOut, ifindex, ..., nil), and LookupLoadOrStore is not atomic with deleteCachedInnerAndOuter's outer.Delete:

  1. Reaper: outerMap.Lookup(X) succeeds → inner = A
  2. Destroy: deleteCachedInnerAndOuter does LoadAndDelete(X) (cache miss) then outer.Delete(X)
  3. Reaper: LoadOrStore(X, A) re-caches an ifindex whose outer key is now gone

Nothing evicts it (destroy already ran its eviction; GC only runs at startup). When the kernel reuses ifindex X for a new TAP, acquireInnerMap cache-hits A and all policy setup writes into a map the dataplane never looks up — the create succeeds but egress policy is silently detached, with no error and no recovery until restart. deleteCachedInnerAndOuter has the mirror window: its LoadAndDelete runs before outer.Delete, so a concurrent acquire landing between them re-populates the cache after eviction.

The comment at lines 34–36 justifies skipping a per-key lock with the "no concurrent ownership of one ifindex" lifecycle invariant — but that invariant covers two creators of the same ifindex, not a background reader racing a destroy, which is precisely what the reaper now is.

Suggested fix: per-key serialization around acquire/release (per-key mutex, or lock-per-key via sync.Map), or in deleteCachedInnerAndOuter re-evict any entry stored between the first LoadAndDelete and outer.Delete; validating the outer on cache-hit works but defeats the Lookup-cost goal.

_ = created.Close()
return nil, fmt.Errorf("map.Put failed: %w, name: %s", err, mapName)
}
actual, loaded := policyInnerMaps.LoadOrStore(key, created)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — creation branch is only safe under an unenforced external invariant.

If two goroutines create the same (mapName, ifindex) concurrently (both newInner non-nil), both miss the Lookup, both Put; the second Put overwrites the outer key with the loser's map, then the loser's LoadOrStore reports loaded and closes its map while the outer still references it, and the cache returns the winner's map which the outer no longer references — policy writes then go to a detached map.

Today the only concurrent background reader (the reaper) passes nil newInner, so this isn't triggerable — but the "no concurrent ownership" invariant is external to this helper and untested. A per-key lock (or only Put the outer when the cache-store wins) makes the helper correct independent of caller discipline. Consider folding this into the fix for the stale-cache TOCTOU at line 41.

//
// Lookup is by deterministic tap name (RTM_GETLINK), not LinkList. A full dump
// of every host interface on every pool-miss create races with concurrent TAP
// churn and surfaces as netlink ErrDumpInterrupted under density load.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW/MEDIUM — this claim doesn't hold for netlink v1.3.1, and the conflict path has no test coverage.

In vishvananda/netlink v1.3.1 (per Cubelet/go.mod), LinkByName and LinkByIndex are implemented on top of a full LinkList() (RTM_GETLINK dump) that they iterate to find the name/index. So tapAdapter.GetByName(...) still performs a full host-interface dump on every pool-miss create — the "not LinkList" rationale (also in the PR description: "stop doing a full LinkList on every pool-miss create") doesn't hold for this version. What actually fixes the ErrDumpInterrupted density failure is the new WithDumpRetry wrapper around netlinkLinkByName/netlinkLinkByIndex (tap_device.go:1350-1362), not the avoidance of the dump; per-create dump traffic is unchanged.

Also: this conflict-detection path has no direct unit coverage — the test fakes return nil, nil from GetByName, so neither the not-found branch nor the checkTapConflict branch is exercised. Worth rewording the comment and adding a test that makes GetByName return a colliding tap.

// Scan returns every valid state file under hash shards in deterministic
// shard-name then filename order. Invalid names are ignored so unrelated files
// in the state directory do not break runtime startup. Flat (unsharded) layout
// was never released, so root-level "*.json" state files are not scanned.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — removing flat-layout handling depends on a "never released" claim.

Dropping migrateFlatStateFiles() (and the root-level scan here) means any state file still in the pre-shard flat layout is silently ignored — not migrated, not scanned, not loadable. The removed helper explicitly handled "files written by a downgraded binary," implying a downgrade scenario was considered real. If the "never released" claim is wrong for any deployment path, those sandboxes will fail to restore on upgrade and their TAPs will be treated as stale and destroyed. Please confirm the claim against release history, or keep a one-time migration for installs that ran a pre-shard build.

}
return 42, nil
})
if err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use testify packages for assertions.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}
}
} else {
CubeLog.WithContext(context.Background()).Warnf(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we reach here, all inner maps will be deleted?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — if List() fails the keep set is incomplete, so we now skip this GC pass entirely instead of treating live TAP ifindexes as stale.

Comment thread CubeNet/cubevs/netpolicy.go Outdated
func flushInnerEntries(inner *ebpf.Map, value any) error {
var key lpmKey
iter := inner.Iterate()
for iter.Next(&key, value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use a 2-pass flush here, collect keys and then delete.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@FakeLearne
FakeLearne force-pushed the refactor-network branch 2 times, most recently from eea191c to b6ca369 Compare August 19, 2026 02:48

// deleteCachedInnerAndOuter removes the outer key and its cached userspace FD.
func deleteCachedInnerAndOuter(outer *ebpf.Map, mapName string, ifindex uint32) error {
key := policyInnerKey{mapName: mapName, ifindex: ifindex}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding (Medium, PLAUSIBLE): use-after-close on a shared cached FD — the 5s DNS reaper can hold a pointer to an inner that deleteCachedInnerAndOuter has just closed.

deleteCachedInnerAndOuter does LoadAndDelete + Close() on the cached inner FD, while the reaper (dns_reaper.goacquireInnerMap(allowOut, ifindex, MapNameAllowOutV3, nil) → iterate) fetches the same cache entry and uses it without holding any lock. The exclusivity reasoning in the acquireInnerMap doc comment (line 32–36: "TAP lifecycle prevents concurrent ownership of one ifindex") holds for the create/apply path, but the background reaper is a separate goroutine sharing the same cache entries: its tick can snapshot an ifindex, then TAP destroy closes the cached FD, then the reaper iterates/updates the closed map → EBADF; in the worst case the FD number is reused and the reaper touches an unrelated kernel object.

Pre-PR each reaper pass opened its own FD via ebpf.NewMapFromID and closed it locally, so this race did not exist. Impact is bounded (a destroyed TAP's entries are moot), but this is a new concurrency hazard on shared mutable state and will fire under the create/destroy churn this PR targets. Consider per-key refcounting (or a per-key mutex spanning Load→use→release, with the destroy path taking the same lock), or having the reaper recover from EBADF by re-acquiring the outer.

defer allowOut.Close()

meta, err := loadPinnedMap(MapNameIfindexToMVMMetadata)
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding (Low, PLAUSIBLE): the fast path only reaps ifindices present in ifindex_to_mvmmeta; the allow_out_v3 outer scan is now reachable only when the metadata map fails to load, not when an ifindex is simply missing from it.

If the "metadata deleted ⟺ allow_out flushed" invariant is ever violated (crash or failed cleanup between the two steps, partial pool cleanup failure), an allow_out_v3 inner whose ifindex is absent from mvmmeta will never have its expired DNS-learned entries reaped — the previous outer-scan covered every outer key. The fallback covers map-load failures only. Low likelihood given the invariant, but it's a silent robustness regression in exactly the partial-failure modes the old code tolerated. Consider also reaping allow_out outers whose ifindex is not in the metadata set, or documenting/asserting that the invariant is enforced atomically.

dest, err := lookupInnerMap(newOuter, ifindex)
dest, err := lookupInnerMap(newOuter, ifindex, MapNameDNSAllowV2)
if err != nil {
t.Fatalf("lookupInnerMap: %v", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding (Medium, CONFIRMED by inspection): cross-test cache contamination makes this assertion vacuous.

policyInnerMaps is a process-global sync.Map keyed only by (mapName, ifindex) — not by the outer map object — and clearPolicyInnerCacheForTest() (inner_cache.go:304) is never called by any test. This test reuses ifindex = 42 with dns_allow_v2, as do TestMigrateDNSAllowInnerMapFromLegacy (line 102) and TestMigrateDNSAllowInnerMapFromCurrent (line 220), which run earlier and populate the cache.

So in TestMigrateDNSAllowMapOuterWithBpffs:

  1. migrateDNSAllowMapensureDNSAllowInnerMap(newOuter, 42)acquireInnerMap cache-hits the earlier test's inner FD and skips outerMap.Put(&ifindex, ...) — the freshly pinned dns_allow_v2 outer is never populated.
  2. lookupInnerMap(newOuter, 42, MapNameDNSAllowV2) returns that same stale inner.
  3. The assertions read the stale inner, so the test passes while verifying nothing about the pinned newOuter map the test is named after.

Please call clearPolicyInnerCacheForTest() in setup (or scope the cache per-test / make the key include the outer map identity) so these tests actually exercise the maps they construct.

// Scan returns every valid state file under hash shards in deterministic
// shard-name then filename order. Invalid names are ignored so unrelated files
// in the state directory do not break runtime startup. Flat (unsharded) layout
// was never released, so root-level "*.json" state files are not scanned.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding (Low, verify before merge): the removed flat-layout migration also served as a downgrade backstop, not just legacy-release support.

The deleted migrateFlatStateFiles doc comment read: "files written by a downgraded binary are picked up on the next startup." If any released build ever wrote root-level *.json state files (or a binary downgrade writes them), removing this migration and the flat-root scan in Scan() silently stops recovering those sandbox states at startup — a recovery regression that would surface only after an operator downgrades. Please confirm the "Flat (unsharded) layout was never released" claim against released tags before merging, or keep the root-level scan as a compatibility shim.

…nsity

Avoid hot-path outer Lookups that trigger synchronize_rcu, delete policy maps
on TAP destroy, and GC leftover HashOfMaps keys so network-policy create
latency stays near the no-policy baseline.

Retry dump-style netlink reads on ErrDumpInterrupted/EINTR and stop doing a
full LinkList on every pool-miss create, so EnsureNetwork no longer fails
with "results may be incomplete or inconsistent" under high-concurrency TAP
churn. Skip stale-policy GC when listing TAPs fails, and flush inner maps
with a two-pass collect-then-delete of key values.

Signed-off-by: yarrischen <yarrischen@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
return nil
}

func clearPolicyInnerCacheForTest() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearPolicyInnerCacheForTest is defined but never called anywhere in the PR, so the process-global policyInnerMaps cache leaks across tests in the cubevs package. The migration tests reuse ifindex = 42 (six tests) and 43, all with the same map names (MapNameDNSAllowV2 / MapNameAllowOutV3), and each test creates a fresh outer map. With the old lookupInnerMap(outerMap, ifindex) the inner was resolved from that test's own outer and every test was isolated; now acquireInnerMap/lookupInnerMap return the cached inner FD for (name, ifindex) from whichever test ran first — an FD belonging to a different, already-closed outer. The removed defer dest.Close() calls in migration_test.go masked this: writes and the subsequent dest.Lookup assertions both go through the stale cached FD, so the tests still pass but now validate a detached map the outer under test never references. Please wire t.Cleanup(clearPolicyInnerCacheForTest) into the cache-touching tests (or clear in TestMain), and consider adding the outer map's ID to the cache key so an entry can never be served for a different outer.

_ = created.Close()
return nil, fmt.Errorf("map.Put failed: %w, name: %s", err, mapName)
}
actual, loaded := policyInnerMaps.LoadOrStore(key, created)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create race: if two goroutines both miss the cache and both observe ErrKeyNotExist before either Puts, then G1 Puts I1 and wins LoadOrStore while G2 Puts I2 (overwriting the outer reference) and then loses LoadOrStore and closes I2. The cache returns I1, but the outer — and the kernel datapath — references I2, whose only userspace FD was just closed. Policy applied through the cached I1 is invisible to the datapath and I2 is orphaned. The comment at lines 34-36 documents the "no concurrent ownership" assumption, but nothing here enforces it, and the DNS reaper now runs concurrently on a ticker (though it passes nil newInner). Since the failure mode is silent policy divergence, consider a per-key singleflight or a test that drives two concurrent creators for the same key to pin down the expected behavior.

mvmMeta mvmMetadata
)
iter := meta.Iterate()
for iter.Next(&ifindex, &mvmMeta) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fast path only reaps ifindices present in ifindex_to_mvmmeta. Any allow_out_v3 outer key whose ifindex lost its metadata (invariant violation, e.g. a partially-cleaned TAP) is never visited here, and the fallback reapDNSLearnedPoliciesFromAllowOutOuter only triggers when the metadata map fails to load, not when a specific key is missing. The previous code iterated allow_out_v3 outers directly and reaped every ifindex with an outer key, so expired DNS-learned entries for metadata-less ifindices now persist until the next startup stale-GC. Worth either iterating the outer when the two maps disagree, or documenting the invariant. Also note this new fast path (and the fallback trigger) has no direct unit test — the kept reapDNSLearnedPoliciesForInnerMap is exercised, but not the mvmmeta iteration.

// churn and surfaces as netlink ErrDumpInterrupted under density load.
func (s *NetworkController) cleanupConflictingTap(ip net.IP) error {
taps, err := s.tapAdapter.List()
tap, err := s.tapAdapter.GetByName(tapName(ip.String()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above claims this lookup is "by deterministic tap name (RTM_GETLINK), not LinkList," and the PR description credits GetByName with "stop doing a full LinkList on every pool-miss create." Please verify against the vendored vishvananda/netlink v1.3.1 (per Cubelet/go.mod): netlink.LinkByName is implemented via LinkList() plus a linear search — it is not a single RTM_GETLINK. If so, GetByName still performs a full link dump on every pool-miss create, and the latency win here comes from WithDumpRetry on interruption rather than from avoiding the dump. (The PR's own tap_device.go comment labels netlinkLinkByName a "dump-style read" that needs ErrDumpInterrupted retry — which is consistent with the dump-based implementation and contradicts this comment.)

@zhouxianping
zhouxianping merged commit 725b766 into TencentCloud:master Aug 19, 2026
37 of 38 checks 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.

3 participants