Skip to main content

Migrate data between storage classes

Inspect 1.36

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:

target-pvc.yamlyaml
		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:

copy-source.yamlyaml
		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:

copy-job.yamlyaml
		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 uses, or it can land on a bare metal server where the volume cannot be created.

A 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.

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. covers reclaiming local pool space, and 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, is your next stop for reclaiming the volume you left behind.