Migrate data between storage classes
Moving a workload onto faster disk, from cloud volumes to local-nvme, or back the other way for durability, comes up often. The catch is that you cannot do it by editing the volume. A bound PVC's storageClassName is fixed for the life of the claim; Kubernetes will not repoint an existing volume at a different class. A volume is also pinned to its node or location, so moving a workload's data means moving or recreating the volume, never changing it in place. The data has to be copied from the old volume into a new one on the target class, and then the workload cut over to it.
The shape of the migration is always the same: create a new PVC on the target class, copy the data across, cut the workload over, verify, and reclaim the old volume. How you copy depends on what the workload is.
Create the target PVC #
Make a new claim on the class you are moving to. Size it to fit the existing data with room to grow. Only one line differs from the old claim, the class you are copying onto:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-local
spec:
storageClassName: standard
storageClassName: local-nvme
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
Note
A local-nvme PVC uses WaitForFirstConsumer, so it stays Pending until a pod mounts it. That is expected. The copy Job in the next step is what triggers the volume to be created, on whichever server the Job lands.
Copy the data #
For a workload whose data is just files, copy it across with rsync. Stop the workload first, or copy from a quiesced source, so nothing changes underneath you mid-copy.
A cloud volume attaches only to cloud nodes, and a local volume only to the server holding its disk, so no single pod can mount both at once. Serve the old volume over the network instead: one pod exports it read-only, and a Job on the target's node pulls it into the new volume.
Start the exporter. It mounts the old claim, so Kubernetes places it on the node that already holds that volume:
apiVersion: v1
kind: ConfigMap
metadata:
name: rsyncd-conf
data:
rsyncd.conf: |
uid = 0
gid = 0
use chroot = no
max connections = 4
[data]
path = /from
read only = true # [!code highlight]
---
apiVersion: v1
kind: Pod
metadata:
name: copy-source
labels:
app: copy-source
spec:
containers:
- name: rsyncd
image: instrumentisto/rsync-ssh
command:
["rsync", "--daemon", "--no-detach", "--config=/etc/rsyncd/rsyncd.conf", "--port=873"]
ports:
- containerPort: 873
volumeMounts:
- name: source
mountPath: /from
readOnly: true
- name: conf
mountPath: /etc/rsyncd
volumes:
- name: source
persistentVolumeClaim:
claimName: data-cloud
- name: conf
configMap:
name: rsyncd-conf
---
apiVersion: v1
kind: Service
metadata:
name: copy-source
spec:
selector:
app: copy-source
ports:
- port: 873
targetPort: 873
Then run the copy. This Job mounts only the new claim, so it lands on the target's node and pulls the data across the cluster network:
apiVersion: batch/v1
kind: Job
metadata:
name: copy-data
spec:
template:
spec:
restartPolicy: Never
containers:
- name: copy
image: instrumentisto/rsync-ssh
command: ["rsync", "-a", "--delete", "rsync://copy-source/data/", "/to/"]
volumeMounts:
- name: target
mountPath: /to
volumes:
- name: target
persistentVolumeClaim:
claimName: data-local
rsync -a carries permissions, ownership, timestamps and symlinks across unchanged, and --delete removes anything on the target that is no longer on the source, so running the Job again converges instead of accumulating. Delete the exporter Pod, Service and ConfigMap once you have verified the copy.
The exporter is reachable by anything in the cluster that can resolve its Service, so keep it running only for the length of the migration.
When the target is a cloud class, give the Job the same node affinity Use Hetzner Cloud volumes uses, or it can land on a bare metal server where the volume cannot be created.
A Velero restore into the new PVC is an alternative when you already back up the volume, and it lets you migrate from a backup rather than the live disk.
Do not rsync a live database's files. Copying data files out from under a running database gives you a corrupt copy. Use the database's own tools instead:
- Replication: stand up a new instance backed by the target-class PVC, make it a replica of the old one, let it catch up, then promote it and cut traffic over. This is the path to near-zero downtime.
- Dump and restore: take a logical dump from the old instance and load it into a new one on the target class. Simpler, but the database is read-only or offline for the window, so it fits a planned maintenance slot.
Cut over and verify #
Point the workload at the new PVC by updating the pod or StatefulSet template, then start it. Confirm the application reads and writes correctly, and that the data is complete, before you treat the old volume as disposable. Check row counts, file counts, or a checksum, whatever proves the copy is whole for your workload.
Reclaim the old volume #
Every class here uses the Retain reclaim policy, so deleting the old PVC does not free the space. The old cloud volume keeps costing money, and the old local volume keeps holding pool space, until you clean it up by hand. Do this only after the new volume is verified and running. Local PV lifecycle covers reclaiming local pool space, and Volume attach constraints and reclaim policies covers deleting the underlying cloud volume.
Zero-downtime or a maintenance window#
Two paths, chosen by how much downtime the workload can take. Replication and a rolling cutover keep the service up while the new instance catches up, at the cost of running two copies for a while. A stop-copy-start migration with rsync or a dump-restore is simpler to reason about, but the workload is down for the copy. Pick the maintenance window when you can afford the pause; reach for replication only when you cannot.
With the workload verified on its new class, Local PV lifecycle is your next stop for reclaiming the volume you left behind.