fix(metrics): cap distinct HTTP path labels per container - #335
mayankpande88 wants to merge 1 commit into
Conversation
`path` is the only L7 label an outside party controls. On the Rackspace
cluster the two internet-facing ingress-nginx pods had accumulated 10,349
distinct path values, 9,601 of them 404s from vulnerability scanners
(/axds.php, /HNAP1, /wp-content/plugins/hellopress/wp_filemanager.php).
normalizeHttpPath cannot collapse these — they are genuinely distinct
literals — so container_http_requests_total grew a permanent series per
probe for as long as the scanning continued, with no upper bound.
Bound it per container: paths seen before the cap always pass through, so
a steady application keeps reporting its real routes; paths first observed
after the cap fills collapse to {other}, which keeps the request count
correct while the series count stops growing. Normalization still runs
first, so a parameterized route consumes one slot rather than one per
parameter value. Default 1000, well above what a real application serves,
so the cap should only ever engage on scanner traffic. Set
MAX_HTTP_PATHS_PER_CONTAINER=0 to restore the old unbounded behaviour.
The limiter lives in common/ rather than containers/ because CI excludes
the containers package (NVML's dlsym aborts the test binary on non-GPU
runners), so a test placed there would never run.
Note this is separate from the destination-IP churn fixed in #280; on that
cluster the two together account for 447k of ~690k head series.
There was a problem hiding this comment.
Code Review
This pull request introduces a PathLimiter to bound the number of distinct HTTP path label values per container, preventing cardinality explosion from scanner traffic. It also adds a configuration flag MaxHttpPathsPerContainer and integrates the limiter into L7Stats. Feedback suggests making the zero value of PathLimiter usable by lazily initializing the seen map to prevent potential panics when the struct is zero-initialized.
| 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 | ||
| } |
There was a problem hiding this comment.
In Go, it is idiomatic and highly recommended to make the zero value of a struct usable. Currently, if PathLimiter is initialized as a zero value (e.g., var limiter common.PathLimiter), calling Limit will panic on the first write because the seen map is nil. We can easily make the zero value usable by lazily initializing the seen map under the write lock and safely checking for nil in the read lock path.
l.mu.RLock()
var seen bool
if l.seen != nil {
_, 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()
if l.seen == nil {
l.seen = make(map[string]struct{})
}
// 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
}
Problem
Prometheus in
nudgebee-agenton the Rackspace cluster is being OOMKilled (7 restarts against an 8Gi limit).job=nudgebee-node-agentis 447k of ~690k head series (65%).Two independent drivers. This PR addresses the second.
1. Destination IP churn — already fixed, not shipped there.
actual_destinationwas a rawIP:port, sosqs.us-east-1.amazonaws.comalone minted 331 distinct series for one logical destination. #280 collapses this to workload identity and landed in v0.1.1; the cluster runs0.1.0. Fixed by upgrading, no code change needed.2. Scanner-driven path cardinality — unfixed until now. The two internet-facing
ingress-nginxpods had accumulated 10,349 distinctpathvalues, 9,601 of them 404s:normalizeHttpPathcannot collapse these — they are genuinely distinct literals, not parameterized routes.pathis the only L7 label an outside party controls, and nothing bounded it, socontainer_http_requests_totalgrew a permanent series per probe for as long as the scanning continued.Change
A per-container
common.PathLimiterin front of thepathlabel:{other}, so the request count stays correct while the series count stops growing./api/users/{id}consumes one slot, not one per ID.MAX_HTTP_PATHS_PER_CONTAINER, well above what a real application serves.0restores the previous unbounded behaviour.Latency histograms are untouched — they carry no
pathlabel.Why
common/and notcontainers/CI runs
go test $(go list ./... | grep -v '/containers$'). Thecontainerspackage is excluded because NVML's dlsym aborts the test binary on non-GPU runners, so a test placed there would never execute. The logic lives incommon/, which CI does test, andL7Statsjust holds one.Testing
6 tests, race-clean, verified in a Linux container (
containersdoes not compile on macOS):{other}0means unlimitedFull CI-equivalent run on Linux:
gofmt -lclean,go vet ./...clean, all non-containerspackages pass,go build -mod=readonly .succeeds.Rollout
Upgrading Rackspace off
0.1.0is the larger win of the two (it removes the 255k histogram-bucket series that come purely from IP churn). This cap is what stops the counter side regrowing afterward. Watch the chart pin on that upgrade: k8s-agent charts <0.1.17 hardcodecommand: ["coroot-node-agent"], so an image-only bump gives RunContainerError.