, ,

Pilot Migration, One Non-Prod Cluster End to End (TKGI to OpenShift Series, Part 22)

A pilot that passes proves nothing unless the app team runs it. Here is how I moved one non production TKGI cluster onto OpenShift 4 end to end, with the exit criteria scorecard, the real timings and the rollback.

TKGI to OpenShift Series · Part 22 of 26
Key takeaways:
1. Run the pilot on the OpenShift cluster you intend to keep, not on a throwaway sandbox. Most of what breaks is cluster configuration, and a sandbox carries different configuration.
2. Write the exit criteria before anything moves. Mine ran to 14 lines and every line is a command with a pass condition. A criterion that cannot be expressed as a command is an opinion.
3. Headline command: velero backup create pilot-staging-20260816 --include-namespaces wave1-web,wave2-data --snapshot-volumes=false --default-volumes-to-fs-backup --wait
4. Hand the keyboard to the application team. A pilot executed by the platform engineer holding cluster-admin proves only that the platform works for the one person who already knows the answers.
5. Budget double. My six hour plan took 9 hours and 40 minutes, and every minute of overrun landed in identity and verification, not in data movement.

A pilot that passes tells you almost nothing. A pilot that fails in a way you can name, on a cluster that matters, in front of the team that will inherit the runbook, tells you everything worth knowing before production day. That distinction drives every choice in this Part, starting with a deliberate decision about where to go and break something.

Who this is for: you have worked through Parts 12 to 21. An OpenShift Container Platform 4 (OCP) cluster is installed on vSphere with OVN-Kubernetes, projects and quotas exist, OAuth is wired to LDAP, OADP (OpenShift API for Data Protection, Red Hat packaging of Velero) is installed and validated, and you have moved a stateless tier and a stateful tier by hand at least once. Nothing new is invented in this Part. Everything already built gets run once, against a clock, by other people.

Pilot Scope, and Why Staging Not Dev

Our reference estate carries three TKGI (Tanzu Kubernetes Grid Integrated) clusters: dev, staging and prod. Instinct points at dev, because nobody files a ticket when dev falls over. That instinct costs you the pilot. Dev has no LDAP groups anyone cares about, certificates are self signed, the database holds a few gigabytes of seed rows, no dashboard is watched, and no team is inconvenienced by an outage. Each of those absences quietly removes a failure mode you specifically need to trigger while the stakes are low.

Staging is different in exactly the ways that matter. It shares the production LDAP group structure, so identity is real. Its certificates come from the corporate certificate authority, so trust chains are real. Its database is restored weekly from a production dump, so data volume and row counts are real. And an application team watches it, so a broken deployment produces a complaint within twenty minutes. That last property is not a risk to be managed, it is the instrument you are trying to read.

Staging holds 34 namespaces. Two of them move in the pilot: wave1-web, the stateless tier from Part 18, and wave2-data, the PostgreSQL tier from Part 19. Between them they exercise every mechanism in the series: a Deployment behind an Ingress, persistent volumes with an image that wants a fixed user identifier, secrets sourced from a vault, network policy between tiers, and a Jenkins pipeline that pushes to Harbor. Tutorials tell you to pilot with your simplest workload. Do the opposite. Your simplest workload will restore cleanly and teach you nothing, and you will discover the admission and identity walls in production instead.

Against the usual advice: do not build a separate sandbox OpenShift cluster for the pilot. A sandbox gets installed in an afternoon with default Security Context Constraints (SCC, the OpenShift admission policy that decides what a pod may ask for), the default self provisioner role still bound, a default ingress certificate and no LDAP identity provider. Roughly four out of every five problems I hit in a pilot were cluster configuration problems, and a sandbox has none of your configuration. Run the pilot in dedicated projects on the cluster you plan to run production on. Isolate with projects, quotas and network policy, which is what those controls exist for.
flowchart TD
  S[Scope two namespaces on staging] --> X[Write exit criteria as commands]
  X --> P[Preflight both platforms]
  P --> B[Backup on TKGI with Velero]
  B --> R[Restore on OpenShift with OADP]
  R --> V{All criteria green}
  V -->|no| F[Fix cluster config, roll back, log it]
  F --> P
  V -->|yes| Z[Freeze the runbook and the timings]
  Z --> C[Production cutover]
Pilot loop. Failing the criteria is the expected path on the first run, and the loop returns to preflight rather than to the backup, because the fix is nearly always platform configuration rather than application data.

Exit Criteria Scorecard, Written Before Anything Moves

A pilot without written exit criteria always passes, because the person running it decides at the end what passing meant. Write the list first, circulate it, and let the application owner add two lines of their own. My version ran to 14 rows and it is the artifact from this Part worth keeping. Each row names a criterion, the command that proves it, and the output that counts as a pass. Anything you cannot reduce to a command belongs in a risk log, not in the scorecard.

#CriterionProving commandPass condition
1Object storage reachable from both clustersvelero backup-location getPhase Available on source and target
2Backup completes without errorsvelero backup describe $VELERO_BACKUPPhase Completed, Errors 0
3Target projects exist and are Activeoc get project wave1-web wave2-dataBoth Active
4Object counts match the sourceoc get all -n wave1-web –no-headers | wc -lEquals the source count taken at freeze
5Every workload pod is Runningoc get pods -n wave2-dataNo CrashLoopBackOff, no Init errors
6No SCC escalation was neededoc get pod -o jsonpath of openshift.io/sccrestricted-v2 or nonroot-v2, never anyuid
7Persistent volume claims are boundoc get pvc -n wave2-dataAll Bound, capacity matches source
8Data landed intact, not merely presentpsql -tAc select count from ordersRow count equals the frozen source count
9Route serves the applicationcurl -sSI https://shop.pilot.example.comHTTP 200 within 2 seconds
10Certificate comes from the corporate CAopenssl s_client -connect host:443Issuer is the corporate CA, not the default ingress certificate
11Application team can work without cluster-adminoc login with an LDAP account, then oc get podsSucceeds with project scoped rights only
12Network policy still isolates the tiersoc rsh into a web pod, curl the database serviceRefused unless an explicit policy allows it
13Pipeline builds and deploys to the targettkn pipelinerun list -n wave1-webSucceeded, image lands in the internal registry
14Rollback was executed and timedstopwatch, from decision to source healthyUnder 15 minutes, TKGI serving 100 percent

On my first run, 11 of those 14 came back green. Rows 6, 11 and 14 failed, and all three failures were platform configuration rather than application behaviour. That ratio is normal and it is the reason the pilot exists. If you score 14 out of 14 on a first attempt, check whether you ran every step yourself as a cluster administrator, because that is the usual explanation.

Preflight and Version Baseline

Pin versions in writing before the first command, because a pilot whose result you cannot reproduce three weeks later is a story rather than evidence. On the OpenShift side use oc rather than kubectl, since only oc knows about projects, routes and SCC review commands. TKGI has no oc, so the source side stays on kubectl throughout.

# Versions this pilot was executed against oc version –client Client Version: 4.19.9 oc get clusterversion version -o jsonpath='{.status.desired.version}’ 4.19.9 oc -n openshift-adp get csv -o custom-columns=NAME:.metadata.name,PHASE:.status.phase NAME PHASE oadp-operator.v1.5.1 Succeeded # Source side, on the TKGI jump host velero version –client-only Client: Version: v1.16.2 kubectl version -o json | jq -r .serverVersion.gitVersion v1.29.8 # TKGI 1.18 staging cluster

Preflight then measures the pilot rather than describing it. Count the objects and the bytes, because both numbers become verification inputs later and both feed the timing estimate. No credential appears in any manifest below. Every access key is read from an environment variable that the shell sources from the vault, which is the pattern established in Part 21.

# How much of staging the pilot actually touches kubectl get ns –no-headers | wc -l 34 for NS in wave1-web wave2-data; do echo "$NS $(kubectl get all,pvc,ingress -n $NS –no-headers | wc -l)"; done wave1-web 27 wave2-data 19 # Volume footprint, which sets the restore estimate kubectl get pvc -n wave2-data -o custom-columns=NAME:.metadata.name,SIZE:.spec.resources.requests.storage,SC:.spec.storageClassName NAME SIZE SC pgdata 200Gi fast-vsan pgwal 16Gi fast-vsan uploads 4Gi fast-vsan # Target preflight, and the first thing that was not ready oc -n openshift-adp get backupstoragelocation NAME PHASE LAST VALIDATED AGE default Unavailable 12s 6d oc -n openshift-adp logs deploy/velero | tail -1 level=error msg=backup storage location valid check failed error=InvalidAccessKeyId: the access key id you provided does not exist in our records

That failure had nothing to do with the key itself. Our DataProtectionApplication referenced a secret whose data key was named credentials, while the AWS style plugin looks for a key named cloud. Six days of a healthy looking Operator install hid it, because nothing validates a backup storage location until something asks it to. Add row 1 of the scorecard for exactly this reason, and run it the morning of the pilot rather than the week before.

Running the Pilot, Hour by Hour

Step 1, Freeze Writes and Back Up on TKGI

Scale the web tier to zero first, capture the row count that verification will compare against, then take the backup. Capturing the count after the freeze rather than before is what turns row 8 of the scorecard from a guess into a proof.

kubectl -n wave1-web scale deploy/web –replicas=0 deployment.apps/web scaled kubectl -n wave2-data exec deploy/postgres — psql -U app -d shop -tAc ‘select count(*) from orders’ 4812697 export VELERO_BACKUP=pilot-staging-20260816 velero backup create $VELERO_BACKUP –include-namespaces wave1-web,wave2-data –snapshot-volumes=false –default-volumes-to-fs-backup –wait velero backup describe $VELERO_BACKUP | head -6 Name: pilot-staging-20260816 Phase: Completed Total items to be backed up: 312 Items backed up: 312 Errors: 0 Started: 2026-08-16 08:52:11 +0000 UTC Completed: 2026-08-16 10:04:47 +0000 UTC

Step 2, Prepare Projects and Identity on OpenShift

Here is where my first run came apart, and where a sandbox cluster would have lied to me. Following the reference architecture from Part 11 we removed the self provisioner binding, so ordinary users cannot mint projects. That is correct policy and it is also the reason the application team could not start.

# What the app team saw on the first attempt, with their own LDAP account oc new-project wave1-web Error from server (Forbidden): You may not request a new project via this API. # Correct sequence, run once by a cluster administrator before the pilot window opens oc adm new-project wave1-web –display-name=’Pilot web tier’ oc adm new-project wave2-data –display-name=’Pilot data tier’ oc adm policy add-role-to-group admin ldap-app-shop -n wave1-web oc adm policy add-role-to-group admin ldap-app-shop -n wave2-data # Prove row 11 of the scorecard before anything else runs oc login -u a.rao https://api.ocp1.example.com:6443 oc auth can-i create deployments -n wave1-web yes oc auth can-i create projects no

Step 3, Restore in Two Passes

Restore the stateless namespace first and let it settle, then restore the data namespace. Two passes cost about eight minutes more than one and buy you a clean attribution when something fails. A single combined restore that reports PartiallyFailed forces you to read a hundred lines of describe output to work out which tier hurt.

velero restore create pilot-web-1 –from-backup $VELERO_BACKUP –include-namespaces wave1-web –namespace openshift-adp –wait Restore completed with status: Completed. velero restore create pilot-data-1 –from-backup $VELERO_BACKUP –include-namespaces wave2-data –namespace openshift-adp –wait Restore completed with status: PartiallyFailed. velero restore describe pilot-data-1 –namespace openshift-adp –details | grep -m2 -i error Errors: 1 wave2-data: error preparing persistentvolumeclaims/wave2-data/pgdata: storageclass.storage.k8s.io fast-vsan not found # Fix, then rerun. Map the TKGI class onto the vSphere CSI class present on OCP. oc -n openshift-adp create configmap change-storage-class-config –from-literal fast-vsan=thin-csi oc -n openshift-adp label configmap change-storage-class-config velero.io/plugin-config="" velero.io/change-storage-class=RestoreItemAction velero restore create pilot-data-2 –from-backup $VELERO_BACKUP –include-namespaces wave2-data –namespace openshift-adp –wait Restore completed with status: Completed.

Step 4, Point Pilot Traffic at OpenShift

Pilot traffic moves the same way production traffic will, using the weighted pool technique from Part 20, with the staging DNS record dropped to a 60 second time to live the day before. Rehearsing the mechanism matters more than the traffic volume. If your pilot shifts traffic by editing a hosts file, you have rehearsed nothing that production will use.

Step 5, Stop the Clock and Publish the Timings

Record elapsed minutes per phase while you work, not from memory afterwards. These numbers are how you size the production window in Part 23, and they are the only part of the pilot that a director will actually read. My plan said six hours. Reality said 9 hours and 40 minutes, and the shape of the overrun was more instructive than the total.

Pilot phase duration, planned against actual One staging cluster, 2 namespaces, 220 GiB of volume data, OCP 4.19.9 and TKGI 1.18 200 150 100 50 0 Minutes elapsed Planned 360 min Actual 580 min Preflight Backup Identity Restore Verify Sign off
Restore ran 23 percent over plan, which is forgivable arithmetic. Identity ran 217 percent over and verification 133 percent over, and neither has anything to do with how fast your object storage is.
Sizing note: 220 GiB moved through Kopia in 185 minutes, roughly 21 MiB per second across three node-agent pods on a 10 Gb network. That rate is bounded by object storage and by node-agent parallelism, not by the link. If the arithmetic on your production volumes gives a number your maintenance window cannot absorb, stop planning a lift and read Part 19 again, because a database dump and reload beats a file level copy at scale.

Verification, Rollback and Common Failures

Green looks like this. Note that the SCC annotation is checked explicitly, because a pod running under anyuid will look perfectly healthy and quietly fail your security review two months later.

oc get pods -n wave2-data NAME READY STATUS RESTARTS AGE postgres-0 1/1 Running 0 14m reports-7c8d94f-2xkvz 1/1 Running 0 11m oc get pod postgres-0 -n wave2-data -o jsonpath='{.metadata.annotations.openshift.io/scc}’ nonroot-v2 oc get pvc -n wave2-data NAME STATUS CAPACITY STORAGECLASS AGE pgdata Bound 200Gi thin-csi 14m pgwal Bound 16Gi thin-csi 14m uploads Bound 4Gi thin-csi 14m oc rsh -n wave2-data postgres-0 psql -U app -d shop -tAc ‘select count(*) from orders’ 4812697 curl -sSI https://shop.pilot.example.com | head -1 HTTP/2 200

Rollback is not a contingency in a pilot, it is a scorecard row you must execute even on a successful run. Nothing on TKGI was deleted, so backing out is a traffic decision followed by a cleanup. Time it with a stopwatch and write the number down.

# 1. Return the load balancer pool weights to TKGI, which takes effect in about one second # 2. Bring the frozen source workload back up kubectl -n wave1-web scale deploy/web –replicas=3 deployment.apps/web scaled # 3. Remove the OpenShift copy so no stale replica can serve traffic later oc delete restore pilot-web-1 pilot-data-2 -n openshift-adp oc delete project wave1-web wave2-data # 4. Confirm the source is whole kubectl -n wave1-web get deploy web NAME READY UP-TO-DATE AVAILABLE AGE web 3/3 3 3 402d # Measured elapsed, decision to source serving 100 percent: 12 minutes
Failure seen in the pilotCauseRemediation
BackupStorageLocation Unavailable, InvalidAccessKeyId in the velero logCredential secret key named credentials, plugin expects cloudRecreate the secret with the key name cloud, then delete the velero pod to force revalidation
You may not request a new project via this APISelf provisioner role removed, group holds no project creation rightPre create projects with oc adm new-project and bind admin to the LDAP group
storageclass fast-vsan not found during restoreTKGI StorageClass name does not exist on OpenShiftApply a change-storage-class ConfigMap in openshift-adp before rerunning the restore
forbidden: unable to validate against any security context constraintPod spec carries a fixed runAsUser that restricted-v2 rejectsRemove runAsUser and let the project allocate, or bind the service account to nonroot-v2. Avoid anyuid.
Route returns 503 while pods are RunningService has no ready endpoints because the readiness probe targets a port the Route does not useoc get endpoints, align the probe port with the Service targetPort
Restore PartiallyFailed on a Deployment resourceManifest still uses an API version the OCP 4.19 server no longer servesRewrite to apps/v1 at source before the backup, and add an API deprecation scan to preflight
node-agent pods in CrashLoopBackOff on the source clusterKopia repository credential secret missing from the velero namespaceRecreate the repository credential and restart the daemonset before retrying the backup

Field Note on a Pilot That Passed Because I Ran It

On an earlier engagement I ran a pilot exactly like this one and declared it green in five and a half hours. Six namespaces restored, smoke tests passed, route served 200s, database row counts matched. I wrote a confident summary and we scheduled the first production wave for eleven days later.

Every command in that pilot came from my laptop, under a kubeconfig with cluster-admin. When the application team ran the identical runbook with their own LDAP backed accounts, 3 of the 6 namespaces stopped at the first step, because they could not create a project and nobody had noticed that I never needed to. Then a second wall appeared behind the first: their group had edit rather than admin on the projects we did pre create, so they could not bind a service account to nonroot-v2 either, which is the exact operation the stateful tier requires. Two full days went into rewriting the role bindings and rerunning the pilot, and the production wave slipped by a week.

Nothing was technically wrong with the platform. What was wrong was the experiment. I had tested whether OpenShift could host the workloads, when the question that decides a migration is whether the people who own the workloads can move them under the permissions they will actually hold. Since then the rule in my runbooks is blunt: the platform engineer may sit in the room and may not touch the keyboard. If a step cannot be completed by the application team, it is a defect in the platform configuration, and finding it during a pilot costs an hour instead of a week.

Pilot on Staging, With the Application Team Driving

My recommendation for this Part is one sentence: pilot on staging, on the OpenShift cluster you intend to keep, with your two ugliest namespaces, and let the application team hold the keyboard while you take notes. Avoid the dev cluster and avoid a disposable sandbox. Both will hand you a pass you cannot bank.

A clean result looks like this. All 14 scorecard rows green on a rerun, with the first run failures written down and fixed in cluster configuration rather than in the application. Per phase timings recorded, so the production window in the next Part is arithmetic rather than optimism. A rollback actually performed and timed, under 15 minutes. A runbook frozen at a version number, executed at least once by someone who is not you. And a short list of platform defects closed, each with the command that proves the fix.

Two questions come up every time. First, can the pilot namespaces stay on OpenShift afterwards. Yes, and they should, because a pilot you tear down stops generating evidence the moment it ends. Leave staging running on OpenShift and let it accumulate a fortnight of operational history before production day. Second, does a pilot on VMware vSphere behave differently if the target is VKS rather than OpenShift. The method transfers, the admission and identity failures do not, and that comparison belongs in the TKGI to VKS Series rather than here.

On Monday, open a document and write your 14 exit criteria against your own estate before you touch a cluster. If you cannot write the proving command for a row, you have found the first gap in your platform, and you have found it for free. Full context for the whole migration sits in the TKGI to OpenShift guide.

TKGI to OpenShift Series · Part 22 of 26
« Previous: Part 21  |  Guide  |  Next: Part 23 »

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