Automatic worker scaling¶
A worker pool that grows when pods cannot be scheduled and shrinks when nodes sit
idle, without anyone running kubectl scale.
CAPVCO does not implement an autoscaler. It uses the upstream
cluster-autoscaler
with its clusterapi cloud provider, which scales a MachineDeployment by
writing .spec.replicas — and CAPVCO already turns a replica change into a VM.
What this guide covers is the three things that are ours: the template flavour
that marks a pool as autoscalable, the deployment shape that keeps credentials
where they belong, and the capacity CAPVCO publishes so a pool can come back from
zero.
The short version¶
# 1. A cluster whose worker pool is autoscalable (min 1, max 5).
CLUSTER_NAME=demo \
VCO_LOCATION=<location> VCO_IMAGE_ID=<id> VCO_EXTNET_ID=<id> \
KUBERNETES_VERSION=v1.35.8 \
WORKER_MIN_COUNT=1 WORKER_MAX_COUNT=5 \
clusterctl generate cluster demo --flavor autoscaling > demo.yaml
kubectl apply -f demo.yaml
# 2. The autoscaler for it, in the MANAGEMENT cluster.
CLUSTER_NAME=demo make autoscaler | kubectl apply -f -
Both halves are required. The template alone changes nothing: the annotations it stamps are inert until an autoscaler reads them.
No checkout? Render it from the release
make autoscaler needs the repository. The same manifest ships as a release
asset with its placeholders intact, so it can be rendered in place — the same
convention as the CSI add-on:
curl -fsSL https://gitlab.whitesky.cloud/api/v4/projects/730/packages/generic/capvco/v0.6.1/autoscaler.yaml \
| CLUSTER_NAME=demo NAMESPACE=default \
AUTOSCALER_IMAGE=registry.k8s.io/autoscaling/cluster-autoscaler:v1.35.2 \
AUTOSCALER_SCALE_DOWN_UNNEEDED_TIME=10m \
AUTOSCALER_SCALE_DOWN_DELAY_AFTER_ADD=10m \
AUTOSCALER_SCALE_DOWN_UTILIZATION=0.5 \
AUTOSCALER_MAX_NODE_PROVISION_TIME=30m \
AUTOSCALER_LOG_LEVEL=2 \
envsubst '$CLUSTER_NAME $NAMESPACE $AUTOSCALER_IMAGE $AUTOSCALER_SCALE_DOWN_UNNEEDED_TIME $AUTOSCALER_SCALE_DOWN_DELAY_AFTER_ADD $AUTOSCALER_SCALE_DOWN_UTILIZATION $AUTOSCALER_MAX_NODE_PROVISION_TIME $AUTOSCALER_LOG_LEVEL' \
| kubectl apply -f -
Every variable must be given a value: envsubst renders an unset one as
empty, which here produces things like --v= or a container with no image.
make autoscaler supplies these defaults for you, which is why it is the
recommended route when you have a checkout.
What the flavour actually changes¶
--flavor autoscaling is cluster-template.yaml with two differences on the
worker MachineDeployment:
metadata:
annotations:
cluster.x-k8s.io/cluster-api-autoscaler-node-group-min-size: "1"
cluster.x-k8s.io/cluster-api-autoscaler-node-group-max-size: "5"
spec:
# no `replicas:` — deliberately
The annotations are how cluster-autoscaler discovers a node group at all.
WORKER_MAX_COUNT is your cost ceiling: the autoscaler will never go past it, so
size it against your backend quota rather than your optimism.
The missing replicas is not an oversight. replicas is the field the
autoscaler writes. If the manifest also declared it, every re-apply of the
rendered YAML — a GitOps sync, a kubectl apply to change something unrelated —
would reset the pool and the autoscaler would scale it again. Nodes flap, and
nothing in either system reports a problem. Leaving it out hands ownership over
cleanly.
An unset replicas is well-defined: Cluster API's own webhook defaults it from
these annotations — min-size for a new MachineDeployment, and otherwise the
previous value clamped into [min, max]. So the pool starts at WORKER_MIN_COUNT.
--worker-machine-count does nothing on this flavour
clusterctl generate cluster --worker-machine-count 3 sets
WORKER_MACHINE_COUNT, which this flavour does not use. Set
WORKER_MIN_COUNT / WORKER_MAX_COUNT instead.
Where the autoscaler runs, and why it matters¶
It runs in the management cluster, in the workload cluster's namespace — not
as a ClusterResourceSet add-on like csi or gpu.
That is a security decision, not a packaging one. The autoscaler needs two connections:
| Flag | Cluster | What it does there |
|---|---|---|
--cloud-config |
management | reads and scales MachineDeployments |
--kubeconfig |
workload | watches Pending pods and idle Nodes |
Running it inside the workload cluster is possible, and it would fit our add-on
pattern neatly — but it means placing a management-cluster credential inside a
tenant cluster, one that can scale and delete Machines for every cluster the
management cluster owns. So it runs on the management side, where the workload
kubeconfig it needs is the one Cluster API already mints
(<cluster>-kubeconfig). CAPVCO creates no credential of its own for this.
Passing only --kubeconfig looks like it works
If --cloud-config is omitted it does not fall back to in-cluster
config — it falls back to the --kubeconfig value. The autoscaler then looks
for MachineDeployments inside the workload cluster, finds none, and sits
there scaling nothing, with no error. make autoscaler passes both
explicitly.
The rendered manifest is one ServiceAccount, a namespaced Role (the CAPI node
groups plus read access to vcomachinetemplates), a ConfigMap holding a
kubeconfig that points at the pod's own ServiceAccount, and a single-replica
Deployment. Leader election is off and the strategy is Recreate, because two
autoscalers scaling one pool is worse than a few seconds of none.
Scaling from zero¶
WORKER_MIN_COUNT=0 is allowed, and it is the interesting case: a pool that
costs nothing while idle. It needs one thing that only the infrastructure
provider can supply.
With at least one replica running, the autoscaler learns a pool's shape by reading a real Node. At zero replicas there is no Node, so it cannot tell whether adding one would help the pending pod — and a pool at zero would never come back. The Cluster API contract's answer is for the provider to publish the shape on the machine template, which CAPVCO's manager does:
$ kubectl get vcomachinetemplate demo-md-0 -o jsonpath='{.status}' | jq
{
"capacity": {
"cpu": "2",
"memory": "4Gi",
"ephemeral-storage": "40Gi",
"pods": "110"
},
"nodeInfo": { "architecture": "amd64", "operatingSystem": "linux" }
}
It is derived from the template's own vcpus / memoryMiB / bootDiskGiB — no
backend call, no credential.
Two honest caveats:
- This is the VM's capacity, not the node's allocatable. A real node reports less, because the kernel, the kubelet and system reservations take their cut first. So the autoscaler models a slightly roomier node than it gets, which can cost one wasted scale-up for a pod that only just fits. Every Cluster API provider has this wart; understating it instead would refuse scale-ups that would have worked.
- A GPU pool only scales from zero when the vendor is named. The autoscaler
needs the extended resource name the device plugin will advertise
(
nvidia.com/gpu), and that is vendor-specific. CAPVCO publishes it whenspec.gpu.typenames the vendor; when the pool is pinned byprofileId, or asks for a vendor not yet supported (#36), the GPU is deliberately left out of the capacity. The pool then simply does not scale up from zero for a GPU pod — a visible no-op, rather than repeatedly building nodes the pod still cannot use.
Scale-down, and the ways it can hurt¶
Scale-up is the easy direction. Scale-down removes a node that is currently running pods, after cordoning and draining it, and that is where the sharp edges are.
The shipped defaults are conservative:
| Flag | Default here | Why |
|---|---|---|
--scale-down-unneeded-time |
10m |
a whitesky.cloud VM takes minutes to build; churn is expensive |
--scale-down-delay-after-add |
10m |
do not trim a pool that just grew |
--max-node-provision-time |
30m |
the upstream 15m gives up while the VM is still legitimately booting, and the autoscaler then marks the pool unhealthy |
--skip-nodes-with-local-storage |
true |
never evict a pod whose data is on the node |
--skip-nodes-with-system-pods |
true |
never remove a node running a non-DaemonSet kube-system pod |
Override any of them via the make autoscaler variables (AUTOSCALER_*).
A pool cannot shrink past the kube-system pods it hosts¶
--skip-nodes-with-system-pods=true means the autoscaler will never remove a
node running a non-DaemonSet pod in kube-system. That is the right default —
the alternative lets it evict cluster infrastructure at will — but it has a
consequence that surprises people:
In a CAPI cluster the control plane is tainted, so coredns can only run on
workers. At its default two replicas with anti-affinity it lands one per worker,
which makes every worker unremovable and means the pool never shrinks at all.
So before expecting scale-down to do anything:
# Confine coredns so it pins one worker instead of all of them.
kubectl -n kube-system scale deploy/coredns --replicas=1
and for a pool that must reach zero, coredns (and anything else like it)
has to be able to live somewhere else entirely — a small always-on pool, or a
control-plane toleration. A pool hosting kube-system Deployments has a hard floor
of one node no matter what WORKER_MIN_COUNT says, and the only symptom is an
autoscaler log line saying the node is unremovable.
The same applies to any workload of your own in kube-system.
Pod disruption budgets are your responsibility¶
The autoscaler drains through the Eviction API, so a PodDisruptionBudget is
honoured — and a workload without one can be evicted to zero replicas mid-drain.
If an application must not lose all of its pods at once, give it a PDB. The
autoscaler will refuse to remove a node rather than violate one.
RWO volumes: read this before enabling scale-down on a stateful cluster¶
A pod with a ReadWriteOnce CSI volume is tied to the node its disk is attached to. Scale-down evicts it, the disk must detach from the removed VM, and the rescheduled pod must attach it elsewhere.
On this platform that path is not yet safe, for reasons tracked in
#22:
provisioned PVs carry no nodeAffinity, and the CSI driver reports no
PUBLISH_UNPUBLISH_VOLUME, so Kubernetes' attach/detach tracking never engages.
A rescheduled pod can hang on a volume the old node has not cleanly released,
with a low-signal mount error rather than a clean Multi-Attach failure.
Until #22 is fixed, for a pool running stateful workloads either:
- keep
--skip-nodes-with-local-storage=true(the default) and give stateful pods a PDB that prevents eviction, or - run stateful workloads in a separate, non-autoscaled
MachineDeployment— the autoscaler only touches pools carrying the annotations.
MachineHealthChecks¶
The flavour keeps the worker MachineHealthCheck from the base template. The two
do not conflict — the MHC remediates unhealthy nodes, the autoscaler removes
idle ones — but note that the MHC's unhealthyLessThanOrEqualTo: 40% is a
percentage of a pool whose size now moves. On a pool that has scaled down to one
node, a single unhealthy node is 100% and remediation stops, by design.
Picking the autoscaler image¶
cluster-autoscaler is released in lockstep with Kubernetes: use the image whose minor matches your workload cluster.
CLUSTER_NAME=demo \
AUTOSCALER_IMAGE=registry.k8s.io/autoscaling/cluster-autoscaler:v1.36.1 \
make autoscaler | kubectl apply -f -
The default is pinned in the Makefile. Note that at the time of writing there
is no 1.37 release, so a Kubernetes 1.37 cluster should run the 1.36 image.
Checking it works¶
$ kubectl -n default logs deploy/demo-autoscaler | grep -i 'node group'
I0915 ... Found multiple availability zones... node group: MachineDeployment/default/demo-md-0 (min: 1, max: 5, replicas: 1)
A wedged autoscaler still reports Ready
The provider builds an informer for every node-group kind on the API server —
MachineDeployments, MachineSets, Machines and MachinePools — then blocks
waiting for all of them to sync. If the ServiceAccount cannot list one of
them, that reflector retries forever, the main loop never starts, and nothing
scales. Meanwhile the pod is Running, 1/1, the Deployment is Available
and kubectl rollout status passes. The only evidence is the log stopping at:
So when nothing is scaling, check for denials first:
The shipped Role covers this; a hand-edited one may not.
If no node group is found, in order of likelihood: the annotations are missing
(wrong flavour), the --node-group-auto-discovery cluster name or namespace does
not match, or the Role is missing the machinedeployments/scale subresource — a
scale-up then fails with a 403 that appears only in this log.
To watch a scale-up:
What is validated¶
The autoscale scenario in hack/e2e-feature-matrix.sh runs on every merge
request against a real backend: it provisions an autoscaling cluster, asserts the
pool starts at its annotated minimum and that the VCOMachineTemplate publishes
capacity, deploys the shipped autoscaler manifest (not a test copy), makes a pod
Pending for lack of CPU, and asserts the pool grows, the new worker joins and runs
the pod, and then shrinks back to its minimum with the Node removed.
It then drives the pool to zero and back: the floor is moved to 0 live,
coredns is evicted so nothing pins the last worker, and a Pending pod has to
bring the pool back from nothing. That leg is the one that actually exercises the
provider-side contract — with no Node left to measure, the only thing that can
tell the autoscaler a new node would fit is
VCOMachineTemplate.status.capacity. A wrong resource name or capacity that is
never published passes every other assertion and fails only there.
Still uncovered: geo clusters have a worker pool per site, each needing its own annotations, and that combination is untested.