, ,

Migration Toolchain, Velero on TKGI and OADP on OpenShift (TKGI to OpenShift Series, Part 17)

Velero on the TKGI side writes to object storage and OADP restores on OpenShift. Here is the install, the version pin that keeps both ends compatible, and the read only backup location that stops OpenShift deleting your source backups.

TKGI to OpenShift Series · Part 17 of 26

My first cross platform restore reported Phase: Completed with zero errors and zero warnings. Eleven minutes later the same workload on OpenShift had a Deployment sitting at 0 of 3 replicas and a ReplicaSet event reading forbidden: unable to validate against any security context constraint. Both of those things were true at once, and closing that gap is most of what building this toolchain actually involves.

Who this is for: you have an OpenShift 4 cluster installed on vSphere with OVN-Kubernetes, projects, quotas, OAuth and network policy already in place from Parts 12 to 16, and a TKGI 1.18 estate still carrying production. Nothing has moved yet. This part builds the pipe that moves it.
Key takeaways:
1. Velero on TKGI and OADP on OpenShift are two installs of the same engine. Pin them to one Velero minor version, because Velero only tests restores across two minors of skew.
2. Register the TKGI bucket on OpenShift as a ReadOnly BackupStorageLocation. A writable one lets the OpenShift garbage collector expire and delete your source backups.
3. Velero node agent pods crash on TKGI until you point the DaemonSet hostPath at the BOSH kubelet directory instead of the upstream one.
4. A restore that says Completed only means the objects were written. Admission is a separate gate, and restricted-v2 is where lifts die.
5. Headline command: velero backup create wave1-preflight –include-namespaces wave1-web –default-volumes-to-fs-backup

Two Parts back this series settled the question of what tool to use. Red Hat’s Migration Toolkit for Containers, MTC, migrates between OpenShift clusters and from OpenShift 3 to 4, and its source has to be an OpenShift cluster. A TKGI cluster is not one, so MTC has no entry point here at all. What remains is Velero, the upstream backup and restore engine, running twice: once on TKGI where it reads your workloads, and once inside OADP, the OpenShift API for Data Protection, which is Red Hat’s supported packaging of Velero as an Operator. Object storage sits between them and is the only thing the two clusters share.

Preflight, Object Storage and Version Skew

Before installing anything, settle three decisions, because each one is expensive to reverse after you have written a terabyte into a bucket.

First, buckets. Use two, not one. Bucket tkgi-migration holds everything Velero on TKGI writes. Bucket ocp-backups holds everything OADP writes for OpenShift’s own protection. They serve different lifecycles and different owners, and mixing them is how source backups get deleted. More on that below, because it cost me a weekend.

Second, the version pin. Velero maintainers test the upgrade path across n minus 2 minor releases, meaning a build tagged 1.16 is verified against backups produced by 1.15 and 1.14. Backup file format follows semantic versioning, so older minors usually restore fine, but usually is not a support statement. OADP 1.5.0 moved the bundled Velero server from 1.14 to 1.16, and Red Hat supports exactly one OADP version per OpenShift minor, so on OpenShift 4.19 your target side is Velero 1.16 whether you like it or not. That fixes the number you have to hit on the TKGI side.

Third, the data mover. On TKGI you have two choices, file system backup through the node agent, or the Velero plugin for vSphere which takes First Class Disk snapshots. Snapshots are faster to take and useless to you, because a vSphere snapshot handle restores to vSphere volumes owned by the same plugin, and OADP on the target side is not running that plugin. File system backup produces a portable Kopia or restic repository that anything speaking Velero can read. Take the slower option. Portability is the entire point of this exercise.

Versions tested: TKGI 1.18 on NSX-T, Velero CLI and server v1.16.2 signed by the vendor, Velero plugin for AWS v1.12.2, OpenShift Container Platform 4.19.9, oc client 4.19.9, OADP 1.5.0 from channel stable-1.5 with Velero 1.16 and Kopia as the uploader, S3 compatible object storage on premises. Never put an access key in a manifest. Every credential below is read from an environment variable that your shell sources from a vault.
# Source side, on the TKGI jump host $ velero version –client-only Client: Version: v1.16.2 # Target side, after the OADP operator is installed $ oc -n openshift-adp get deployment/velero -o jsonpath='{.spec.template.spec.containers[0].image}{"n"}’ registry.redhat.io/oadp/oadp-velero-rhel9@sha256:… $ oc -n openshift-adp exec deploy/velero — ./velero version –client-only Client: Version: v1.16.0 # Credentials never live in a file you commit $ export AWS_ACCESS_KEY_ID="$(vault kv get -field=access_key secret/migration/s3)" $ export AWS_SECRET_ACCESS_KEY="$(vault kv get -field=secret_key secret/migration/s3)" $ printf ‘[default]naws_access_key_id=%snaws_secret_access_key=%sn’ "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" > ./credentials-migration $ chmod 600 ./credentials-migration

Client 1.16.2 on one side and server 1.16.0 on the other is a patch difference, which is inside the tested window with room to spare. If your TKGI release only ships a signed Velero 1.11 or 1.12, you are five minors behind the target and outside anything Velero verifies. Two options exist and neither is fun: run an unsigned upstream Velero 1.16 binary on TKGI and accept that the source side is unsupported for the duration of the migration, or restore through an intermediate. I take the first, because the source cluster is being decommissioned anyway and a support case against a platform that reaches End of Support in October 2027 is not the safety net people imagine it is.

Restore wall clock by dataset size Kopia file system restore, OADP 1.5.0, four pilot namespaces, 10 GbE path to object storage 060120180240300 restore wall clock, minutes wave1-api, 4 GB wave1-web, 18 GB wave2-cache, 96 GB wave2-db, 210 GB 7 min 26 min 2 h 14 m 5 h 08 m
Throughput stayed near 11 MB per second regardless of dataset size. Budget one hour of cutover window for every 40 GB you intend to restore.

That flatness is the useful part. File system restore is bound by file count and object storage round trips, not by link speed, so a bigger pipe buys you very little. Anybody sizing a maintenance window from raw network bandwidth will be wrong by an order of magnitude. Measure one real namespace, divide, and plan from that number.

Installing Velero on a TKGI Cluster

Velero CLI context follows kubectl context, so set that first with tkgi get-credentials and confirm you are pointed at the cluster you think you are. Install with the node agent enabled and volume snapshots disabled, which forces every volume through file system backup.

$ tkgi get-credentials prod-cluster-01 $ kubectl config current-context prod-cluster-01 $ velero install –provider aws –plugins velero/velero-plugin-for-aws:v1.12.2 –bucket tkgi-migration –secret-file ./credentials-migration –use-node-agent –default-volumes-to-fs-backup –use-volume-snapshots=false –backup-location-config region=onprem,s3ForcePathStyle="true",s3Url=https://s3.internal.example.com CustomResourceDefinition/backups.velero.io: created DaemonSet/node-agent: created Velero is installed! Use ‘kubectl logs deployment/velero -n velero’ to view the status. $ kubectl get pod -n velero NAME READY STATUS RESTARTS AGE node-agent-96zjb 0/1 CrashLoopBackOff 4 (21s ago) 2m5s node-agent-9r7tn 0/1 CrashLoopBackOff 4 (29s ago) 2m5s node-agent-bw5pf 0/1 CrashLoopBackOff 4 (27s ago) 2m5s velero-7d459ffc95-44sps 1/1 Running 0 2m5s

Three crashing node agents on a fresh install is not a broken download, it is TKGI being TKGI. Velero’s DaemonSet mounts the kubelet pod directory at the upstream path /var/lib/kubelet/pods. BOSH deployed nodes put it under /var/vcap/data/kubelet/pods, so the mount resolves to nothing and the agent exits. Broadcom documents the fix and it is a one line edit.

$ 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"}]’ daemonset.apps/node-agent patched $ kubectl get pod -n velero NAME READY STATUS RESTARTS AGE node-agent-6ljm5 1/1 Running 0 23s node-agent-94cfd 1/1 Running 0 23s node-agent-brv77 1/1 Running 0 22s velero-7d459ffc95-44sps 1/1 Running 0 4m24s # Confirm the volume index before patching, it is not always 0 $ kubectl -n velero get daemonset node-agent -o jsonpath='{range .spec.template.spec.volumes[*]}{.name}{"t"}{.hostPath.path}{"n"}{end}’ host-pods /var/vcap/data/kubelet/pods host-plugins /var/lib/kubelet/plugins scratch # Raise Velero memory before the first large backup, not after it stalls $ kubectl -n velero set resources deployment/velero –limits=cpu=1,memory=512Mi –requests=cpu=500m,memory=256Mi deployment.apps/velero resource requirements updated

Raise the memory limit now. Velero ships with a 256Mi limit and a backup that walks tens of thousands of files will sit at status=InProgress for hours without ever printing an error, because the process is being throttled rather than killed. Doubling it to 512Mi took my longest namespace backup from a stall I abandoned after 4 hours down to 51 minutes.

Installing OADP on OpenShift 4

On the OpenShift side you do not run velero install. You install the OADP Operator from OperatorHub into the openshift-adp namespace and then declare a DataProtectionApplication, the custom resource that OADP reconciles into a Velero deployment, a node agent DaemonSet and backup locations. I use oc rather than kubectl throughout the OpenShift half of this series, and will keep doing so, because oc understands Routes, Projects, SCCs and oc adm policy, none of which kubectl knows exist.

Two backup locations go in the same DataProtectionApplication. One points at the TKGI bucket and is marked ReadOnly. One points at the OpenShift bucket and is the default.

$ oc create namespace openshift-adp $ oc -n openshift-adp create secret generic cloud-credentials –from-file cloud=./credentials-migration secret/cloud-credentials created $ cat <<‘EOF’ | oc apply -f – apiVersion: oadp.openshift.io/v1alpha1 kind: DataProtectionApplication metadata: name: dpa-migration namespace: openshift-adp spec: configuration: nodeAgent: enable: true uploaderType: kopia velero: defaultPlugins: – aws – openshift – csi backupLocations: – name: tkgi-source velero: provider: aws accessMode: ReadOnly objectStorage: bucket: tkgi-migration prefix: velero credential: name: cloud-credentials key: cloud config: region: onprem s3ForcePathStyle: "true" s3Url: https://s3.internal.example.com – name: ocp-default velero: default: true provider: aws objectStorage: bucket: ocp-backups prefix: velero credential: name: cloud-credentials key: cloud config: region: onprem s3ForcePathStyle: "true" s3Url: https://s3.internal.example.com EOF dataprotectionapplication.oadp.openshift.io/dpa-migration created $ oc -n openshift-adp get backupstoragelocation NAME PHASE LAST VALIDATED AGE DEFAULT ocp-default Available 4s 62s true tkgi-source Available 4s 62s # Backups written on TKGI appear here within one sync interval $ oc -n openshift-adp get backup NAME AGE wave1-preflight 3m
Do not skip accessMode: every walkthrough you will find registers one bucket, writable, and gets on with the demo. Velero’s backup sync controller pulls backup metadata from every location it can see, and its garbage collector then honours the TTL stamped on those backups. On a writable location, expiry means deletion, and the deletion happens in the bucket your source cluster depends on. ReadOnly makes the OpenShift side a reader and nothing else.

First Round Trip, Backup on TKGI and Restore on OpenShift

Pick the smallest stateless namespace you have and run the whole loop once before anything real depends on it. My pilot is wave1-api, four gigabytes, no persistent volumes, seven pods.

flowchart LR
  A[TKGI cluster, wave namespaces] --> B[Velero server and node agent]
  B --> C[Kopia repository in bucket tkgi migration]
  C --> D[BackupStorageLocation tkgi source, ReadOnly]
  D --> E[Velero inside OADP on OpenShift]
  E --> F[Restore into project]
  F --> G[SCC admission decides if pods start]
  E --> H[Bucket ocp backups, writable, OpenShift own backups]
Object storage is the only shared surface. Nothing on OpenShift ever talks to the TKGI API server, which is why this works across two unrelated platforms.
# On TKGI $ velero backup create wave1-preflight –include-namespaces wave1-api –default-volumes-to-fs-backup –wait Backup request "wave1-preflight" submitted successfully. ……………. Backup completed with status: Completed. $ velero backup describe wave1-preflight –details | head -18 Name: wave1-preflight Namespace: velero Phase: Completed Errors: 0 Warnings: 0 Started: 2026-08-16 09:14:02 +0000 UTC Completed: 2026-08-16 09:16:51 +0000 UTC Total items to be backed up: 248 Items backed up: 248 # On OpenShift, exclude what does not belong on the target $ cat <<‘EOF’ | oc apply -f – apiVersion: velero.io/v1 kind: Restore metadata: name: wave1-preflight-r1 namespace: openshift-adp spec: backupName: wave1-preflight includedNamespaces: – wave1-api excludedResources: – nodes – events – events.events.k8s.io – podsecuritypolicies.policy – persistentvolumes restorePVs: false EOF restore.velero.io/wave1-preflight-r1 created $ oc -n openshift-adp get restore wave1-preflight-r1 NAME AGE PHASE WARNINGS wave1-preflight-r1 96s Completed 3

Excluding podsecuritypolicies.policy matters and it is easy to forget. PodSecurityPolicy was removed from Kubernetes in 1.25 and does not exist on OpenShift 4.19, so leaving it in the restore set produces resource errors that clutter the log and hide the failures you actually care about. Your TKGI namespaces are full of PSP bindings that mean nothing on the target. Drop them at the door.

Now for the part that surprises everyone the first time. Phase Completed, three warnings, and no running pods.

$ oc -n wave1-api get deploy NAME READY UP-TO-DATE AVAILABLE AGE orders-api 0/3 0 0 2m11s $ oc -n wave1-api get events –field-selector type=Warning LAST SEEN TYPE REASON OBJECT MESSAGE 2m Warning FailedCreate replicaset/orders-api-5c6f8b Error creating: pods "orders-api-5c6f8b-" is forbidden: unable to validate against any security context constraint: [provider "restricted-v2": .spec.securityContext.fsGroup: Invalid value: []int64{1001}: 1001 is not an allowed group, provider "restricted-v2": .containers[0].runAsUser: Invalid value: 1001: must be in the ranges: [1000750000, 1000759999]] $ oc get namespace wave1-api -o jsonpath='{.metadata.annotations.openshift.io/sa.scc.uid-range}{"n"}’ 1000750000/10000

Velero did its job perfectly. It wrote a Deployment that asks to run as UID 1001, exactly as it ran on TKGI under a permissive PodSecurityPolicy. OpenShift’s restricted-v2 Security Context Constraint, the admission policy that decides which UIDs, groups and capabilities a pod may request, allocates each project a UID range and rejects anything outside it. Part 7 of this series covered why this gap exists and how to decide between fixing the workload and granting a constraint; the short version is that removing runAsUser and fsGroup from the manifest so OpenShift assigns from the project range is the right fix roughly four times out of five, and granting nonroot-v2 to a dedicated ServiceAccount covers most of the rest.

What matters for the toolchain is the sequencing lesson. Admission runs after restore, on pod creation, not on object creation. Velero cannot see it and will never report it. If you plan a migration wave around Velero exit codes you will declare victory while nothing is serving traffic.

Verification, Rollback and Toolchain Failures

Green looks like four things, checked in this order, and a restore is not done until all four pass.

# 1. Restore reached Completed and you have read the warnings, not just counted them $ oc -n openshift-adp describe restore wave1-preflight-r1 | sed -n ‘/Warnings/,/^$/p’ # 2. Every workload has its full replica count $ oc -n wave1-api get deploy -o custom-columns=NAME:.metadata.name, WANT:.spec.replicas,HAVE:.status.availableReplicas NAME WANT HAVE orders-api 3 3 # 3. No pod is blocked at admission $ oc -n wave1-api get events –field-selector reason=FailedCreate No resources found in wave1-api namespace. # 4. The application answers, not just the pod $ oc -n wave1-api run probe –rm -it –restart=Never –image=registry.access.redhat.com/ubi9/ubi-minimal — curl -s -o /dev/null -w ‘%{http_code}n’ http://orders-api.wave1-api.svc:8080/healthz 200 pod "probe" deleted

Rollback is genuinely cheap here and that is the best property of this toolchain. Nothing you have done touches TKGI. Velero on the source cluster only reads; the workloads keep running and keep serving. To back out of a failed restore, delete the OpenShift project and start again. Traffic has not moved, DNS has not moved, and the source of truth is still where it was this morning.

# Back out a restore. Delete the Restore object first so OADP stops reconciling it. $ oc -n openshift-adp delete restore wave1-preflight-r1 $ oc delete project wave1-api project.project.openshift.io "wave1-api" deleted # Verify the source is untouched, because that is the whole rollback plan $ kubectl –context prod-cluster-01 -n wave1-api get deploy NAME READY UP-TO-DATE AVAILABLE AGE orders-api 3/3 3 3 412d

Six failures account for nearly everything I have hit building this pipe on three clusters. Keep this table next to the runbook.

SymptomError you will seeRemediation
node-agent pods CrashLoopBackOff right after install on TKGIno such file or directory /var/lib/kubelet/podsPatch the DaemonSet hostPath to /var/vcap/data/kubelet/pods. BOSH nodes do not use the upstream kubelet path.
Restore Completed, Deployment stuck at 0 replicasforbidden: unable to validate against any security context constraintRemove runAsUser and fsGroup so the project range applies, or bind nonroot-v2 to a dedicated ServiceAccount.
PVC stays Pending after a stateful restorestorageclass.storage.k8s.io "pks-fast" not foundCreate a change-storage-class-config ConfigMap in openshift-adp labelled velero.io/change-storage-class: RestoreItemAction, mapping old class names to the vSphere CSI class.
Backup sits at InProgress for many hours with no errorstatus=InProgress, no log advanceRaise the Velero deployment memory limit from 256Mi to 512Mi and the request from 128Mi to 256Mi.
Restore errors on resources that no longer exist in OCP 4.19the server could not find the requested resourceAdd podsecuritypolicies.policy and any removed API kinds to excludedResources, and convert deprecated workload APIs before backup.
Source backups vanish from the TKGI bucketbackup has expired, deletingSet accessMode: ReadOnly on the tkgi-source BackupStorageLocation. Recreate the backups; expired deletions are not recoverable.

Field Note from a Backup That Reported Success

I built the first version of this pipe with one bucket, because two felt like ceremony. TKGI wrote to it, OADP read from it, and I registered the location on OpenShift the way every tutorial does, writable, default settings, no thought given. That worked for nine days.

On the tenth morning three backups covering two namespaces were gone. Not corrupt, not unreadable. Gone from the bucket, and gone from velero backup get on both clusters. Velero on TKGI had stamped a 720 hour TTL on those backups by default, the clock had not run out, but I had also created a short lived test backup with a 24 hour TTL and OADP’s garbage collector, doing precisely what it is documented to do on a writable location, cleaned up what it considered expired and pruned repository data that the older backups shared. Total cost was one weekend: 5 hours and 8 minutes to re-run the wave2-db backup alone, plus a Monday morning conversation with a database team who had signed off on a migration window that no longer had a valid restore point behind it.

I reversed the design that afternoon. Two buckets, and accessMode: ReadOnly on the source location, which is a single line in the DataProtectionApplication and is the most valuable line in this entire part. Nothing on the OpenShift side has deleted a source backup since. If you take one thing from Part 17, take that line.

A second habit came out of the same week. Set an explicit TTL on every migration backup and make it longer than your entire migration programme, because the default is 30 days and a wave that slips twice will outlive its own restore point. I use –ttl 4380h, six months, on anything that backs a production cutover.

Pin Both Sides to One Velero Minor

My recommendation for this part is narrow and I will defend it: decide your Velero minor version from the OpenShift end, then make TKGI match it. OADP gives you no choice, since Red Hat supports one OADP release per OpenShift minor and that release carries a fixed Velero server. Everything upstream of that is negotiable, so negotiate it. Running an unsigned upstream Velero on a source cluster that reaches End of Support in October 2027 is a smaller risk than restoring across five minors of format skew that nobody has ever tested.

Avoid the Velero plugin for vSphere on the source side for migration work. It is a fine tool for backing up TKGI to restore onto TKGI, and it is a dead end for anything leaving the platform. If you are still weighing whether to leave the platform at all, the TKGI to VKS migration guide covers the landing place where vSphere native snapshots do stay useful, and the storage mapping work behind all of this sits in Part 9 on storage and persistent volumes.

A clean result at the end of this part looks like this. Velero runs on all three TKGI clusters with node agents healthy and memory raised. OADP 1.5.0 runs on OpenShift with two backup locations, one of them ReadOnly. A backup taken on TKGI appears in oc -n openshift-adp get backup within one sync interval without anybody copying a file. One stateless namespace has completed a full round trip, and any SCC rejections it produced are written down rather than worked around by hand. You have a measured restore rate for your own storage, expressed in gigabytes per hour, that you can put in front of an application owner.

Two questions come up every time I run this with a team. Do you need Velero on every TKGI cluster, or one central install? Every cluster, because the node agent has to read pod volumes from the node it runs on, and there is no remote mode. Can OADP restore a backup taken by a newer Velero than it runs? Assume no. Format compatibility is documented in one direction, older backups into newer servers, and the reverse is where people discover that a major format bump is a real thing.

On Monday, install Velero on your least important TKGI cluster, patch the hostPath, and back up a single namespace. You will know within an hour whether your object storage, your credentials and your network path work, and every later wave rests on those three things. Next in this series we take a real stateless application through the whole journey, from waves defined in Part 6 to traffic on OpenShift.

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

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