I Set a Container Memory Limit and the Whole Machine Froze Anyway

A 15-hour incident where Docker memory limits did nothing on a DGX Spark and a 26B LoRA run froze the whole machine, plus the two-layer defense I built

The DGX Spark shares 128 GB of unified memory between CPU and GPU, and the serving stack for summaries runs on it. On top of that I kicked off a LoRA training run on a 26B model and went to bed. The container had a 100 GB memory limit, so I figured the worst case was losing the container, but by morning neither SSH nor the console responded.

What I set out to do

Yesterday evening I kicked off a google/gemma-4-26B-A4B-it LoRA training run on the DGX Spark (GB10) and went to bed. BF16, rank 16. I had set a --memory=100g limit on the container, so I figured that in the worst case only the container would die.

This morning the whole machine was dead. SSH didn’t work and the console didn’t respond, so I physically went over and power-cycled it. Recovery took 15 hours.

How I asked

I had evidence to find the cause. The Prometheus I had wired up on the day I set up the server had recorded the death minute by minute.

UTCAvailable memory
09:48 training start114 GB
10:03 model loaded54 GB
10:233.5 GB
10:330.3 GB (entering swap)
10:38swap 9.7 GB
11:07collection stops = hang

Just 30-45 minutes after start. Memory ran dry, it spilled into swap, and when swap filled too, the kernel froze. Classic swap death.

Where it broke

The core question was why the container limit didn’t work. I ran a reproduction test with 30 long samples while watching the container and the host at the same time.

Where I lookedValue
Container working_set7.7 GB
Actual host usage110 GB

While the GPU eats 99 GB of system RAM, the container shows 7.7 GB. Two reasons.

  1. The DGX Spark shares 128 GB between CPU and GPU, and CUDA unified memory allocations bypass cgroup accounting. What the GPU grabs is ultimately host RAM, but it doesn’t show up in memory.current.
  2. container_memory_working_set_bytes excludes mmap/shared. The MEM column in docker stats is this value.

So the Docker OOM-kill never even triggered. From the container’s point of view, it was a well-behaved process using 7.7 GB of a 100 GB limit.

“Even at the moment it went down, the container wasn’t using much memory.”

The dashboard stayed green to the end. On top of that, while going over the period around the incident, I found the monitoring container receiving 60 MB per second; digging in, cAdvisor was copying the host NIC’s total values and exporting them as container values. Every container on this machine uses the host network directly. With no per-container interface to count, the one countable number, the host NIC’s, gets attached identically to every container. When a metric looks odd, suspect the metric before suspecting the system.

How I fixed it

First I looked at the starting point again. Available memory at training start was 114 GB. On a 128 GB machine, 14 GB was already gone. Because serving was running alongside.

--gpu-memory-utilization 0.85

With this setting, serving alone occupied 93% of unified memory. 6.4 GB left. There was no headroom before training even started.

If you read gpu-memory-utilization as “what % of GPU memory,” 0.85 looks conservative. But on a unified-memory machine, that’s 85% of the entire system. I’m correcting what I wrote on day one about it being a “conservative value.”

I actually brought it down the next day. I lowered 0.85 to 0.65, switched the served model from the quantized 31B to the 26B, and capped concurrent requests at 10. Context went the other way, from 8,192 up to 12,288. The headroom freed by claiming less went into length.

So I put up two layers of defense.

First layer: training config. Capped max_seq_length from 16384 to 8192, turned on gradient_checkpointing (30-50% activation savings), and dropped samples over 10,000 characters from the training set. Measuring by length, peak exceeds 110 GB at seq 16,000 and is 80 GB at 8,000. 26B-A4B is MoE, so activations grow with the number of active experts, which makes it worse. Roughly, you need 4-8x the weights. 26B BF16 weights are 50 GB, so with default settings that’s 200 GB. It never fit in the first place.

Last line of defense: earlyoom. SIGTERM below 8% available, SIGKILL below 4%. It bites on swap at 10% and 5% too. Processes ending in python3, java, or node are preferred targets, and sshd and systemd are excluded from being targets at all. Something has to die before the kernel freezes, or you can’t get in over SSH. That’s why I lost 15 hours.

And turn off swap before training. It’s paradoxical, but with swap present, instead of dying it drags on until the kernel hangs. With swap off, the OOM killer bites quickly.

Lessons