, ,

OpenShift Projects, Quotas and MachineSet Scaling (TKGI to OpenShift Series, Part 14)

TKGI gave every team a cluster and a plan. OpenShift gives them a Project, a quota and a share of one node pool. Here is the runbook that makes that swap, with the project request template, the vSphere MachineSet, the autoscaler pair and the quota rejection that took out a whole migration wave.

TKGI to OpenShift Series · Part 14 of 26
Key takeaways:
1. A Tanzu Kubernetes Grid Integrated (TKGI) plan becomes a ResourceQuota plus a LimitRange, not a new OpenShift cluster.
2. Ship the quota inside the project request template, otherwise every new Project is a ticket.
3. A ResourceQuota that names requests.cpu forces every container to declare requests, and a LimitRange is the only thing that saves your migration wave from mass rejection.
4. Three infra MachineSets take the router, registry and monitoring off your billable workers.
5. A ClusterAutoscaler with no MachineAutoscaler never scales anything. Headline command: oc adm create-bootstrap-project-template -o yaml > template.yaml
Who this is for: You installed OpenShift Container Platform 4 (OCP) on vSphere in Part 12 and gave it a data path in Part 13. The cluster is up, the default ingress works, and nothing tenant facing exists yet. You still run TKGI beside it. Nothing has been migrated.

Every TKGI plan you ever wrote is now a ResourceQuota, and every TKGI cluster you handed to a team is now a Project. That is the whole trade in this Part, and it is a better deal than it sounds. A plan bought you a fixed virtual machine shape and a BOSH deploy to change it. A quota buys you a ceiling you can raise on a Tuesday afternoon with one apply and no reboot.

A Project in OpenShift is a Kubernetes namespace with extra annotations and a lifecycle the API server owns. It is the unit of isolation, the unit of quota, and the unit of role assignment. Part 2 argued for consolidating a three cluster TKGI estate onto fewer, larger OpenShift clusters. This Part is where that argument turns into objects: the template that stamps out Projects with limits already attached, the MachineSets that supply nodes underneath them, and the autoscaler pair that grows the pool when a migration wave lands.

Preflight and Versions Tested

Prefer oc over kubectl for the rest of this series. Both talk to the same API server, but only oc knows the OpenShift specific verbs and object groups you are about to use, including oc adm create-bootstrap-project-template and the quota.openshift.io group. That is said once and not repeated.

Three things must already be true. You need cluster-admin. You need the infrastructure ID, because every MachineSet you write embeds it in four separate labels and a wrong value produces a MachineSet that reconciles into nothing. And you need to know how many compute MachineSets the installer created, because the installer provisioned ones carry a rule you will meet again in the autoscaling step.

# Versions this runbook was built and tested against oc version Client Version: 4.18.21 Server Version: 4.18.21 Kubernetes Version: v1.31.9 # TKGI source estate: 1.18, NSX-T with NCP, UAA backed by LDAP # Target: OCP 4.18 EUS, IPI on vSphere 8.0 U3, OVN-Kubernetes, vSphere CSI # Infrastructure ID. Every MachineSet below embeds this string. oc get -o jsonpath='{.status.infrastructureName}’ infrastructure cluster ocp1-7t2xk # What the installer already gave you oc get machinesets -n openshift-machine-api NAME DESIRED CURRENT READY AVAILABLE AGE ocp1-7t2xk-worker 3 3 3 3 6d4h oc get nodes NAME STATUS ROLES AGE VERSION ocp1-7t2xk-master-0 Ready control-plane,master 6d4h v1.31.9 ocp1-7t2xk-master-1 Ready control-plane,master 6d4h v1.31.9 ocp1-7t2xk-master-2 Ready control-plane,master 6d4h v1.31.9 ocp1-7t2xk-worker-a1 Ready worker 6d3h v1.31.9 ocp1-7t2xk-worker-a2 Ready worker 6d3h v1.31.9 ocp1-7t2xk-worker-a3 Ready worker 6d3h v1.31.9

Green here is three control plane nodes Ready, three workers Ready, and exactly one compute MachineSet named after the infrastructure ID. If oc get machinesets returns nothing, your cluster was installed user provisioned rather than installer provisioned, the Machine API has no provider to drive, and the second half of this Part does not apply to you. Part 12 covers that fork.

Plan to Project Mapping for a Three Cluster Estate

This is the artifact worth keeping from this Part. Every construct your TKGI operators use daily has a target on the OpenShift side, and in four cases out of nine the target is not the obvious one. Print this, argue about it with your platform team, then build to it.

TKGI constructOpenShift objectWhat changes for you
Cluster per teamProjectIsolation moves from a VM boundary to an API and network policy boundary. Part 16 covers the network half.
Plan, small or medium or largeResourceQuotaSizing becomes elastic and editable in place. No BOSH deploy to resize.
Plan worker VM shapeMachineSet providerSpecShape is now per node pool, not per tenant. One pool serves many Projects.
tkgi resizeoc scale machinesetMinutes rather than a maintenance window. Nodes join and drain themselves.
No container default requestsLimitRangeMandatory once a quota names requests or limits. Skipping it breaks migrations.
Quota shared across a team estateClusterResourceQuotaOne ceiling spanning many Projects, selected by label or annotation.
Permissive PodSecurityPolicySecurityContextConstraintsrestricted-v2 by default and it will reject workloads. Covered in Part 7.
Dedicated cluster for shared servicesInfra MachineSet plus taintRouter, registry and monitoring get their own nodes, excluded from the subscription count.
Ops Manager cluster creation formProject request templateSelf service returns, and every Project is born with its guardrails attached.

Two of those rows deserve a decision rule rather than a mapping. Reach for a per Project ResourceQuota when the boundary you care about is one application and one team owns it, which covers most migrated TKGI workloads. Reach for a ClusterResourceQuota when a single business owner spans several Projects and you want one ceiling across all of them, because that is the shape a TKGI team estate usually had: three clusters, one budget. A ClusterResourceQuota selects Projects by label or by annotation, so decide the label taxonomy before the first wave rather than relabelling forty namespaces later. Project administrators cannot read the ClusterResourceQuota object itself, which surprises people who file tickets asking why their quota looks wrong. Point them at AppliedClusterResourceQuota instead, which is the read only view intended for them and shows the shared usage they are competing with.

One path decides whether a migrated Deployment lives or dies, and it runs in a fixed order. Defaults are injected first, quota is charged second, and admission by SecurityContextConstraints (SCC) happens last. Operators who learned Kubernetes on TKGI usually expect the SCC rejection and get blindsided by the quota one, because TKGI clusters rarely carried quotas at all.

flowchart TD
  A[oc apply Deployment] --> B[ReplicaSet creates Pod]
  B --> C{LimitRange in Project}
  C -- present --> D[Default requests and limits injected]
  C -- absent --> E[Container carries no requests]
  D --> F{ResourceQuota check}
  E --> F
  F -- within ceiling --> G{SCC evaluation}
  F -- rejected --> H[FailedCreate on ReplicaSet]
  G -- restricted v2 satisfied --> I[Pod scheduled]
  G -- denied --> J[Pod forbidden by admission]
Admission order inside a quota bearing Project. LimitRange runs before ResourceQuota, which is why a missing LimitRange looks like a quota failure.

Step by Step, Project Request Template and Default Quotas

Step 1. Generate the template. OpenShift provisions every self service Project from a template named by the projectRequestTemplate field in the cluster Project configuration. If that field is empty, the API server invents a minimal default and no quota is ever attached. Start from the built in one rather than writing yours from scratch, because the generated file already contains the RoleBindings that make the requesting user an admin of their own Project.

Step 2. Insert the ResourceQuota and the LimitRange. Both objects go into the objects list, and the definitions must sit before the parameters section of the template. The numbers below map the TKGI medium plan for the reference estate, which gave a team roughly 20 cores and 40Gi of usable request capacity.

oc adm create-bootstrap-project-template -o yaml > template.yaml # Add these two objects to the objects list, before the parameters section – apiVersion: v1 kind: ResourceQuota metadata: name: compute-resources namespace: ${PROJECT_NAME} spec: hard: pods: ’40’ requests.cpu: ’20’ requests.memory: 40Gi limits.cpu: ’40’ limits.memory: 80Gi persistentvolumeclaims: ’12’ services.loadbalancers: ‘0’ – apiVersion: v1 kind: LimitRange metadata: name: default-limits namespace: ${PROJECT_NAME} spec: limits: – type: Container default: cpu: 500m memory: 512Mi defaultRequest: cpu: 100m memory: 256Mi # Load it, then point the cluster at it oc create -f template.yaml -n openshift-config template.template.openshift.io/project-request created oc patch project.config.openshift.io/cluster –type=merge -p ‘{"spec":{"projectRequestTemplate":{"name":"project-request"}}}’ project.config.openshift.io/cluster patched

Note the services.loadbalancers: ‘0’ line. On TKGI, NCP turned a Service of type LoadBalancer into an NSX-T virtual server automatically and teams got used to asking for them freely. On OCP with vSphere there is no such watcher, those Services sit pending forever, and a hard zero turns a confusing wait into an immediate and readable rejection. Part 13 covers what to use instead.

Step 3. Watch what happens without the LimitRange. This is the failure that costs migrations their afternoon, so build it deliberately once in a scratch Project. A ResourceQuota that names requests.cpu or requests.memory requires every incoming container to make an explicit request for those resources, and the same applies to limits. Manifests lifted off TKGI almost never carry them.

oc apply -f wave1/ -n mig-payments deployment.apps/payments-web created service/payments-web created oc get pods -n mig-payments No resources found in mig-payments namespace. oc describe rs -n mig-payments payments-web-6b9f7c4d8 | tail -5 Events: Type Reason Age From Message —- —— —- —- ——- Warning FailedCreate 14s replicaset-controller Error creating: pods ‘payments-web-6b9f7c4d8-x2kqp’ is forbidden: failed quota: compute-resources: must specify limits.cpu,limits.memory,requests.cpu,requests.memory # Fix. Apply the LimitRange, then let the ReplicaSet retry on its own. oc apply -f default-limits.yaml -n mig-payments limitrange/default-limits created oc get pods -n mig-payments NAME READY STATUS RESTARTS AGE payments-web-6b9f7c4d8-x2kqp 1/1 Running 0 22s payments-web-6b9f7c4d8-9wfmc 1/1 Running 0 22s

Step 4. Decide about self-provisioner, and read this before you strip it. Nearly every OpenShift hardening guide tells you to remove the self-provisioner cluster role from system:authenticated:oauth so that developers cannot create Projects. During a migration that advice is wrong, and it is wrong for a reason worth stating plainly. A migration wave creates Projects in bursts of twenty or thirty, and routing every one of them through a platform ticket queue turns a two hour cutover into a two day one. Once the quota and the LimitRange live inside the template, a self served Project is no longer an ungoverned Project. Leave the role in place until the last wave lands, then remove it if your auditors insist.

Step by Step, MachineSets and Infra Nodes on vSphere

Step 5. Build three infra MachineSets. A MachineSet is the OpenShift object that declares a pool of identically shaped nodes and asks the Machine API to keep that many alive. It is the closest thing to a TKGI plan worker definition, except it belongs to the cluster rather than to a tenant. Red Hat recommends at least three infrastructure MachineSets in a production deployment, and machines that host only infrastructure components are excluded from the total subscription count. That second sentence is the one your finance team cares about.

apiVersion: machine.openshift.io/v1beta1 kind: MachineSet metadata: labels: machine.openshift.io/cluster-api-cluster: ocp1-7t2xk name: ocp1-7t2xk-infra namespace: openshift-machine-api spec: replicas: 3 selector: matchLabels: machine.openshift.io/cluster-api-cluster: ocp1-7t2xk machine.openshift.io/cluster-api-machineset: ocp1-7t2xk-infra template: metadata: labels: machine.openshift.io/cluster-api-cluster: ocp1-7t2xk machine.openshift.io/cluster-api-machine-role: infra machine.openshift.io/cluster-api-machine-type: infra machine.openshift.io/cluster-api-machineset: ocp1-7t2xk-infra spec: metadata: labels: node-role.kubernetes.io/infra: "" providerSpec: value: apiVersion: machine.openshift.io/v1beta1 kind: VSphereMachineProviderSpec credentialsSecret: name: vsphere-cloud-credentials userDataSecret: name: worker-user-data diskGiB: 120 memoryMiB: 24576 numCPUs: 8 numCoresPerSocket: 4 network: devices: – networkName: ocp-prod-pg snapshot: ” template: ocp1-7t2xk-rhcos workspace: datacenter: DC1 datastore: /DC1/datastore/vsanDatastore folder: /DC1/vm/ocp1-7t2xk resourcepool: /DC1/host/Cluster1/Resources/ocp-prod server: vcenter01.corp.local taints: – key: node-role.kubernetes.io/infra effect: NoSchedule value: reserved

Step 6. Apply it and watch the first clone fail. Roughly half the vSphere MachineSets I have written failed on their first apply, and the cause was always a path in the workspace block or the template name. Red Hat Enterprise Linux CoreOS (RHCOS) machines are cloned from a vCenter template the installer left behind, and the name is not the one you guessed.

oc create -f infra-machineset.yaml machineset.machine.openshift.io/ocp1-7t2xk-infra created oc get machines -n openshift-machine-api -l machine.openshift.io/cluster-api-machine-role=infra NAME PHASE TYPE REGION ZONE AGE ocp1-7t2xk-infra-2xhqp 8m ocp1-7t2xk-infra-c4vjt 8m ocp1-7t2xk-infra-lm7rk 8m # Empty PHASE for eight minutes means the controller never got as far as cloning. oc -n openshift-machine-api logs deploy/machine-api-controllers -c machine-controller | tail -2 E0812 09:14:22 controller/machine Reconciler error error=ocp1-7t2xk-infra-2xhqp: reconciler failed to Create machine: unable to obtain template: ocp1-7t2xk-rhcos not found under folder /DC1/vm/ocp1-7t2xk # Find the real template name in vCenter, then correct the MachineSet. govc find /DC1/vm -type m -name ‘*rhcos*’ /DC1/vm/ocp1-7t2xk/ocp1-7t2xk-rhcos-generated-region-zone oc get machines -n openshift-machine-api -l machine.openshift.io/cluster-api-machine-role=infra NAME PHASE TYPE REGION ZONE AGE ocp1-7t2xk-infra-8qd2v Running 6m ocp1-7t2xk-infra-pw4kt Running 6m ocp1-7t2xk-infra-vz9nc Running 6m

Step 7. Move the router and the registry onto them. Labelling nodes does nothing on its own. Nothing relocates until you set nodePlacement on the component itself, and if you applied the NoSchedule taint above you must also give the component a matching toleration. Miss the toleration and the router pods sit Pending while you stare at three idle infra nodes.

One more object is worth building while you are here, and most teams discover it late. A node carrying the infra label still belongs to the worker MachineConfigPool, which means every configuration change aimed at workers reboots your routers alongside your application nodes. Create a MachineConfigPool named infra whose machineConfigSelector matches both the worker and infra roles and whose nodeSelector matches the infra label. Custom pools inherit machine configs targeted at workers, so you lose nothing, and you gain the ability to drain and update infrastructure nodes on a schedule that does not collide with a migration wave. Expect one reboot when the pool is created, because the Machine Config Operator renders a fresh config for it and the member nodes apply it. Do that reboot on an empty cluster in week one, not on the morning your ingress is carrying production traffic.

The arithmetic behind this is the reason to bother. Our reference estate ran three TKGI clusters, each with its own three node control plane. Consolidating onto one OpenShift cluster deletes six control plane virtual machines outright, and moving the router, registry and monitoring stack onto tainted infra nodes takes three more machines out of the subscription count.

Virtual machine count, before and after consolidationReference estate: TKGI dev, staging and prod versus one OCP 4.18 cluster on vSphere08162492014303Control planeBillable workersInfra nodesTKGI, three clustersOpenShift, one cluster
Six control plane machines disappear on consolidation. Three more leave the subscription count once the router, registry and monitoring move to tainted infra nodes.

Step by Step, Autoscaling Without Surprise Node Churn

Step 8. Create both objects, because one alone does nothing. Autoscaling on OpenShift is two resources, not one. A ClusterAutoscaler sets cluster wide ceilings. A MachineAutoscaler binds those ceilings to a specific MachineSet. Define a ClusterAutoscaler without any MachineAutoscaler and the cluster will never scale, silently and with no error to read. That trips people who came from a platform where scaling was a single field on a plan.

apiVersion: autoscaling.openshift.io/v1 kind: ClusterAutoscaler metadata: name: default spec: podPriorityThreshold: -10 resourceLimits: maxNodesTotal: 24 cores: min: 24 max: 256 memory: min: 96 max: 1024 scaleDown: enabled: true delayAfterAdd: 30m delayAfterDelete: 10m delayAfterFailure: 30s unneededTime: 30m utilizationThreshold: ‘0.4’ — apiVersion: autoscaling.openshift.io/v1beta1 kind: MachineAutoscaler metadata: name: worker-ocp1 namespace: openshift-machine-api spec: minReplicas: 3 maxReplicas: 14 scaleTargetRef: apiVersion: machine.openshift.io/v1beta1 kind: MachineSet name: ocp1-7t2xk-worker oc create -f autoscaling.yaml clusterautoscaler.autoscaling.openshift.io/default created machineautoscaler.autoscaling.openshift.io/worker-ocp1 created oc get machineautoscaler -n openshift-machine-api NAME REF KIND REF NAME MIN MAX AGE worker-ocp1 MachineSet ocp1-7t2xk-worker 3 14 31s

Two numbers above are deliberate departures from the documented defaults. Red Hat ships unneededTime at 10m and delayAfterAdd at 10m. Both are too eager during a migration, because a Velero restore can leave a node quiet for eleven minutes while persistent volume claims bind, and a scale down in that window destroys work you cannot cheaply repeat. Thirty minutes on both costs you a handful of idle virtual machines and saves you a restarted wave. Also check that maxNodesTotal of 24 covers every machine in the cluster and not just the pool you are scaling, because it counts control plane and infra too.

Step 9. Leave the installer created MachineSets alone below their floor. Do not set minReplicas to 0 on the compute MachineSets the installer created during an installer provisioned deployment. On other platforms zero is a legitimate value and on vSphere it is accepted, which is exactly why people set it and then wonder why cluster components lost their homes.

Recommendation, and it costs you money on purpose: do not enable scale down at all during an active migration wave. Set scaleDown.enabled to false, run the wave with a manually scaled pool, verify every restore, then turn scale down back on. Autoscaler evictions and Velero restores compete for the same pods, and the autoscaler always wins the race in the least useful way. Two weeks of extra worker capacity is cheaper than one repeated stateful cutover.

Verification, Rollback and Failure Remediation

Run all four checks before you hand a single Project to a team. Green means a quota that shows usage rather than zeros, a ClusterResourceQuota with your Projects listed under it, three infra nodes carrying the infra role, and a MachineAutoscaler bound to a real MachineSet.

oc describe quota compute-resources -n mig-payments Name: compute-resources Namespace: mig-payments Resource Used Hard ——– —- —- limits.cpu 1 40 limits.memory 1Gi 80Gi pods 2 40 requests.cpu 200m 20 requests.memory 512Mi 40Gi # One ceiling across an entire team estate, selected by Project label oc create clusterresourcequota team-payments –project-label-selector=team=payments –hard=pods=120 –hard=requests.cpu=60 oc describe AppliedClusterResourceQuota -n mig-payments Name: team-payments Label Selector: team=payments Resource Used Hard ——– —- —- pods 2 120 requests.cpu 200m 60 oc get nodes -l node-role.kubernetes.io/infra NAME STATUS ROLES AGE VERSION ocp1-7t2xk-infra-8qd2v Ready infra,worker 22m v1.31.9 ocp1-7t2xk-infra-pw4kt Ready infra,worker 22m v1.31.9 ocp1-7t2xk-infra-vz9nc Ready infra,worker 22m v1.31.9 oc get pod -n openshift-ingress -o wide | awk ‘{print $1, $7}’ NAME NODE router-default-64c8b7d9f5-4kzqw ocp1-7t2xk-infra-8qd2v router-default-64c8b7d9f5-t9xmn ocp1-7t2xk-infra-pw4kt

Rollback is genuinely cheap here, which is unusual in this series. Deleting a ResourceQuota releases every pending workload immediately with no restart. Reverting the project request template is a single patch that sets projectRequestTemplate back to an empty object, and existing Projects keep the quota they were born with. Only the MachineSet work needs care: scale an infra MachineSet to 0 before deleting it, confirm the router and registry pods have rescheduled onto ordinary workers, and only then remove the object. Delete a populated MachineSet and you will watch three nodes drain at once while your ingress has nowhere to land.

What you seeCauseRemediation
failed quota: must specify limits.cpu,limits.memoryQuota names a compute resource and the container declares noneApply a LimitRange with default and defaultRequest, then let the ReplicaSet retry
Machine stuck with an empty PHASEWrong template name or workspace path in providerSpecRead machine-controller logs, confirm the path with govc find, correct and recreate the MachineSet
Nodes never scale up under pressureClusterAutoscaler exists but no MachineAutoscaler annotates a MachineSetCreate a MachineAutoscaler per pool and confirm oc get machineautoscaler lists it
Scale up stops well below maxReplicasmaxNodesTotal counts control plane and infra machines tooRaise maxNodesTotal above the whole machine count, not just the scaling pool
Router pods Pending after nodePlacementInfra taint applied without a matching toleration on the IngressControllerAdd the toleration for key node-role.kubernetes.io/infra with value reserved
Service of type LoadBalancer stays pendingNo NCP equivalent on OCP vSphere to program a virtual serverSet services.loadbalancers to 0 in quota and route traffic per Part 13
New Projects appear with no quotaprojectRequestTemplate unset, so the API server used its invented defaultPatch project.config.openshift.io/cluster and recreate one test Project to confirm

Sized Projects and a Node Supply That Answers

My worst afternoon with this material cost forty minutes and taught me the sequence in the diagram above. We were moving wave one of a payments estate, 22 Deployments lifted straight off a TKGI cluster. I had written a careful ResourceQuota, felt pleased about it, and shipped it in the project request template without a LimitRange. Nineteen of the 22 Deployments produced zero pods. No CrashLoopBackOff, no scheduling event, no pods at all, because the rejection happens at pod creation and lives on the ReplicaSet where nobody looks first. I spent most of that forty minutes convinced it was an SCC problem, since restricted-v2 is the usual suspect and I had been burned by it before. It was not. One LimitRange, six minutes to replay the wave, and every remaining Project inherited the fix automatically because the template carried it.

My recommendation is to treat the quota, the LimitRange, the RoleBindings and the label that a ClusterResourceQuota selects on as a single unit that only ever ships inside the project request template. Never create a Project by hand during a migration, not even a test one, because a hand made Project is the one that later fails in a way nobody can explain. Pair that with three infra MachineSets built before the first workload lands and a scale down setting you deliberately turn off for the duration of each wave. The option to avoid is the tidy looking one: locking down self-provisioner on day one and creating Projects through a request queue. It feels governed and it quietly adds a day to every wave.

A clean result looks like this: a new Project created by an ordinary developer arrives with a ResourceQuota, a LimitRange and an admin RoleBinding already attached. Three infra nodes carry the router and registry and appear in no subscription count. One MachineAutoscaler is bound to the worker MachineSet with scale down disabled until the last wave lands. And oc describe quota in any Project shows real usage rather than a column of zeros.

On Monday, run oc adm create-bootstrap-project-template -o yaml against your own cluster and diff the generated template against whatever your team has been creating Projects with. If the two differ, you already have Projects in flight with no ceiling on them, and the fix takes an hour before it takes a week. Part 15 wires OpenShift OAuth to the same LDAP directory that UAA has been reading, and decides what happens to Harbor. If you are still weighing OpenShift against VMware vSphere Kubernetes Service as your landing place, the TKGI to VKS guide covers that alternative, and the target reference architecture in Part 11 is where the node counts in this Part came from.

TKGI to OpenShift Series · Part 14 of 26
« Previous: Part 13  |  Guide  |  Next: Part 15 »

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