Diagnosing Distroless .NET Applications on Kubernetes
Minimal container images are a good production default. Distroless and chiseled .NET images reduce image size and attack surface by leaving out package managers, shells, and troubleshooting tools.
That becomes a challenge when a running application has high CPU usage, increasing memory consumption, or unexplained latency. Installing tools in the application container is not an option, and rebuilding the image changes the environment that needs to be investigated.
dotnet-k8s-debug-containers provides a separate diagnostics image for this scenario. It adds the standard .NET diagnostic tools to a running Pod through a Kubernetes ephemeral container, without adding them to the application image.
Why Use a Separate Diagnostics Container?
The diag image is based on Azure Linux 3 and contains:
dotnet-countersdotnet-tracedotnet-dumpdotnet-gcdumpdotnet-stack
The application container remains unchanged. It does not need a shell, the .NET
SDK, or a /diag volume mount.
The diagnostics container joins the target container’s process namespace. A
PowerShell helper locates the .NET runtime’s diagnostic socket through /proc
and exposes it under /diag, where the standard dotnet-* tools can discover
the process.
The same /diag directory is used for collected traces, dumps, and GC dumps.
It is backed by a Pod-scoped emptyDir that is mounted only into the ephemeral
container.
Prepare the Pod
The required Pod settings must be present before an incident occurs. Kubernetes cannot add a shared process namespace or a new Pod volume through an ephemeral container.
The following sample shows the important parts:
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
shareProcessNamespace: true
securityContext:
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: app
image: ghcr.io/example/my-app:latest
securityContext:
runAsUser: 1654
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumes:
- name: diagnostics
emptyDir: {}
For a Deployment, add the settings below .spec.template.spec instead. The
important requirements are:
shareProcessNamespace: truemakes the application process visible to the diagnostics container.- The application runs as UID
1654, matching the default user in the dotnet anddiagimages. - Pod
fsGroup1654makes the diagnostics volume writable. - A writable
emptyDirnameddiagnosticsis available for the ephemeral container. - .NET diagnostics remain enabled. This is the runtime default.
The complete sample is maintained in
examples/kubernetes/pod-with-diag-volume.yaml.
The operator running the helper also needs permission to:
- read Pods;
- update
pods/ephemeralcontainers; - create
pods/execrequests.
Creating pods/attach requests is required only when directly attaching to the
ephemeral container.
Start a Reusable Diagnostics Session
Clone the repository on a machine that has PowerShell and kubectl configured
for the target cluster:
git clone https://github.com/koepalex/dotnet-k8s-debug-containers.git
Set-Location .\dotnet-k8s-debug-containers
Start the diagnostics container without directly attaching its primary shell:
.\scripts\Start-DotnetDiagSession.ps1 `
-Pod my-app `
-TargetContainer app `
-Namespace default `
-NoAttach
The helper validates the target container and the diagnostics volume, creates
the ephemeral container, waits for it to start, and prepares .NET diagnostic
socket discovery.
It generates a unique name such as dotnet-diag-a1b2c and prints commands for
entering the container and listing .NET processes. Use the printed command
rather than constructing it manually:
kubectl exec -it --namespace default pod/my-app --container dotnet-diag-a1b2c -- /bin/sh
-NoAttach is important for a reusable session. The container’s primary shell
continues running when a later kubectl exec session ends, so artifacts can
still be copied.
Ephemeral containers cannot be restarted or replaced. Omitting
-ContainerName lets the helper generate a new unique name for every session.
Use -WhatIf before an incident if you want to inspect the generated Pod
payload without creating an ephemeral container:
.\scripts\Start-DotnetDiagSession.ps1 `
-Pod my-app `
-TargetContainer app `
-Namespace default `
-NoAttach `
-WhatIf
Collect Runtime Evidence
After entering the diagnostics container, list the automatically discoverable .NET processes:
dotnet-trace ps
Use the returned process ID for the following commands.
Start with runtime counters when you need a low-overhead view of CPU, allocation, garbage collection, thread pool, and exception activity:
dotnet-counters monitor --process-id <pid> System.Runtime
Collect a trace for offline performance analysis:
dotnet-trace collect \
--process-id <pid> \
--output /diag/app.nettrace
Collect a memory dump when a complete process snapshot is required:
dotnet-dump collect \
--process-id <pid> \
--output /diag/app.dmp
Collect a GC dump for managed heap analysis with a smaller artifact than a full process dump:
dotnet-gcdump collect \
--process-id <pid> \
--output /diag/app.gcdump
The files are written to the ephemeral container’s /diag volume. The
application container does not need access to that directory.
Copy the Artifacts
Exit the kubectl exec shell after collection. Because the diagnostics session
was created with -NoAttach, the ephemeral container remains available.
Use the generated container name printed by the helper:
kubectl cp \
--namespace default \
my-app:/diag/app.nettrace \
./app.nettrace \
--container dotnet-diag-a1b2c
The diagnostics image includes tar, which kubectl cp uses for the transfer.
Repeat the command for .dmp or .gcdump artifacts.
The emptyDir and its files disappear when the Pod is removed. Copy required
artifacts before restarting, replacing, or deleting the Pod.
If you omit -NoAttach, the helper attaches directly to the container’s primary
shell. In that mode, copy artifacts from another terminal before exiting the
attached shell. Exiting it terminates the ephemeral container, and Kubernetes
cannot restart it.
Production Safety and Troubleshooting
Production guidance: Prefer continuous metrics, distributed traces, and structured logs as the first line of investigation, ideally collected through OpenTelemetry and exported outside the Pod. This observability data is safer and cheaper to collect continuously than an on-demand process dump. However, it cannot explain every failure. A dump or targeted runtime trace may still be required for issues such as unexplained memory retention, deadlocks, thread pool starvation, native crashes, or application state that was not captured by existing instrumentation. Use this diagnostics workflow when normal observability narrows the problem but does not provide enough evidence to find the root cause.
Diagnostic collection affects the process being observed. Start with counters and collect only the evidence needed for the investigation. Traces add CPU and I/O overhead, while full memory dumps can briefly pause the process and require significant memory, disk space, and transfer time.
Dump files can contain credentials, personal data, request payloads, and other process memory. Store and transfer them as sensitive production data, restrict access, and delete them according to the applicable retention policy.
Common failure cases include:
- The target process is not visible: confirm that
shareProcessNamespace: truewas set in the Pod template before the Pod was created. - No diagnostic socket is found: confirm that .NET diagnostics are enabled and that the application process uses the same UID as the diagnostics image.
- The diagnostics volume is rejected: confirm that the Pod declares an
emptyDirnameddiagnostics. The helper cannot add the Pod-level volume. - The Kubernetes API returns
Forbidden: check access to Pods,pods/ephemeralcontainers, andpods/exec. - The ephemeral container is blocked: review Pod Security, seccomp, and AppArmor policies for the namespace.
- A previous container name already exists: start another session without
specifying
-ContainerNameso the helper generates a unique name.
The helper fails early when the target container or diagnostics volume is missing, and it verifies socket discovery before presenting the session as ready.
Conclusion
Minimal application images and production diagnostics do not have to be conflicting goals. A prepared Pod template and an ephemeral diagnostics container keep troubleshooting tools out of the application image while still providing access to counters, traces, memory dumps, and GC dumps when they are needed.
The source, container definitions, helper scripts, and current usage examples are available in the dotnet-k8s-debug-containers repository.