Repository navigation
Save workload images in the container, without SSH or control-plane data transfer - #764
Merged
Merged
Conversation
Exporting a workload's container used to SSH to the node and run nerdctl commit/login/push there. That only works on nodes the platform can SSH into with containerd on them, puts the registry credential on the node, and commits everything in the container, including what the platform's own launcher installed (sshd and its host keys among it). The export now reads the container through the Kubernetes exec API, which reaches a container on a kubelet and one on a virtual kubelet the same way: - a probe reports the uid, GNU find/tar, the mount table and when the launcher handed over to the user's entry point (the change time of the .run.sh it writes just before starting it); - find lists the root file system with change times; paths changed after the hand-over are archived by tar and streamed back over stdout; - deletions are measured against the base image, read from the registry by the digest in the container status, and become OCI whiteouts; - the layer is appended to that base and pushed from the controller with go-containerregistry, so the credential never enters the container. The base is read from the target registry by the same digest first, so the push can mount its layers and a node registry the controller cannot reach or does not trust is not needed. Mount points, /proc, /sys, /dev, /etc/hosts, /etc/hostname, /etc/resolv.conf and SSH host keys are never exported. A container that does not run as root is refused rather than exported with the files it cannot read. When the user ran apt after the launcher did, the dpkg status file also records the launcher's packages; records whose file lists are not in the image are dropped so the next launcher installs them again instead of believing they are there. The target tag is unique to the second plus a random suffix, the job outputs the pushed digest, the apiserver creates the export project in the built-in Harbor when it is missing, and exporting needs update permission on the workload rather than the right to see it. Co-Authored-By: Claude <noreply@anthropic.com>
A saved image is a snapshot of someone's container with whatever they kept in it; a public project would let anyone who can reach the registry pull another user's snapshot. The built-in Harbor's export project is now created private when it is missing, and an existing project is never modified. The creation moves to its own file so the Harbor helpers are untouched. The base image may sit in a registry only the workload's own image pull secrets open. Those secrets are now consulted for reading the base, after the platform credential, so they never decide how the target registry is written to. Co-Authored-By: Claude <noreply@anthropic.com>
Workloads pull saved images without a pull credential, so the built-in Harbor's export project is created public, like the import project. An existing project is still left as it is. Co-Authored-By: Claude <noreply@anthropic.com>
The container now computes and uploads its own layer. A static save-image binary, shipped on the Pod's shared volume by the preprocess image, records the files the container started with when the launcher begins (so deletions no longer need every base layer read from the registry), and on export writes the changes since the launcher's hand-over as one layer and streams it to a staging repository made for that job. The resource manager issues a registry token whose grant it checks covers that repository alone, passes it with the CA on the program's standard input, then puts the image together in the registry by cross-repository mounts of the base layers and the staged layer, writing only the config and manifest. It never copies a blob: a base not in the staging registry, or a registry that will not mount, fails the job. What the container reports only locates the layer; the published digest is the registry's. Where a cluster's images are staged and published is configured per cluster. When the staging registry is not the target, the registry's own replication carries the image and the job waits for the digest to arrive. The job manager adds the cluster's configured host aliases to every Pod so containers can resolve the staging registry on every node type. Containers started before this change have no record and are refused with a request to restart. Co-Authored-By: Claude <noreply@anthropic.com>
…r cluster Harbor gives a registry token the power of the account that minted it, whatever repository the token names: a token minted with the platform's credential for one staging repository pushes anywhere. The container's token is now minted with a separate credential limited to the staging project, and saving is refused when none is configured. A cluster's settings name one registry: the container uploads to it and the image is put together and published there. The separate staging registry and the replication wait are removed. Co-Authored-By: Claude <noreply@anthropic.com>
…ed registry Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
A file capability such as ping's security.capability is now archived as a PAX SCHILY.xattr record; the SELinux label and overlay's own attributes, which describe the machine, are left out. The first name of a hard-linked inode is recorded only after its contents are written, so a name that vanished is never the target of a link the layer cannot unpack. Co-Authored-By: Claude <noreply@anthropic.com>
…les in the background The agent now uploads with the registry's chunked upload (POST, PATCH per 16 MiB chunk, PUT): a chunk that fails is sent again from where the registry says the upload is, and a token about to expire, or refused, is replaced. It asks the controller for new tokens over the exec's standard input and output (JSON lines); the controller mints each with the staging-only credential, no more often than every 20 seconds. The token's 30 minutes no longer bound the layer, and the agent stops at the job's deadline instead. The CA sent to the container is added to its system roots instead of replacing them, so a registry with a publicly trusted certificate stays reachable. The probe's output is bounded like the agent's, and the probe checks the agent speaks this protocol. The launcher starts the record in the background at the lowest priority. The record writes each path as it is listed, and the export compares it with the current listing side by side in walk order, so neither keeps the whole file list in memory. Directories the user had changed before the record listed them are counted and logged. Co-Authored-By: Claude <noreply@anthropic.com>
…Harbor, record a timed-out export's reason A registry named without a dot or a port no longer reads as Docker Hub: names are parsed strictly and must resolve to the configured host before any credential is used. The built-in Harbor's CA goes to the container only when it uploads to the built-in Harbor. The outcome of an export is written with a context of its own, so one that ran out of time records why. Co-Authored-By: Claude <noreply@anthropic.com>
For the built-in Harbor the apiserver now creates the private save-staging project, a robot account limited to push and pull there, and the primus-safe-save-image-staging-cred Secret holding its credential, so an upgraded deployment saves images without manual steps. An existing Secret is never changed. It retries in the background until Harbor answers. Co-Authored-By: Claude <noreply@anthropic.com>
The record logs it, and the export's result carries it into the resource manager's log. Co-Authored-By: Claude <noreply@anthropic.com>
A registry takes a fraction of a second for each request (about 0.2 s on Harbor), so 16 MiB chunks halved the upload rate. The agent now holds its two chunks, 512 MiB each, in files on the Pod's shared volume, which the export leaves out, and keeps 16 MiB chunks in memory only when there is no such volume. A chunk can still be sent again after a failure. Co-Authored-By: Claude <noreply@anthropic.com>
…s, and where the CA applies Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts: # SaFE/job-manager/pkg/dispatcher/dispatcher_help.go
go mod tidy marks it direct, since resource-manager imports it, and adds the requirements it pulls in. Only these lines change; the rest of what tidy would change is already that way on main. Co-Authored-By: Claude <noreply@anthropic.com>
The save-image binary in it is built from resource-manager's module, so a dependency bump changes it. Co-Authored-By: Claude <noreply@anthropic.com>
…or it before issuing a token A launcher that cannot write the working directory starts the entry point from a temporary file, so the export found no .run.sh and failed, after the upload token was issued. The launcher now records the file's absolute path on the shared volume; the agent and the probe read the file from there (the working directory's .run.sh when nothing is recorded), and the probe refuses a container whose file is gone, with the path and what to do, before any token is issued. Co-Authored-By: Claude <noreply@anthropic.com>
…RD=0 The record stays on by default: it can only be made when the container starts, so a workload that finds it missing must restart, losing what it wanted to save. It runs in the background at the lowest priority; on a root file system of 905k paths it took 6.8 s, about 7 CPU-seconds on one core, 98 MB on the shared volume and 10 MiB of memory. With the variable at 0 the launcher skips it and notes that it did, and saving such a container is refused with that reason, before any token is issued. Co-Authored-By: Claude <noreply@anthropic.com>
The platform was fixed at linux/amd64, so on another architecture a multi-platform base image resolved to the wrong manifest. It now comes from the node's reported operating system and architecture (its kubernetes.io/os and kubernetes.io/arch labels when it reports none), and the export is refused when neither says. The save-image binary is built for the architecture the preprocess image is built for. Co-Authored-By: Claude <noreply@anthropic.com>
A staging repository holds blobs only; Harbor records a repository only when a manifest is pushed, so its API has no repository to delete, and a retention policy has no artifact to select. The layer of a failed export is deleted by Harbor's garbage collection once it is older than the collection's time window, which needs a schedule. Co-Authored-By: Claude <noreply@anthropic.com>
…s pattern Co-Authored-By: Claude <noreply@anthropic.com>
The apiserver authorized the first of "workload"/"workloadId" while the controller saved the first "workload", so a request naming two workloads was authorized for one and exported the other. The apiserver now refuses inputs that name different workloads and rewrites them to one "workload" parameter and the workload's own image; the controller refuses a job whose workload is not the one its label says was authorized. Co-Authored-By: Claude <noreply@anthropic.com>
The agent read the run file's change time when the export ran, so a chmod or chown of the run file by the user moved the boundary past their earlier changes and left them out of the saved image. The launcher now runs "save-image mark", which records the run file and its change time as it hands over; the agent uses that time when it is recorded. Co-Authored-By: Claude <noreply@anthropic.com>
An export runs as long as the user's container keeps uploading, and the three workers had no share per user, so one user could hold all of them. A user's further export now waits, retried every 15s, until theirs ends or its own deadline passes; another user's export runs meanwhile. Co-Authored-By: Claude <noreply@anthropic.com>
The tests minted the container's token and the platform's with the same account, so minting the container's with the platform account passed. The fake registry now has a staging account and records which account asked for each token; the end-to-end and renewal tests check that the container's tokens, and only they, come from it. The controller's traffic excluded every token with a push scope on a "python" repository, which took in the token that puts the image together. It now counts every token the export asked for, other than the container's, and leaves out only those that set the registry up. Co-Authored-By: Claude <noreply@anthropic.com>
An export waiting for its user's earlier one can be taken up after its own deadline, when the job has no time left and would run without one. It now fails, saying it timed out before it could start. Co-Authored-By: Claude <noreply@anthropic.com>
The apiserver dropped a request's workload, workloadId and image inputs by
exact name, but the OpsJob webhook then renames every input with
stringutil.NormalizeName. {"name":"Image"} therefore survived the handler,
became a second "image" ahead of the one read from the authorized workload,
and the controller exported under the request's value; {"name":"Workload"}
likewise escaped the check that all workload names agree.
Compare names through the same NormalizeName the webhook applies, and have
the controller refuse a job carrying more than one image, as it already does
for workloads.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
The response body was closed before transport.CheckError read it, so every refusal (quota, immutable tag, missing project, MANIFEST_INVALID) surfaced as "http: read on closed response body". Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
It was created once at start, so a Harbor that came up after the apiserver never got it and every export failed at the final manifest push until the apiserver restarted. It is now made by the same retried step as the staging project. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
An empty value names no workload; the agreement check no longer depends on which spelling comes first. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
The bootstrap (apt-get in build_authoring.sh) deletes and rewrites files of the image. A record listing them first made the export white them out as the user's deletions, so the saved image lost base-image files. The previous record is still removed and marked in progress before anything else. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
A missing harbor-core ConfigMap ended the retried staging setup as if it had succeeded, so a Harbor installed after the apiserver never got the staging project, robot and Secret until the apiserver restarted. A missing Harbor is now retried like any other failure, every ten minutes at most and logged once. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
Since the file record starts after the bootstrap, it also lists the dpkg file lists of what the launcher installs (openssh-server, socat), so a dpkg status file the user rewrote kept claiming those packages although their files are never saved, and the next launcher skipped installing them. The launcher now lists /var/lib/dpkg/info/*.list before its bootstrap (save-image packages) and the export reconciles the status file against that list; without it, nothing is recorded and saving is refused. A container started by an earlier launcher falls back to the record. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
An export took the general ops job timeout (an hour by default), far shorter than the 12 hours it is designed to take, and the controller uses the job's remaining time as the export's deadline. ops_job.export_image. timeout_second (default 43200) is now the default in the apiserver and the webhook; a timeoutSecond the request gives stands. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
A refused renewal (asked within 20 s of the last one, or the registry failing to mint) failed an export that may have run for hours. A refused grant is now retried with the upload's backoff, a bounded number of times; a broken exchange with the controller still is not. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
initHarbor ran once at start-up, so a Harbor that came up later got its staging project, robot and Secret from the retried setup but never became the default registry, which saving an image pushes to. The retried setup now runs initHarbor first, until both succeed. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
… was The dial hook added to harbor.go for the previous test made the file part of the change, and the secret scanner rejects lines it has always had. The retried setup now takes its two steps as functions, and the test drives it with stand-ins. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
weilei0120
approved these changes
Oct 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Save Image (OpsJob
exportimage) no longer SSHes to the node to runnerdctl commit/login/push, and no image data passes through the API server or the resource manager. The container computes and uploads its own changes; the resource manager only issues a short-lived token, starts one command throughpods/exec, and puts the image together in the registry with kilobyte-sized requests. The same path serves a container on a kubelet and one on a virtual kubelet.Why
nerdctl commitcaptures everything in the container, including what the platform launcher installs at start (openssh-server, socat, curl and dependencies, several thousand files), SSH host keys among them.pods/execand read every base layer from the registry to find deletions. Measured on a real cluster, that cost the API server about 14 CPU-seconds per GB streamed (3–4 cores for the duration) and grew its RSS by ~390 MB, and listing an 11.7 GB base image took 218 s and 266 CPU-seconds in the resource manager, per export.Data flow
/shared-data/save-image recordas its first step (before the AINIC/bnxt/authoring bootstrap). It lists the root file system (no other file system, no mount point, no/proc,/sys,/dev) into/shared-data/save-image.base, atomically./shared-datais a mount, so the record is never part of a saved image. A failure only disables saving for that container.sh -c, bytes): uid, presence of the binary and the record. A container started before this change has neither and is refused with "restart the workload, then save it again". A non-root container is refused.status.containerStatuses[].imageIDmust be in the cluster's registry (same repository path); otherwise the job fails rather than copying the base. Only its manifest and config are read.repository:<staging project>/<job name>:push,pull, minted with a separate credential limited to the staging project. Harbor gives a bearer token the power of the account that minted it, whatever repository the token names (measured: a token minted with an administrator credential for one repository pushed to another), so the platform's own credential is never used for this, and saving is refused when no staging-only credential is configured. The grant is also read back and refused unless it names the staging repository alone./shared-data/save-image export): the token, the registry CA and the deadline (token expiry minus a minute) go on standard input only, never argv, environment or the Pod spec. Inside the container the binary:.run.sh(written after the launcher's own bootstrap, immediately before the user's entry point) are the changes;/proc/sys/dev,/etc/hosts/etc/hostname/etc/resolv.conf,/etc/ssh/ssh_host_*, sockets;/var/lib/dpkg/statuswith the files the image holds (drops the launcher's packages);<registry>/<target project>/<ns>/<repo>; a registry that answers a mount with an upload has the upload cancelled and the job fails, so no blob is ever copied through the resource manager. Only the config (KB) and manifest are written. The job'sdigestoutput is what the registry reports for the tag, not what the container printed.What the container can still forge is its own layer's content and diff ID, which only affects the user's own image.
Configuration and deployment prerequisites
New chart values, rendered into the resource manager's and job manager's config:
Before this is deployed, the registry administrator needs to:
config.jsonor.dockerconfigjson) named bystaging_push_secret; never an administrator account;host_aliaseswhere the cluster's DNS does not resolve the registry name, andca_secretwhere its CA differs from the built-in one;custom) is still created public by the apiserver when missing, as before.The preprocess image gains the static
save-imagebinary (a Go build stage); the build workflow now rebuilds it when the binary's sources change. Containers started before the new preprocess image is rolled out cannot be saved until restarted.Kept from the previous revision
Launcher hand-over boundary (
.run.shctime), exclusions, dpkg reconciliation, refusal of non-root containers,updatepermission on the workload, publiccustomproject creation,digestoutput, unique<YYYYMMDDHHMMSS>-<6 hex>tags.Removed: reading the base file set from the registry (
BaseFileSet), streaming the layer back over exec, GNU find/tar as dependencies, and the pod pull secrets in the keychain (the base must be readable with the platform's credential anyway, to be mounted).Tests
.run.sh, missing token, wrong CA, plain HTTP), dpkg reconciliation; an end-to-end upload to a TLS registry.openssh-server/socat/curlbootstrap, with a 1 GiB random file added, a file modified, a file and a directory deleted: export took 10.5–13.8 s for a 1.07 GB layer (six runs); the controller side used ~30 ms CPU, ~29 KB of registry traffic and ~2.4 KB over exec per export; API server CPU during exports stayed within its idle range (no sustained rise, against ~14 CPU-s/GB before). The image ran on another node with the 1 GiB file's checksum intact, the deletions applied, a sibling of the deleted directory kept, and no sshd, host keys or.run.sh. A container without the record was refused.Not covered
/shared-datathere and records the file system, but the container could not reach the registry in the test environment (name not resolvable without host aliases); the end-to-end upload there is unverified.🤖 Generated with Claude Code