-
Notifications
You must be signed in to change notification settings - Fork 1.1k
perf/fix(network): cut policy apply latency and harden TAP create under density #1318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,13 @@ func reapDNSState() { | |
| reapDNSQueryTrack(now) | ||
| } | ||
|
|
||
| // reapDNSLearnedPolicies scans allow_out_v3 and removes expired DNS-learned entries. | ||
| // reapDNSLearnedPolicies removes expired DNS-learned allow_out_v3 entries. | ||
| // | ||
| // The fast path iterates ifindex_to_mvmmeta so Ready-pool TAPs (metadata | ||
| // deleted, allow_out flushed) are skipped without walking every HashOfMaps | ||
| // outer key. If metadata cannot be loaded, fall back to iterating allow_out_v3 | ||
| // outers so a transient pin/ENOENT failure cannot stall DNS TTL expiry for | ||
| // every subsequent tick. | ||
| func reapDNSLearnedPolicies(now uint64) { | ||
| allowOut, err := loadPinnedMap(MapNameAllowOutV3) | ||
| if err != nil { | ||
|
|
@@ -35,13 +41,46 @@ func reapDNSLearnedPolicies(now uint64) { | |
| } | ||
| defer allowOut.Close() | ||
|
|
||
| meta, err := loadPinnedMap(MapNameIfindexToMVMMetadata) | ||
| if err != nil { | ||
| enqueueEvent(Event{ | ||
| Error: err, | ||
| Message: "failed to load ifindex_to_mvmmeta map; falling back to allow_out_v3 outer scan", | ||
| }) | ||
| reapDNSLearnedPoliciesFromAllowOutOuter(allowOut, now) | ||
| return | ||
| } | ||
| defer meta.Close() | ||
|
|
||
| var ( | ||
| ifindex uint32 | ||
| innerMapID uint32 | ||
| ifindex uint32 | ||
| mvmMeta mvmMetadata | ||
| ) | ||
| iter := meta.Iterate() | ||
| for iter.Next(&ifindex, &mvmMeta) { | ||
|
FakeLearne marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fast path only reaps ifindices present in |
||
| if err := reapDNSLearnedPoliciesForIfindex(allowOut, ifindex, now); err != nil { | ||
| enqueueEvent(Event{ | ||
| Error: err, | ||
| Message: fmt.Sprintf("failed to reap DNS-learned policies, ifindex: %d", ifindex), | ||
| }) | ||
| } | ||
| } | ||
| if err := iter.Err(); err != nil { | ||
| enqueueEvent(Event{ | ||
| Error: err, | ||
| Message: "failed to iterate ifindex_to_mvmmeta map", | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func reapDNSLearnedPoliciesFromAllowOutOuter(allowOut *ebpf.Map, now uint64) { | ||
| var ( | ||
| ifindex uint32 | ||
| value uint32 | ||
| ) | ||
| iter := allowOut.Iterate() | ||
| for iter.Next(&ifindex, &innerMapID) { | ||
| if err := reapDNSLearnedPoliciesForInnerMap(innerMapID, now); err != nil { | ||
| for iter.Next(&ifindex, &value) { | ||
| if err := reapDNSLearnedPoliciesForIfindex(allowOut, ifindex, now); err != nil { | ||
| enqueueEvent(Event{ | ||
| Error: err, | ||
| Message: fmt.Sprintf("failed to reap DNS-learned policies, ifindex: %d", ifindex), | ||
|
|
@@ -51,20 +90,25 @@ func reapDNSLearnedPolicies(now uint64) { | |
| if err := iter.Err(); err != nil { | ||
| enqueueEvent(Event{ | ||
| Error: err, | ||
| Message: "failed to iterate allow_out_v3 map", | ||
| Message: "failed to iterate allow_out_v3 outer map", | ||
| }) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| // reapDNSLearnedPoliciesForInnerMap deletes expired DNS-learned entries from one allow_out_v3 inner map. | ||
| func reapDNSLearnedPoliciesForInnerMap(innerMapID uint32, now uint64) error { | ||
| inner, err := ebpf.NewMapFromID(ebpf.MapID(innerMapID)) | ||
| func reapDNSLearnedPoliciesForIfindex(allowOut *ebpf.Map, ifindex uint32, now uint64) error { | ||
| inner, err := acquireInnerMap(allowOut, ifindex, MapNameAllowOutV3, nil) | ||
| if err != nil { | ||
| return fmt.Errorf("ebpf.NewMapFromID failed: %w, id: %d", err, innerMapID) | ||
| if errors.Is(err, ebpf.ErrKeyNotExist) { | ||
| return nil | ||
| } | ||
| return fmt.Errorf("failed to open allow_out_v3 inner map: %w", err) | ||
| } | ||
| defer inner.Close() | ||
| return reapDNSLearnedPoliciesForInner(inner, now) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Failure modes: an iteration on a closed FD returns 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 |
||
| } | ||
|
|
||
| // reapDNSLearnedPoliciesForInner deletes expired DNS-learned entries from one | ||
| // allow_out_v3 inner map. | ||
| func reapDNSLearnedPoliciesForInner(inner *ebpf.Map, now uint64) error { | ||
| var ( | ||
| key lpmKeyV3 | ||
| value netPolicyValueV3 | ||
|
|
@@ -84,6 +128,17 @@ func reapDNSLearnedPoliciesForInnerMap(innerMapID uint32, now uint64) error { | |
| return nil | ||
| } | ||
|
|
||
| // reapDNSLearnedPoliciesForInnerMap is kept for tests/callers that still pass a | ||
| // map ID. Prefer reapDNSLearnedPoliciesForInner with a cached FD. | ||
| func reapDNSLearnedPoliciesForInnerMap(innerMapID uint32, now uint64) error { | ||
| inner, err := ebpf.NewMapFromID(ebpf.MapID(innerMapID)) | ||
| if err != nil { | ||
| return fmt.Errorf("ebpf.NewMapFromID failed: %w, id: %d", err, innerMapID) | ||
| } | ||
| defer inner.Close() | ||
| return reapDNSLearnedPoliciesForInner(inner, now) | ||
| } | ||
|
|
||
| // reapDNSQueryTrack deletes expired pending DNS queries that never got a response. | ||
| func reapDNSQueryTrack(now uint64) { | ||
| queryTrack, err := loadPinnedMap(MapNameDNSQueryTrack) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| package cubevs | ||
|
|
||
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "sync" | ||
|
|
||
| "github.com/cilium/ebpf" | ||
| ) | ||
|
|
||
| // policyInnerCache keeps open FDs for HashOfMaps inners keyed by outer map name | ||
| // and TAP ifindex. Userspace BPF_MAP_LOOKUP_ELEM on HashOfMaps is extremely | ||
| // expensive on large hosts; create/apply/reaper paths must reuse cached inners | ||
| // instead of looking up the outer map on every call. | ||
| // | ||
| // FD budget: each live TAP may pin up to one FD per net-policy outer | ||
| // (allow_out_v2, deny_out, dns_allow) — about 3 × active-TAP-count on Cubelet, | ||
| // plus any Active ifindexes warmed by the DNS reaper. Entries are released only | ||
| // on TAP destroy / startup stale-outer GC; there is no size-bounded eviction. | ||
| // There is also no BPF-reload generation counter today (no reload path); if | ||
| // pinned outers are ever recreated, call clearPolicyInnerCacheForTest-style | ||
| // invalidation (or add a generation) before reuse. | ||
| type policyInnerKey struct { | ||
| mapName string | ||
| ifindex uint32 | ||
| } | ||
|
|
||
| var policyInnerMaps sync.Map // policyInnerKey -> *ebpf.Map | ||
|
|
||
| // acquireInnerMap returns the inner map for ifindex, creating it when missing | ||
| // and newInner is non-nil. The returned map is owned by the process-wide cache; | ||
| // callers must not Close it. A nil newInner means "must already exist". | ||
| // | ||
| // Cubelet completes stale-outer GC synchronously before starting background TAP | ||
| // work or serving requests, and its TAP lifecycle prevents concurrent ownership | ||
| // of one ifindex. This cache therefore does not add another per-key lock. | ||
| func acquireInnerMap(outerMap *ebpf.Map, ifindex uint32, mapName string, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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. |
||
| newInner func() (*ebpf.Map, error), | ||
| ) (*ebpf.Map, error) { | ||
| key := policyInnerKey{mapName: mapName, ifindex: ifindex} | ||
| if cached, ok := policyInnerMaps.Load(key); ok { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (
Nothing evicts it (destroy already ran its eviction; GC only runs at startup). When the kernel reuses ifindex X for a new TAP, 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 |
||
| return cached.(*ebpf.Map), nil | ||
| } | ||
|
|
||
| var inner *ebpf.Map | ||
| err := outerMap.Lookup(&ifindex, &inner) | ||
| if err == nil { | ||
| actual, loaded := policyInnerMaps.LoadOrStore(key, inner) | ||
| if loaded { | ||
| _ = inner.Close() | ||
| return actual.(*ebpf.Map), nil | ||
| } | ||
| return inner, nil | ||
| } | ||
| if !errors.Is(err, ebpf.ErrKeyNotExist) { | ||
| return nil, fmt.Errorf("map.Lookup failed: %w, name: %s", err, mapName) | ||
| } | ||
| if newInner == nil { | ||
| return nil, fmt.Errorf("map.Lookup failed: %w, name: %s", ebpf.ErrKeyNotExist, mapName) | ||
| } | ||
|
|
||
| created, err := newInner() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if err := outerMap.Put(&ifindex, created); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Interleaving:
End state: the process-wide cache holds Worth serializing creation per There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The create-and-Put critical section is unsynchronized per key. Two concurrent Separately, a |
||
| _ = created.Close() | ||
| return nil, fmt.Errorf("map.Put failed: %w, name: %s", err, mapName) | ||
| } | ||
| actual, loaded := policyInnerMaps.LoadOrStore(key, created) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Today the only concurrent background reader (the reaper) passes There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Create race: if two goroutines both miss the cache and both observe |
||
| if loaded { | ||
| _ = created.Close() | ||
| return actual.(*ebpf.Map), nil | ||
| } | ||
| return created, nil | ||
| } | ||
|
|
||
| // releaseCachedInner closes and drops a cached inner FD. | ||
| func releaseCachedInner(mapName string, ifindex uint32) { | ||
| key := policyInnerKey{mapName: mapName, ifindex: ifindex} | ||
| if cached, ok := policyInnerMaps.LoadAndDelete(key); ok { | ||
| _ = cached.(*ebpf.Map).Close() | ||
| } | ||
| } | ||
|
|
||
| // 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} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Pre-PR each reaper pass opened its own FD via |
||
| var toClose *ebpf.Map | ||
| if cached, ok := policyInnerMaps.LoadAndDelete(key); ok { | ||
| toClose = cached.(*ebpf.Map) | ||
| } | ||
| err := outer.Delete(&ifindex) | ||
| if toClose != nil { | ||
| _ = toClose.Close() | ||
| } | ||
| if err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { | ||
| return err | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func clearPolicyInnerCacheForTest() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| policyInnerMaps.Range(func(k, v any) bool { | ||
| policyInnerMaps.Delete(k) | ||
| if m, ok := v.(*ebpf.Map); ok && m != nil { | ||
| _ = m.Close() | ||
| } | ||
| return true | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,11 +127,10 @@ func TestMigrateDNSAllowInnerMapFromLegacy(t *testing.T) { | |
| t.Fatalf("migrateDNSAllowInnerMap: %v", err) | ||
| } | ||
|
|
||
| dest, err := lookupInnerMap(current, ifindex) | ||
| dest, err := lookupInnerMap(current, ifindex, MapNameDNSAllowV2) | ||
| if err != nil { | ||
| t.Fatalf("lookupInnerMap: %v", err) | ||
| } | ||
| defer dest.Close() | ||
|
|
||
| for _, e := range entries { | ||
| key, want, err := makeDNSAllowRule(e.domain, e.flags) | ||
|
|
@@ -175,11 +174,10 @@ func TestMigrateDNSAllowInnerMapFromCurrent(t *testing.T) { | |
| t.Fatalf("migrateDNSAllowInnerMap: %v", err) | ||
| } | ||
|
|
||
| dest, err := lookupInnerMap(current, ifindex) | ||
| dest, err := lookupInnerMap(current, ifindex, MapNameDNSAllowV2) | ||
| if err != nil { | ||
| t.Fatalf("lookupInnerMap: %v", err) | ||
| } | ||
| defer dest.Close() | ||
|
|
||
| var got dnsAllowValue | ||
| if err := dest.Lookup(&key, &got); err != nil { | ||
|
|
@@ -311,11 +309,10 @@ func TestMigrateDNSAllowMapOuterWithBpffs(t *testing.T) { | |
| } | ||
|
|
||
| // The new outer must now hold the migrated rules (NameLen+Flags, PortCount=0). | ||
| dest, err := lookupInnerMap(newOuter, ifindex) | ||
| dest, err := lookupInnerMap(newOuter, ifindex, MapNameDNSAllowV2) | ||
| if err != nil { | ||
| t.Fatalf("lookupInnerMap: %v", err) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
So in
Please call |
||
| } | ||
| defer dest.Close() | ||
| for _, e := range entries { | ||
| key, want, err := makeDNSAllowRule(e.domain, e.flags) | ||
| if err != nil { | ||
|
|
@@ -581,11 +578,10 @@ func TestMigrateAllowOutMapOuterWithBpffs(t *testing.T) { | |
| t.Fatalf("migrateAllowOutMap: %v", err) | ||
| } | ||
|
|
||
| dest, err := lookupInnerMap(newOuter, ifindex) | ||
| dest, err := lookupInnerMap(newOuter, ifindex, MapNameAllowOutV3) | ||
| if err != nil { | ||
| t.Fatalf("lookupInnerMap: %v", err) | ||
| } | ||
| defer dest.Close() | ||
|
|
||
| // The L7 entry expands to the default {80/http, 443/https} /48 set. | ||
| for _, tc := range []struct { | ||
|
|
||
There was a problem hiding this comment.
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; theallow_out_v3outer 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.