, ,

Production Cutover, Traffic Shift, Validation and Rollback (TKGI to OpenShift Series, Part 23)

Moving live production traffic off TKGI onto OpenShift without a visible outage, using weighted Routes as the only lever on the night. Includes a five step traffic ladder, real preflight commands, and a cutover I reversed at 03:40.

TKGI to OpenShift Series · Part 23 of 26

Our first production cutover rehearsal produced this, and both clusters were completely healthy at the time:

$ curl -s -o /dev/null -w ‘%{http_code}’ https://shop.example.com/healthz 503

Eleven seconds of hard 503, no pod restarts, no failed probes, no NSX alarm. What happened was that I patched the two Route backend weights with two separate commands, and for those eleven seconds both weights were zero. Red Hat documents exactly this behaviour: if every backend weight on a Route is zero, the router treats the Route as having no backends and returns a 503. A cutover is full of small mechanical facts like that one, and production night is a bad time to discover them.

Who this is for: you have completed the pilot from Part 22 on staging, with real timings written down. Your OpenShift Container Platform 4 (OCP) cluster on vSphere is running OVN-Kubernetes, OAuth is bound to the same LDAP directory as TKGI, OADP (OpenShift API for Data Protection, the Red Hat packaging of Velero) restores cleanly, and Routes are already replacing Ingress objects per Part 20. What remains is moving live customer traffic off the TKGI (Tanzu Kubernetes Grid Integrated) estate without a visible outage.
Key takeaways:
1. Do not use DNS as the traffic shift control. Move DNS to the OpenShift ingress VIP days early, park the Route on a proxy that still forwards to TKGI, and make Route weight the only lever you touch on the night.
2. Headline command, and it must be one atomic patch, never two: oc -n prod-web patch route/shop --type=json -p '[{"op":"replace","path":"/spec/to/weight","value":50},{"op":"replace","path":"/spec/alternateBackends/0/weight","value":50}]'
3. Route weights run 0 to 256 with a default of 100, and a Route accepts at most three alternate backends. Share is weight divided by the sum of weights, so 90 and 10 is genuinely 90 percent.
4. Pre-seed bulk volume data 24 hours ahead. Our pilot spent 185 minutes on restore; the production window spent 55, because 214 GiB had already landed.
5. Rollback is a patch that completes in under two seconds. Keep it armed through a 60 minute soak at 100 percent, and rehearse it before you need it.

Cutover Window Sizing From Pilot Timings

Part 22 finished with a number that nobody enjoyed reading: a six hour pilot plan that took 9 hours and 40 minutes, or 580 minutes of wall clock. That number is not the production window. It is the raw material for building one. Three of the pilot phases were one time costs that never repeat, one phase can be moved out of the window entirely, and one phase does not exist in a pilot at all.

Work through it line by line rather than applying a contingency multiplier to the total. A multiplier hides which phase is actually risky, and it produces a window that a change board will reject on principle.

One more discipline before the table. Size the window against the phase you cannot interrupt, which is the stretch between freezing writes and reconciling row counts. Everything before that is reversible by doing nothing, and everything after it is reversible by a Route patch. In our plan that uninterruptible stretch is 80 minutes of the 270, and it is the only number the business genuinely needs to hear, because it is the only period during which the application is unavailable for writes.

PhasePilot actualProduction planWhy the difference
Preflight55 min40 minScripted after the pilot, still run by a human reading output
Bulk volume pre-seednot attempted0 min in window214 GiB restored at T minus 24 hours, outside the change window
Freeze and final backup72 min25 minOnly the 24 hour delta moves, not the full dataset
Restore and reconcile185 min55 minPersistent volumes already populated by the pre-seed
Projects and identity95 min0 minOne time cost, paid in Part 15 and proved in the pilot
Weight ladder and soaknot applicable120 minNew work in production, a pilot has no live traffic to shift
Verification and sign off173 min30 minPilot scorecard turned into a script that exits nonzero
Total580 min270 minAsk for 4 hours 30 minutes, publish 6 hours to the business

Note what the pre-seed does to the shape of the night. Moving 214 GiB of PostgreSQL data out of the change window converted the single scariest phase into a delta copy. It also means the OpenShift persistent volumes are already bound, already attached to nodes, and already proved by vSphere CSI (Container Storage Interface, the driver that provisions vSphere disks as Kubernetes volumes) before anyone is awake to care.

Preflight, Freeze Gates and Version Baseline

State your versions before your commands, because a cutover runbook that does not name its versions is a runbook nobody can reproduce. Everything below was executed against OCP 4.19.6 with the matching oc client, OADP 1.5.0 which packages Velero 1.16, and Velero CLI 1.16.0 driving the source TKGI 1.18 cluster. Red Hat supports one OADP version per OpenShift version, so check the pairing before you upgrade either one mid migration.

$ oc version –client Client Version: 4.19.6 $ oc get clusterversion NAME VERSION AVAILABLE PROGRESSING SINCE STATUS version 4.19.6 True False 31d Cluster version is 4.19.6 $ oc -n openshift-adp get csv -o custom-columns=NAME:.metadata.name,VERSION:.spec.version NAME VERSION oadp-operator.v1.5.0 1.5.0 $ velero version –client-only Version: v1.16.0 # Both router replicas ready. A single ready replica is not a cutover position. $ oc -n openshift-ingress get pods -l ingresscontroller.operator.openshift.io/deployment-ingresscontroller=default NAME READY STATUS RESTARTS AGE router-default-6c4f9d7b58-4mkzq 1/1 Running 0 9d router-default-6c4f9d7b58-r7t2v 1/1 Running 0 9d # TTL already low. Run this 48 hours before the window, never on the night. $ dig +noall +answer shop.example.com shop.example.com. 60 IN A 10.80.14.31 # Object storage reachable from both platforms $ velero backup-location get NAME PROVIDER BUCKET/PREFIX PHASE LAST VALIDATED default aws tkgi-migration/ Available 2026-08-16 01:12:04 +0000 UTC

Two of those checks earn their place because of what they prevent. A 60 second TTL on the A record means a bad DNS decision costs you a minute of stale answers instead of an hour. Two ready router pods means that shifting traffic onto OpenShift does not simultaneously concentrate all of it onto one HAProxy instance, which is how a cutover turns into a capacity incident.

Against the usual advice: almost every migration plan I review treats DNS as the cutover switch. Change the A record from the NSX-T load balancer VIP to the OpenShift ingress VIP, watch, revert if it hurts. That plan has a fatal property: DNS is not revocable. Resolvers ignore short TTLs, JVMs cache addresses for the life of the process, and corporate forwarders do whatever they were configured to do in 2019. Move DNS to OpenShift days early while the Route still forwards everything back to TKGI, and the shift becomes a Route weight you own end to end.

Concretely, that means standing up a small reverse proxy Deployment in the target project whose only job is to forward to the existing NSX-T VIP, exposing it as a Service called shop-legacy-proxy, and pointing the Route at it. Customers reach OpenShift ingress and get served by TKGI. Nothing about the application has changed, DNS has already settled, and every subsequent move is a patch against one Route object.

stateDiagram-v2
  [*] --> Preflight
  Preflight --> Frozen: gates green
  Frozen --> Restored: delta backup and restore done
  Restored --> Shadow: verified on shadow hostname
  Shadow --> Shifting: weight ladder starts
  Shifting --> Soaking: 100 percent on OpenShift
  Soaking --> Landed: 60 minute soak clean
  Shifting --> RolledBack: abort trigger fires
  Restored --> RolledBack: row counts disagree
  RolledBack --> Frozen: fix, then retry
  Landed --> [*]
Cutover gate states. Rollback stays reachable from two of them, and rejoins at Frozen rather than at the start.

Cutover Procedure, Step by Step

Step 1, Freeze Writes and Take the Delta Backup on TKGI

Freeze at the edge, not in the database. Scaling the web tier to zero stops new writes while leaving the database live for the backup and for the reconciliation query. Capture a business number before the backup runs and hold it, because that number is your only honest proof that no orders were lost.

# Stop writes at the edge $ kubectl -n prod-web scale deploy/shop-web –replicas=0 deployment.apps/shop-web scaled # Reconciliation number, captured BEFORE the backup $ kubectl -n prod-data exec statefulset/shop-db — psql -U app -d shop -t -c ‘select count(*) from orders’ 4182306 # Delta backup only. Credentials come from the environment, never from this file. $ velero backup create prod-cutover-20260816 –include-namespaces prod-web,prod-data –snapshot-volumes=false –default-volumes-to-fs-backup –wait Backup request "prod-cutover-20260816" submitted successfully. Waiting for backup to complete. You may safely press ctrl-c to stop waiting. …. Backup completed with status: Completed.

Step 2, Restore on OpenShift and Reconcile

This is where a migration that has been polite for twenty two Parts finally bites. Our production database image runs as a fixed UID, which TKGI permitted under its PodSecurityPolicy posture and which restricted-v2, the default Security Context Constraint (SCC) on OpenShift, rejects outright. We had fixed this in staging. Nobody had applied the fix to the production project.

$ velero restore describe prod-cutover-20260816-restore –details Phase: PartiallyFailed (run "velero restore logs prod-cutover-20260816-restore" for more information) Errors: Velero: <none> Cluster: <none> Namespaces: prod-data: error restoring pods/prod-data/shop-db-0: pods "shop-db-0" is forbidden: unable to validate against any security context constraint: [provider "restricted-v2": .spec.containers[0].securityContext.runAsUser: Invalid value: 999: must be in the ranges: [1000820000, 1000829999]] # Fix, then let the StatefulSet controller recreate the pod $ oc -n prod-data adm policy add-scc-to-user nonroot-v2 -z shop-db clusterrole.rbac.authorization.k8s.io/system:openshift:scc:nonroot-v2 added: "shop-db" $ oc -n prod-data rollout status statefulset/shop-db –timeout=300s statefulset rolling update complete 1 pods at revision shop-db-7f4c9b6d84… # Reconcile against the number captured in Step 1 $ oc -n prod-data exec statefulset/shop-db — psql -U app -d shop -t -c ‘select count(*) from orders’ 4182306

Matching row counts is the gate. If they disagree, you are in the RolledBack state on the diagram above, and the correct move is to scale the TKGI web tier back up, release the freeze, and go home. Prefer oc over kubectl on the OpenShift side throughout, because subcommands like oc adm policy and oc get route have no kubectl equivalent, and mixing the two clients in one runbook is how people run the right command against the wrong cluster.

Step 3, Prove the Target on a Shadow Hostname

Before any customer traffic touches the new backend, exercise it through a second Route on a hostname only you know. Same ingress controller, same TLS termination, same OVN-Kubernetes data path, zero blast radius. Run the application team’s smoke suite against it, not a health endpoint, because a health endpoint proves that a process is alive and nothing more.

$ oc -n prod-web create route edge shop-shadow –service=shop-web –hostname=shop-shadow.apps.ocp1.example.com route.route.openshift.io/shop-shadow created $ oc -n prod-web get route shop-shadow -o jsonpath='{.status.ingress[0].conditions[0].type}{" "}{.status.ingress[0].conditions[0].status}’ Admitted True $ ./smoke.sh https://shop-shadow.apps.ocp1.example.com checkout flow ……… pass (1.9s) order write ……….. pass (0.4s) search facets ……… pass (2.2s) session persistence … FAIL (sticky session lost across 2 of 20 requests)

That failing line is worth more than the three passing ones. Sticky sessions on TKGI were held by the NSX-T load balancer; OVN-Kubernetes and the OpenShift router do not reproduce that behaviour by default. We caught it on a shadow hostname at 01:15 with nobody watching, instead of at 50 percent traffic with everybody watching.

Traffic Shift With Weighted Routes, and DNS Last

A Route can carry a primary backend plus up to three alternate backends, each with a weight between 0 and 256, defaulting to 100. With roundrobin scheduling the share a backend receives is its weight divided by the sum of all weights, so a 90 and 10 pair really is ninety percent and ten percent rather than an approximation. That arithmetic is the whole traffic shift mechanism, and it lives entirely inside one object you can patch and unpatch.

Set the balance annotation to roundrobin explicitly rather than accepting whatever the ingress controller defaults to. With leastconn, which is a perfectly reasonable default for a steady state application, the weights still apply but connection counts skew the effective share in ways that make a ladder step hard to reason about at three in the morning. During a cutover you want the share to be arithmetic, verifiable against a request count, and boring. Switch back afterwards if leastconn suits the workload better in normal operation.

apiVersion: route.openshift.io/v1 kind: Route metadata: name: shop namespace: prod-web annotations: haproxy.router.openshift.io/balance: roundrobin spec: host: shop.example.com to: kind: Service name: shop-legacy-proxy # forwards to the NSX-T VIP on TKGI weight: 100 alternateBackends: – kind: Service name: shop-web # the migrated workload on OpenShift weight: 0 port: targetPort: 8080 tls: termination: edge

Now the ladder. This table is the artifact worth keeping from this Part: five steps, each with the exact weights, a dwell time long enough for a metric to move, and a named abort trigger that anyone in the bridge call can read out loud. An abort trigger you have to interpret is not an abort trigger.

StepLegacy weightOpenShift weightDwellAbort trigger
0 baseline100015 minAny 5xx above the recorded baseline of 0.02 percent
1901010 min5xx rate above 0.20 percent over 5 minutes
2505020 minp95 latency above 900 ms, or any failed order write
3109015 minAny failed order write, no tolerance
4010060 min soakRollback stays armed, TKGI stays running and warm
5 land itremovedprimary, 100next dayDelete alternateBackends and the proxy after 24 clean hours

Every step is a single JSON patch touching both weights at once. Splitting it into two commands is what produced the 503 at the top of this page, and it is the most common self inflicted wound I see on cutover night.

# Step 1 of the ladder, both weights in one atomic patch $ oc -n prod-web patch route/shop –type=json -p ‘[{"op":"replace","path":"/spec/to/weight","value":90}, {"op":"replace","path":"/spec/alternateBackends/0/weight","value":10}]’ route.route.openshift.io/shop patched # Confirm what the router actually holds, not what you meant to send $ oc -n prod-web get route shop -o jsonpath='{.spec.to.weight}{"/"}{.spec.alternateBackends[0].weight}’ 90/10 # 200 probes, counted by status code $ for i in $(seq 1 200); do curl -s -o /dev/null -w ‘%{http_code}’ https://shop.example.com/healthz; echo; done | sort | uniq -c 200 200
Rollback, in one command: patch the two weights back to 100 and 0 in the same atomic form. It completed in 1.4 seconds in our estate and needs no DNS change, no scaling, and no approval, because the TKGI side never stopped running. Rehearse it once at step 1, when reversing costs nothing, so that reversing at step 3 is muscle memory.

Verification, Rollback and Common Failures

Our first attempt did not reach step 4. Here is what both attempts looked like plotted against the ladder, which is the single most useful picture to put in front of a change board when you ask for a second window.

Error rate across the weight ladder, two attempts Percentage of requests returning 5xx, production Route shop.example.com, OCP 4.19.6 2.5 2.0 1.5 1.0 0.5 0.0 5xx rate, percent 0 10 50 90 100 soak Percentage of traffic weighted to OpenShift 1.90 percent at 03:40, reversed after 22 minutes Attempt 1, reversed Attempt 2, landed
Two production attempts against the same ladder. Attempt 1 never reached the 90 percent step.

Field Note, a Cutover Reversed at 03:40

Attempt 1 was clean through baseline and clean at 10 percent. At 03:18 I patched to 50 and 50. For eighteen minutes the 5xx rate sat at 0.07 percent and I let myself relax. Then it climbed to 1.90 percent over roughly four minutes, concentrated entirely in checkout. Cause: connection pool exhaustion on the migrated PostgreSQL pod, which had inherited a max_connections value tuned for a TKGI node with more memory than the OpenShift worker it landed on. At 03:40, after 22 minutes at 50 percent, I reversed. That patch took 1.4 seconds and the error rate was back to baseline before I had finished typing the message to the bridge call.

Total customer impact: 22 minutes at a raised error rate on half of traffic, roughly 3,100 failed requests. Total cost of the reversal: one 90 minute change window burned and a fortnight of schedule. What made that survivable was not skill. It was that TKGI never went away, and the only thing standing between us and a healthy platform was an integer in a Route. Attempt 2, nine days later with the pool sized correctly and the workers resized, landed with a peak of 0.09 percent and no reversal.

Verdict on dwell times: pick 20 minutes at the 50 percent step, not 10. Our problem took 18 minutes to surface, and a 10 minute dwell would have shipped it straight to 90 percent. Avoid the tempting shortcut of jumping baseline directly to 50 because the pilot went well. A pilot has no connection pools under real load.

Symptom or errorCauseRemediation
503 on every request, router logs show no backendBoth weights momentarily zero because the patch was split into two commandsAlways one atomic JSON patch. A Route with all weights zero returns 503 by design
is forbidden: unable to validate against any security context constraint [provider restricted-v2]Image runs as a fixed UID outside the project rangeBind nonroot-v2 to the service account, or rebuild the image to run as an arbitrary UID
Traffic still hitting TKGI 40 minutes after the DNS changeResolver cached the old A record at the previous 3600 second TTLLower TTL 48 hours ahead and stop using DNS as the shift control
Phase: PartiallyFailed with warnings only under ClusterCluster scoped resources already present from the pre-seed runExpected on a second restore. Read the warnings, confirm they are pre-existing, do not re-run blindly
Sessions dropped once weight reaches 50Session affinity was provided by the NSX-T load balancer, not by the applicationMove sessions to a shared store before the window, or declare the drop to the business in advance
p95 latency doubles at the first ladder stepCold caches and empty connection pools on the new clusterExtend the 10 percent dwell rather than skipping it, and check pool sizing against the worker node memory

Cut Over in Halves and Keep DNS Out of It

Recommendation for this Part, stated plainly: separate the DNS move from the traffic move by several days, make Route weight the only lever anybody touches during the window, and keep TKGI running and warm until you have banked 24 clean hours. Everything else in a cutover plan is negotiable. Those three are not, because together they are what turns a reversal from an incident into a patch.

A clean result looks like this. DNS has pointed at OpenShift ingress for a week and nobody noticed. Row counts match on both sides of the freeze. Every ladder step is recorded with a timestamp, a weight pair, and an error rate. Rollback has been exercised at least once. The soak finished at or below the baseline error rate. Only then do you delete the alternateBackends stanza and the legacy proxy, and only then does the TKGI cluster become a decommissioning candidate rather than a safety net.

Your Monday action: open your production Route definition and write the five row ladder table for one real application, filling in the abort trigger column with metrics you can actually query today. If a row’s abort trigger has no query behind it, you have found the observability gap that Part 25 of this guide exists to close. If you are still weighing whether OpenShift is the right landing place at all, the alternative path is laid out in the TKGI to VKS guide, and it is a legitimate choice for estates staying on VMware Cloud Foundation. Next Part covers what daily life looks like once the traffic is yours: Operators, upgrades and scaling, measured against what BOSH used to do for you.

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

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