How-to: Enable the Image-Volume Cache
Every create volume from image pulls the image through Glance, converts it, and writes it onto the backend. Booting twenty instances off one image means twenty of those downloads, all for bytes that have not changed since the first. imageVolumeCache keeps the first one as a volume on the backend and clones it for the rest. This guide turns the cache on for the devstack's nfs1 backend, watches it engage, and cleans the cached volume up again.
For the full field reference, see the CinderBackend CRD API Reference and the Cinder CRD.
Prerequisites
Devstack
This guide is written against the Quick Start (ControlPlane) devstack. Stand it up first:
KIND_HOST_PORT=8443 WITH_CONTROLPLANE=true WITH_NFS=true make deploy-infraFollow that tutorial through the block-storage block of Step 4 and the Create a first volume check in Step 7, so the projected controlplane-cinder child is Ready in openstack with nfs1 and nfsbk attached. Every resource name in the examples below is one that devstack produces.
Set storage on the ControlPlane, never on the projected children
The controlplane-cinder Cinder CR, the CinderBackend nfs1 and the CinderBackupBackend nfsbk are projected by the c5c3-operator. A child you edit, or delete, by hand is reverted (or recreated) on the next reconcile. Change services.cinder on the ControlPlane CR and let the operator project it down; that block is the single source of truth for the projected storage.
- The
OS_*environment variables from the tutorial's token-issue step, so theopenstackclient reaches the ControlPlane's Keystone. - A Glance the block-storage service can read images through. The devstack's
services.glanceblock provides it, and it is what gives the Cinder child itsglanceEndpoint.
What the cache changes
The first create volume from image on a backend leaves a second volume behind, named image-<image-id>. Later creates from the same image on the same backend clone that volume on the backend instead of pulling the image through Glance again. The bound is per backend, because the clone is a backend operation: a cached entry serves the backend it was written on and no other.
The cached volume belongs to the deployment rather than to the tenant whose request populated it, so cinder creates it as the project and user spec.internalTenant names on the Cinder. On a ControlPlane that block is derived, not declared: the KeystoneService registration publishes the service account's projectID and userID on its status.account, and the reconciler copies both onto the child as soon as they are there. Read them back:
kubectl get cinder controlplane-cinder -n openstack \
-o jsonpath='{.spec.internalTenant}' | jqTwo consequences follow from that ownership. A cached volume is invisible in a tenant's own volume list, and nothing a tenant deletes removes it.
Enable it
imageVolumeCache sits on a backend entry, so the switch is per backend. Add it to the nfs1 entry:
kubectl edit controlplane controlplane -n openstackspec:
services:
cinder:
backends:
- name: nfs1
type: NFS
nfs:
server: nfs-server.openstack.svc.cluster.local
path: /volumes
imageVolumeCache:
enabled: true
maxSizeGB: 10
maxCount: 5The switch is the enabled field itself, so a block carrying enabled: false keeps the two bounds configured in a ControlPlane that runs without the cache. Both bounds are optional and independent, with a minimum of 1 each; whichever is reached first evicts the least recently used entry. Leaving both unset caches without a bound.
The operator re-renders the backend's section and rolls its volume pod:
kubectl rollout status deploy/controlplane-cinder-volume-nfs1 -n openstackThe rendered section lives in the content-hashed Secret controlplane-cinder-backend-nfs1-<hash>, which only this backend's Deployment mounts. Read it off the pod:
kubectl exec -n openstack deploy/controlplane-cinder-volume-nfs1 -- \
cat /etc/cinder/backends.conf.d/backend.conf[nfs1]
backend_host = controlplane-cinder
image_volume_cache_enabled = true
image_volume_cache_max_count = 5
image_volume_cache_max_size_gb = 10
...The two image_volume_cache_max_* keys are written only when the matching field is set. image_volume_cache_enabled = true on its own is the cache running unbounded.
Verification
Upload a small raw image, create two volumes from it, and count the cache entries after each. A cache that never engaged leaves none, and one that engaged twice leaves two:
dd if=/dev/urandom of=/tmp/cache.img bs=1024 count=1024
openstack --insecure image create --disk-format raw --container-format bare \
--file /tmp/cache.img demo-cache-img
IMG=$(openstack --insecure image show demo-cache-img -f value -c id)The first create is the one that downloads:
openstack --insecure volume create --size 1 --image "$IMG" demo-img-1
openstack --insecure volume show demo-img-1 -c status -f value
openstack --insecure volume list --all-projects -f value -c Name | grep -cx "image-$IMG"The count is 1. --all-projects is what makes it visible: the cache volume belongs to the internal tenant, not to the project that issued the create, so a plain volume list never shows it.
The second create clones it:
openstack --insecure volume create --size 1 --image "$IMG" demo-img-2
openstack --insecure volume show demo-img-2 -c status -f value
openstack --insecure volume list --all-projects -f value -c Name | grep -cx "image-$IMG"The count is still 1. A 2 would mean the second request downloaded the image again and wrote its own entry.
Clean up. The two tenant volumes go the usual way, and the cache volume has to be deleted by id, since nothing above owns it:
openstack --insecure volume delete demo-img-2 demo-img-1
CACHE_ID=$(openstack --insecure volume list --all-projects -f value -c ID -c Name \
| awk -v n="image-$IMG" '$2 == n {print $1}')
openstack --insecure volume delete "$CACHE_ID"
openstack --insecure image delete demo-cache-img
rm -f /tmp/cache.imgClearing imageVolumeCache on the entry turns the cache off again on the next rollout. It leaves the entries already written behind, which is the same delete by id.
Standalone Cinder, without a ControlPlane
Without a ControlPlane nothing derives the internal tenant from a registration, so you supply it on the Cinder CR alongside the cache block on the backend:
apiVersion: cinder.openstack.c5c3.io/v1alpha1
kind: Cinder
metadata:
name: cinder
namespace: openstack
spec:
internalTenant:
projectID: 8a1c4c0e7f5b4d2a9c6e3f8b1d7a4c52
userID: 3f9d2b7c1a8e4f60b5d3c7a9e2b6f841Both fields are required and both are IDs, not names. Cinder passes them to the volume API without resolving them through Keystone, so a name reaches the backend as a project that does not exist. Read the real values off Keystone:
openstack --insecure project show service -f value -c id
openstack --insecure user show cinder -f value -c idA backend that enables the cache on a Cinder carrying no spec.internalTenant is admitted, with an admission warning. The two CRs are applied independently, so GitOps ordering may present the backend first. Until the block is there the cache stays inactive.
See also
- CinderBackend CRD API Reference — the three fields, the rendered keys, and the eviction rule.
- Cinder CRD — why both internal-tenant fields are IDs.
- ControlPlane CRD API Reference — the curated
imageVolumeCacheentry and the derivedinternalTenant. - Attach an NFS Backend to Cinder — the backend entry this block hangs off.
Tested by
A create-from-image that leaves one cache entry, a second create that clones it instead of adding another, and the delete-by-id above are asserted end-to-end on the CI e2e kind cluster by this chainsaw suite:
chainsaw test --test-dir tests/e2e/c5c3/full-controlplane-keystoneThe ControlPlane fixture the suite applies
The fixture is isolation-named: the ControlPlane is called controlplane-keystone, so its projected children carry that prefix (controlplane-keystone-cinder, controlplane-keystone-ovn) where the walkthrough above uses the controlplane / controlplane-cinder names the devstack produces. Its services.cinder block is the one this guide edits: the nfs1 entry carries imageVolumeCache.enabled: true, with no bounds set, beside the nfsbk backup target. The backend entry names are the same ones the devstack uses, because cinder keys every volume by the backend it was created on and the entry name reaches the satellite unprefixed.
apiVersion: c5c3.io/v1alpha1
kind: ControlPlane
metadata:
name: controlplane-keystone
namespace: openstack
spec:
openStackRelease: "2026.1"
region: RegionOne
# Link 5 asserts this literal on the adopted Region CR, and Link 7b reads it
# back through `openstack region show`.
regionDescription: CobaltCore e2e region
# The Minimal sizing profile (spec.sizing below) gives the full fresh-create
# chain a single-node topology: a single-instance non-Galera MariaDB on a
# 512Mi volume instead of the Standard profile's 3-node Galera on 100Gi, a
# single Memcached pod, and one pod per service Deployment.
infrastructure:
database:
clusterRef:
name: openstack-db
database: keystone
secretRef:
name: keystone-db
cache:
clusterRef:
name: openstack-memcached
backend: dogpile.cache.pymemcache
# The shared message bus, referenced brownfield against a REAL broker: the
# shared-rabbitmq the kind messaging stack runs in the openstack namespace
# (deploy/kind/messaging, WITH_MESSAGING=true on the CI job). The Secret
# controlplane-keystone-messaging is not a fixture file. The chainsaw script
# has tests/e2e/cinder/broker-vhost.sh write it against a vhost of this
# suite's own, which is what keeps the suite's RPC topics out of the vhosts
# the parallel cinder suites hold on the same broker.
#
# A placeholder URL is no longer enough: the block-storage service casts
# real RPC over the bus: a volume create travels from the API through the
# scheduler to the volume service and back, all of it over these queues. The
# network service reads the same delivered Secret and keeps working against a
# broker that answers.
#
# A managed clusterRef is still not used: it would project a RabbitmqCluster
# beside the kind broker this job already runs, and that second broker
# would count against the node budget Link 6z enforces. The managed
# projection is what tests/e2e/c5c3/messaging/ proves.
messaging:
secretRef:
name: controlplane-keystone-messaging
services:
keystone: {}
# Horizon: single dashboard replica; the secretKeyRef override points at
# the per-CP ExternalSecret this suite applies
# (02-horizon-secret-key-externalsecret.yaml) — the documented
# multi-ControlPlane path, since the kind shim Secret is pinned to the
# default identity.
horizon:
secretKeyRef:
name: controlplane-keystone-horizon-secret-key
key: secret-key
# single-replica Glance against the Garage S3 bucket the deploy stack
# provisions (deploy/flux-system/infrastructure/garage.yaml, ESO-synced
# garage-s3-credentials Secret in the openstack namespace).
glance:
backends:
- name: default
type: S3
isDefault: true
s3:
endpoint: "http://garage.shared-services.svc.cluster.local:3900"
bucket: glance-images
region: garage
credentialsSecretRef:
name: garage-s3-credentials
# Placement takes the bare shape Keystone does: it needs no backends and no
# extra blocks, so a single API replica against the shared database and cache
# is the whole declaration.
placement: {}
# Barbican takes a DEDICATED secret store, so the c5c3-operator provisions an
# OpenBao instance of its own beside the child and attaches the store to it.
# That is the point of declaring it here: the suite then exercises the whole
# projected instance chain (instance, store, minted AppRole, secret
# round-trip) rather than a server run outside this control plane. In the
# openstack namespace the kind overlay's proving tenant already admits the
# namespace to the openbao-operator, so no ControlPlane-owned OpenBaoTenant
# is created beside the instance.
barbican:
secretStore:
dedicated: {}
# The network service. Its central is the standalone OVNCentral that
# 03-ovncentral-cr.yaml applies beside this CR; the ControlPlane only reads
# that CR's readiness and never owns it. The centralRef namespace is left
# empty and defaults to the ControlPlane's own (openstack).
# The Minimal profile runs one replica of each RPC worker Deployment, so two
# worker pods land on this single node instead of the six the Standard
# profile would schedule.
neutron:
ovn:
centralRef:
name: controlplane-keystone-ovn
# The block-storage service: one NFS volume backend and one NFS backup backend
# on the kind NFS export (deploy/kind/nfs, WITH_NFS=true), one API replica.
# The volume and backup Deployments are singletons the projection pins to
# one replica. The image-volume cache is on so the verify Job can prove a
# second create-from-image reuses the cached volume.
cinder:
backends:
- name: nfs1
type: NFS
nfs:
server: nfs-server.openstack.svc.cluster.local
path: /volumes
imageVolumeCache:
enabled: true
backupBackend:
name: nfsbk
type: NFS
nfs:
server: nfs-server.openstack.svc.cluster.local
path: /backups
# The compute service: one replica of each of the four Deployments the
# projection sizes, so the API, the metadata API, the scheduler and the
# conductor fit on this single node beside the seven services already there.
# No gateway on any of the three listeners: the suite reaches the compute
# API, the metadata API and the console proxy in-cluster, the way its peers
# are reached. The metadata shared secret is left to the plane, which
# generates one and hands the same reference to the Nova child and to the
# metadata agent 05-neutronmetadataagent-cr.yaml applies. hypervisorOperator
# provisions the account openstack-hypervisor-operator runs as and its auth
# Secret, which 07-hypervisor-operator-verify-job.yaml authenticates with.
nova:
hypervisorOperator: {}
# One small node: the budget gate of Link 6z holds the whole stack to 4 CPU
# and 16 GiB of requests.
sizing:
profile: Minimal
globalPolicyOverrides:
rules:
"identity:list_users": "role:admin"
korc:
adminCredential:
cloudCredentialsRef:
cloudName: admin
secretName: k-orc-clouds-yaml
passwordSecretRef:
name: keystone-admin
key: password
applicationCredential:
restricted: true
rotation:
mode: PasswordDriven
# NOTE: Each declared service registers through a KeystoneService child the
# c5c3-operator projects for it (controlplane-keystone-glance, -placement,
# -barbican, -neutron, -cinder and -nova, one per service, in that service's
# namespace), carrying both the service's catalog entry and its service
# account. The account is the user glance / placement / barbican / neutron /
# cinder / nova in a Keystone project of its own (service-glance,
# service-placement, service-barbican, service-neutron, service-cinder,
# service-nova, created by the registration) bound to the "service" role,
# which Keystone's SRBAC default for identity:validate_token requires.
# Cinder takes the "admin" role beside it, because it deletes the Barbican
# secret of an encrypted volume as a fallback when the volume's owner
# cannot, and Barbican's secure-RBAC defaults put that reach behind admin.
# Nova takes it for the matching reason on the other side of that wire: it
# calls the block-storage API as its own service user in admin contexts,
# where cinder refuses a caller holding service alone.
# The network service takes a SECOND registration beside its own,
# controlplane-keystone-neutron-nova. It carries an account alone (the user
# neutron-nova in the referenced project service-neutron, with "service" and
# "admin"), no catalog entry: it is the identity neutron posts its port
# notifications to nova as, and nova answers those unelevated.
# The registration delivers the account's credentials as the Secret
# <child>-credentials beside the service, and the service child reads its
# service-user password from there. Exercising that projection on a live
# cluster is the point: each role projects as one unmanaged K-ORC Role import
# plus one managed RoleAssignment; the chainsaw link asserts the five
# children reach Ready and that the K-ORC rows they label-own are Available,
# and the verify Job asserts the role is listed against each user in
# Keystone.