Skip to content

fix: stop custom resource reflectors on context cancellation#2883

Open
colega wants to merge 2 commits intokubernetes:mainfrom
colega:fix/cr-reflector-goroutine-leak
Open

fix: stop custom resource reflectors on context cancellation#2883
colega wants to merge 2 commits intokubernetes:mainfrom
colega:fix/cr-reflector-goroutine-leak

Conversation

@colega
Copy link
Copy Markdown

@colega colega commented Mar 6, 2026

Summary

Fixes #2867

Custom resource reflectors only listened to their per-GVK stop channel, ignoring context cancellation from BuildWriters. When BuildWriters rebuilt stores after a CRD update event, it cancelled the context to stop old reflectors, but custom resource reflectors kept running since they were waiting on a different stop channel. Each rebuild accumulated leaked Reflector.Run and StreamWatcher.receive goroutine pairs, causing unbounded memory, CPU, and network growth.

This merges both stop signals so CR reflectors stop on either context cancellation or per-GVK stop channel close.

I found one caveat however: the Builder's context is replaced after we start the resource reflectors, so I changed how WithContext behaves: instead of replacing the context, we subscribe to that context's cancellations. I think this can be achieved in some more cleaner way, for example removing WithContext and exporting a Stop() method on builder's interface, but I'm not familiar with the codebase and I don't know whether this exported contract can be modified.

@k8s-ci-robot k8s-ci-robot added do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. do-not-merge/invalid-commit-message Indicates that a PR should not merge because it has an invalid commit message. labels Mar 6, 2026
@k8s-ci-robot
Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: colega
Once this PR has been reviewed and has the lgtm label, please assign mrueg for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@k8s-ci-robot k8s-ci-robot added cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. needs-triage Indicates an issue or PR lacks a `triage/foo` label and requires one. labels Mar 6, 2026
@k8s-ci-robot
Copy link
Copy Markdown
Contributor

This issue is currently awaiting triage.

If kube-state-metrics contributors determine this is a relevant issue, they will accept it by applying the triage/accepted label and provide further guidance.

The triage/accepted label can be added by org members by writing /triage accepted in a comment.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@k8s-ci-robot
Copy link
Copy Markdown
Contributor

Welcome @colega!

It looks like this is your first PR to kubernetes/kube-state-metrics 🎉. Please refer to our pull request process documentation to help your PR have a smooth ride to approval.

You will be prompted by a bot to use commands during the review process. Do not be afraid to follow the prompts! It is okay to experiment. Here is the bot commands documentation.

You can also check if kubernetes/kube-state-metrics has its own contribution guidelines.

You may want to refer to our testing guide if you run into trouble with your tests not passing.

If you are having difficulty getting your pull request seen, please follow the recommended escalation practices. Also, for tips and tricks in the contribution process you may want to read the Kubernetes contributor cheat sheet. We want to make sure your contribution gets all the attention it needs!

Thank you, and welcome to Kubernetes. 😃

@github-project-automation github-project-automation Bot moved this to Needs Triage in SIG Instrumentation Mar 6, 2026
@k8s-ci-robot k8s-ci-robot added the size/S Denotes a PR that changes 10-29 lines, ignoring generated files. label Mar 6, 2026
Custom resource reflectors only listened to their per-GVK stop channel,
ignoring context cancellation from BuildWriters. When BuildWriters
rebuilt stores (e.g. after a CRD update event), it cancelled the context
to stop old reflectors, but custom resource reflectors kept running.
Each rebuild accumulated leaked Reflector.Run and StreamWatcher.receive
goroutine pairs, causing unbounded memory, CPU, and network growth.

Merge both stop signals so CR reflectors stop on either context
cancellation or per-GVK stop channel close.
@colega colega force-pushed the fix/cr-reflector-goroutine-leak branch from a09ff70 to 6863cff Compare March 6, 2026 09:25
@k8s-ci-robot k8s-ci-robot removed the do-not-merge/invalid-commit-message Indicates that a PR should not merge because it has an invalid commit message. label Mar 6, 2026
@colega
Copy link
Copy Markdown
Author

colega commented Mar 6, 2026

I pushed a fix for the data race uncovered by the e2e tests, however I think the issue is bigger than just the data race: startReflector uses b.ctx before it's set by WithContext() in the MetricsHandler.BuildWriters.

Builder now creates its own cancellable context in NewBuilder instead of
accepting one via WithContext. WithContext subscribes to the provided
context's cancellation rather than replacing b.ctx, which avoids a data
race with concurrent startReflector reads and ensures already-running
reflectors get notified on cancellation.
@colega colega force-pushed the fix/cr-reflector-goroutine-leak branch from d9d8357 to 6efdcb4 Compare March 6, 2026 14:17
@colega
Copy link
Copy Markdown
Author

colega commented Mar 6, 2026

I pushed another fix to fix that: by the time when WithContext() is called on the Builder, the reflectors are already running, so we shouldn't replace b.ctx but instead watch the cancellations of the incoming context.

@colega
Copy link
Copy Markdown
Author

colega commented Mar 6, 2026

I have built an image with this and will run it over the weekend on our dev infrastructure to verify that the bug is fixed.

@colega colega marked this pull request as ready for review March 9, 2026 05:27
@k8s-ci-robot k8s-ci-robot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Mar 9, 2026
@k8s-ci-robot k8s-ci-robot requested a review from mrueg March 9, 2026 05:28
@colega
Copy link
Copy Markdown
Author

colega commented Mar 9, 2026

I've tested this fix and it shows no leaks.

@mrueg mrueg requested a review from Copilot March 10, 2026 09:54
@mrueg
Copy link
Copy Markdown
Member

mrueg commented Mar 10, 2026

Thanks for your contribution! I had a similar fix in #2870 but yours looks more complete.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes leaked goroutines for custom resource reflectors by ensuring they stop when either the per-GVK stop channel closes or the BuildWriters context is canceled (e.g., during store rebuilds after CRD updates).

Changes:

  • Merge stop signals for custom-resource reflectors so they stop on either context cancellation or per-GVK stop.
  • Initialize Builder with an internal cancelable context.
  • Change WithContext behavior to observe cancellation instead of replacing the builder context.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/store/builder.go
Comment on lines +154 to 165
// WithContext will observe the cancellations of the provided context
// to cancel the internal b.ctx.
func (b *Builder) WithContext(ctx context.Context) {
b.ctx = ctx
// WithContext might be called concurrently with startReflector.
// In order to avoid the data race, and also to notify the reflectors that
// are already running with the context, we don't replace b.ctx,
// but just subscribe to the cancellations of the provided context.
go func() {
<-ctx.Done()
b.cancel()
}()
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

WithContext now cancels a single internal context (created in NewBuilder) when the provided ctx is canceled, but that internal context is never replaced/reset. Since MetricsHandler.BuildWriters cancels the previous ctx on every rebuild, the second BuildWriters call will cancel b.ctx permanently, causing all subsequently started reflectors (including non-CR ones using b.ctx.Done()) to stop immediately and the cache to stop updating. To preserve rebuild behavior, WithContext should continue to replace the builder context (or atomically swap to a new derived context/cancel func) rather than subscribing old contexts to cancel a long-lived internal context.

Copilot uses AI. Check for mistakes.
Comment thread internal/store/builder.go
Comment on lines +634 to +642
stopCh := make(chan struct{})
crStopCh := (*b.GVKToReflectorStopChanMap)[cr.GroupVersionKind().String()]
go func(builderContext context.Context) {
select {
case <-builderContext.Done():
case <-crStopCh:
}
close(stopCh)
}(b.ctx)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The new stop-signal merge for custom resource reflectors is a concurrency/cancellation fix but isn't covered by tests in internal/store/builder_test.go. Adding a focused unit/integration test that exercises a CR reflector stopping on context cancellation (without closing the per-GVK stop channel) would help prevent regressions like goroutine leaks or reflectors not stopping on rebuild.

Suggested change
stopCh := make(chan struct{})
crStopCh := (*b.GVKToReflectorStopChanMap)[cr.GroupVersionKind().String()]
go func(builderContext context.Context) {
select {
case <-builderContext.Done():
case <-crStopCh:
}
close(stopCh)
}(b.ctx)
// For custom resources, prefer a per-GVK stop channel, but fall back to the
// builder context if the map or entry is not available.
if b.GVKToReflectorStopChanMap == nil {
go reflector.Run(b.ctx.Done())
return
}
gvk := cr.GroupVersionKind().String()
crStopCh, found := (*b.GVKToReflectorStopChanMap)[gvk]
if !found || crStopCh == nil {
go reflector.Run(b.ctx.Done())
return
}
stopCh := make(chan struct{})
go func(builderContext context.Context, crStopCh <-chan struct{}, stopCh chan struct{}) {
defer close(stopCh)
select {
case <-builderContext.Done():
case <-crStopCh:
}
}(b.ctx, crStopCh, stopCh)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@bhope bhope left a comment

Choose a reason for hiding this comment

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

Thanks for raising this PR with the fix!

Your commit 6efdcb4 will break the regression. If you check copilot's comment above, it describes why.

If you can drop above and just keep 6863cff that itself should fix the memory leak issue. Can you please also add tests for stop signal logic?

@github-project-automation github-project-automation Bot moved this from Needs Triage to In Progress in SIG Instrumentation Apr 9, 2026
@colega
Copy link
Copy Markdown
Author

colega commented Apr 9, 2026

There's no way I can keep just 6863cff: it adds a race condition. Builder's WithContext is being called concurrently with BuildCustomResourceStoresFunc invokations, which use that context, this is why I made that change.

I'm not sure what's the expected behavior for WithContext and how that lifecycle interacts with BuildCustomResourceStoresFunc.

I don't think I'll have time to dig into this in the short-term future, maybe you should consider reverting the piece of code that introduced this leak in the first place.

@bhope
Copy link
Copy Markdown
Member

bhope commented Apr 10, 2026

No worries @colega - Thanks for your time on this so far. We are continuing to fix this via a different PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. needs-triage Indicates an issue or PR lacks a `triage/foo` label and requires one. size/S Denotes a PR that changes 10-29 lines, ignoring generated files.

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Elevated Memory Utilization (v2.18.0)

5 participants