Skip to content

fix(nvsnap): restore the placeholder pid reservation and enforce it - #1027

Open
balajinvda wants to merge 9 commits into
nvsnap/e2e-restore-guardsfrom
nvsnap/pidns-dump-root
Open

fix(nvsnap): restore the placeholder pid reservation and enforce it#1027
balajinvda wants to merge 9 commits into
nvsnap/e2e-restore-guardsfrom
nvsnap/pidns-dump-root

Conversation

@balajinvda

Copy link
Copy Markdown
Contributor

Why

Restore into a criu-v2 placeholder was failing most of the time. A measured
sweep of the single-GPU suite, every workload twice, passed 3 of 14. The
failures all looked like this:

Error (criu/cr-restore.c:1242): Can't fork for 363: File exists
Error (criu/pie/restorer.c:2878): Unable to create a thread: -17

Both are EEXIST from clone3(set_tid=N). CRIU recreates a dumped tree at its
exact original pids -- they are baked into the image (cached getpid, pthread
TCBs, robust futex lists, file lock owners) and cannot be renumbered. So any
long-lived process the placeholder parks inside that range breaks the restore.

The placeholder used to push its own pid allocations clear of that range with

echo 100000 > /proc/sys/kernel/ns_last_pid

That line was removed on the grounds that the write returns EPERM inside a
container, and replaced with a comment pointing at an agent-side
reservePlaceholderPIDs that was never written.

The premise was wrong. /proc is mounted rw in these pods and the write
succeeds: measured in a live placeholder, the next child landed at pid 100003.
Without it the placeholder's login shell forks a few hundred times sourcing
profile.d before the manifest's tail -F starts, so the tail parks at pid ~363,
inside the restored range. Where it lands varies per run, which is why this
read as flakiness rather than breakage, and why a green suite was not evidence
of anything.

What changed

  • Restore the ns_last_pid reservation on the ten criu-v2 restore manifests.
  • Reserve in restore-entrypoint as well, at the top of main before anything
    forks. Production restore pods run a tenant image with the command rewritten
    to that binary, so a manifest-level bump never covered them.
  • Have the agent refuse to restore into a placeholder whose pid range was never
    pushed up, and wait up to 90s for it rather than sampling once. Sampling
    raced the placeholder's own startup and rejected placeholders that were
    seconds from correct.
  • Run the NIM restore placeholder as runAsUser: 0. That image defaults to uid
    1000 and privileged does not confer root, so its reservation silently did
    nothing.
  • Drop the NVSNAP_DUMP_PIDNS_ROOT environment switch. It gated an unvalidated
    capture change that hung on its only trial; the design and the measured
    reason it hung are kept in docs/proposals/pidns-capture.md.

Customer Release Notes

Fixes intermittent GPU checkpoint restore failures. Restores that previously
failed with a process-creation error now succeed.

Plan Summary

Not applicable.

Usage

Not applicable. The reservation is automatic.

Testing

Single-GPU suite, every workload twice, on a QA cluster:

before after
trtllm-small FAIL / PASS PASS / PASS
vllm-small FAIL / FAIL PASS / PASS
sglang-small FAIL / PASS PASS / PASS
e5-mistral PASS / FAIL PASS / PASS
vllm-8b FAIL / FAIL PASS / PASS
sglang-8b FAIL / FAIL PASS / PASS
nim-llama-8b FAIL / FAIL PASS

3/14 before, 13/13 after. nim-llama-8b was verified in a separate run after its
uid fix; restore-to-serving was 1m54s from a 99G checkpoint.

Repeat runs are the point. The failure is probabilistic, so one pass per
workload cannot distinguish working from lucky, which is how this survived for
as long as it did.

Unit tests cover the pid-namespace walk, the acceptance floor, the wait's
success and timeout paths, and that a procfs read failure is reported
differently from a missing reservation.

Notes

Reserving from inside the pod cannot be the long-term answer for production.
Restore pods run tenant images, non-root is normal practice there, and those
cannot write ns_last_pid at all. The agent guard makes that a named refusal
rather than a mid-restore failure, but it is a refusal. Dumping the pid
namespace root would remove the requirement instead of satisfying it; see
docs/proposals/pidns-capture.md and the discussion on the issue.

This branch is stacked on nvsnap/e2e-restore-guards and targets it rather than
main, so the diff here is only the pid work.

Issues

Relates to #925

References

None

Related Pull Requests

Stacked on the branch for the restore-verification guards.

Dependencies

None

balaji-g and others added 7 commits August 18, 2026 16:36
CRIU restore fails with clone3 EEXIST -- "Can't fork for N: File exists",
"Unable to create a thread: -17" -- on most single-GPU workloads. A full
suite run had 6 of 7 fail this way.

Capture targets the workload's session leader, so CRIU dumps a subtree.
CRIU writes a pidns image only when the target is the namespace root, so
the dump has none, and restore logs "No pidns-1.img image". Without it
restore cannot create a namespace: it must recreate the original PIDs in
the placeholder's existing one, because PIDs are baked into the memory
image (cached getpid, pthread TCBs, robust futexes, sempid). When the
placeholder has already used one, the clone fails.

That is also why this reads as flaky rather than broken: it depends on
where the placeholder's PID counter happens to sit. A green run was luck,
not correctness, so historical pass rates for this path are unverified.

Add NVSNAP_DUMP_PIDNS_ROOT=1 to dump the container's namespace init
instead, so CRIU records the namespace and restore creates a fresh one
where every PID is free by construction -- removing the failure rather
than making it less likely.

Off by default: it changes what a capture contains and must not switch
silently under a running deployment. docs/proposals/pidns-capture.md has
the analysis, the rejected alternatives (ns_last_pid, nested namespace,
smaller placeholder -- all tune the race rather than remove it), and the
validation required before it becomes the default, including the
CaptureFormatVersion bump without which old captures are silently reused.

Refs #925

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
First run of the gated pid-namespace dump did not fail -- it hung. No
image files, no dump.log, agent log stops at the criu invocation, and
the harness gave up at 10m13s, well inside criu's own 1200s timeout.
For comparison the subtree dump on the same workload completes in 1m08s.

Leading hypothesis, recorded as unverified: the nsenter carries -p, which
places criu inside the pid namespace it is dumping. That is harmless when
the target is a subtree (criu is not a descendant of the session leader)
and self-defeating when the target is the namespace root, because criu is
then a member of the tree it freezes. Stock container checkpoint avoids
this by running criu in the host pid namespace and naming the container
init by host pid, so the next attempt should drop -p rather than abandon
the approach.

Worth noting for the pending go-criu removal: that path spawns criu swrk
as a child of the agent and targets by pid with Root set, so it drives
criu the way runc does and cannot hit this. It is currently the only
in-tree caller doing so.

Also bump CaptureFormatVersion to 2. Captures predating this change carry
no pidns image, and replaying one silently keeps restore on the old path
-- the bump is what makes any future fix here observable instead of
masked by a cache hit.

Refs #925

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
Restore into a criu-v2 placeholder failed 79% of the time across the
single-GPU suite (3/14 passed) with

    Error (criu/cr-restore.c:1242): Can't fork for 363: File exists

CRIU recreates the dumped tree at its exact original pids. The placeholder
bumped ns_last_pid to 100000 so its own processes stayed clear of that
range. That line was removed on the belief that the write returns EPERM
inside a container, replaced by a comment pointing at an agent-side
reservePlaceholderPIDs that was never written.

The premise was wrong. /proc is mounted rw in these pods and the write
succeeds -- measured in a live placeholder, the next child landed at pid
100003. Without the bump the login shell forks a few hundred times
sourcing profile.d before the manifest's `tail -F` starts, so the tail
parks on a pid in the restored range: measured at 363, against observed
collisions at 292, 336, 340, 343, 363 and 365. It reads as flakiness
because the exact landing pid varies per run.

Restore the bump on the ten criu-v2 restore manifests, and have the agent
refuse to restore into a placeholder whose highest pid is still low. The
guard matters more than the line it protects: this failed silently for
days because `|| echo` swallowed the failure and the suite ran each
workload once, which cannot distinguish "broken" from "unlucky".

Refs #925

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
The pid reservation that keeps CRIU's exact-pid forks from colliding with
the restore pod's own processes only existed in the restore manifests we
hand-write for the test suite. Production restores do not use those: the
webhook stamps nvsnap.io/restore-from on a tenant pod and rewrites its
command to restore-entrypoint, and nothing along that path reserved
anything. A tenant entrypoint can hold far more pids than our three-process
placeholder, so the same collision applies with more room to go wrong:

    Error (criu/cr-restore.c:1242): Can't fork for 363: File exists

Do it in restore-entrypoint instead, at the top of main before anything
forks. That covers every production restore pod whatever the tenant's own
command is, which a manifest-level bump never could.

Not fatal on failure: a restore pod that cannot reserve still has the
cold-start fallback, and crash-looping would turn a degraded restore into
no workload at all. The agent refuses the restore instead, so this surfaces
as a named error rather than as the intermittent flakiness it caused
before. Logged loudly either way -- the previous shell version ended in
`|| echo`, and that swallowed failure is what hid a 79% failure rate.

A test ties the value written here to the agent's acceptance floor; they
live in different packages and nothing else connects them.

Refs #925

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
NVSNAP_DUMP_PIDNS_ROOT gated an unvalidated capture change that hung on its
only trial run. Shipping it would have left a 26th NVSNAP_* environment
switch in the agent for a code path nobody can turn on safely.

An environment variable is the wrong surface for this in particular: it
changes what a capture contains, is invisible in the pod spec, is untyped,
and survives long after the experiment that introduced it. If the approach
is revisited it should arrive as an agent flag plumbed through chart
values.

Nothing is lost by removing it. The failure it targeted -- clone3 EEXIST
during restore -- is fixed by the placeholder pid reservation, and the
design plus the measured reason the trial hung stay in
docs/proposals/pidns-capture.md.

Also correct that document. It listed the ns_last_pid bump under rejected
alternatives, on the grounds that the in-pod write returns EPERM even when
privileged. That is false: /proc is mounted rw in these pods and the write
succeeds, with the next child landing at pid 100003. The same wrong claim
is what deleted the reservation originally, so leaving it in a design doc
invites the regression a second time.

Refs #925

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
…e pids

The NIM image's default user is uid 1000, and privileged does not confer
root, so the placeholder's ns_last_pid write fails with

    echo: write error: Operation not permitted

and the pid reservation silently does nothing. The restore then either
collides with the placeholder's own processes or, since the agent guard
landed, is refused outright. nim-llama-8b was the one workload of seven
still failing after the reservation was restored.

Only the placeholder runs as root. CRIU restores the workload with the uid
recorded in the checkpoint, so what the workload itself runs as is
unchanged.

This does not generalise to production, where the pod runs a tenant image
that is often non-root by design. Recorded on #925: reserving from inside
the pod cannot be the long-term answer there, which makes dumping the pid
namespace root load-bearing rather than a cleanup.

Refs #925

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
…ling once

The guard added alongside the pid reservation sampled the placeholder's pid
range a single time and refused the restore if it was still low. That races
the placeholder's own startup: the pod reports Running as soon as its shell
starts, but the reservation only lands after that shell finishes sourcing
profile.d, which is a few hundred forks in these images. The guard could
therefore reject a placeholder that was seconds from correct.

It did, on the first sweep after the guard landed: trtllm-small failed with
"did not reserve its pid range (highest pid 356)", 356 being a transient
profile.d fork rather than the tail the reservation was meant to move.

Wait up to 90s instead, returning as soon as the reservation appears. This
also matters beyond the test manifests: in production the reservation is
done by restore-entrypoint, whose timing differs again.

With this the single-GPU suite went from 3/14 to 13/13 across two passes
of all seven workloads, including nim-llama-8b, which had failed every
attempt until its placeholder was also given a uid that can write
ns_last_pid.

Refs #925

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
@balajinvda
balajinvda requested a review from a team as a code owner August 20, 2026 01:33
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: afdcab70-6bcc-42e5-8e28-3ba7b7aa41a4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

How we establish whether nvsnap can snapshot and restore Dynamo workers in
aggregated and disaggregated mode, without producing a green result that
means nothing.

Dynamo is built to survive worker loss, so a restore that fails completely
still yields a successful inference request served by another worker. That
is the same masking that let a cold start be measured as a restore, except
here the system is designed to hide it. The plan is shaped around defeating
that: one worker of the type under test, and assert which worker served the
request rather than that a request succeeded.

Records what is established from source, notably that component-level
annotations on a DynamoGraphDeployment reach pod metadata, so restore-from
will trigger our webhook on operator-created pods. Separates that from the
assumptions that still gate the design, chiefly whether workers rejoin after
an abrupt restart.

Also states why restore must happen in place: our harness deletes the source
pod and creates a placeholder, which an operator will reconcile against,
leaving two pods and a test that passes while proving nothing.

Refs #1009

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
… behaviour

Replaces assumptions with what the Dynamo runtime and operator actually do.

Worker identity is addressable per request via x-dynamo-worker-instance-id
and x-dynamo-prefill-instance-id, so a verification request can be pinned to
the restored worker. Since Dynamo is built to survive worker loss, a failed
restore would otherwise be served by a healthy peer and the test would pass
while proving nothing. Pinning removes that.

Discovery on Kubernetes is readiness-driven, not lease-driven: the operator
sets DYN_DISCOVERY_BACKEND=kubernetes, and a pod is discoverable when it is
ready in an EndpointSlice and has a pod-owned DynamoWorkerMetadata CR. So
restoring in place preserves registration and recovery is automatic, while
deleting the pod would garbage collect the CR.

Refs #1009

Co-Authored-By: Balaji Ganesan <bganesan@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants