The B2B Podcast Index
Index
All categories
MarketingSalesSaaSFinanceHROpsLeadershipCustomer SuccessAI & DataProductStartups & FoundersRevOpsEngineering & DevTools
MethodologySubmit
Best of:MarketingSalesSaaSFinanceHROpsLeadershipCustomer SuccessAI & DataProductStartups & FoundersRevOpsEngineering & DevTools
An independent project byFame
SearchBest episodesGuestsInsightsMethodologySubmit a podcast
Index/Engineering & DevTools/DevOps Daily with Fexingo
DevOps Daily with Fexingo artwork

How Kubernetes Custom Resource Definitions Cause Controller Memory Leaks

DevOps Daily with Fexingo · 2026-06-30 · 11 min

0:00--:--

Key moments - from our scoring

Substance score

85 / 100

Five dimensions, 20 points each

Insight Density18 / 20
Originality16 / 20
Guest Caliber17 / 20
Specificity & Evidence19 / 20
Conversational Craft15 / 20

This episode dissects a subtle but pervasive memory leak pattern in Kubernetes controllers managing custom resource definitions. The core problem: controllers often store pointers to objects retrieved from the client-go informer cache in their own local data structures (maps, slices, caches), preventing garbage collection even after the resources are deleted from the cluster. Lucas walks through a real fintech case where a controller managing 50,000 payment transaction CRDs grew from 200MB to 3GB over two weeks before hitting OOM, traced to keying a deduplication cache by object UID rather than namespace/name, causing the controller to accumulate stale object versions. The fix involves three disciplines: always deep-copy objects before storing them (using `obj.DeepCopy` or `runtime.DeepCopyObject`), key internal caches by namespace/name not UID, and scope informer watches with label or field selectors. Detection relies on monitoring memory trends in Prometheus metrics and using `go tool pprof` heap dumps to identify unreachable CRD instances. Even frameworks like Operator SDK and controller-runtime don't prevent this - it's a developer discipline issue. The episode covers how to enable pprof profiling endpoints safely and implement proper cache pruning strategies.

Key takeaways

  • →Controllers leak memory by storing pointers to informer cache objects in their own data structures, pinning memory that can never be freed even after the resource is deleted.
  • →Always deep-copy custom resource objects before storing them locally and key internal caches by namespace/name, not by UID, to prevent accumulating stale versions.
  • →Memory leaks manifest as linear heap growth over time without plateau; detect them with Prometheus monitoring, heap dumps via `go tool pprof`, and profiling endpoints enabled on internal ports.
  • →The fintech case reduced controller memory from 3GB to 400MB with a single fix: changing the cache key from UID to namespace/name and adding a deep copy, a 10x improvement.
  • →Scope informer watches with label or field selectors to avoid caching unnecessary CRD instances, and set container memory limits to catch runaway growth early.

Guests

Luna

Topics in this episode

Operator SDKKubernetes custom resource definitions (CRDs)client-go informer cacheshared informer objectsmemory leaks in controllersgo tool pprofPrometheus metricscontroller-runtimefinalizersdeep-copy patterns

Questions this episode answers

Why does a Kubernetes controller's memory grow unbounded even though it's not storing large objects?

The controller is storing pointers to objects from the shared informer cache rather than deep copies. Since the informer cache and controller cache reference the same underlying objects, the garbage collector cannot free the memory when the resource is deleted, causing the heap to grow linearly with each create-delete cycle.

How do you detect a memory leak in a Kubernetes custom resource controller?

Monitor the container's memory usage for linear growth without plateau using Prometheus `container_memory_working_set_bytes`. Take a heap dump with `go tool pprof` and look for large numbers of your CRD struct instances still reachable from the main controller struct. Verify that memory doesn't drop after deleting resources and that reconciliation latency increases over time.

What's the difference between keying a controller cache by UID versus namespace/name?

UIDs change each time a resource is recreated or updated, so keying by UID causes the controller to accumulate a separate cache entry for every version of a resource. Keying by namespace/name ensures only the current state is cached, preventing stale object accumulation.

Does the Operator SDK or controller-runtime prevent this memory leak pattern?

No. While controller-runtime includes internal caching, it doesn't prevent developers from storing pointers to informer objects. Deep-copying before storage is a developer responsibility, not something the framework enforces.

How much does deep-copying objects cost in terms of performance?

Deep-copying typically adds 5-10% overhead to reconciliation time (negligible in most cases) and is far outweighed by the cost of preventing unbounded memory growth and OOM kills.

What our scoring noted

Our reviewer’s read on each dimension, with quotes from the episode.

Insight Density

18 / 20

The episode is packed with specific, actionable technical insights about a non-obvious failure mode that most Kubernetes operators don't anticipate. Nearly every exchange reveals a new layer of the problem - from the shared pointer issue, to UID-based caching mistakes, to detection patterns with heap dumps and Prometheus metrics. There is minimal filler; almost every statement advances understanding of the root cause and solution.

The cache itself doesn't grow unbounded - it's keyed by namespace and name, so in theory it's bounded by the number of custom resource instances. The problem is what the controller does with the objects it gets from the cache.
If you store that pointer in your own data structure, the cache can never release the memory for that key-value pair.

Originality

16 / 20

This is a deep dive into a specific, subtle Kubernetes failure mode that is not part of mainstream devops discourse. While the underlying concepts (garbage collection, pointer retention, caching) are standard, the application to CRD controllers and the fintech case study feel fresh and non-obvious. The recommendation to deep-copy before storing is straightforward but clearly underexecuted in practice, making this contrarian relative to typical operator documentation.

It's one of those things that seems obvious in hindsight but is easy to miss when you're focused on business logic.
The Kubernetes documentation does mention that informer objects should not be modified, but it doesn't explicitly say 'deep-copy before storing'.

Guest Caliber

17 / 20

Lucas appears to be a seasoned Kubernetes operator developer with direct production debugging experience at fintech scale (50k+ CRDs, 3GB heap leaks). He demonstrates deep knowledge of client-go internals, profiling tools, and has debugged this exact issue multiple times. Luna is clearly knowledgeable but acts more as an interviewer; Lucas is the practitioner with hard-won scars from production incidents.

I worked with a fintech that ran a controller managing about fifty thousand custom resources representing payment transactions.
I've had three separate debugging sessions over the past year where the root cause was exactly this.

Specificity & Evidence

19 / 20

The episode is exceptionally specific: named companies (fintech), exact memory numbers (3GB, 400MB, 200MB), object counts (50k CRDs), time windows (12-15 days, 2 weeks), heap percentages (90%), and performance impact (5% CPU overhead, 10x improvement). Includes specific tools (`go tool pprof`, Prometheus metrics, heap dumps) and code patterns (UID vs namespace/name keying, `DeepCopy()`, `runtime.DeepCopyObject`). Detection heuristics are concrete: linear growth, memory not dropping after delete, increasing latency.

I worked with a fintech that ran a controller managing about fifty thousand custom resources representing payment transactions. After two weeks, the controller's heap was three gigabytes, and it got oom killed.
Memory dropped to 400 megabytes and stabilised.

Conversational Craft

15 / 20

Luna asks sharp follow-up questions that clarify the root cause ('So it's not the informer, it's the controller holding references') and pushes back on assumptions (asking whether the informer cache itself leaks, noting that deep-copying adds overhead). However, Luna largely validates Lucas's points without pushing back on recommendations or exploring edge cases more aggressively. The conversation stays on track and productive but lacks the kind of productive disagreement or deeper skepticism that would elevate it further.

And because the informer cache shares the same underlying object pointer, the controller is effectively pinning that memory, preventing the garbage collector from freeing it. Lucas: Yes.
Or use the Go race detector? No, that's for concurrency bugs. Lucas: Right.

Conversation analysis

Computed from the transcript - who did the talking, and the words that came up most.

Most-used words

controller34lucas30luna29cache22memory18informer14object13custom12resource10leak10objects10deep10copy9pattern7storing7resources6

Episode notes

Episode 83 of DevOps Daily with Fexingo dives into a subtle but costly Kubernetes issue: Custom Resource Definitions (CRDs) and the controllers built to manage them can silently leak memory over weeks of operation. Lucas and Luna walk through a real incident at a mid-sized fintech company where in-memory caches of CRD objects grew unbounded, leading to OOM-killed controllers and cascading failures. They explain why the problem stems from the way client-go's informer cache interacts with custom resources under high-churn workloads, and what operators can do to catch it before it hits production. If you manage a Kubernetes cluster with custom resources, this episode will change how you monitor controller memory. #Kubernetes #CRD #CustomResourceDefinitions #MemoryLeak #Controller #ClientGo #InformerCache #GoProgramming #OOMKill #Fintech #ProductionIncident #KubernetesOperator #CloudNative #SiteReliabilityEngineering #DevOps #Technology #FexingoBusiness #BusinessPodcast Keep every episode free: buymeacoffee.com/fexingo

Full transcript

11 min

Transcribed and scored by The B2B Podcast Index.

Lucas: So you deploy a custom resource definition, write a controller to reconcile it, and everything runs fine for days. Then, somewhere between day twelve and day fifteen, the controller gets oom killed. Luna: Classic Kubernetes memory leak pattern - but the controller isn't doing anything obviously wrong. The code looks clean.

Lucas: Right. And the root cause is almost never in the controller logic itself. It's in the client-go informer cache and how it interacts with custom resources under churn. Luna: Can you unpack that?

The informer cache is supposed to be a thread-safe store. What's leaking? Lucas: The cache itself doesn't grow unbounded - it's keyed by namespace and name, so in theory it's bounded by the number of custom resource instances. The problem is what the controller does with the objects it gets from the cache.

Luna: So it's not the informer, it's the controller holding references. Lucas: Exactly. The typical pattern: a controller's reconciliation loop receives an object from the informer, processes it, and then - often implicitly - stores a pointer to that object in a local map or slice for later use. Luna: And because the informer cache shares the same underlying object pointer, the controller is effectively pinning that memory, preventing the garbage collector from freeing it.

Lucas: Yes. The shared informer returns a pointer to the object in its cache. If you store that pointer in your own data structure, the cache can never release the memory for that key-value pair. Luna: Even after the custom resource is deleted from the cluster?

Lucas: The informer cache will evict the entry when it receives a delete event. But your controller's local map still holds a reference to the old object, so the GC can't collect it. That's a leak. Luna: So the controller accumulates stale objects over time.

How bad can that get? Lucas: I worked with a fintech that ran a controller managing about fifty thousand custom resources representing payment transactions. Transactions are created and deleted constantly - high churn. After two weeks, the controller's heap was three gigabytes, and it got oom killed.

Luna: Three gigs for fifty thousand objects? That's about sixty kilobytes per object. For a CRD that probably has a handful of fields, that's a lot. Lucas: It's because the controller wasn't just holding the latest state.

It was retaining every version of each object it had ever seen, because it was caching reconciliation results by object UID, not by name. Luna: Ah, the UID changes on each update - even a spec change generates a new UID if the resource is recreated. So the controller accumulated entries for every version. Lucas: Exactly.

The fix was simple: key the local cache by namespace and name, not UID, and explicitly deep-copy objects before storing them. But nobody thinks to do that until they see the memory graph. Luna: Let's talk about detection. If you're not profiling the controller, how do you spot this pattern?

Lucas: First sign: the controller's memory usage grows linearly over time, with no plateau. Second: after a delete event for an old resource, memory doesn't drop. Third: the controller's reconciliation latency increases because it's iterating over an ever-growing local cache. Luna: And you can confirm by taking a heap dump and looking for objects of the custom resource's Go type that are referenced from your controller's internal maps.

Lucas: Right. Use `go tool pprof` with the controller's profiling endpoint. If you see hundreds of thousands of instances of your CRD struct that are still reachable from the main controller struct, you've found the leak. Luna: What about the informer cache itself?

Could that ever leak? Lucas: It can, in a different way. If the controller registers a custom event handler that does not properly filter or if it watches custom resources across all namespaces without setting a label selector, the informer cache will hold every instance of that CRD in the cluster. Luna: But that's not a leak, that's just a large cache.

It's bounded by the number of CRD instances. Lucas: True. But if you have a cluster with hundreds of thousands of custom resources, that cache alone could be gigabytes. And if the controller doesn't need all of them, you're wasting memory.

Luna: So the lesson is: always use field selectors or label selectors to scope the informer's watch, and always deep-copy objects before storing them in your own data structures. Lucas: I'd add one more: set a memory limit on the controller's container, and monitor the `container_memory_working_set_bytes` metric in Prometheus. If it trends upward without bound, you have a leak. Luna: And if you're using an operator framework like the Operator SDK, does it protect against this?

Lucas: Partially. The controller-runtime library that the Operator SDK uses does some caching internally, but it doesn't prevent the controller from storing pointers to informer objects. The developer still has to deep-copy. Luna: So it's a discipline issue.

Let's talk about a real-world case - the fintech you mentioned. How did they debug it? Lucas: They noticed that the controller's memory usage grew from 200 megabytes to 3 gigabytes over two weeks. They added profiling endpoints, took a heap dump, and saw that 90% of the heap was occupied by their CRD struct.

Luna: And the struct was mostly strings and timestamps, so the memory was from the sheer number of objects, not from large fields. Lucas: Right. They had stored a reference to the object in a map keyed by UID for deduplication. They changed the key to namespace/name and added a deep copy.

Memory dropped to 400 megabytes and stabilised. Luna: That's a ten-x improvement from one code change. I'm surprised this isn't more widely documented in best practices guides. Lucas: It's one of those things that seems obvious in hindsight but is easy to miss when you're focused on business logic.

The Kubernetes documentation does mention that informer objects should not be modified, but it doesn't explicitly say 'deep-copy before storing'. Luna: And if you modify the object in place, you corrupt the informer cache for other controllers watching the same resource. Lucas: Exactly. That's an even worse bug - silent data corruption.

But the memory leak is more common because many developers assume the informer cache is immutable and don't even think about storing references. Luna: So what's the recommended pattern? Should every controller deep-copy the object at the start of the reconciliation loop? Lucas: Yes.

Use `obj.DeepCopy` or, if you're using client-go's `Object` interface, use `runtime.DeepCopyObject`. Then work with the copy.

Never store the original pointer. Luna: Does deep-copying add significant overhead? Lucas: It adds CPU and memory allocation, but it's usually negligible compared to the cost of a leak. In the fintech case, the deep copy added about 5% to reconciliation time, but prevented unbounded growth.

Luna: That's a good trade-off. What about controllers that need to cache state across reconciliations? Like an operator that maintains a list of all resources it has processed. Lucas: Then you definitely need a separate cache with deep-copied objects.

And you should periodically prune that cache - remove entries for resources that no longer exist in the cluster. A common approach is to use a finalizer to ensure cleanup. Luna: But finalizers can cause their own issues, as we covered in episode 67. Lucas: Fair point.

But for this use case, a finalizer on the custom resource ensures that when the resource is deleted, the controller's cache entry is cleaned up. Without it, the cache entry becomes a zombie. Luna: Let me ask a broader question: are there any tools that can detect this pattern automatically? Lucas: Not specifically for this leak pattern.

But you can write a custom Prometheus rule that alerts if the controller's memory grows by more than, say, 10% per day over a week. That would catch it. Luna: Or use the Go race detector? No, that's for concurrency bugs.

Lucas: Right. The memory leak is a logic bug, not a race. The best detection is monitoring and profiling. I actually wish the Kubernetes controller-runtime project would include a built-in memory profiler endpoint by default.

Luna: It does include a metrics endpoint, but not a pprof endpoint. You have to enable it yourself. Lucas: Which is a one-liner: `import _ "net/http/pprof"` and expose an HTTP server. But many production controllers don't have it enabled because it's considered a security risk.

Luna: You can expose it on a separate, internal port. That's what we do at my company. Lucas: Good practice. Let's wrap up with a practical checklist.

When writing a controller for a custom resource: one, always deep-copy the object before storing it. Two, key your internal caches by namespace and name, not UID. Three, set a memory limit and monitor it. Four, enable profiling endpoints on an internal port.

Luna: And five, if you're using finalizers, ensure your cache cleanup runs even if the controller crashes and restarts. Lucas: Absolutely. The pattern is subtle, but once you've seen it, you'll never unsee it. I've had three separate debugging sessions over the past year where the root cause was exactly this.

Luna: I think it's worth doing a follow-up episode on how to properly design operator caches. Lucas: That's a good idea. We could talk about using work queues, rate limiting, and how to avoid storing state altogether by using the API server as the source of truth. Luna: Yeah, the less state you keep in the controller, the fewer leaks you can have.

Lucas: I think we've covered the core issue. If you're running a controller for a CRD, go check your code for stored pointers. It might save you a 3 a.m.

pager call.

Related episodes across the Index

Other episodes covering the same guests and topics, from across The B2B Podcast Index.

  • How Idempotency-Key Design Prevents Payment DisastersThe Developer Tools Podcast with Fexingo · features Luna98 / 100
  • Why Pipeline Velocity Trumps Deal Size Every TimeThe Growth Operator with Fexingo · features Luna95 / 100
  • Why Enterprise Software Deals Now Include a Vendor AI Model Explainability MandateB2B SaaS Talks with Fexingo · features Luna94 / 100
  • How B2B Brands Wreck Pipeline with Unsyncroned CRM DataThe Marketing Operator Podcast with Fexingo · features Luna92 / 100
  • Why Marketing Attribution Misses the Seasonality PatternMarketing Analytics with Fexingo · features Luna91 / 100
  • How to Sell Against a Competitor Already in the BuildingSales Leadership with Fexingo · features Luna85 / 100

More from DevOps Daily with Fexingo

All episodes →
  • How Kubernetes StatefulSet PVC Resizing Causes Node Disk Failures94 / 100
  • How Kubernetes Topology Spread Constraints Create Scheduling Hotspots95 / 100
  • How Kubernetes CRD Versioning Breaks Controller Upgrades90 / 100
  • How Kubernetes Audit Logging Causes etcd Performance Degradation91 / 100
  • How Kubernetes Volume Snapshots Cause Storage Backend Data Corruption85 / 100
Explore the best B2B Engineering & DevTools podcasts →
All DevOps Daily with Fexingo episodes →