Finding and Fixing Memory Leaks in .NET Services on Linux Containers

Aug 02, 20267 min read

Share|

Category:.NET

Finding and Fixing Memory Leaks in .NET Services on Linux Containers

Your .NET service on Linux consumes more memory every hour until the container OOMs. Diagnose managed and native memory leaks step by step — from metrics and heap dumps to the exact fix. Based on real production incidents.

The Symptom

Your .NET service runs in a Linux container. Memory usage climbs steadily — 200MB at startup, 400MB after an hour, 800MB after three hours. Eventually the container hits its memory limit, the OOM killer terminates the process, and Kubernetes restarts it. The cycle repeats.

You check the code. No obvious leaks. No large collections held in memory. No static dictionaries growing without bound. The garbage collector is running — you can see Gen0 and Gen1 collections in the metrics — but memory keeps climbing anyway.

This is a memory leak. But it may not be in your code.


Managed vs Native: The Two Leak Types in .NET on Linux

.NET on Linux has two memory spaces that can leak independently. Understanding which one is leaking is the first diagnostic step.

Leak TypeMemory SpaceToolsCommon Causes
Managed leak.NET GC heapdotnet-gcdump, dotnet-dumpStatic collections, event handler registrations, undisposed IDisposable, pinned objects
Native leakProcess heap (malloc)dotnet-dump, perf, heaptrackNative interop (P/Invoke without free), socket/file handle leaks, TLS/SSL session caches, third-party native libraries

The key distinction: a managed leak shows up in the GC heap size. Garbage collection runs but cannot free the memory because something is still referencing the objects. A native leak grows outside the GC heap. The GC heap looks normal — the leak is happening at the OS level, outside managed memory entirely.


How to Diagnose

Step 1: Determine Which Type of Leak

Run dotnet-counters against your container. Look at two specific metrics:

code
dotnet-counters monitor -p <pid> --counters System.Runtime

Watch these counters over time:

CounterWhat It Tells You
gc-heap-sizeTotal size of managed objects on the GC heap. Growing without bound = managed leak.
working-setTotal physical memory the process is using. Growing while gc-heap-size is stable = native leak.

If gc-heap-size is climbing, the leak is managed. Skip to Step 2.

If working-set is climbing but gc-heap-size is stable, the leak is native. Skip to Step 3.

Step 2: Diagnose a Managed Leak

Take a GC dump. This is different from a full memory dump — it contains only the GC heap, making it smaller and faster to collect:

bash
dotnet-gcdump collect -p <pid> -o leak.gcdump

Open it in PerfView or dotnet-gcdump report:

bash
dotnet-gcdump report leak.gcdump

Look at the top objects by total size. Sort by inclusive size (the total memory an object and everything it references).

The most common managed leak patterns:

1. Event handler leak. An object subscribes to a static event and is never unsubscribed. The event source holds a reference to the subscriber forever. In the heap dump, look for objects of a type you expect to be short-lived appearing in Gen2 (the long-lived generation).

2. Static collection growth. A static List<T> or static Dictionary<K,V> that only ever has items added, never removed. In the heap dump, look for large collections referenced from static roots.

3. Pinned objects. Memory pinned with GCHandle.Alloc(obj, GCHandleType.Pinned) cannot be moved or collected by the GC. These show up as "pinned" objects in the heap analysis. Common in async I/O and native interop.

4. Large Object Heap fragmentation. Objects larger than 85,000 bytes go to the Large Object Heap (LOH). The LOH is not compacted by default. Over time, fragmentation creates unusable gaps between objects. The total heap size grows even though the "live" object size is stable. Check LOH size vs LOH fragmentation in the GC dump.

Step 3: Diagnose a Native Leak

Native leaks are harder because they happen outside the GC. Use dotnet-dump to analyze the native heaps:

bash
dotnet-dump collect -p <pid> -o leak.dmp
dotnet-dump analyze leak.dmp

Run these commands in the dump analyzer:

code
> eeheap -gc     # Shows managed heap — check if it matches working set
> dumpheap -stat  # Top managed types — confirm nothing unexpected

If the GC heap is small but working set is large, the leak is native. Common causes on Linux:

1. P/Invoke without cleanup. Calling native functions via [DllImport] that allocate memory but never free it. Most common with custom native libraries or poorly wrapped C APIs. Look for Marshal.AllocHGlobal calls without corresponding Marshal.FreeHGlobal.

2. Socket and file handle leaks. Each open socket or file handle consumes a small amount of native memory for kernel buffers. Over days, thousands of leaked handles add up to hundreds of megabytes. Check with lsof -p <pid> inside the container. Look for an unexpectedly high number of open sockets or files.

3. TLS/SSL session caches. HttpClient and SslStream cache TLS sessions for performance. Under high connection churn, the cache grows without bound. Configure limits with SslClientAuthenticationOptions.CertificateValidationCallback or reduce PooledConnectionLifetime on SocketsHttpHandler.

4. Third-party native libraries. Database drivers, gRPC native components, image processing libraries — any library with a native dependency can leak. Isolate by disabling libraries one at a time in a staging environment and watching working-set.


The Fix

Managed Leak Fixes

CauseFix
Event handler leakUnsubscribe in Dispose() or a finalizer. Use weak event patterns for long-lived publishers.
Static collection growthAdd eviction policies (LRU cache, max size). Use ConcurrentDictionary with periodic cleanup. Never let a static collection grow without bound.
Pinned objectsFree GCHandle with .Free() when done. Use ArrayPool<T> for large buffers instead of pinning arrays manually.
LOH fragmentationEnable LOH compaction in .NET 6+: GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce. Consider pooling large objects.

Native Leak Fixes

CauseFix
P/Invoke leakAudit all [DllImport] calls. Every AllocHGlobal must have a matching FreeHGlobal in a finally block. Use SafeHandle wrappers.
Socket/handle leakWrap all disposable I/O resources in using statements. Enable DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2UNENCRYPTEDSUPPORT=1 only if needed.
TLS session cacheSet SocketsHttpHandler.PooledConnectionLifetime to 2-5 minutes. Set SslClientAuthenticationOptions.AllowRenegotiation = false.
Third-party native leakUpgrade the library. Report the leak. If neither is possible, restart the container on a schedule as a mitigation while you migrate away from the library.

Prevention

  1. Monitor working-set and gc-heap-size separately. A combined "memory usage" metric hides which type of leak you have. Monitor them independently and alert on sustained growth for either.

  2. Set container memory limits with headroom. If your service normally uses 500MB, set the container limit to 800MB. The 300MB headroom gives you time to detect a leak before OOM. Alert when usage exceeds 70% of the limit.

  3. Run memory leak tests in CI. Use dotnet-gcdump in integration tests that run for extended periods. Assert that gc-heap-size after 10,000 requests is within 20% of the baseline. Catch leaks before they reach production.

  4. Restart on a schedule as a last resort. If a leak cannot be fixed (third-party library, legacy code), configure Kubernetes to restart the pod during low-traffic hours. This is not a fix. It is a mitigation with a deadline.


When You Need This Diagnosed Now

Memory leaks in .NET on Linux require understanding both managed and native memory, reading GC heap dumps, and tracing native allocations. Getting it wrong means days of restarting containers every few hours while the real leak continues.

I diagnose and fix memory leaks in .NET services as part of an Architecture & Performance Audit. Three to five business days. Root cause identified. Written report with prioritized fixes delivered.

Book a diagnostic call.

Related reading: Diagnosing Thread Pool Starvation in ASP.NET Core Under Production Load — same production diagnostic approach applied to a different failure mode.


FAQ

Monitor gc-heap-size and working-set together over an hour. If they grow at roughly the same rate, the leak is managed. If working-set grows significantly faster than gc-heap-size, the leak is native. This test takes 15 minutes and requires only dotnet-counters — no dumps needed for the initial classification.

Production traffic patterns — connection churn, request volume, concurrent users — trigger leak paths that low-volume dev traffic never hits. Event handler leaks require thousands of subscribe/unsubscribe cycles to become visible. Socket leaks require connection churn that does not happen locally. Test with production-scale load in a staging environment.

No. The GC can only free objects that are unreachable. A leak means objects are still reachable (managed leak) or the allocation is outside the GC entirely (native leak). The GC running is a sign that memory is being managed correctly — but it cannot fix references you are holding or native allocations you are not freeing.

As infrequently as possible. Weekly during a maintenance window is the most aggressive schedule that is operationally acceptable. Daily restarts mean the leak is severe enough that diagnosis should be your top engineering priority. If the leak fills the headroom in under 24 hours, the system is at risk of OOM during any traffic spike. Stop the mitigation cycle and fix the leak.

Related posts

Next step

Minimal APIs, OpenTelemetry, idempotency, and legacy .NET rescue patterns.

Explore .NET Production Reliability →