Core Concepts · How it works

19 min read
Senior14 min read
Rapid overview

How it works

The scenario

A real production single-node K3s cluster:

ResourceValue
vCPU4
RAM7.9 GB
Swapnone
Pods~89
Symptompods being OOM-killed (SIGKILL) under spikes

The instinct is "too many containers" or "need a bigger CPU." Both wrong. The diagnosis showed CPU 26% used but memory 95% requested with only ~880 MB free, and limits overcommitted to 244%. The binding constraint was memory, and the lack of swap turned every spike into an abrupt kernel kill.

Step 1 — Diagnose: memory is the binding constraint

Three commands tell the whole story:

kubectl top nodes                # live actual usage (CPU + memory)
kubectl describe node <node>     # "Allocated resources" → requests vs limits
free -h                          # host-level free/used/available memory

The tell-tale pattern on this box: CPU low, memory requested near 100%, free memory tiny. Once requests approach allocatable, nothing new can schedule even if actual usage is lower — the scheduler reserves against requests, not live usage.

Step 2 — Requests vs limits (the single most important scheduling concept)

TermMeaningEffect when wrong
RequestRAM/CPU reserved for scheduling — the scheduler won't place a pod unless the request fits the node's free allocatableRequests at 95% of allocatable ⇒ nothing new can schedule
LimitThe ceiling a container may burst toLimits overcommitted to 244% on a no-swap node ⇒ guaranteed OOM-kills under spikes

Rule of thumb: set requests to honest steady-state usage; set limits only modestly above the request (limits are blast-radius control, not a wishlist). Over-fat requests waste reserved capacity and break bin-packing; reckless limits on a no-swap node invite the OOM killer.

Q: On a small node, which resource is usually the binding constraint, and how do you confirm it?

A: Memory, not CPU and not the raw container count. Confirm with kubectl top nodes (live usage), kubectl describe node → "Allocated resources" (requests vs limits), and free -h on the host. The classic signature is CPU low (e.g. 26%) while memory is ~95% requested with very little free — requests near allocatable means nothing new can schedule even though live usage is lower.

Q: What is the difference between a Kubernetes request and a limit, and why is overcommitting limits dangerous on a no-swap node?

A: A request is the amount reserved for scheduling — the scheduler only places a pod if its request fits the node's free allocatable. A limit is the ceiling the container may burst to. On a node with no swap, summed limits at 244% of RAM means that if enough containers burst toward their limits at once there is no overflow space, so the kernel OOM-kills processes (SIGKILL). Set requests to honest steady-state and keep limits only modestly above.

Step 3 — .NET garbage-collector mode (the theory — true, but verify it)

The textbook theory is correct: ASP.NET Core defaults to Server GCone heap per logical core, optimized for throughput, with a higher memory baseline that scales roughly with core count. Workstation GC uses a single heap with slightly more frequent collections — a lower footprint, often a fit for low-traffic services. It flips via an environment variable, no rebuild required:

# Switch a deployment to Workstation GC
kubectl set env deployment/my-service DOTNET_gcServer=0

# Revert (trailing dash deletes the env var)
kubectl set env deployment/my-service DOTNET_gcServer-

But never assume which mode a service is already running — VERIFY it. Check the live pod before you "fix" anything:

kubectl exec <pod> -- printenv | grep -i gc
# look for DOTNET_gcServer / DOTNET_SYSTEM_GC_SERVER (=0 means Workstation)
# also check the app's runtimeconfig.json (System.GC.Server)
Q: Before flipping a .NET service to Workstation GC to save memory, what must you check first, and why?

A: Verify which GC mode the service is already running — kubectl exec <pod> -- printenv | grep -i gc (look for DOTNET_gcServer / DOTNET_SYSTEM_GC_SERVER=0) and the app's runtimeconfig.json (System.GC.Server). If it is already on Workstation GC, flipping the flag changes nothing — you would ship a "fix" that is already in place and the memory wouldn't budge. Don't act on the assumed default; confirm the actual mode against the live pod.

Step 3b — The plot twist: ground truth contradicted the plan (the centerpiece)

The capacity plan assumed the fat services were heavy because they ran Server GC, and that flipping them to Workstation GC would save ~150 MB each. We inspected the live cluster (kubectl top, kubectl exec ... printenv) and the assumption was exactly backwards:

ServiceFootprintGC mode (verified in-pod)
kefi-api416 MBWorkstation (DOTNET_SYSTEM_GC_SERVER=0)
questioner377 MBWorkstation
payment336 MBWorkstation
onlinemenu334 MBWorkstation
notification295 MBWorkstation
content284 MBWorkstation
tenant209 MBWorkstation
poueni85 MBServer (default — env unset)
dynalux78 MBServer (default)

The fat services were already on Workstation GC. The lean services (poueni, dynalux) were on default Server GC and were tiny anyway. The cheap "win" the plan was built around was already applied — and it hadn't made the big apps small.

The memory limit wasn't the driver either: poueni had the same 512Mi limit as kefi but used 85 MB vs 416 MB. Same ceiling, 5× the usage.

Conclusion: the footprint difference was real application working set, not GC mode. The big apps simply hold more live memory — bigger EF Core models, MassTransit/RabbitMQ consumer prefetch buffers, S3/storage clients, more loaded assemblies and caches. poueni/dynalux are just leaner apps. No GC-mode change touches genuinely-live objects.

Q: Two .NET services on the same node and the same 512Mi memory limit use 85 MB and 416 MB. Inspecting the pods shows the 416 MB one is already on Workstation GC and the 85 MB one is on default Server GC. What does this tell you?

A: It tells you the assumption "fat = Server GC, flipping to Workstation will shrink it" is false here — the footprint gap is real application working set, not GC mode. The heavy app holds more live memory (bigger EF Core models, RabbitMQ/MassTransit prefetch buffers, storage clients, loaded assemblies and caches), which no GC-mode change can reclaim. The shared 512Mi limit being identical proves the limit is a ceiling, not the cause — the heap only grows toward ~75% of the limit if the app actually allocates that much. Lean apps are lean because they allocate less, full stop.

Step 3c — Committed memory vs live working set (why GC tuning sometimes does nothing)

GC mode and DOTNET_GCConserveMemory only affect committed/free pages returned to the OS — slack the GC is holding onto. They cannot reclaim genuinely-live objects (open EF Core contexts/models, open broker channels, populated caches). If most of a service's footprint is live working set, GC tuning yields little.

So the diagnostic question is always: is this footprint reclaimable slack, or is it live? If it's live, GC knobs are the wrong lever.

Q: What is the difference between committed memory and live working set, and which one can GC tuning actually reduce?

A: Committed memory includes free slack the GC reserved from the OS but isn't currently using for live objects; live working set is memory backing objects the app is actively referencing (EF models, open broker channels, caches). GC mode and DOTNET_GCConserveMemory only act on the committed/free slack — they return unused pages to the OS — and cannot reclaim genuinely-live objects. So if most of a service's footprint is live working set, GC tuning barely moves the number; you need app-level reduction or scale-to-zero instead.

Step 3d — When the footprint is real working set, what ARE the levers?

If a service is already on the right GC mode and most of its footprint is live, GC-mode flipping is not a lever. The real options, in order of safety:

  1. Scale-to-zero for idle / long-tail services (KEDA) — idle = 0 RAM. Safest, no app change. (See Step 6.)
  2. App-level reduction — trim the EF Core model, lower MassTransit PrefetchCount, lazy-load features, drop unused caches. Real savings but per-service and riskier (needs testing, can affect throughput).
  3. DOTNET_GCConserveMemory=0..9 — returns free slack to the OS more aggressively, trading a little CPU. Bounded gains — only reclaims slack, not live objects.
Q: A service is already on Workstation GC and most of its 400 MB is live working set. What are your levers to cut its node footprint, and which is safest?

A: GC-mode flipping is off the table (already on Workstation). The levers are: (1) scale-to-zero with KEDA for idle/long-tail services so idle costs 0 RAM — the safest, needs no app change; (2) app-level reduction — trim the EF Core model, lower MassTransit PrefetchCount, lazy-load features, prune caches — real but per-service and riskier; (3) DOTNET_GCConserveMemory=0..9 to return free slack to the OS, with bounded gains since it can't touch live objects. Start with scale-to-zero; reach for app-level changes only when you can test them.

Step 3e — The A/B test: the real driver was UPTIME ACCUMULATION

We A/B-tested on the live cluster with content-api (.NET 10, already Workstation GC) and a long-running pod, controlling for everything but uptime and the conserve-memory knob:

PodStateFootprint
content-apifresh pod, no extra tuning94 MB
content-apifresh pod + DOTNET_GCConserveMemory=5121 MB (the knob did not help an idle pod — it went up)
content-apiaged pod (long uptime)284 MB
kefi-api16h uptime, never restarted419 MB

The dominant memory driver was uptime accumulation, not GC mode and not GCConserveMemory. Long-running .NET services accumulate working set over time — caches, large-object-heap (LOH) growth, broker prefetch buffers — and the GC holds onto it rather than returning it to the OS. A fresh pod was 3-4× smaller than the aged one. This is why the GC-mode assumption looked plausible (the fat services were big) but was the wrong attribution: they were big because they'd been up for hours, not because of their GC mode.

So the validated levers, in order:

  1. Lower the container memory LIMIT. Because the .NET GC heap hard limit defaults to ~75% of the cgroup limit, a tighter limit (e.g. 512Mi → 256Mi) forces the GC to stay compact and return memory — it caps the climb. Risk: a managed OOM if the genuine live set exceeds the cap, so roll one service at a time and keep a swap cushion.
  2. Periodic restart — cheap but a non-durable band-aid; memory climbs back as the pod ages again.
  3. App-level reduction — cache eviction policy, MassTransit PrefetchCount, investigate the LOH — durable but per-service work.
  4. Scale-to-zero for idle services (KEDA)sidesteps accumulation entirely: a pod at 0 replicas accumulates nothing. The strongest lever for low-traffic services.
Q: An A/B test showed a fresh content-api pod at 94 MB but the same service aged to 284 MB (and kefi-api at 419 MB after 16h uptime), all already on Workstation GC, with DOTNET_GCConserveMemory=5 making a fresh pod WORSE (121 MB). What is the real memory driver, and what are the validated levers in order?

A: The real driver is uptime accumulation, not GC mode or GCConserveMemory. Long-running .NET services accumulate working set — caches, large-object-heap growth, broker prefetch buffers — and the GC holds it rather than returning it to the OS, so an aged pod runs 3-4× larger than a fresh one; GCConserveMemory didn't help an idle pod (it rose to 121 MB). Validated levers in order: (1) lower the container memory limit (the GC heap hard limit ≈ 75% of the cgroup limit, so 512Mi→256Mi forces the GC compact and caps the climb — risk a managed OOM, so roll one service at a time with a swap cushion); (2) periodic restart (cheap but non-durable — it climbs back); (3) app-level reduction (cache eviction, MassTransit PrefetchCount, LOH investigation — durable, per-service); (4) scale-to-zero (KEDA) for idle services, which sidesteps accumulation entirely and is the strongest lever for low-traffic services.

Q: Why does lowering a .NET pod's memory limit (e.g. 512Mi → 256Mi) actually reduce its footprint, and what is the risk?

A: Inside a cgroup the .NET GC heap hard limit defaults to ~75% of the container memory limit, so a tighter limit forces the GC to stay compact and return memory to the OS instead of letting working set climb as the pod ages — it caps the accumulation. The risk is a managed OutOfMemoryException if the genuine live set exceeds the new cap, so you roll it out one service at a time, watch with kubectl top, and keep a swap cushion so a misjudged cap degrades gracefully rather than killing the pod.

Step 4 — DATAS: the middle option (.NET 8+)

DATAS (Dynamic Adaptation To Application Sizes) keeps Server GC but auto-collapses the heap count under low load:

kubectl set env deployment/my-service DOTNET_GCDynamicAdaptationMode=1

Reported ~8× memory reduction when idle while retaining Server-GC throughput when load returns. Use it when a service genuinely needs Server-GC throughput sometimes but not always — the middle ground between full Server GC and Workstation GC.

Q: When would you choose DATAS (DOTNET_GCDynamicAdaptationMode=1) over plain Workstation GC?

A: When the service needs Server-GC throughput under load but not all the time. DATAS keeps Server GC but dynamically collapses the heap count when idle (a reported ~8× idle memory reduction), so you keep multi-heap throughput when traffic arrives without paying the full idle baseline. Workstation GC is the simpler pick for services that are always low-traffic; DATAS is the middle option for bursty ones.

Step 5 — GC memory knobs and the cgroup heap limit

# Trade a little CPU to return memory to the OS more aggressively (0..9)
kubectl set env deployment/my-service DOTNET_GCConserveMemory=5

Crucially, inside a cgroup the GC heap hard limit defaults to max(20 MB, 75% of the container memory limit). So simply setting a tight resources.limits.memory caps the managed heap — and a managed OutOfMemoryException (catchable, logged) is far preferable to a kernel OOM SIGKILL (silent, takes the whole process with no stack trace).

Q: What does the .NET GC heap hard limit default to inside a cgroup, and why does that matter?

A: It defaults to max(20 MB, 75% of the container memory limit). That means setting a tight resources.limits.memory automatically caps the managed heap, so the runtime throws a catchable, logged OutOfMemoryException instead of the kernel issuing a silent OOM SIGKILL. You convert an invisible process kill into an observable, handleable error.

Step 6 — Scale-to-zero for "0 RAM when idle"

OptionWhat it doesVerdict on a tiny node
KEDA http-add-onScales a Deployment 0↔N on HTTP traffic, buffers the first request during cold startBest lightweight choice
Knative ServingRequest-driven autoscaling + scale-to-zeroRejected — its own control plane is RAM-heavy and can cost more than it saves
kube-greenTime-based sleep (a clock, not traffic)Complementary — good for non-prod overnight

KEY CAVEAT: stateful workloads (databases, brokers, object stores) and message-queue consumers must NOT scale to zero — a sleeping consumer stops draining its queue, so the backlog grows unbounded. Mitigate cold-start latency on hot paths with ReadyToRun/AOT and a minReplicaCount: 1.

Q: Why is KEDA preferred over Knative for scale-to-zero on a small node, and what must never scale to zero?

A: KEDA's http-add-on is lightweight — it scales a Deployment 0↔N on HTTP traffic and buffers the first request during cold start — whereas Knative Serving runs a RAM-heavy control plane that can cost more than it saves on a tiny node. What must never scale to zero: stateful workloads (databases, brokers, object stores) and message-queue consumers, because a sleeping consumer stops draining its queue and the backlog grows unbounded. Keep hot paths warm with minReplicaCount: 1 and ReadyToRun/AOT for faster cold starts.

Step 7 — Cost honesty (a frequently-confused point)

Scale-to-zero gives 0 RAM per idle container — but not a $0 hardware bill. A self-hosted always-on node costs the same whether idle or full. Literal "$0 when no traffic" only exists on per-request serverless (Cloud Run, AWS Lambda, Fly.io auto-stop).

Practical split: keep the stable platform self-hosted (it's paid for anyway) and scale-to-zero its idle services to free RAM for others; send genuinely bursty, isolated workloads to serverless where you actually stop paying at idle.

Q: Does scaling to zero make your cloud bill $0 when there is no traffic?

A: No. Scale-to-zero frees RAM per idle container, which lets other workloads schedule, but a self-hosted always-on node costs the same idle or full — the hardware bill doesn't move. Only per-request serverless (Cloud Run, Lambda, Fly.io auto-stop) actually bills $0 at idle. The practical split: keep the always-paid platform self-hosted and scale-to-zero its idle services; push genuinely bursty, isolated workloads to serverless.

Step 8 — Runtime footprint comparison & when to rewrite

RuntimeTypical idle footprint
Go~10-30 MB
.NET Native AOT~10-40 MB (Go-class)
.NET Workstation GC (tuned)~90-130 MB
.NET Server GC (untuned)~250-300 MB

These are idle baselines; a busy app's real footprint is baseline plus its live working set. AOT is Go-class only if AOT-compatible — no EF Core, no heavy runtime reflection. The lesson: don't rewrite a whole service in another language to chase the delta and forfeit a shared framework/library ecosystem. First find out why it's heavy — if it's GC slack on an untuned service, tuning is the free win; if it's real working set (our case), neither a GC flip nor a Go rewrite of the same feature set removes the live objects — you'd reduce the app or scale it to zero. Reserve Go for genuinely lean, always-on edge/proxy components where its tiny baseline matters.

Q: Should you rewrite a .NET service in Go to cut its memory footprint on a small node?

A: Usually not — and first find out why it's heavy. If it's an untuned Server-GC service holding slack, switching to Workstation GC (an env var, no rewrite) often captures most of the win while keeping the framework/library ecosystem. But if the footprint is real live working set (big EF models, broker buffers, caches — our actual case), neither a GC flip nor a Go rewrite of the same features removes those live objects; you reduce the app or scale it to zero instead. A full rewrite also forfeits the ecosystem and team velocity. Native AOT (~10-40 MB) reaches Go-class baselines without leaving .NET, but only if AOT-compatible (no EF Core / heavy reflection), and it shrinks the baseline, not the working set. Reserve Go for lean always-on edge/proxy components.

Step 9 — AI/LLM placement on constrained infra

Don't self-host large models on a small GPU-less node: a quantized 7B model alone blows an 8 GB box and is unusably slow CPU-only. Use an external inference API plus a light local orchestrator (prompt-building, tenant quotas, storing results). A dedicated GPU node or serverless GPU is warranted only for data-residency requirements, high steady volume, or self-hosted fine-tuned models.

Step 10 — Node disk hygiene

The container-image store bloats because every digest-pinned deploy leaves stale images behind — here it reached 50 GB / 426 images.

# k3s containerd image store lives here:
#   /var/lib/rancher/k3s/agent/containerd
crictl rmi --prune              # remove unused images (in-use images are protected)
# reclaimed ~35 GB, 426 → 88 images

journalctl --vacuum-size=500M   # trim systemd journal logs

crictl rmi --prune protects in-use images. Treat image-store and journal cleanup as a recurring / quarterly chore.

Q: Why does a node's container-image store keep growing, and how do you reclaim the space safely?

A: Every digest-pinned deploy pulls a new image and leaves the previous (now-unreferenced) one behind, so the store accumulates stale images — here 50 GB / 426 images. crictl rmi --prune removes unused images while protecting in-use ones, reclaiming ~35 GB (426 → 88). On k3s the store is at /var/lib/rancher/k3s/agent/containerd. Pair it with journalctl --vacuum-size=… for systemd logs and run it on a recurring/quarterly cadence.

Step 11 — Swap as an OOM cushion

A no-swap node gets abrupt kernel OOM-kills (SIGKILL) rather than a soft landing. Add a swap file with a low vm.swappiness so it's used only under genuine pressure:

fallocate -l 4G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile        # does NOT restart kubelet → node stays Ready
sysctl vm.swappiness=10                      # only swap under real pressure

k3s defaults --fail-swap-on=false, and Kubernetes NodeSwap is GA in v1.34, so enabling swap is safe. swapon doesn't restart kubelet, so the node stays Ready throughout.

Q: Why add a swap file with low vm.swappiness to a constrained Kubernetes node?

A: A no-swap node gives abrupt kernel OOM-kills (SIGKILL) under memory spikes — no soft landing. A swap file gives the kernel overflow space so a spike degrades to slower paging instead of a kill, and a low vm.swappiness (e.g. 10) ensures swap is used only under genuine pressure, not routinely. It's safe: k3s defaults --fail-swap-on=false, NodeSwap is GA in Kubernetes v1.34, and swapon doesn't restart kubelet so the node stays Ready.

Step 12 — Observability for capacity

Run Prometheus in agent mode — it remote-writes metrics to a central store with a ~120 MB footprint — instead of a full local Prometheus + Grafana on a tight node. View capacity via Grafana's built-in "Kubernetes / Compute Resources / Cluster" dashboards.

Q: How do you get capacity observability without a full Prometheus+Grafana stack on a tight node?

A: Run Prometheus in agent mode, which scrapes locally and remote-writes the metrics to a central store with only a ~120 MB footprint — far lighter than a full local Prometheus + Grafana. You then view node/pod capacity (requests vs limits vs usage) through Grafana's built-in "Kubernetes / Compute Resources / Cluster" dashboards hosted centrally.

Step 13 — The meta-lesson: measure before you attribute (the most valuable takeaway)

A capacity plan is a hypothesis, not a fact. Validate every assumption against live ground truth — kubectl top, kubectl describe node, in-pod printenvbefore you act. In this real case the headline assumption ("the fat services are heavy because of Server GC; flip them and save ~150 MB each") was false: the fat services were already on Workstation GC, the lean ones were on Server GC, and the difference was real application working set. The "cheap win" the whole plan rested on was already applied and changed nothing.

plan = a set of HYPOTHESES, each one falsifiable
  → for each assumption: verify against the live cluster
      (kubectl top · describe node · kubectl exec ... printenv · free -h)
  → only act on assumptions that survive contact with ground truth
  → pilot ONE low-risk service → apply → verify (top, Grafana, E2E smoke) → roll out
keep every change reversible (env vars deletable with a trailing dash)

Change one thing, measure, then proceed — never big-bang on production. Reversibility makes it safe: kubectl set env deploy/x DOTNET_gcServer- cleanly undoes the flip. But the deeper discipline is attribution: don't apply a fix until you've confirmed the cause is what you think it is — otherwise you "fix" something that was never broken and the metric doesn't move.

Q: What is the single most important methodology lesson from this right-sizing exercise?

A: Measure before you attribute — treat a capacity plan as a set of falsifiable hypotheses and validate each one against live ground truth (kubectl top, describe node, in-pod printenv, free -h) before acting. In this real case the headline assumption (fat services are heavy due to Server GC; flipping saves ~150 MB) was exactly backwards — the fat services were already on Workstation GC and the gap was real working set — so the planned "cheap win" was already in place and would have changed nothing. Then pilot one low-risk service, verify, roll out service-by-service, and keep every change reversible. Don't ship a fix until you've confirmed the cause.

See also