, ,

Migrating a Stateful Application with Persistent Volumes (TKGI to OpenShift Series, Part 19)

Moving a PostgreSQL StatefulSet and 61 GiB of live data from TKGI to OpenShift with Velero and OADP, including the storage class mapping, the pre-backup hook and the admission failure that costs most teams an afternoon.

TKGI to OpenShift Series · Part 19 of 26

Six minutes after a restore that reported Completed, my database pod sat in CrashLoopBackOff and its log carried exactly one useful line: FATAL: data directory /var/lib/postgresql/data/pgdata has wrong ownership. Nothing had gone wrong with the backup. Nothing had gone wrong with the restore either.

OpenShift had done exactly what it is built to do, which is refuse to run a container as the user identifier baked into the image. That one line is the entire distance between Part 18 and this Part. Moving a stateless Deployment is a YAML problem. Moving a StatefulSet, meaning a workload whose pods keep stable identities and stable volumes, with 61 GiB of live PostgreSQL underneath it, is four problems stacked on top of each other: YAML, data consistency, storage classes and admission. They surface in that order, and only the first one is easy.

Who this is for: you finished Part 17, so Velero runs on TKGI and OADP runs on OpenShift against a shared bucket, and Part 18 moved wave1-web without touching a single persistent volume. Now wave2-data moves: a two replica web tier with an 8 GiB uploads volume, and one PostgreSQL 15 StatefulSet holding 61.4 GiB of real data on a 100 GiB claim.
Key takeaways:
1. Your headline command is velero backup create wave2-data-20260816 –include-namespaces wave2-data –default-volumes-to-fs-backup –wait.
2. Pin the Velero you install on TKGI to the version OADP ships on the target, not to the newest release on GitHub. A newer source writes backups a older server may decline to read.
3. File system backup reads a live file system, and Velero says so in its own documentation. For a database, add a pre-backup hook that writes a logical dump, then verify the migration with a row count rather than a pod status.
4. StorageClass names do not travel between clusters. Map them with a ConfigMap before the restore, not after a PersistentVolumeClaim is already stuck Pending.
5. Budget more wall clock for admission and volume ownership than for the data transfer. On my run the transfer took 77 minutes and the admission work took 94.

Preflight and Backing Up wave2-data on TKGI

Two conventions before any command runs. On the OpenShift side I use oc rather than kubectl, because it is a superset that understands Projects, Routes and Security Context Constraints, and half of this Part is about the last of those. On the TKGI side there is no oc, so kubectl it is. Mixing them by accident is how people end up debugging the wrong cluster at midnight.

Version pinning matters more here than anywhere else in the series. OADP, which is OpenShift API for Data Protection and is Velero packaged as an Operator, bundles a specific Velero server. OADP 1.5 ships Velero 1.16. Upstream Velero has since moved to 1.18. Installing 1.18 on TKGI because it is current, then asking a 1.16 server to restore what it wrote, is an avoidable class of problem. Pin the source to match. A second reason to care: from Velero 1.17 the restic path is disabled for new backups entirely, so kopia is the only file system uploader left, and you want that decision made deliberately rather than discovered.

Step 1. Point the node agent at the TKGI kubelet path

File system backup, or FSB, is the mode where Velero copies the contents of a mounted volume rather than asking the storage layer for a snapshot. It runs inside a DaemonSet called node-agent, and that DaemonSet mounts the kubelet pod directory from each host. Upstream defaults assume /var/lib/kubelet/pods. TKGI does not put it there. BOSH deployed nodes keep kubelet data under /var/vcap/data, and if you skip this, every pod volume backup fails with a missing path under /host_pods and no obvious reason why.

# Versions this runbook was tested against TKGI 1.18.3 Velero (TKGI) v1.16.1, kopia uploader, pinned to match OADP OpenShift 4.19.9 OADP Operator 1.5.x, subscription channel: stable Velero (bundled) 1.16 vCenter and ESXi 8.0 Update 3 vSphere CSI (TKGI) 3.1.x oc client 4.19.9 # Preferred, at install time on a fresh TKGI cluster velero install –provider aws –use-node-agent –privileged-node-agent –uploader-type kopia –kubelet-root-dir /var/vcap/data/kubelet –bucket $VELERO_BUCKET –secret-file ./credentials-velero # Repair path, because Part 17 already installed it kubectl -n velero patch daemonset node-agent –type json -p ‘[{"op":"replace","path":"/spec/template/spec/volumes/0/hostPath/path","value":"/var/vcap/data/kubelet/pods"}]’ kubectl -n velero get pods -l name=node-agent NAME READY STATUS RESTARTS AGE node-agent-4bqzt 1/1 Running 0 92s node-agent-h7xkm 1/1 Running 0 92s node-agent-r2f9c 1/1 Running 0 91s
TKGI gotcha: the node agent needs to run privileged to mount that host path, which means the TKGI plan backing this cluster must have Allow Privileged enabled in Tanzu Operations Manager. If the plan does not, the DaemonSet pods sit Pending and nothing in the Velero logs tells you the plan is the reason. Check the plan before you check anything else.

Step 2. Quiesce Postgres with a pre-backup hook

Velero is explicit that FSB copies data from a live file system, so the bytes are not captured at a single point in time. For a web asset directory that is fine. For a database with an active write path it is a coin toss, and I lost that toss once already, which is the war story further down. Backup hooks exist precisely to close this gap. Annotate the pod so Velero runs a command inside the container before it starts copying. Note the command value has to be a JSON array; a bare shell string silently does nothing useful because Velero does not run it through a shell by default.

Credentials never go in the annotation. PGPASSWORD and the user come from the environment the container already has, sourced from the existing Secret, so the hook reads them and nothing sensitive is written into an annotation that every reader of the namespace can list.

# On the StatefulSet pod template, not the StatefulSet metadata annotations: pre.hook.backup.velero.io/container: postgres pre.hook.backup.velero.io/command: ‘["/bin/bash","-c","pg_dump -U $POSTGRES_USER -d $POSTGRES_DB -Fc -f /var/lib/postgresql/data/pgdata/pre_migration.dump"]’ pre.hook.backup.velero.io/timeout: 20m pre.hook.backup.velero.io/on-error: Fail post.hook.backup.velero.io/container: postgres post.hook.backup.velero.io/command: ‘["/bin/bash","-c","rm -f /var/lib/postgresql/data/pgdata/pre_migration.dump"]’ post.hook.backup.velero.io/on-error: Continue kubectl -n wave2-data rollout status statefulset/postgres statefulset rolling update complete 1 pods at revision postgres-7c4f9d8b56

Setting on-error: Fail on the pre-hook is deliberate. If pg_dump cannot run, I want the backup to fail loudly rather than produce a file system copy that looks complete and is not. A post-hook removes the dump afterwards so the source volume does not slowly fill with migration artefacts across repeated dry runs. That dump lands inside the data directory on purpose, because that is the directory FSB is about to copy, which means the logical dump travels with the physical copy in one object and there is no second bucket to reconcile.

Step 3. Run the backup and read what it actually captured

velero backup create wave2-data-20260816 –include-namespaces wave2-data –default-volumes-to-fs-backup –wait Backup request "wave2-data-20260816" submitted successfully. Waiting for backup to complete. You may safely press ctrl-c to stop waiting. …………………………………………. Backup completed with status: Completed. velero backup describe wave2-data-20260816 –details Phase: Completed Namespaces: Included: wave2-data Resource List: apps/v1/StatefulSet: wave2-data/postgres apps/v1/Deployment: wave2-data/web v1/PersistentVolumeClaim: wave2-data/pgdata-postgres-0, wave2-data/uploads Backup Volumes: Pod Volume Backups – kopia: Completed: wave2-data/postgres-0: pgdata: 61.4 GiB wave2-data/web-0: uploads: 8.2 GiB CSI Snapshots: <none included> HooksAttempted: 1 HooksFailed: 0 Total items to be backed up: 38 Items backed up: 38

Read HooksAttempted and HooksFailed every single time. A backup can report Completed with a failed hook if you set on-error: Continue, and that is the exact shape of a backup that will restore into a corrupt database. Read the byte counts too. If pgdata came back at a few hundred megabytes when the claim holds 61 GiB, the node agent read an empty host path and reported success on nothing.

Where 4 hours 32 minutes went wave2-data, 69.6 GiB across two volumes, TKGI 1.18 to OpenShift 4.19, single attempt after the rehearsal Backup on TKGI 53 min Restore with OADP 77 min Admission and ownership 94 min Verification and counts 48 min 0 25 50 75 100 minutes
Measured on my reference migration. Data movement was never the long pole.

StorageClass Mapping and Target Project Preparation

A PersistentVolumeClaim, or PVC, carries the name of the StorageClass it was provisioned from. That name is local to a cluster. My TKGI clusters used a hand written class called tkgi-vsphere-gold. An installer provisioned OpenShift 4 on vSphere creates one called thin-csi. Restore a PVC referencing a class that does not exist and it sits Pending forever with a provisioning event that names the missing class. Nothing about that failure is subtle, but it happens at minute 80 of a cutover window, which is the worst possible time to discover it.

Velero solves this with a restore item action driven by a labelled ConfigMap. Both labels are mandatory and both are easy to typo. It lives in the OADP namespace, which is openshift-adp, not in the application namespace, and Velero reads it at restore time with no restart required.

Step 4. Map the source StorageClass to thin-csi

oc get sc NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE thin-csi (default) csi.vsphere.vmware.com Delete WaitForFirstConsumer true 26d # Confirm this on your own cluster before you rely on it, the operator sets these oc get sc thin-csi -o yaml | grep -E ‘reclaimPolicy|volumeBindingMode|is-default-class’ cat <<‘EOF’ | oc apply -f – apiVersion: v1 kind: ConfigMap metadata: name: change-storage-class-config namespace: openshift-adp labels: velero.io/plugin-config: ” velero.io/change-storage-class: RestoreItemAction data: tkgi-vsphere-gold: thin-csi EOF configmap/change-storage-class-config created

Step 5. Create the target project and read its UID range

Every OpenShift project gets an allocated block of user identifiers, and admission validates pod security context against it. You want those numbers written down before the restore, because in twenty minutes an error message will quote them back at you and you should recognise them rather than guess.

oc new-project wave2-data-pilot Now using project "wave2-data-pilot" on server "https://api.ocp.example.internal:6443". oc get ns wave2-data-pilot -o jsonpath='{.metadata.annotations}’ | tr ‘,’ ‘n’ | grep scc "openshift.io/sa.scc.mcs":"s0:c26,c10" "openshift.io/sa.scc.supplemental-groups":"1000670000/10000" "openshift.io/sa.scc.uid-range":"1000670000/10000"
Against the obvious advice: most migration write ups steer you toward CSI snapshot data movement because it is newer and more consistent, and for OpenShift to OpenShift I would agree. For this hop I deliberately do not use it. Velero requires the CSI driver name to match across clusters for snapshot portability, snapshots on vSphere need vCenter and ESXi at 8.0 Update 1 or later on both ends, and a restored snapshot reconstructs a PersistentVolume that still remembers where it came from. File system backup restores through ordinary dynamic provisioning, so OpenShift creates a genuinely new volume with no source lineage attached. On a cross platform move that property is worth more than the consistency you give up, especially once a hook has already given the consistency back.
Consideration File system backup, kopia CSI snapshot data movement
Where data is readLive file system inside the running podA point in time CSI snapshot
ConsistencyWeaker, and Velero documents that plainly. Recover it with a hookStronger, the snapshot is atomic
Target volume creationDynamic provisioning makes a fresh PV, no source identity followsVelero reconstructs the PV from snapshot metadata
Cross cluster requirementShared bucket and matching Velero versionsIdentical CSI driver name on both clusters
vSphere version floorNone beyond what the clusters already needvCenter and ESXi 8.0 Update 1 or later
Verdict for TKGI to OpenShiftPick this, then close the consistency gap with a pre-backup hookKeep it for day 2 backups once both sides are OpenShift

Restoring Volume Data with OADP

OADP does not install a velero binary on your workstation. It runs one inside the Velero Deployment, and the supported way to drive it is an alias that execs into that container. Restore into a pilot namespace first, never straight over the name you intend to serve production from. A namespace mapping costs nothing and buys you the ability to run this twice.

Step 6. Restore into a pilot namespace

alias velero=’oc -n openshift-adp exec deployment/velero -c velero -it — ./velero’ velero backup get | grep wave2-data wave2-data-20260816 Completed 0 0 2026-08-16 09:14:02 +0000 UTC 29d default velero restore create wave2-data-r1 –from-backup wave2-data-20260816 –namespace-mappings wave2-data:wave2-data-pilot velero restore describe wave2-data-r1 Phase: PartiallyFailed (run ‘velero restore logs wave2-data-r1’ for more information) Errors: Velero: <none> Cluster: <none> Namespaces: wave2-data-pilot: error restoring servicemonitors.monitoring.coreos.com/wave2-data/pg-exporter: the server could not find the requested resource

PartiallyFailed is the normal first result and it is not a reason to start over. Velero backs up each resource at whatever API version the source cluster preferred, and restoring needs that same group and version to exist on the target. My TKGI cluster ran the Prometheus Operator, so it had a ServiceMonitor custom resource definition. My OpenShift cluster had user workload monitoring switched off, so it did not. Two valid choices: enable the feature first, or exclude the resource from the restore and add monitoring back once the application is stable. I chose the second, because a cutover window is not the moment to change a cluster wide monitoring setting.

Ownership, fsGroup and Admission

Part 7 covered why Security Context Constraints, or SCCs, are the biggest portability gap in this migration. Here is what that looks like when a real database hits it. A community PostgreSQL image pins itself to a fixed user identifier and sets a matching group. TKGI, with a permissive PodSecurityPolicy posture, never questioned it. OpenShift default policy is restricted-v2, which enforces a run as user range drawn from the project annotation you noted in Step 5, and enforces an fsGroup drawn from the same block. A pinned identifier of 999 is not in a range that starts at 1000670000, so the StatefulSet controller cannot create the pod at all.

Step 7. Fix admission, then fix ownership

oc get events -n wave2-data-pilot –field-selector reason=FailedCreate Error creating: pods "postgres-0" is forbidden: unable to validate against any security context constraint: [provider "anyuid": Forbidden: not usable by user or serviceaccount, provider restricted-v2: .spec.securityContext.fsGroup: Invalid value: []int64{999}: 999 is not an allowed group, spec.containers[0].securityContext.runAsUser: Invalid value: 999: must be in the ranges: [1000670000, 1000679999]] # Option chosen here, grant nonroot-v2 to the workload service account only oc -n wave2-data-pilot adm policy add-scc-to-user nonroot-v2 -z postgres-sa clusterrole.rbac.authorization.k8s.io/system:openshift:scc:nonroot-v2 added: "postgres-sa" # Prove which SCC actually admitted the pod, never assume oc get pod postgres-0 -n wave2-data-pilot -o jsonpath='{.metadata.annotations.openshift.io/scc}{"n"}’ nonroot-v2 # Then the ownership half, on the pod securityContext securityContext: fsGroup: 1000670000 fsGroupChangePolicy: OnRootMismatch

That fsGroupChangePolicy line is not decoration. By default OpenShift walks every file on a mounted volume and rewrites ownership to match fsGroup. On a 61 GiB data directory with a few million small files that took just over 6 minutes on first mount, during which the pod is not ready and a readiness probe with a short failure threshold will happily restart it into a loop. Setting OnRootMismatch makes the kubelet check the top level directory and skip the recursive walk when it already matches, which took the same mount to under 20 seconds on every subsequent start.

flowchart TD
  A[Pod rejected at creation] --> B{Image pins a fixed UID}
  B -->|No| C[Stay on restricted v2]
  B -->|Yes| D{Can you rebuild the image}
  D -->|Yes| E[chgrp 0 and chmod g equals u on written paths]
  D -->|No| F{Does it only need non root}
  F -->|Yes| G[Grant nonroot v2 to the service account]
  F -->|No| H[Grant anyuid and file a dated exception]
  E --> I[Set fsGroup plus fsGroupChangePolicy OnRootMismatch]
  C --> I
  G --> I
  H --> I
  I --> J[Verify with the scc annotation on the running pod]
Admission decision path for a database image arriving from TKGI. Every route ends at the same ownership step.
Approach What changes Resulting posture Pick it when
Rebuild for an arbitrary UIDImage only. Written paths owned by group 0 and group writableStays on restricted-v2, no SCC grant at allYou own the Dockerfile. This is the default answer
Swap to a Red Hat built database imageImage plus some environment variable namesStays on restricted-v2You can absorb a config change inside the migration window
Grant nonroot-v2SCC binding on one service accountPod may keep its own non root UIDImage is already non root but pins an identifier
Grant anyuidSCC binding on one service accountPod may run as rootLast resort. Record it as an exception with an expiry date

My verdict, and it has not changed across four of these migrations: rebuild the image. Grant nonroot-v2 only as a bridge with a ticket attached, and treat anyuid as something you have to defend to an auditor, because eventually you will. A migration is the one moment when nobody argues about touching a Dockerfile, and if you spend an SCC grant instead, that grant outlives everyone who understood why it was made.

Verification, Rollback and Common Failures

A running pod proves the platform accepted your workload. It proves nothing about your data. Verification for a stateful migration is arithmetic, and it has to be arithmetic taken on both sides within the same quiet window.

# Bound, correct class, correct size oc get pvc -n wave2-data-pilot NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE pgdata-postgres-0 Bound pvc-3f1c9a7e-52b8-4c1a-9d4e-77a2b0e6c114 100Gi RWO thin-csi 78m uploads Bound pvc-a1d4e2b9-0c77-49f5-88ab-2e9f5c31d0aa 20Gi RWO thin-csi 78m # The number that actually matters, run the same query on TKGI oc rsh -n wave2-data-pilot postgres-0 psql -U app -d orders -c ‘select count(*) from orders;’ count ——— 2418077 (1 row) # And the logical dump is sitting right there if the count disagrees oc rsh -n wave2-data-pilot postgres-0 ls -la /var/lib/postgresql/data/pgdata/pre_migration.dump -rw——- 1 1000670000 1000670000 2214592512 Aug 16 09:16 pre_migration.dump # Rollback, and it is genuinely this cheap at this stage oc delete project wave2-data-pilot

Rollback deserves a sentence of its own. Up to and including this Part, nothing on TKGI has been modified. Users are still served by the old cluster, the old NSX-T load balancer and the old DNS record. Backing out is one oc delete project and a conversation you do not have to have. That changes in Part 20 when DNS moves, so extract every ounce of value from cheap rollback while you still have it.

Symptom What you see Cause Remediation
PVC never bindsstorageclass.storage.k8s.io tkgi-vsphere-gold not foundSource class name does not exist on OpenShiftApply the labelled ConfigMap from Step 4 and rerun the restore, or create a class of the same name on the target
Pod never createdforbidden: unable to validate against any security context constraintrestricted-v2 rejects the pinned UID or fsGroupRebuild for arbitrary UID, or grant nonroot-v2 to that service account only
Database crash loops after a clean restoreFATAL: data directory … has wrong ownershipRestored files carry the source UID and GIDSet fsGroup on the pod securityContext so the volume is relabelled at mount
Pod not ready for several minutes on first startNo error, readiness probe times out and restarts the podRecursive ownership rewrite across a large volumeSet fsGroupChangePolicy OnRootMismatch and raise the readiness failure threshold for the first boot
Restore ends PartiallyFailedthe server could not find the requested resourceA CRD present on TKGI is absent on OpenShiftInstall the operator that owns it first, or exclude the resource and add it back after cutover
Pod volume backup completes on almost no dataA missing path under /host_pods in node agent logsTKGI keeps kubelet data under /var/vcap/data, not /var/lib/kubeletPatch the node-agent DaemonSet host path or reinstall with –kubelet-root-dir
Row count short, everything greenNo error anywhere in Velero or OpenShiftFile system backup copied a data directory that was being written toAdd the pre-backup hook, restore the logical dump, and stop treating a green restore as evidence

Field Note from a Restore That Was Green and Wrong

On my first real attempt at this namespace there was no pre-backup hook. I did not think one was needed, because the plan was to quiesce the application at the load balancer and I assumed a quiet application meant a quiet data directory. Velero reported Completed. OADP restored into the pilot namespace. Postgres started, the web tier connected, and a colleague ran a smoke test that passed. Everything looked finished at 11:40 on a Saturday.

At 15:20 someone compared row counts because I had asked for it as a formality. TKGI had 2,418,077 rows in the orders table. OpenShift had 2,411,930. A gap of 6,147 rows, all of them written in the eleven minutes the backup was running, because a background reconciliation job I had forgotten about was still inserting while the load balancer sat drained. No error was ever raised, at any layer, by any tool. The only signal that anything was wrong was a number somebody bothered to check.

That cost three days. One to re-plan, one to add hooks and rehearse them across every database in the wave, and one to rebuild trust in a migration plan that had just quietly lost six thousand orders in a lab. I reversed a decision I had defended in design review, which was that file system backup alone is sufficient for a database if you quiesce traffic. It is not. Traffic is not the only writer. Since then every database in every wave gets a logical dump written into the volume by a pre-backup hook, and every migration gets a row count on both sides before anything is called done.

Dump the Database, Then Move the Volume

My recommendation for every stateful workload in this migration is a belt and braces one, and I make no apology for it. Move the volume with file system backup because it lands you a clean, freshly provisioned PersistentVolume on OpenShift with no memory of vSphere objects owned by another cluster. Carry a logical dump inside that same volume because file system backup is honest about its consistency limits and a dump costs you a few gigabytes and twenty minutes. Verify with a count, never with a pod status. Then decide the identity question deliberately: rebuild the image for an arbitrary user identifier if you possibly can, and if you cannot, grant the narrowest SCC to a single service account and write down when that grant expires.

Part 20 takes what is now a working application in a pilot namespace and puts real traffic on it, converting Ingress objects to Routes and moving DNS off the NSX-T load balancer. If a landing on VMware vSphere Kubernetes Service rather than OpenShift is still on your table, the equivalent stateful hop is covered in the TKGI to VKS Complete Guide, and the admission story there is considerably shorter. Otherwise, do this on Monday: pick your smallest production database, run the backup with a hook, restore it into a pilot project, and compare one row count. Whatever breaks will break in miniature, which is the only good time for it.

Related reading in this series: Part 7 on Security Context Constraints, Part 9 on storage assessment, and Part 17 on the toolchain itself. Everything above sits on top of all three, and the full map is in the TKGI to OpenShift Complete Guide.

TKGI to OpenShift Series · Part 19 of 26
« Previous: Part 18  |  Guide  |  Next: Part 20 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading