scmigrate is a kubectl plugin and rclone-backed helper image for moving selected
Kubernetes PVCs from one StorageClass to another without ever intentionally
leaving the cluster with only an in-flight copy of the data.
The plugin is built around resumable phases. It writes migration state to PVC, PV, and sync-pod annotations before each cluster mutation that matters. If the process is interrupted, a later run uses those annotations to continue from the last durable point.
Download the archive for your platform from a tagged release. Archive names use
the version without the leading v; release tags and runner image tags keep the
leading v.
TAG=v0.1.0
VERSION="${TAG#v}"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) echo "unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
ARCHIVE="kubectl-scmigrate_${VERSION}_${OS}_${ARCH}.tar.gz"
BASE_URL="https://github.com/laverya/scmigrate/releases/download/${TAG}"
curl -fL -o "$ARCHIVE" "$BASE_URL/$ARCHIVE"
curl -fL -o checksums.txt "$BASE_URL/checksums.txt"
if command -v sha256sum >/dev/null 2>&1; then
grep " $ARCHIVE$" checksums.txt | sha256sum -c -
else
grep " $ARCHIVE$" checksums.txt | shasum -a 256 -c -
fi
tar -xzf "$ARCHIVE"
mkdir -p ~/.local/bin
install -m 0755 kubectl-scmigrate ~/.local/bin/kubectl-scmigrate
kubectl scmigrate versionFor Windows, download the windows_amd64 archive from the same release and put
kubectl-scmigrate.exe on PATH.
Tagged releases publish a matching runner image at:
ghcr.io/laverya/scmigrate-runner:v0.1.0
Release binaries default --runner-image to the matching vX.Y.Z image tag, so
you normally do not need to build or push a runner image yourself. The release
workflow also publishes a bare X.Y.Z tag and latest, but scmigrate run
intentionally rejects latest; use a version tag or digest for migrations.
scmigrate uses the credentials in your current kubeconfig. The user, group, or
service account that runs kubectl scmigrate must be allowed to list
namespaces and to list or mutate PVCs, PVs, pods, supported workload
controllers, and the migration-managed ConfigMaps used for StatefulSet restore
records.
deploy/rbac.yaml provides the required ClusterRole and binds it to the
scmigrate-system/scmigrate ServiceAccount. Use it directly only if you will run
the plugin with that service account's credentials. For a local kubectl
workflow, ask a cluster admin to bind the scmigrate ClusterRole to the actual
kube user or group that will run the migration.
To install that template role and service-account binding:
kubectl create namespace scmigrate-system
kubectl apply -f deploy/rbac.yamlBefore running a real migration, verify permissions in the namespaces you plan to touch:
kubectl auth can-i list persistentvolumeclaims --all-namespaces
kubectl auth can-i patch persistentvolumes
kubectl auth can-i create pods -n default
kubectl auth can-i patch deployments.apps -n defaultStatefulSet migrations require permission to delete and recreate StatefulSets. DaemonSet migrations require permission to patch DaemonSets and delete affected pods.
Plan a migration:
kubectl scmigrate plan \
--namespace default \
--selector app=postgres \
--source-storage-class old-sc \
--target-storage-class new-scRun it:
kubectl scmigrate run \
--namespace default \
--selector app=postgres \
--target-storage-class new-sc \
--yesRelease-versioned plugin builds default --runner-image to the matching runner
image tag, for example ghcr.io/laverya/scmigrate-runner:v0.1.0. Dev builds do
not guess; pass --runner-image explicitly when kubectl scmigrate version
does not show a default runner image.
PVCs can be selected by namespace, label selector, source storage class, and repeatable annotation filters:
kubectl scmigrate plan \
--all-namespaces \
--selector 'migration=scmigrate' \
--annotation scmigrate/approved=true \
--target-storage-class premium-rwoFor each matching bound PVC, the plugin:
- Records the original PVC metadata/spec in annotations.
- Creates a temporary destination PVC using the target storage class.
- Starts a rclone sync pod mounting the source read-only and the destination read-write while the workload remains online.
- Quiesces every active non-terminal pod that is using that PVC by grouping consumers by their owning workload and scaling/deleting each affected parent once.
- Runs a final rclone sync pass.
- Sets both the source PV and destination PV reclaim policies to
Retain. - Deletes the original and temporary PVCs.
- Clears the destination PV
claimRef. - Recreates the PVC with the original name, labels, and annotations, explicitly bound to the destination PV.
- Restores the workload replica count.
The old PV is deliberately left retained. Delete it manually only after the new PVC has been verified.
The destination PVC does not have to bind before the first sync pod is created.
That keeps storage classes with WaitForFirstConsumer binding from deadlocking.
When exactly one workload pod is using the source PVC, the initial sync pod is
pinned to that same node so local or topology-constrained volumes can bind in
the same place as the source consumer.
Final sync requires the PVC to have no active writers. scmigrate determines
all non-terminal pods that mount each selected PVC, resolves their owning
workload, and quiesces every affected workload before the final sync and
cutover. It also rechecks for active consumers immediately before cutover.
For Deployment and standalone ReplicaSet workloads, the plugin scales each
affected parent to zero once per PVC and restores the original replica count
after cutover. This covers PVCs shared by multiple replicas and PVCs mounted by
multiple parent resources.
For StatefulSet workloads using ordinary volumeClaimTemplates, each PVC is
usually consumed by one pod. The plugin sorts those PVCs by trailing ordinal in
descending order, stores the StatefulSet restore spec in a migration-managed
ConfigMap, temporarily orphans the StatefulSet, deletes the consumer pod, and
recreates the StatefulSet after cutover. This keeps one StatefulSet pod down at
a time. If a StatefulSet shares one PVC across multiple pods, the plugin scales
the StatefulSet to zero and restores the original replica count after cutover.
For DaemonSet workloads, the plugin temporarily switches the DaemonSet to
OnDelete, adds required node-affinity exclusions for every node with a pod
that is consuming the PVC, and deletes those pods. Other DaemonSet pods keep
running because the strategy change prevents a rolling update. After cutover,
the original affinity and update strategy are restored and the plugin waits for
ready DaemonSet pods on the original nodes.
All important objects are marked with annotations under:
scmigrate.laverya.github.com/*
The temporary destination PVC stores the original PVC metadata and workload quiesce record. That means a run can resume even after the original PVC has been deleted but before the final PVC has been recreated.
During cutover, the destination PV is also annotated with the original PVC snapshot before either PVC is deleted. That lets a later run continue even if the process stops after both PVC objects are gone but before the final PVC is created.
Use kubectl scmigrate run ... --yes again with the same selection flags to
continue an interrupted migration.
Keep these flags stable when resuming: --namespace or --all-namespaces,
--selector, --annotation, --source-storage-class, and
--target-storage-class. If a resumable object was prepared for a different
target storage class, the plugin stops instead of continuing with mixed target
state.
- The source PVC must be bound.
- Only filesystem PVCs are supported. Block-mode PVCs are rejected because the rclone runner mounts PVCs as filesystems.
ReadWriteOncePodPVCs are rejected because the live initial sync requires a second pod to mount the source PVC.- The destination storage class must support the requested access modes, filesystem volume mode, capacity, and any topology required by the source workload.
- PVC selectors are restored on the final PVC. Static PV selectors that only matched the old PV can prevent the final PVC from binding to the new PV.
- The runner image runs rclone as root so it can preserve ownership and mode
bits when
--metadatais enabled. Namespaces with restricted Pod Security Admission must allow the migration worker pod or run it under a policy that permits the required filesystem operations. runrequires an explicit--runner-imagewith a non-latesttag or digest. For release-versioned plugin builds, this defaults to the matchingghcr.io/laverya/scmigrate-runner:<version>tag.- The default rclone arguments passed after
rclone syncare:
--config=/dev/null --links --metadata --create-empty-src-dirs --stats=15s
--metadataasks rclone's local backend to preserve mode, owner, group, timestamps, and supported metadata such as user extended attributes.--linksis required for local symlink transfer. Rclone does not preserve source hard-link relationships as rsync-Hdid.--rclone-argsis split on whitespace and passed afterrclone sync; it is not evaluated by a shell.- The plugin leaves PV reclaim policies as
Retainby default. Pass--restore-reclaim-policyto restore the destination PV's original policy after cutover. - The old PV is intentionally retained after a successful migration. Remove it only after validating the new PVC and application data.
--skip-initial-syncmeans the whole data copy happens during the final outage window.
scmigrate can quiesce pods owned by:
- standalone Pods
- Deployments
- standalone ReplicaSets
- StatefulSets
- DaemonSets
Other owners, including Jobs, CronJobs, Argo Rollouts, operator-specific controllers, and custom controllers, are rejected rather than guessed. If an operator, HPA, or other reconciler can recreate writers while a workload is quiesced, pause that reconciler before running the migration.
The migration state is durable across reruns. Use the same command and selection flags to resume.
- Before quiesce: rerun the migration; it reuses the prepared destination PVC and sync pod state.
- After quiesce: rerun the migration promptly. Workloads may intentionally remain scaled down or orphaned until restore completes.
- During cutover: rerun the migration. The temporary destination PVC or the destination PV cutover record contains the original PVC snapshot needed to recreate the final PVC.
- After restore: verify the workload, then clean up retained old PVs and any migration-managed ConfigMaps after you no longer need rollback context.
Build the kubectl plugin from source:
make build VERSION=dev
mkdir -p ~/.local/bin
install -m 0755 bin/kubectl-scmigrate ~/.local/bin/kubectl-scmigrateSource-built binaries may not have a release-version default runner image. For
local testing, build a runner image with a non-latest tag, push it to a
registry your target cluster can pull from, and pass it to run:
RUNNER_IMAGE=ghcr.io/you/scmigrate-runner:dev-test
make build VERSION=dev
mkdir -p linux/amd64
cp bin/kubectl-scmigrate linux/amd64/kubectl-scmigrate
docker build --build-arg TARGETPLATFORM=linux/amd64 -f Dockerfile.runner -t "$RUNNER_IMAGE" .
docker push "$RUNNER_IMAGE"
kubectl scmigrate run \
--namespace default \
--selector app=postgres \
--target-storage-class new-sc \
--runner-image "$RUNNER_IMAGE" \
--yesRun unit tests:
make testRun the kind-backed end-to-end tests:
make e2eThe e2e target creates temporary kind clusters, builds and loads the scratch
rclone runner image, then verifies real PVC migrations for Deployment,
StatefulSet, DaemonSet, shared-PVC, multi-parent, and etcd StatefulSet
cases. It requires docker, kind, and kubectl. Set KEEP_E2E_CLUSTER=1 to
leave the cluster running after the test.
Public releases are created by pushing a v* tag. The release workflow first
checks that the tagged commit already has a successful Test workflow run, then
GoReleaser publishes:
kubectl-scmigratearchives for Linux, macOS, and Windowschecksums.txt- the
ghcr.io/laverya/scmigrate-runnerimage forlinux/amd64andlinux/arm64
git tag v0.1.0
git push origin v0.1.0