Skip to content

Save workload images in the container, without SSH or control-plane data transfer - #764

Merged
weilei0120 merged 40 commits into
mainfrom
feat/exportimage-exec
Oct 11, 2026
Merged

weilei0120 merged 40 commits into
mainfrom
feat/exportimage-exec

Conversation

@zoroyihan7

@zoroyihan7 zoroyihan7 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

What

Save Image (OpsJob exportimage) no longer SSHes to the node to run nerdctl 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 through pods/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

  • SSH + nerdctl only works on nodes the platform can SSH into and that run containerd, and it copies the registry credential onto the node.
  • nerdctl commit captures 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.
  • An earlier revision of this PR streamed the layer back over pods/exec and 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

  1. At container start the launcher runs /shared-data/save-image record as 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-data is a mount, so the record is never part of a saved image. A failure only disables saving for that container.
  2. Probe (exec 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.
  3. Base: the digest in status.containerStatuses[].imageID must 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.
  4. Token: the resource manager asks the registry's token service for 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.
  5. Export (exec /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:
    • lists the file system; paths whose ctime is later than the launcher's .run.sh (written after the launcher's own bootstrap, immediately before the user's entry point) are the changes;
    • paths in the start record and gone now become OCI whiteouts (collapsed under a deleted directory or a directory replaced by a file);
    • leaves out mounts, /proc /sys /dev, /etc/hosts /etc/hostname /etc/resolv.conf, /etc/ssh/ssh_host_*, sockets;
    • reconciles /var/lib/dpkg/status with the files the image holds (drops the launcher's packages);
    • writes one tar layer (numeric owners, hard links, no atime/ctime) and streams it gzip-compressed to the staging repository, TLS only, against the given CA, never retried over plain HTTP;
    • prints the layer digest, diff ID, size and counts.
  6. Assembly (resource manager, registry API only): the staging blob is checked to exist with the reported size; the config and manifest are built from the base (pinned by the controller) plus that one layer; the base layers and the new layer are cross-repository mounted into <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's digest output 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:

save_image:
  clusters:
  - cluster: <cluster id>
    registry: <registry host>          # default: the default registry
    target_project: custom             # default
    staging_project: save-staging      # default
    ca_secret: <ns>/<name>             # key ca.crt; default: the built-in Harbor's CA
    staging_push_secret: <ns>/<name>   # default: primus-safe/primus-safe-save-image-staging-cred
    host_aliases:                      # added to every Pod the platform creates in the cluster
    - ip: <registry IP>
      hostnames: [<registry host>]

Before this is deployed, the registry administrator needs to:

  • create the staging project (private) in each cluster's registry; a retention rule and periodic garbage collection reclaim staged blobs (once mounted into a saved image they are referenced by it);
  • create an account that can push to the staging project only (a Harbor robot account of that project) and store it as a Docker config Secret (config.json or .dockerconfigjson) named by staging_push_secret; never an administrator account;
  • make sure the containers of each cluster can reach the registry over TLS: set host_aliases where the cluster's DNS does not resolve the registry name, and ca_secret where its CA differs from the built-in one;
  • keep the images workloads start from in that registry (same repository path and digest), or saving them is refused;
  • the target project (custom) is still created public by the apiserver when missing, as before.

The preprocess image gains the static save-image binary (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.sh ctime), exclusions, dpkg reconciliation, refusal of non-root containers, update permission on the workload, public custom project creation, digest output, 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

  • Agent: walk/exclusions, baseline record and its atomic replacement, layer contents (hard links, symlinks, whiteouts, headers), refusals (non-root, no record, truncated record, no .run.sh, missing token, wrong CA, plain HTTP), dpkg reconciliation; an end-to-end upload to a TLS registry.
  • Controller: a fake Harbor-like registry with a token service, per-repository blobs and cross-repository mounts runs the full export with the agent in-process on a real directory tree, then flattens the published image and checks additions, modifications, deletions, volume content, launcher files, host keys and dpkg; it also counts bytes per token: the layer is uploaded by the container's token while the controller's requests stay under 64 KB. Further tests: old container, non-root, base not in the registry, a token granting more, no staging credential, layer size/digest mismatch, a registry that will not mount (nothing copied, nothing published), the token kept out of errors.
  • Config/chart: the values render into both services and parse; the job manager adds host aliases (external/virtual-kubelet Pods included) after the template's own.
  • Each guard was mutated and the tests went red, then reverted.
  • Real cluster, containerd node, launcher-started root container after an openssh-server/socat/curl bootstrap, 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

  • Virtual-kubelet nodes: the binary runs from /shared-data there 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.
  • A staging-only robot account's token was not exercised against a real Harbor (the measurement above used an administrator token in a test container).
  • Token lifetime is the registry's (Harbor default 30 minutes); a layer whose upload outlasts it fails.
  • The container is not paused while it is read; a file that changes during the read is archived at the size it had when listed.

🤖 Generated with Claude Code

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>
@zoroyihan7
zoroyihan7 requested a review from a team as a code owner October 8, 2026 18:35
zoroyihan7 and others added 5 commits October 8, 2026 18:45
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>
@zoroyihan7 zoroyihan7 changed the title Save workload images through pods/exec instead of SSH and nerdctl Save workload images in the container, without SSH or control-plane data transfer Oct 9, 2026
zoroyihan7 and others added 21 commits October 9, 2026 03:32
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>
zoroyihan7 and others added 13 commits October 10, 2026 06:45
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
weilei0120 merged commit fef06c8 into main Oct 11, 2026
15 checks passed
@weilei0120
weilei0120 deleted the feat/exportimage-exec branch October 11, 2026 12:44
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