You can checkpoint a container. You cannot restore one.

A checkpoint captures a container's runtime state — the memory it holds, the files it has open, the processes it spawned — where a backup captures only what you declared it should be. Kubernetes can take one through a kubelet endpoint, and has no API to put it back.

in short

I built a single-node cluster that can actually do this and measured what survives a round trip. An in-memory counter, an open file descriptor and a child process all came back, the child keeping its original PID. An established TCP connection prevents the checkpoint from being taken at all. The archive lands at roughly the size of the process’s resident memory, and a Secret the container read into memory is recoverable from it with strings. containerd cannot serve any of this — its CRI checkpoint call is a 34-line stub that returns Unimplemented.

Container checkpointing has been in Kubernetes since 1.25 and on by default since 1.30, and almost everything written about it describes the alpha. I wanted the current behaviour, so I built a cluster that could actually run it. Getting that far was itself informative.

First: you probably can't run this at all

The kubelet asks the container runtime to do the work, over the CRI. So the runtime has to implement the call. Here is containerd's entire implementation, on main:

func (c *criService) CheckpointContainer(ctx context.Context, r *runtime.CheckpointContainerRequest) (res *runtime.CheckpointContainerResponse, err error) {
	// The next line is just needed to make the linter happy.
	containerCheckpointTimer.WithValues("no-runtime").UpdateSince(time.Now())
	return nil, status.Errorf(codes.Unimplemented, "method CheckpointContainer not implemented")
}

Thirty-four lines, and it declines. containerd is the default runtime for kind, for most managed offerings, and for most self-managed clusters. On all of them the endpoint exists, passes authorization, and then fails at the runtime. No configuration changes this. CRI-O is the only runtime that implements it today — which is why the lab runs CRI-O, and why I'd check your runtime before planning around this feature.

A search result I hit while researching claimed container-level checkpoint support "is already available" in containerd. The file above is the whole thing.

What a checkpoint actually captures

It's worth being precise about the layer, because people reach for this expecting a backup.

DECLARED STATE Deployments, Services, ConfigMaps, Secrets lives in the API server / etcd · recreatable from YAML VOLUME STATE files a container wrote to a PersistentVolume lives on a disk somewhere · survives the pod RUNTIME STATE heap, stack, open file descriptors, sockets, child processes, the current instruction exists only in RAM · normally destroyed when the pod stops backup tools e.g. Velero · API objects volume snapshots CSI · block/filesystem checkpoint CRIU · this set
Each row has its own tool, and none of them reaches down a row. Restoring every API object gives you a pod that starts from the beginning. Restoring a volume gives you the files but not the process that had them open. Only the bottom row captures what the program was in the middle of doing.

A backup captures what you declared — API objects, recreatable from git. A volume snapshot captures what you stored. A checkpoint captures what the process was doing: heap, stack, open file descriptors, child processes, the instruction it had reached. None of these substitutes for the others, and only the last one is destroyed when a pod stops.

The call itself is one POST to the kubelet, and the first one fails usefully:

POST /checkpoint/{ns}/{pod}/{container} with the apiserver's kubelet client certmeasured
Forbidden (user=kube-apiserver-kubelet-client, verb=create,
           resource=nodes, subresource(s)=[checkpoint])
HTTP 403

The kubelet delegates authorization to the API server, and nothing grants create on the nodes/checkpoint subresource by default. The error names the user, the verb and the subresource — it's effectively the ClusterRole you need to write. One trap: after applying that grant the next call still returned 403, because the kubelet caches authorization decisions including denials. Don't conclude your RBAC is wrong from a single retry.

With the grant in place you get a path back, and a tar on the node:

the successful callmeasured
{"items":["/var/lib/kubelet/checkpoints/checkpoint-counter_default-app-2026-09-13T17:38:36Z.tar"]}
HTTP 200

-rw------- 1 root root 242K  checkpoint-counter_default-app-…tar

Inside: 21 CRIU image files (memory pages, registers, open descriptors, namespaces), a rootfs-diff.tar of files the container changed, and the dumped OCI spec. The container is untouched — still Running, restartCount=0, its counter still advancing. That isn't configuration; CRI-O hardcodes it, with a comment saying why: "For the forensic container checkpointing use case we keep the container running after checkpointing it."

The asymmetry

Now the part that surprised me. Save is a verb. Load is a procedure.

running pod one API call POST /checkpoint/… archive on node no API call exists for this direction build an OCI image you do this yourself pod referencing it ordinary pod spec container continues measured: counter 1158 → 1182 one direction is a Kubernetes feature; the other is something you assemble
Save is a verb; load is a procedure. The lower path is not a fallback — it is the only documented way back, and it runs entirely in userland. Chapter 03 measures it end to end and shows a restored process resuming its counter mid-count.
kubelet HTTP server, on mastermeasured
$ grep -in "restore" pkg/kubelet/server/server.go
(no matches)

There is no restore endpoint, on any version I checked. The documented way back is userland: wrap the archive in an OCI image carrying one annotation, and start an ordinary pod from it. CRI-O recognises the image and takes the restore path. Nothing in the pod manifest tells a reader that this pod resumes a process rather than starting one.

It does work. A busybox container incrementing a counter, checkpointed at 1158, restored into a new pod, read 1182 about twenty seconds later — it continued rather than restarting. But it needed a change nobody mentions: CRI-O refuses restore while a namespaced signature policy resolves, and the static bundle installs one by default. Clearing it is what made restore work — and it's worth knowing that restoring a checkpoint image bypasses image signature verification by design, since the archive isn't a signed artifact.

A RestorePod RPC is arriving in cri-api on master (pod-level work, KEP-5823, created the same day the Checkpoint/Restore Working Group was announced). It's a CRI RPC — runtime-facing — and it isn't in a release. "Kubernetes can restore containers" is still not a true statement about the API.

What survives

This is what I actually wanted to know. I wrote a small Go subject holding four kinds of state that exist only inside a running process, each switchable independently so a failure attributes cleanly: an in-memory counter, an open file descriptor, a child process, and an established TCP connection to a peer pod. Checkpoint, wrap, restore into a new pod, read what comes back. Twice.

Runtime stateCheckpointRestoreEvidence
In-memory countersucceedssurvivestick continues 11 → 21, exactly one boot banner
Open file descriptorsucceedssurvivesfd=ok(3), still writable
Child processsucceedssurvivesalive(12)same PID
Established TCPrefused— never reachedcriu/sk-inet.c:200
ORIGINAL POD RESTORED POD checkpoint → archive → restore counter = 11 counter = 12, 13, … 21 fd 3 open, written fd 3 still writable child PID 12 alive child PID 12 alive same number TCP to peer:9000 established no archive is written the checkpoint call returns an error
Three arrows cross; one stops before the boundary. The child keeping PID 12 is the detail worth pausing on — CRIU restores the process tree with its original identifiers, which is why a parent’s stored PID is still valid after the round trip.

Three of four came back perfectly — not approximately. The child even kept its PID, because CRIU restores process trees with their original identifiers.

The fourth didn't degrade. It aborted the operation:

the TCP variantmeasured
Error (criu/sk-inet.c:200): inet: Connected TCP socket, consider using --tcp-established option.
Error (criu/cr-dump.c:1975): Dumping FAILED.
HTTP 500  — no archive written

CRIU isn't saying this is impossible; it's saying there's a flag. The problem is you can't reach it. The CRI request carries three fields — container ID, location, timeout. CRI-O's internal options struct carries three fields — Keep, KeepRunning, TargetFile. There is nowhere in the chain for a CRIU flag to travel.

What this rules out

Any container holding an open TCP connection when you call the endpoint cannot be checkpointed, and no configuration changes that. Database clients with a connection pool, service mesh sidecars, anything with a long-poll or a watch open. The feature is reliable for compute-shaped work that's momentarily idle on the network, and unreliable-by-construction for connected services — which is a much narrower thing than "capture any container's memory".

So "what does not survive a checkpoint" is slightly the wrong question. The hard edge is at dump time. CRIU's own documentation makes the point: "there is no 'What cannot be restored' article, and never will be. If something was dumped, it should be restored."

The archive is a memory dump

One more measurement, and it's the one I'd want a security team to see. I gave a pod a synthetic canary through a Kubernetes Secret, projected as an environment variable — the normal way. The workload holds it in a variable and never logs or writes it.

Secret object RBAC-protected env var secretKeyRef process memory held in a variable where an operator looks pod logs: 0 pod spec: 0 one POST the checkpoint archive — mode 0600, root:root checkpoint/pages-1.img ← the memory itself spec.dump ← the container’s env recoverable with: strings | grep the Secret’s RBAC protects the object. Nothing carries that protection into the archive.
The access control does not travel with the data. Reading the Secret object requires RBAC on Secrets. Reading the same bytes out of a checkpoint requires only the ability to read a file on a node — a completely different, and usually much larger, set of people.
before, and then inside the archivemeasured
occurrences in pod logs : 0
occurrences in pod spec : 0

archive mode=600 owner=root:root

$ grep -rl "$SECRET" /tmp/extracted
spec.dump
checkpoint/pages-1.img

$ strings checkpoint/pages-1.img | grep -o 'CANARY-[a-z0-9-]*'
CANARY-b7f3e91d-do-not-log-4417

No exotic tooling — tar, strings, grep. It's in two places for two reasons: pages-1.img is the process's anonymous memory dumped verbatim, and spec.dump is the container's OCI spec, which includes its environment. The second one catches people out, because the secret is in the archive even if the process never reads the variable — and it therefore rides along in any restore image you build, into whatever registry you push it to.

The permission that matters isn't the API one. create on nodes/checkpoint is off by default and governs creation. Once the archive exists it's an ordinary 0600 root:root file, readable by anyone with root on the node — node admins, privileged DaemonSets, anything that can mount the host filesystem. A workload that could not read a Secret through the API can read it out of a checkpoint of a pod that could.

The feature's name was telling you this. KEP-2008 is Forensic Container Checkpointing, and forensics is the business of extracting everything without the subject's cooperation. Treat these files like core dumps: restricted storage, short retention, deliberate deletion.

Where it actually stands

The KEP's own file records stage: beta, latest-milestone: v1.30, and stable: "v1.33", last updated February 2024. Stable at 1.33 didn't happen — there's still no GA entry in the feature-gate registry at 1.36. When a KEP's milestone block and the gate registry disagree about status, the registry is the one that ships.

And a caution worth stating plainly: this is not live migration. Live migration needs iterative pre-copy, dirty page tracking, a page server to stream memory to another host, and TCP connection repair. Those are all real CRIU features — and none of them are reachable through a CRI request carrying three fields. What upstream provides is a cold, one-shot, node-local dump to a file, plus a userland way to start a container from it. It's closer to gcore than to a hypervisor's live migration.

The plumbing to watch is containerd's 34-line stub. If that's ever replaced, this feature becomes available to most clusters overnight.