Your Container Lies About Its Size, and Your Library Believes It

September 3, 2026Engineering15 min read
Your Container Lies About Its Size, and Your Library Believes It
TL;DR: You read the task config, so you know the machine: 4 CPUs, 7.53 GiB. The code inside asked the OS and got 32 cores and 62 GiB, the host's numbers, then sized its concurrency from those. Ours opened eight Chrome tabs on four cores and OOMed. The more useful half of this post is what came after: five confident explanations, all wrong, two of them produced while writing this post, and none of them killed by an argument.

Reading a task's configuration tells you what you asked for. It does not tell you what the code inside will believe about the machine it's on. Those two diverge in containers, and the gap is where a certain class of production failure lives.

We hit it rendering video on Trigger.dev(opens in new tab) with Remotion(opens in new tab). The failure was an OOM kill. The interesting part isn't the fix. It's that we misdiagnosed it repeatedly, and the traces of the wrong answers are still in our codebase on purpose.

What the container says versus what it has

We stopped guessing and ran a probe task in production. It printed what Node believes alongside what the cgroup actually enforces:

Plain Text
1availableParallelism        32      ← matches the host, not our quota
2cpus().length               32      ← same
3cgroup cpu.max     400000 100000    ← our quota: exactly 4 CPUs
4os.totalmem()          62.10 GiB    ← host memory
5cgroup memory.max    8089997312     ← our limit: 7.53 GiB
6resolveConcurrency(null)     8      ← Remotion's default

An eightfold overstatement on cores, an eightfold overstatement on memory, and a library default computed from the wrong number.

Remotion's default concurrency is round(min(8, max(1, cores / 2))), where cores is the minimum of nproc and os.availableParallelism(). Sixteen would be the naive answer; the min(8, …) clamp is the only reason it stopped at eight.

Eight Chrome tabs, each with a 1920×1080 canvas and its own video decoder, on four cores and 7.53 GiB. The OOM wasn't surprising in retrospect. It was arithmetic.

This isn't Remotion being careless, and the detail matters. @remotion/renderer 4.0.496 opens dist/get-cpu-count.js with a comment about exactly this hazard: "However, Node.js returns the core count of the host system (up to 96!)" — sitting directly under two lines about docker run --cpuset-cpus. It takes the minimum of two sources specifically because one of them can lie.

So the library anticipated the problem. Which raises the question that actually matters: why did that minimum still come out at 32 in our container?

Here's where I have to be careful, because I nearly published something false. I assumed os.availableParallelism() ignores CPU quota. On my machine it doesn't. Three runs (8 host cores, cgroup v2, node:22-alpine):

Table: Container, `availableParallelism()`, `nproc`
ContaineravailableParallelism()nproc
--cpus=2
28
--cpuset-cpus=0,1
22
--cpus=1.5
18

The two split cleanly here. availableParallelism() respects cpuset and quota; the 1.5 case rounding down to 1 proves the quota half, since an affinity mask can't yield a fraction. nproc follows cpuset but ignores quota, because it reads sched_getaffinity and a quota isn't expressed in the affinity mask.

Which left an obvious contradiction: identical mechanism, opposite results. Our container returned the host's number; mine returns the quota. Same function, same kind of limit.

That nproc gap was a long-standing coreutils bug, fixed upstream in August 2025(opens in new tab) — but the fix only reaches you when your base image ships a new enough coreutils. Debian bookworm still carries 9.1, which predates it, so the old behaviour is still what most containers do today. CircleCI has carried a request about the same thing since 2018(opens in new tab).

Reproduce it yourself in thirty seconds:

Bash
1docker run --rm --cpus=2 node:22-alpine \
2  node -p "os.availableParallelism() + ' vs ' + os.cpus().length"

Which means the honest version of our story isn't "the library got containers wrong." It's that in our environment the number came back as the host's, and the general explanation I reached for doesn't survive a thirty-second test.

It gets stranger the closer you look. Our probe reads /sys/fs/cgroup/cpu.max, the cgroup v2 path, and reads it successfully, getting 400000 100000, exactly 4 CPUs. So in one report, from one process: the cgroup file says four, and availableParallelism() says thirty-two. The file is there, it's readable, and it's telling the truth. Whatever the runtime consulted, it wasn't that file.

The answer was in a field nobody printed

We extended the probe and redeployed. The run that came back settled it:

Plain Text
1availableParallelism   32
2nproc                  32      ← measured this time, not inferred
3cpusAllowedList        0-31    ← affinity mask is FULL
4cgroupCpuMax     400000 100000 ← quota is 4 CPUs
5selfCgroup             0::/    ← one cgroup, no nesting
6nodeVersion            21.7.3
7uvVersion              1.48.0

Two theories died at once. Nesting: selfCgroup is 0::/, a single mount, no parent hierarchy to hide a limit in. Wrong cgroup: the standard path is readable and correct.

What's left is the affinity mask, and it's full: 0-31. Any implementation reading only the mask is required to answer 32 here. Which is exactly what happened.

The difference between our two machines isn't the mechanism. It's nodeVersion. Production was running Node 21.7.3 with libuv 1.48.0; my container runs Node 22.22.0 with libuv 1.51.0. So I measured across the boundary:

Table: Image, libuv, `availableParallelism()` under `--cpus=2`
ImagelibuvavailableParallelism() under --cpus=2
node:18-alpine
1.44.28, quota ignored
node:20-alpine
1.46.08, quota ignored
node:22-alpine
1.51.02, quota respected

Quota-awareness arrived in libuv somewhere between 1.46 and 1.51. Production sits on the old side of that line at 1.48.0; my laptop sits on the new side. Both measurements were correct. Neither generalized.

Which means my colleague's "it only reads the affinity mask" wasn't wrong. It was wrong for my environment and right for the production one. Two people, each describing their own runtime, each believing they were describing the library.

And the numbers that settled it lived in process.versions: fields neither of us thought to print across four consecutive diagnoses.

But Node 21 raised a better question than the one we started with. Our repo pins Node 26 and the manifest requires 24. Nothing in it asks for 21. The answer was one line of platform config, runtime: "node" — a legacy default sitting next to explicit node-22, node-24, node-26 options nobody had reason to look at.

So every paid render was executing on a release line that stopped receiving security fixes in mid-2024. That's a worse finding than the OOM we were chasing, and it surfaced only because a probe printed a field that had nothing to do with memory.

Upgrading is not a one-line change either, which is its own lesson: the render path is full of native code — Remotion's Rust compositor, ffmpeg, headless Chrome — so the runtime bump goes staged, free probe first, then one live paid run.

There's a pleasing coda. On a newer libuv, availableParallelism() in that container will return 4, Remotion's default becomes 2, and it will agree with the concurrency we hardcoded while fighting the OOM. The workaround doesn't get deleted; it gets confirmed.

One more piece of honesty, because this post is about exactly this. Before that probe I wrote "both sources returned the host's number" while nproc had never been printed; its value was inferred from the concurrency the formula produced. The new run happens to confirm it at 32, but for four diagnoses it was a guess wearing a measurement's clothes.

The part I'd rather skip: five wrong answers

Here's where this stops being a tidy bug story.

First diagnosis: the caches are sized from the host's 62 GiB, an eightfold discrepancy across the board. Clean, general, matched the CPU evidence. Wrong.

Second diagnosis, after that was challenged: actually Remotion respects our limit, so the cache claim was overblown. Also wrong, and worse: it corrected an error by overshooting in the opposite direction.

What's actually true: the two caches give different answers, and no single sentence covers both.

  • mediaCacheSizeInBytes does respect the cgroup. Remotion passes initialMemoryAvailable: getAvailableMemory(), which checks a Lambda environment variable first and then the cgroup, returning the smaller of freemem and the cgroup's remaining headroom (memory.max minus memory.current). Headroom, note — not the ceiling.
  • offthreadVideoCacheSizeInBytes is unknown, and that's the honest answer. The JavaScript side computes nothing for it: the value goes straight to the Rust compositor as maximum_frame_cache_size_in_bytes. getAvailableMemory is never called on that path. Whether the binary reads the cgroup isn't determinable from our repository.

So the retraction had to be partial. For the frame cache, the original claim might have been right, and withdrawing it wholesale would send the next reader confidently in the opposite wrong direction, the same failure mode as the thing it was fixing.

Both wrong versions are still visible in the comment, marked as wrong. Deleting them would leave a confident-sounding paragraph with no indication that two smart-sounding answers had already been tried and had failed. The next person to touch that file needs to know the ground is soft there.

Third and fourth came while writing this post, and they're the ones I'd have bet on. Reviewing the draft, I claimed os.availableParallelism() ignores CPU quota, which would explain why both sources returned the host's number. A colleague reviewing the same draft proposed a sharper version: it reads the affinity mask, explaining cpuset-awareness and quota-blindness with one mechanism. Two people, two independent explanations, both plausible, both confident.

Both died to the same three-word command. docker run --cpus=1.5 returns 1, and no affinity mask can express one-and-a-half cores. Neither of us had run it.

Except the story has a second half, and it's the better one. When the extended probe came back, my colleague's mask theory turned out to be correct — for production, on libuv 1.48. Mine was correct for my laptop, on 1.51. We had been arguing about the mechanism of a library while each describing a different build of it, and we were both right about our own machine and both wrong about the library. What settled it wasn't a better argument, and it wasn't even the first command. It was printing a version field.

The fifth was mine, and it was in the table you read above. An independent reviewer checking this draft ran the three containers himself and found the --cpuset-cpus row wrong: nproc returns 2 there, not 8. I hadn't measured that cell. I filled it from the rule I'd just formed, "nproc always reports the host", so the number arrived by inertia rather than observation. A post about inferring instead of measuring, containing an inferred cell in its own evidence table.

That one died differently. The third and fourth fell to a command; this one fell to someone looking at a number and asking where it came from. Both are cheap. Neither is reasoning.

So the pattern worth naming: the failure mode isn't carelessness, it's fluency. Every one of these five explanations was coherent, mechanically plausible, and offered by someone who had read the relevant code, including the two I produced while writing a post warning against exactly this. Coherence is what a wrong root cause feels like from the inside, which is why it can't be the thing you check against.

The sharpest version came from the resolution. Two of those five weren't wrong about the world at all. They were right about the machine in front of the person saying them, and wrong to generalize past it. That's a failure mode no amount of care prevents, because from the inside it's indistinguishable from being right. You had evidence. You reproduced it. What you didn't do was ask whether the other person's machine was the same machine.

And what broke the tie wasn't that either of us got more careful. It was that we were talking to each other, and the check cost thirty seconds. Had it cost an hour, we'd both still be holding our explanations, both still confident, both still half right. The cheapness of the check is part of the method, not a convenience. That much we watched happen.

The extrapolation — and it is one, so take it at that weight — is that I'd expect a team needing an afternoon to argue instead, and I'd expect the more articulate person to win. I haven't measured that. It's the shape the day had, not a finding.

What I would keep as method is narrower and better supported: ask whether the other person's machine is the same machine, and ask before the explanations diverge rather than after. Neither of us could have closed this alone. I had no access to their production runtime; they had no reason to run docker run --cpus=1.5 until I showed why it mattered. The answer needed both halves, and the only thing that got them into the same room was one of us being wrong out loud.

"Measured" and "chosen" are different words

We ended up separating two kinds of statement in that comment, and the split turned out to be the most portable idea here.

Measured: the probe table above; the default concurrency formula, read out of the installed package at a pinned version; the fact of the OOM.

Chosen: the concurrency of two. Two isn't an optimum; we never benchmarked render time against tab count. It's a boundary asserted against a default that was computed from the wrong machine. Tabs compete for the same four cores as the encoder, so the third tab's gain is CPU-bound while its memory cost is paid in full.

Writing "we measured two is optimal" would have been a lie of exactly the kind this whole post is about. The caches get explicit limits for a related reason: getAvailableMemory takes the minimum with free memory, not the limit, and it's called once at startup. Two caches sized as a fraction of what was free at boot, plus eight tabs, plus the encoder, all live in the same 7.53 GiB. An explicit ceiling makes the peak predictable, and that's an argument about the sum of consumers, not about a misread limit.

What to check in your own stack

Any library that sizes a pool, a cache, or a worker count from "available resources" is a candidate, and most of them are:

  • Node's os.availableParallelism(), os.cpus(), os.totalmem(), os.freemem()
  • Anything shelling out to nproc
  • JVM heap sizing (fixed years ago with container awareness, but only if your flags are current)
  • Thread pools defaulting to "number of processors" in Go, Rust, Python, and most runtimes

The check is a probe in the environment that actually runs the code, not a second reading of the config. Print both sides:

Plain Text
1availableParallelism   vs   cgroup cpu.max
2os.totalmem()          vs   cgroup memory.max

If they disagree, every default derived from the left column is wrong, and the failure will look like a resource problem rather than a configuration one.

The general lesson

"I read the task config" is not the same as "I know what machine this code thinks it's on." The config is what you requested from the platform. What the code sees is whatever the kernel reports through a stack of interfaces — some container-aware, some not, some depending on your runtime version, and any of them can answer honestly about the wrong machine.

Only a probe in the running environment closes that gap, which is the same argument as instrumenting a pipeline instead of trusting it. We could have read our Trigger.dev config a third time and learned nothing new: the numbers there were always correct. They just weren't the numbers Remotion was using.

And the sharpest version of the question isn't "why did the OS lie." It's why a number the library takes from the environment disagrees with a number that same environment prints in a file sitting right next to it. That answer isn't in Node's docs, the platform's docs, or the library's source. It's only in a probe — which is why ours stayed in the codebase as a permanent task instead of a temporary one.

The general explanation you reach for while writing the postmortem deserves the same suspicion as the config. Mine survived two rounds of internal review and died to a thirty-second docker run.

And when you do write down the answer: separate what you measured from what you chose, and leave the wrong turns visible. We shipped two confident diagnoses that were both wrong. The comment that survives says so, because the next person deserves to know the difference between a fact and a decision.

Clipwright renders video this way in production today. It's in invite-only beta.

FAQ

Why doesn't nproc respect CPU limits?

nproc follows a cpuset restriction but ignores a CPU quota — it reads sched_getaffinity, and a quota isn't in the affinity mask. That gap was fixed in coreutils in August 2025, though most base images still ship an older build. Node's os.availableParallelism() depends on your libuv version. On 1.44 and 1.46 it ignores quota and reports the host; on 1.51 it respects quota, rounding 1.5 CPUs down to 1. The change landed somewhere between those two, so the answer is a property of your build rather than of the API. Print process.versions.uv before trusting either behaviour. That one field explains more than any amount of reading the docs.

Does setting concurrency explicitly fix everything?

No. It fixes the count of parallel browser tabs. Cache sizes are a separate consumer of the same memory, and at least one of them (the frame cache) is sized by a native binary whose behavior we can't verify from our own code. Both need explicit limits.

How do I find the real limits inside a container?

Read the cgroup files directly: /sys/fs/cgroup/cpu.max for the CPU quota and period, /sys/fs/cgroup/memory.max for the memory ceiling (cgroup v2 paths). Compare them against whatever your runtime reports and log both.

Sources

Video as a tool your agent calls

The script goes in over MCP, CLI or REST. Rendering, vendors and limits stay our problem.

About the Author

Dzmitry Vladyka
Dzmitry Vladyka

Dimantika

Founder of Dimantika. Co-founded and exited a SaaS at $1.2M ARR. Now building AI tools for founders who want autonomous growth without blind trust in agents.

View all posts