-
Notifications
You must be signed in to change notification settings - Fork 0
fix(metrics): cap distinct HTTP path labels per container #335
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
Open
mayankpande88
wants to merge
1
commit into
main
Choose a base branch
from
fix/http-path-cardinality-cap
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| package common | ||
|
|
||
| import ( | ||
| "sync" | ||
|
|
||
| "github.com/coroot/coroot-node-agent/flags" | ||
| "k8s.io/klog/v2" | ||
| ) | ||
|
|
||
| // OtherPath is the bucket every path beyond the per-container cap collapses | ||
| // into. Requests are still counted, they just stop minting new series. | ||
| const OtherPath = "{other}" | ||
|
|
||
| // PathLimiter bounds the number of distinct HTTP `path` label values a single | ||
| // container may contribute to container_http_requests_total. | ||
| // | ||
| // `path` is the only L7 label an outside party controls. An internet-facing | ||
| // ingress is probed for vulnerabilities around the clock, and every unique junk | ||
| // path (/axds.php, /HNAP1, ...) becomes a permanent series. Normalization cannot | ||
| // collapse them because they are genuinely distinct literals, so the series count | ||
| // grows without bound for as long as the scanners keep scanning. | ||
| // | ||
| // Paths seen before the cap is reached always pass through, so a steady | ||
| // application keeps reporting its real routes; only paths first observed after | ||
| // the cap fills are folded into OtherPath. Real applications serve far fewer | ||
| // routes than the default cap, which should only ever engage on scanner traffic. | ||
| // | ||
| // The zero value is not usable; construct with NewPathLimiter. | ||
| type PathLimiter struct { | ||
| mu sync.RWMutex | ||
| seen map[string]struct{} | ||
| full bool // latched once the cap is hit, so the hot path skips the map entirely | ||
|
|
||
| owner string // container_id, for the one-shot warning | ||
| } | ||
|
|
||
| func NewPathLimiter(owner string) *PathLimiter { | ||
| return &PathLimiter{seen: make(map[string]struct{}), owner: owner} | ||
| } | ||
|
|
||
| // Limit returns path if it may be reported as-is, or OtherPath if admitting it | ||
| // would push this container past flags.MaxHttpPathsPerContainer. Callers should | ||
| // normalize the path first so that a parameterized route consumes one slot | ||
| // rather than one per parameter value. | ||
| // A nil receiver is tolerated (returns path unchanged) so that an L7Stats built | ||
| // as a zero value rather than through its constructor degrades to the old | ||
| // unbounded behaviour instead of panicking in the L7 hot path. | ||
| func (l *PathLimiter) Limit(path string) string { | ||
| limit := flags.GetInt(flags.MaxHttpPathsPerContainer) | ||
| // An empty path is what non-HTTP protocols and invalid-UTF8 requests report; | ||
| // it must not consume a slot. | ||
| if l == nil || limit <= 0 || path == "" { | ||
| return path | ||
| } | ||
|
|
||
| l.mu.RLock() | ||
| _, seen := l.seen[path] | ||
| full := l.full | ||
| l.mu.RUnlock() | ||
| if seen { | ||
| return path | ||
| } | ||
| if full { | ||
| return OtherPath | ||
| } | ||
|
|
||
| l.mu.Lock() | ||
| defer l.mu.Unlock() | ||
| // Re-check: another request may have admitted this path, or filled the cap, | ||
| // while we waited for the write lock. | ||
| if _, seen := l.seen[path]; seen { | ||
| return path | ||
| } | ||
| if len(l.seen) >= limit { | ||
| if !l.full { | ||
| l.full = true | ||
| klog.Warningf("HTTP path cardinality cap (%d) reached for %s, further unseen paths reported as %s", | ||
| limit, l.owner, OtherPath) | ||
| } | ||
| return OtherPath | ||
| } | ||
| l.seen[path] = struct{}{} | ||
| return path | ||
| } | ||
|
|
||
| // Len reports how many distinct paths have been admitted. For tests and | ||
| // diagnostics. | ||
| func (l *PathLimiter) Len() int { | ||
| l.mu.RLock() | ||
| defer l.mu.RUnlock() | ||
| return len(l.seen) | ||
| } | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package common | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "sync" | ||
| "testing" | ||
|
|
||
| "github.com/coroot/coroot-node-agent/flags" | ||
| ) | ||
|
|
||
| // withPathCap sets the cap for the duration of a test. Flags are not parsed in | ||
| // test binaries (flags.init bails out on the .test suffix), so the pointer holds | ||
| // a zero value rather than the declared default. | ||
| func withPathCap(t *testing.T, limit int) { | ||
| t.Helper() | ||
| prev := *flags.MaxHttpPathsPerContainer | ||
| *flags.MaxHttpPathsPerContainer = limit | ||
| t.Cleanup(func() { *flags.MaxHttpPathsPerContainer = prev }) | ||
| } | ||
|
|
||
| func TestPathLimiterAdmitsUpToCapThenCollapses(t *testing.T) { | ||
| withPathCap(t, 3) | ||
| l := NewPathLimiter("/k8s/test/pod/app") | ||
|
|
||
| for _, p := range []string{"/a", "/b", "/c"} { | ||
| if got := l.Limit(p); got != p { | ||
| t.Fatalf("Limit(%q) = %q, want it admitted", p, got) | ||
| } | ||
| } | ||
|
|
||
| if got := l.Limit("/d"); got != OtherPath { | ||
| t.Errorf("Limit(%q) = %q, want %q once the cap is reached", "/d", got, OtherPath) | ||
| } | ||
|
|
||
| // Paths admitted before the cap keep reporting under their real value — a | ||
| // steady application must not lose its routes to scanner traffic that arrives | ||
| // later. | ||
| if got := l.Limit("/b"); got != "/b" { | ||
| t.Errorf("Limit(%q) = %q, want the already-admitted path", "/b", got) | ||
| } | ||
| } | ||
|
|
||
| func TestPathLimiterUnlimitedWhenCapIsZero(t *testing.T) { | ||
| withPathCap(t, 0) | ||
| l := NewPathLimiter("/k8s/test/pod/app") | ||
|
|
||
| for i := 0; i < 500; i++ { | ||
| p := fmt.Sprintf("/p%d", i) | ||
| if got := l.Limit(p); got != p { | ||
| t.Fatalf("Limit(%q) = %q, want no cap when the flag is 0", p, got) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestPathLimiterPassesEmptyPathThrough(t *testing.T) { | ||
| withPathCap(t, 1) | ||
| l := NewPathLimiter("/k8s/test/pod/app") | ||
|
|
||
| // Non-HTTP protocols and invalid-UTF8 requests report "", which must neither | ||
| // consume a cap slot nor be rewritten to OtherPath. | ||
| for i := 0; i < 10; i++ { | ||
| if got := l.Limit(""); got != "" { | ||
| t.Fatalf("Limit(\"\") = %q, want \"\"", got) | ||
| } | ||
| } | ||
| if got := l.Limit("/real"); got != "/real" { | ||
| t.Errorf("Limit(%q) = %q, want it admitted — empty paths must not fill the cap", "/real", got) | ||
| } | ||
| } | ||
|
|
||
| func TestPathLimiterIsBoundedUnderConcurrency(t *testing.T) { | ||
| const cap = 50 | ||
| withPathCap(t, cap) | ||
| l := NewPathLimiter("/k8s/test/pod/app") | ||
|
|
||
| var wg sync.WaitGroup | ||
| for w := 0; w < 8; w++ { | ||
| wg.Add(1) | ||
| go func(worker int) { | ||
| defer wg.Done() | ||
| for i := 0; i < 200; i++ { | ||
| l.Limit(fmt.Sprintf("/w%d/p%d", worker, i)) | ||
| } | ||
| }(w) | ||
| } | ||
| wg.Wait() | ||
|
|
||
| // The whole point of the cap: concurrent admission must never overshoot it, | ||
| // otherwise the series bound is not actually a bound. | ||
| if n := l.Len(); n > cap { | ||
| t.Errorf("admitted %d distinct paths, want at most %d", n, cap) | ||
| } | ||
| } | ||
|
|
||
| func TestPathLimiterNilReceiverIsSafe(t *testing.T) { | ||
| withPathCap(t, 1) | ||
| var l *PathLimiter | ||
|
|
||
| // Limit runs on every HTTP request; a zero-value L7Stats must degrade to | ||
| // unbounded rather than panic. | ||
| if got := l.Limit("/a"); got != "/a" { | ||
| t.Errorf("(*PathLimiter)(nil).Limit(%q) = %q, want it returned unchanged", "/a", got) | ||
| } | ||
| if got := l.Limit("/b"); got != "/b" { | ||
| t.Errorf("(*PathLimiter)(nil).Limit(%q) = %q, want it returned unchanged", "/b", got) | ||
| } | ||
| } | ||
|
|
||
| func TestPathLimiterIsPerInstance(t *testing.T) { | ||
| withPathCap(t, 1) | ||
| a, b := NewPathLimiter("container-a"), NewPathLimiter("container-b") | ||
|
|
||
| // One noisy container filling its cap must not silence another's routes. | ||
| a.Limit("/only") | ||
| if got := a.Limit("/second"); got != OtherPath { | ||
| t.Errorf("a.Limit(%q) = %q, want %q", "/second", got, OtherPath) | ||
| } | ||
| if got := b.Limit("/second"); got != "/second" { | ||
| t.Errorf("b.Limit(%q) = %q, want it admitted on an independent limiter", "/second", got) | ||
| } | ||
| } |
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
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
Oops, something went wrong.
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.
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.
In Go, it is idiomatic and highly recommended to make the zero value of a struct usable. Currently, if
PathLimiteris initialized as a zero value (e.g.,var limiter common.PathLimiter), callingLimitwill panic on the first write because theseenmap isnil. We can easily make the zero value usable by lazily initializing theseenmap under the write lock and safely checking fornilin the read lock path.