The Symptom: SOS Commands Return Impossible Values
03:47 UTC. The pager fires. An order-processing API — .NET 8, Kubernetes, 4 GiB cgroup limit — restarted after the OOM killer hit it. The orchestrator captured a core dump before terminating the pod, which is what we configured it to do. A colleague pulled the 3.2 GB core.<pid> file off the ephemeral container storage and loaded it into dotnet-dump analyze. Things went sideways fast.
Here is the annotated transcript of his first three commands:
> dotnet-dump analyze core.12345
Loading core dump. Please wait...
> !eeheap -gc
Number of GC Heaps: 2
------------------------------
Heap 0 (00007F8A40000000)
------------------------------
Small Object Heap
segment begin allocated allocated_size committed_size
00007F8A40000000 00007F8A40001000 00007F8A40200000 0x1FF000 0x200000
00007F8A40210000 00007F8A40211000 00007F8A40410000 0x200000 0x200000
00007F8A40600000 00007F8A40601000 00007F8A40800000 0x1FF000 0x200000
Large Object Heap
segment begin allocated allocated_size
00007F8A42000000 00007F8A42001000 00007F8A42000000 0x0 0x0
<--- allocated < begin: impossible
> !threads
ThreadCount: 14
UnstartedThread: 0
DeadThread: 2
Hosted Runtime: not applicable
> !dumpheap -stat -min 85000
Statistics:
MT Count TotalSize Class Name
<no output for LOH>
Look at that LOH line. The allocated pointer precedes the begin pointer. That is a structural impossibility — it means the DAC (Data Access Component) read garbage from the dump’s memory image and reported it as fact. !threads says 14 threads, but ps -eLf | grep 12345 | wc -l on the captured process snapshot showed 87. !dumpheap -stat on the large object heap returned nothing. Not an empty heap — a silent failure, no error, no diagnostic. These are not tooling quirks. They are symptoms of a dump that does not faithfully represent the process address space at the moment of signal delivery.
The evidence for this point is grounded in The Authors Guild, which keeps the article’s claims tied to outside reference material rather than product framing.
Hypothesis Formation: Three Failure Modes
When SOS returns impossible values from a Linux core dump, the cause is one of three things. The dump itself is truncated — the ELF core file is missing one or more PT_LOAD segments because the kernel’s coredump writer was interrupted or ran out of space. Or the DAC table is corrupted — the in-memory structures the CLR uses to describe its own state were mid-mutation when the signal arrived, so the DAC reads a half-written data structure and follows pointers into unmapped memory. Or a runtime feature like DATAS (Dynamic Assembly Transfer Address Space) in .NET 8 has remapped memory regions in a way the dump capture mechanism does not fully preserve, creating phantom mappings or gaps the DAC cannot traverse.
Each failure mode demands a different validation approach, and applying the wrong diagnostic path wastes hours. Truncated dumps are the easiest to detect and the most common on containers. Corrupted DAC tables are the hardest to prove and the rarest. DATAS interaction is specific to .NET 8+ and increasingly relevant as teams migrate.
Validating Dump Integrity Before Trusting Any SOS Output
Before running a single SOS command, validate the dump’s structural integrity. This is the step most engineers skip. They open the dump, run !clrstack, and start building a root-cause hypothesis on potentially fabricated data. The validation workflow has three stages: ELF segment completeness, runtime version consistency, and heap mapping cross-reference.
Stage 1: ELF Segment Completeness
Use readelf to inspect the program headers of the core file. A well-formed core dump contains one PT_LOAD segment for each readable memory mapping in the process. Compare the segment list against /proc/<pid>/maps if you captured it before the process died, or against the expected address space layout for a .NET 8 process if you did not.
$ readelf -l core.12345 | grep LOAD
LOAD 0x000000 0x0000556000000000 0x0000556000000000
LOAD 0x040000 0x0000556000400000 0x0000556000400000
LOAD 0x080000 0x00007F8A40000000 0x00007F8A40000000
LOAD 0x0C0000 0x00007F8A40210000 0x00007F8A40210000
<... 47 segments total ...>
$ grep -c 'r..p' /proc/12345/maps
63
47 LOAD segments in the dump. 63 readable, private mappings in /proc/<pid>/maps. The dump is missing 16 memory regions. Any SOS command that walks a heap segment or thread stack residing in a missing region will return garbage or nothing. That is exactly what happened here: the LOH segment at 0x00007F8A42000000 was present in /proc/<pid>/maps but absent from the ELF core file’s program headers.
The most common cause on Kubernetes is a cgroup-aware ulimit -c or a core_pattern pipe handler that enforces a size cap. Check /proc/sys/kernel/core_pipe_limit and the cgroup memory.max setting. If the core file size exceeds the cgroup’s writable limit for the dump destination, the kernel truncates the file silently. No error code returned to the signal handler. The file just ends mid-segment.
Stage 2: Runtime Version Consistency with !eeversion
Run !eeversion as your first SOS command — before anything else. This command reads the CLR version string from a known offset in the runtime’s data segment. If it returns a version string that does not match the .NET version deployed in the container, the DAC is reading from the wrong memory location, which means either the dump is truncated at a critical boundary or the DAC version loaded by dotnet-dump does not match the runtime that produced the dump.
> !eeversion
8.0.724.31311 @ 00007F8A3F200000
Product: Microsoft .NET Core 8.0.7
Epoch: 6
"Server" GC
The version string looked plausible — .NET 8.0.7, Server GC — consistent with the container image manifest. But cross-reference the reported runtime base address (0x00007F8A3F200000) against the ELF LOAD segments. If the address falls outside any mapped segment, the DAC is dereferencing a pointer into unmapped memory — fabricating the version string from residual data. In our case, the base address pointed into a LOAD segment that readelf reported as having a file size of zero bytes. The header existed. The content did not.
Stage 3: Heap Mapping Cross-Reference
For each heap segment that !eeheap -gc reports, verify that the segment’s begin address falls within an ELF LOAD segment whose file size is non-zero. Tedious, but necessary when you see impossible pointer relationships. Write a script that extracts segment begin addresses from !eeheap -gc output and checks each one against readelf -l output. Any segment whose begin address lands in a LOAD entry with FileSiz: 0 is a phantom — the DAC found the segment metadata in a mapped but unwritten memory region, and the heap walker will produce garbage for that segment.
The DATAS Complication in .NET 8
.NET 8 introduced DATAS (Dynamic Assembly Transfer Address Space), which changes how the runtime manages memory for dynamically generated assemblies and their associated code heaps. In previous .NET versions, the JIT allocated code in regions with predictable layouts tied to the runtime’s primary module base address. DATAS introduces a level of indirection. Assembly code heaps can be relocated to address ranges that the core dump machinery may not associate with the process’s primary address space in a way the DAC expects.
This matters for dump analysis because the DAC — the component that SOS and ClrMD use to traverse CLR data structures — was written with assumptions about memory layout continuity that DATAS partially invalidates. When a DATAS-remapped region falls in an ELF segment that the kernel’s coredump writer decided was not worth capturing (because it appeared to be a secondary mapping of already-captured memory), the DAC walks into a gap and produces the kind of impossible !eeheap -gc output we saw: allocated pointers preceding begin pointers, segment counts that do not match heap counts, and silent failures in !dumpheap.
It is critical to distinguish between the two collection mechanisms at play in this scenario. The kernel’s signal-driven core dump — triggered by SIGSEGV on a crash or SIGABRT from the OOM killer — captures the process address space from outside the runtime, with no coordination with the CLR’s internal state. The diagnostic port collection (dotnet-dump collect) is fundamentally different: it communicates with the runtime via the diagnostic IPC server, requests a suspension, and then captures memory while the runtime is in a known suspended state. In this incident, the dump was collected by the kernel’s signal-driven mechanism, not by dotnet-dump collect. That distinction is why the DAC tables could be mid-mutation: the kernel delivered SIGABRT while the runtime was in the middle of a DATAS remap operation, and no suspension handshake was performed. A diagnostic port collection would have avoided this specific failure mode because it controls the timing of the suspension. However, if a crash signal arrives while a proactive dotnet-dump collect is in progress — during the window between the suspension request and the memory snapshot — the same race can occur. There is no runtime-level fix for this race in .NET 8. The mitigation is to ensure proactive collection completes before the process is in a state where it might receive a signal.
Distinguishing DAC Corruption From Dump Truncation
Once you have validated the ELF structure and confirmed the runtime version, the next question is whether the impossible SOS output comes from a truncated dump or a corrupted DAC table. The distinction matters. A truncated dump requires you to fix the capture mechanism and re-collect. A corrupted DAC table may be irrecoverable from this dump and require a different collection strategy entirely.
The diagnostic is straightforward. Run !verifyheap. This command walks the managed heap and validates that each object’s method table pointer points to a valid type definition. If the dump is truncated but the DAC table is intact, !verifyheap reports errors only for objects in the missing segments — the objects it can reach pass validation. If the DAC table is corrupted, !verifyheap reports errors throughout the heap, including in segments whose ELF LOAD entries have non-zero file sizes.
> !verifyheap
Verifying heap...
Heap 0: ERROR: object 00007F8A40010000 has invalid MT 00007F8A3F240080
Heap 0: ERROR: object 00007F8A40010100 has invalid MT 0000000000000000
Heap 0: ERROR: object 00007F8A40010200 has invalid MT 00007F8A3F240080
Heap 1: OK (no errors found)
<-- Heap 1 is in a complete LOAD segment
In our case, !verifyheap reported errors only in Heap 0, and only for objects at addresses that fell within the ELF LOAD segment with FileSiz: 0. This confirmed the dump was truncated, not that the DAC was corrupted. The DAC table itself was readable and consistent — it was simply pointing to memory regions the dump file did not capture.
Fixing the Capture Pipeline
Once you confirm the dump is truncated, the fix is in the container’s core dump configuration. Three settings must be aligned: ulimit -c inside the container, the core_pattern kernel setting, and the cgroup memory limit for the dump destination.
First, ensure ulimit -c unlimited is set in the container’s entrypoint before the .NET process starts. Kubernetes does not propagate the host’s ulimit settings into containers. The container’s init process inherits the default, which is often zero (no core dumps) or a small cap. Add this to your Dockerfile’s ENTRYPOINT script:
#!/bin/sh
ulimit -c unlimited
exec dotnet MyApi.dll
Second, verify /proc/sys/kernel/core_pattern on the host. If it is set to a pipe handler (a line starting with |), the handler process runs in the host’s root cgroup and may have its own memory limit smaller than the container’s. If core_pattern writes to a filesystem path, ensure that path has enough free space. A 4 GiB process can produce a core file larger than 4 GiB because the file includes shared library mappings and the full stack.
Third, consider switching from signal-driven core dumps to proactive dotnet-dump collect via the diagnostic port for incidents where you have warning signs (rising memory, latency spikes) before a crash. The diagnostic port collection controls the suspension handshake and avoids the race condition between SIGABRT/SIGSEGV delivery and runtime state mutation. Configure a sidecar or init container that can execute dotnet-dump collect --process-id <pid> on demand. The trade-off: diagnostic port collection requires the runtime to be responsive enough to process the IPC request, which is not guaranteed during severe memory pressure. For crash scenarios, the signal-driven kernel core dump remains the fallback, and the ELF validation workflow described above is your safety net.
Reconstructing the Incident Timeline From Fragmented Evidence
When the dump is unusable, you reconstruct the incident from whatever evidence survived. In our case, we had the truncated core file, the container’s stdout/stderr logs, the Kubernetes events for the pod, and the previous pod’s metrics scraped before the OOM kill. None of these artifacts told the full story individually. Together they formed a coherent timeline.
The methodology mirrors the structured incident response framework described in Google’s SRE Book: validate each piece of evidence independently, establish the order of events from timestamps, and identify the divergence point where the system’s behavior departed from expectations. The truncated dump still contained valid thread stacks for threads whose stacks were in captured segments. We extracted those with !clrstack on each thread that !threads could enumerate, cross-referenced the method names against the application’s symbol files, and identified that 12 of the 14 visible threads were blocked on the same Monitor.Enter call. This was not the root cause of the OOM — the OOM was caused by an unbounded ConcurrentDictionary<string, byte[]> cache that grew to 3.1 GiB — but the lock contention explained why the cache was not being evicted by the background cleanup task. The cleanup task’s thread was one of the 73 threads absent from the dump because its stack resided in an uncaptured memory region.
Reconstructing this narrative from partial evidence is a forensic exercise that demands the same rigor as any investigative writing. The postmortem document must read as a coherent narrative — hypothesis, evidence, analysis, conclusion — not as a collection of log excerpts that a reader must mentally assemble. When I draft internal incident postmortems from this kind of fragmented forensic evidence, I use an AI writing app that structures the draft around the investigative narrative so the postmortem reads as a deliberate reconstruction rather than a chronological dump of alerts and log lines. The tool does not analyze the dump or generate conclusions — the forensic analysis is the engineer’s responsibility — but it does enforce the narrative structure that a postmortem requires, which is the same structure this article follows.
Building a Repeatable Validation Workflow
The validation workflow described above is not a one-time procedure for a single incident. It is a repeatable first step for every Linux container core dump you collect. Encode it in a script that runs before any human opens the dump in an interactive session. The script should:
- Run
readelf -l <corefile>and countLOADsegments with non-zeroFileSiz. - Run
dotnet-dump analyze <corefile>with a scripted sequence:!eeversion,!eeheap -gc,!threads,!verifyheap. - Parse
!eeheap -gcoutput for segment begin addresses and cross-reference each against the ELFLOADsegments. - Flag any segment whose begin address falls in a
LOADentry withFileSiz: 0as a phantom segment. - Report the dump as invalid if any phantom segment is found, and output the specific missing memory regions for comparison against
/proc/<pid>/mapsif available.
This script takes under 30 seconds to run. It saves hours of analysis on a dump that will never yield valid results. In our case, running this validation script before opening the dump interactively would have immediately flagged the missing LOH segment and the zero-file-size runtime segment. We would have skipped directly to fixing the capture pipeline instead of spending 90 minutes trying to interpret impossible !eeheap -gc output.
Conclusion
Incomplete core dumps on Linux containers are not rare. They are the expected outcome when container resource limits, kernel core dump configuration, and .NET 8’s DATAS memory layout interact in ways the documentation does not fully address. The defense is not a better tool — it is a validation step you run before trusting any tool’s output. Every SOS command that returns a value is making an assumption about the integrity of the memory it reads. When that assumption is wrong, the command does not error. It returns plausible-looking garbage. The engineer’s job is to detect the garbage before it becomes the foundation of a root-cause hypothesis.
The workflow: validate the ELF structure, cross-reference the runtime version, verify the heap mapping, and only then run your diagnostic commands. If the validation fails, fix the capture pipeline and re-collect. If the dump is valid but the DAC is corrupted — which you confirm with !verifyheap showing errors in fully-captured segments — you need a different collection strategy, likely proactive dotnet-dump collect via the diagnostic port rather than signal-driven kernel core dumps. And when you are left with a truncated dump and must reconstruct the incident from partial evidence, treat the postmortem as a forensic narrative that demands the same structural rigor as any investigative document.