For years, Kubernetes engineers have used the same awkward pattern whenever an application needed large read-only assets:
- Init container
emptyDircp -r- Wait for startup
- Duplicate storage usage
It worked, but it always felt like a workaround rather than a first-class Kubernetes primitive.
With Kubernetes 1.36, the image volume type is now GA, and it fundamentally changes how Pods can consume immutable file bundles.
Instead of:
- pulling files into an init container,
- copying them into an
emptyDir, - and mounting them into the main container,
Kubernetes can now directly mount an OCI image filesystem into a container as a read-only volume.
That means:
- no init-container copy step,
- no duplicated bytes on disk,
- faster startup times,
- smaller artifact images,
- and much cleaner Pod specs.
This becomes especially powerful for:
- ML models
- static websites
- WASM modules
- OPA bundles
- language packs
- Grafana dashboards
- plugin distributions
- and any independently versioned read-only asset bundle
Let me walk through a realistic end-to-end scenario.
The Scenario
- Team A owns nginx (the web server).
- Team B owns the website content (HTML/CSS/JS).
- Team B ships new content 5x/day.
- Team A ships nginx config changes maybe once a month.
- They should not be coupled.
The static assets live in an OCI image:
registry.example.com/web/site-assets:v42
This image is:
scratch + files
No shell. No entrypoint. Just:
/site/index.html
/site/style.css
/site/app.js
The Old Way (Pre-1.36): Init Container + emptyDir
How You Built the Assets Image
# Dockerfile for site-assets
FROM busybox:1.36 AS source
COPY ./site /site
# Final image needs a shell because the init container will run `cp`
FROM busybox:1.36
COPY --from=source /site /site
Notice something important:
You cannot use FROM scratch here because the init container needs tools like:
cpsh
So the image is bloated with BusyBox purely to enable the copy operation.
The Pod Manifest
apiVersion: v1
kind: Pod
metadata:
name: web-old-way
spec:
imagePullSecrets:
- name: regcred
initContainers:
- name: load-assets
image: registry.example.com/web/site-assets:v42
command:
- sh
- -c
- cp -r /site/* /shared/
volumeMounts:
- name: shared
mountPath: /shared
containers:
- name: nginx
image: nginx:1.27
volumeMounts:
- name: shared
mountPath: /usr/share/nginx/html
readOnly: true
volumes:
- name: shared
emptyDir: {}
What's Actually Happening Under the Hood
1. Images are pulled
Kubelet pulls:
nginx:1.27site-assets:v42
using the Pod's imagePullSecrets.
2. Kubelet creates the emptyDir
Kubernetes creates an actual directory on the node filesystem:
/var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~empty-dir/shared/
At this point it is completely empty.
3. Init container starts
The init container's root filesystem is the site-assets image.
The emptyDir gets bind-mounted into the init container at:
/shared
4. The copy operation happens
The init container executes:
cp -r /site/* /shared/
Every byte gets physically copied:
image layer -> emptyDir
5. Init container exits
Kubelet records successful completion.
6. nginx starts
The same emptyDir is mounted into nginx at:
/usr/share/nginx/html
nginx now serves the copied files.
Real Problems With the Old Pattern
1. Disk Usage Doubles
The files now exist in two places:
/var/lib/containerd/...
AND
emptyDir
A 2 GB ML model becomes:
2 GB image layer + 2 GB copy = 4 GB
per Pod.
2. Startup Latency
Large copy operations are expensive.
A 2 GB copy operation can easily add:
10–30 seconds
before the main container even starts.
3. You Need a Shell
You cannot use:
FROM scratch
because the image needs tooling like:
cpsh
That means:
- larger images
- more CVEs
- more attack surface
4. Verbose YAML
You need:
- init containers
- shared volumes
- multiple mounts
- copy commands
All just to move files around.
5. No Sharing Across Pods
Every Pod independently copies the same bytes into its own emptyDir.
Ten Pods on one node means:
10 independent copies
The New Way (Kubernetes 1.36): Image Volume Type
How You Build the Assets Image Now
FROM scratch
COPY ./site /site
That's it.
No shell.
No BusyBox.
No executables.
Just files.
The Pod Manifest
apiVersion: v1
kind: Pod
metadata:
name: web-new-way
spec:
imagePullSecrets:
- name: regcred
containers:
- name: nginx
image: nginx:1.27
volumeMounts:
- name: assets
mountPath: /usr/share/nginx/html
subPath: site
volumes:
- name: assets
image:
reference: registry.example.com/web/site-assets:v42
pullPolicy: IfNotPresent
No init container.
No emptyDir.
No copy operation.
What's Actually Happening Under the Hood Now
1. Kubelet sees an image volume
Kubelet notices:
volumes:
- image:
and asks the CRI runtime to mount the image.
2. Runtime pulls the image
containerd or CRI-O pulls:
site-assets:v42
using the normal image pull pipeline.
Exactly the same mechanism used for container images.
3. Runtime unpacks image layers
The image gets unpacked into the runtime snapshot store.
For example with containerd:
overlayfs snapshotter
No container is started.
Only the filesystem is materialized.
4. Filesystem is bind-mounted directly
The runtime bind-mounts the image filesystem directly into the nginx container:
/usr/share/nginx/html
Read-only by design.
5. nginx starts immediately
No copy step.
No waiting.
The files already exist.
The Mental Model Shift
The old model was:
"Start a helper container and copy files somewhere."
The new model is:
"Mount an OCI image filesystem directly as a volume."
That sounds subtle, but architecturally it's a major shift.
Kubernetes is effectively treating OCI images as generic immutable data artifacts — not just runnable containers.
What You Gain
No Data Duplication
The image is bind-mounted directly.
No copy operation.
Faster Startup
You eliminate the init-container copy phase entirely.
For large datasets or ML models, this is massive.
Smaller and Safer Images
You can now use:
FROM scratch
which means:
- smaller images
- fewer CVEs
- reduced attack surface
Always Read-Only
Image volumes are immutable by specification.
The runtime enforces it.
Applications cannot modify the mounted content.
Shared Across Pods
Ten Pods mounting the same image on the same node share the same underlying bytes.
Huge improvement for large artifact distribution.
Cleaner YAML
The Pod spec now clearly expresses intent:
"Mount this image's filesystem here."
instead of implementing an entire file-copy workflow.
Important Caveats
Image Volumes Are Always Read-Only
If your application needs writable storage, use:
emptyDir- PVCs
- ephemeral storage
instead.
subPath Is Extremely Useful
If your files live under:
/site
inside the image but you want them mounted directly into:
/usr/share/nginx/html
then:
subPath: site
solves that cleanly.
pullPolicy Works Exactly Like Container Images
You can use:
pullPolicy: Always
or:
pullPolicy: IfNotPresent
exactly as you already do with containers.
No Environment Variable Substitution
This does NOT work:
reference: ${ASSET_VERSION}
The field is literal.
Use:
- Helm
- Kustomize
- templating
instead.
Running Pods Don't Automatically Update
If somebody pushes a new image to:
:v42
existing Pods continue using the old mounted bytes.
You must roll the Pod to pick up changes.
Which is good for reproducibility.
In production, pin image digests.
Runtime Support Matters
You need modern runtimes.
Roughly:
- containerd >= 2.1
- CRI-O >= 1.31
Older runtimes will fail with a clear unsupported feature error.
How imagePullSecrets Work
This is one of the nicest parts.
Image volumes automatically use the same authentication flow as normal container images.
That means Kubernetes automatically uses:
- Pod
imagePullSecrets - ServiceAccount
imagePullSecrets - kubelet credential providers
No additional auth wiring required.
So this:
imagePullSecrets:
- name: regcred
works for BOTH:
- container images
- image volumes
Multiple Private Registries
If assets and application images live in different registries:
imagePullSecrets:
- name: app-registry-creds
- name: assets-registry-creds
The runtime tries the secrets in order and uses whichever matches the registry hostname.
Quick Comparison
| Aspect | Init Container + emptyDir | Image Volume |
|---|---|---|
| Pod complexity | Multiple containers and mounts | Single volume |
| Assets image | Needs shell/cp |
FROM scratch works |
| Disk usage | Image + copied bytes | Image only |
| Startup time | Pull + copy | Pull only |
| Writable | Yes | No |
| Sharing across Pods | No | Yes |
| imagePullSecrets | Pod spec | Pod spec |
| Update without restart | No | No |
| Kubernetes support | Always | 1.31 alpha → 1.33 beta → 1.36 GA |
When You Should Actually Use It
Image volumes are ideal when you have:
- large read-only assets
- independently versioned bundles
- OCI-distributed artifacts
- data shared across multiple Pods
Examples include:
- ML models
- static websites
- OPA bundles
- plugins
- WASM modules
- Grafana dashboards
- language packs
They're especially useful when:
- the artifacts are too large for ConfigMaps
- you want registry-native distribution
- you want image signing/scanning/RBAC
- the content must remain immutable
When NOT To Use It
Don't use image volumes when:
- the application needs writable storage
- the content is tiny text configuration
- the data is stateful per Pod
In those cases:
- ConfigMaps
- PVCs
emptyDir
are still better fits.
Final Thoughts
The image volume type feels small on paper, but it removes one of the longest-standing operational hacks in Kubernetes.
For years, platform engineers built elaborate init-container copy workflows just to move immutable files into Pods.
Now Kubernetes finally has a native primitive for it.
If your workloads distribute:
- large read-only assets
- ML models
- frontend bundles
- policy packs
- plugins
- shared runtime data
this feature can significantly reduce:
- startup latency
- storage duplication
- image complexity
- YAML noise
More importantly, it aligns Kubernetes with a broader industry shift:
OCI images are no longer just executable containers.
They're becoming the standard distribution format for software artifacts in general.
And image volumes push Kubernetes one step further in that direction.
Top comments (0)