The closing promise of this post is now a documented pattern, every command in it fails, and the error it prints survives in the current documentation in three incompatible forms¶
Post — Autoscaling in Kubernetes, 2016-07-12, Kubernetes v1.3, part of Five days of Kubernetes 1.3. Two authors from Google, and the subject is two autoscalers used together: the new Cluster Autoscaler adding nodes, and the Horizontal Pod Autoscaler adding pods.
As written — the pitch is cost, stated twice, and both statements are the reason this post is worth walking:
Kubernetes will automatically scale up your cluster as soon as you need it, and scale it back down to save you money when you don't.
Ideally, we would want the number of nodes in the cluster and the number of pods in deployment to dynamically adjust to the load to meet end user demand. The new Cluster Autoscaling feature together with Horizontal Pod Autoscaler can handle this for you automatically.
The cluster half is GCE-specific and built by the in-tree script:
export NUM\_NODES=2
export KUBE\_AUTOSCALER\_MIN\_NODES=2
export KUBE\_AUTOSCALER\_MAX\_NODES=5
export KUBE\_ENABLE\_CLUSTER\_AUTOSCALER=true
The kube-up.sh script creates a cluster together with Cluster Autoscaler add-on. The autoscaler will try to add new nodes to the cluster if there are pending pods which could schedule on a new node.
The pod half is a workload created imperatively, in one command that also creates its Service:
$ kubectl run php-apache \
--image=gcr.io/google\_containers/hpa-example \
--requests=cpu=500m,memory=500M --expose --port=80
service "php-apache" createddeployment "php-apache" created
then an autoscaler over it:
This defines a Horizontal Ppod Autoscaler that maintains between 1 and 10 replicas of the Pods controlled by the php-apache deployment […] the horizontal autoscaler will increase and decrease the number of replicas (via the deployment) so as to maintain an average CPU utilization across all Pods of 50% (since each pod requests 500 milli-cores by kubectl run, this means average CPU usage of 250 milli-cores)
$ kubectl get hpa
NAME REFERENCE TARGET CURRENT MINPODS MAXPODS AGE
php-apache Deployment/php-apache/scale 50% 0% 1 20 14s
Load is generated from a shell in a busybox pod, the HPA reaches 310% and seven replicas, three of
the seven go Pending, and the post reads the reason off the pod:
1m {default-scheduler } Warning FailedScheduling pod (php-apache-2046965998-3ewo6) failed to fit in any node
fit failure on node (kubernetes-minion-group-yhdx): Insufficient CPU
fit failure on node (kubernetes-minion-group-de5q): Insufficient CPU
1m {cluster-autoscaler } Normal TriggeredScaleUp pod triggered scale-up, mig: kubernetes-minion-group, sizes (current/new): 2/3
A node appears, the seven pods run, the load stops, the HPA returns to one replica, and after "approximately 10-12 minutes" the node is removed. The post ends on the cost argument again: "Having machines that do nothing is a waste of money."
As it runs now — nothing the post types survives, and the first thing to establish is why the
post never mentions installing anything to measure CPU. In 2016 kube-up.sh installed the metrics
add-on along with the cluster, so "we need to wait a moment (about one minute) for stats to
propagate" is the only trace of a whole component. At the pin the metrics pipeline is a
prerequisite you satisfy yourself, and the successor page says so before its first command:
To follow this walkthrough, you also need to use a cluster that has a Metrics Server deployed and configured.
A cluster with no metrics-server still accepts a HorizontalPodAutoscaler and reports its own failure in a status condition — which is the one place this post's subject gained a feedback path where 06's lost one.
The component the post is silently relying on is Heapster, which it never names, and which the
pinned tree has not finished forgetting: system:heapster is still listed as a default ClusterRole
in the RBAC reference, annotated "Role for the Heapster component (deprecated)" and linking a
repository, and three other pages still print sample output showing a heapster pod or service
running. A retired component's authorization role outliving the component is the same kind of
residue as the --storage-driver-db flag, which the pin's kubectl run and kubectl autoscale
both still accept, defaulting to "cadvisor" — cAdvisor storage-driver plumbing from this post's era
hanging off the two commands this post runs.
The command-line breakage, in order:
kubectl run --requests=is gone. The pin'skubectl runhas fifty-nine flags and--requestsis not among them; neither is--limits.--exposeand--portboth survive, and--exposenow reads "If true, create a ClusterIP service associated with the pod. Requires --port" — with the pod, because as 04 established,kubectl runmakes a Pod and no longer makes a Deployment. So the post's single command has to fail: it names a flag that does not exist, and if it did exist the object it created would be the wrong kind for thekubectl autoscale deploymenton the next line.--cpu-percentwas renamed.kubectl autoscaleat the pin takes--cpu,--memory,--min,--maxand--name.--cpu-percentis absent, and its replacement is not a rename but a widening:--cpuaccepts both forms — "When specified as a percentage (e.g."70%" for 70% of requested CPU) it will target average utilization. When specified as quantity (e.g."500m" for 500 milliCPU) it will target average value." The post could only express a ratio. And--memoryis new: the post's autoscaler had one signal.- The output columns changed shape. The post prints
TARGETandCURRENTas separate columns; the pin prints oneTARGETcolumn holding0% / 50%, and addsREPLICAS. - Two of the post's three
kubectl get hpasamples contradict its own command. It runs--max=10and then printsMAXPODS 20, twice, before printing10in the final sample. That was wrong on the day it published and no rung of Kubernetes has anything to do with it. - The markdown is damaged in the same way 05
is.
NUM\_NODES,KUBE\_AUTOSCALER\_MIN\_NODES,google\_containers— every underscore inside a fenced block is escaped, so the environment variables and the image path are both unusable as printed. The post also renders "version v1.3.0", a version string turned into a hyperlink to a hostname that never existed, and "Horizontal Ppod Autoscaler". - The image path moved registries.
gcr.io/google_containers/hpa-exampleisregistry.k8s.io/hpa-examplein the pin's manifest — and that manifest carries no tag, which is the same omission the pin fixed in the load generator beside it, where the post's barebusyboxbecamebusybox:1.28. kube-up.shand the in-tree Cluster Autoscaler are not where the post left them. The pinned documentation still linkskubernetes/kubernetes/blob/master/cluster/kube-up.shfrom a kubectl verification page and still describes log rotation "In Kubernetes clusters created by thekube-up.shscript" — two references, no instructions, and nothing that would tell you whether the fourKUBE_AUTOSCALER_*variables are read by anything. Cluster Autoscaler itself now lives atgithub.com/kubernetes/autoscaler, outside the tree this pin covers.
The diff, and why — the post is the fifth case: overtaken by stasis in its documentation while its subject moved underneath. Three things are worth separating.
The thesis survived exactly and got a name. The pin has a section called Horizontal workload autoscaling on its node-autoscaling page whose closing sentence is the post's opening one:
If configured correctly, this pattern ensures that your application always has the Node capacity to handle load spikes if needed, but you don't have to pay for the capacity when it's not needed.
That is the rarest outcome in this whole census: the argument is unchanged, promoted from a release
announcement to a documented pattern, while every keystroke that demonstrated it broke. The
vocabulary did shift, and the pin records the shift as a note rather than leaving it implicit —
"Provisioning was formerly known as scale-up in Cluster Autoscaler." The post's TriggeredScaleUp
event names a concept that has since been renamed by the project that emitted it.
The error message is frozen in three eras, in four places, and the one you will see is in none of them. This is the finding to spend time on. Search the pinned documentation for the scheduler's refusal and you get two distinct historical forms:
pod (php-apache-...) failed to fit in any node/fit failure on node (X): Insufficient CPU— the post's own 2016 form, still printed verbatim in an extended-resource task page and a debug-running-pod page.No nodes are available that match all of the following predicates:: Insufficient cpu (3).— a later form, with a doubled colon, still printed in the assign-cpu-resource and assign-memory-resource task pages.
The current message is a third shape again: it aggregates by reason with counts rather than naming
each node, so a two-node cluster where one node is tainted reports one line, not two. Four task
pages, two dead formats, zero occurrences of the live one. The reader who follows this post will
produce an event that matches neither sample the documentation shows them, and the only way to find
that out is to run it. The pin's own HPA walkthrough carries the same kind of fossil: its
kubectl describe hpa sample is timestamped Fri, 16 Jun 2017, describes a ReplicationController,
and sits under a page whose reviewer list still includes this post's author.
The autoscaler gained the two things the post could not express, and one of them took twenty-one
releases. The post's autoscaler has three knobs — a target ratio, a minimum and a maximum — and
its cost argument logically ends at zero replicas, which it cannot request, because minReplicas: 0
was not accepted. It still is not accepted for a CPU-metric HPA:
Scaling to zero is only supported for object and external metrics. It is not available for resource metrics (such as CPU or memory utilization), because those can only be measured on running Pods. Setting
minReplicas: 0requires at least one object or external metric to be configured; the API server rejects the HorizontalPodAutoscaler otherwise.
| stage | default | locked | releases |
|---|---|---|---|
| alpha | false |
— | v1.16 – v1.36 |
| beta | true |
— | v1.37 – |
HPAScaleToZero sat at alpha, off by default, for twenty-one consecutive releases, and reached
beta at the pin's own version. It is the longest alpha in this year's set by a wide margin. Read
against the post that is the sharpest possible commentary on the cost pitch: the promise was
scale it back down to save you money, and the floor of one replica moved for the first time nine
years later, for metrics the post never mentions.
The second knob is the flapping the post walks past. It observes that node removal is slow — "Cluster Autoscaler makes sure that the node is really not needed so that short periods of inactivity (due to pod upgrade etc) won't trigger node deletion" — and does not say that the pod autoscaler has the same problem with the same shape of answer. It does, and the answer is now two fields:
For scaling down the stabilization window is 300 seconds […] For scaling up there is no stabilization window.
and a tolerance that was cluster-wide and is now per-object:
| stage | default | locked | releases |
|---|---|---|---|
| alpha | false |
— | v1.33 – v1.34 |
| beta | false |
— | v1.35 – v1.36 |
| stable | true |
true |
v1.37 – |
HPAConfigurableTolerance is the year's cleanest example of a shape worth naming: its beta rung
defaults to false. Beta is not the same as on. Two releases at beta with the gate off, then
stable and locked at v1.37 — and the default it makes configurable, "the default cluster-wide
tolerance of 10%", is the number that decided every scaling decision in the post without appearing
in it. The pin adds one more constraint worth reading twice: "(You can't use the Kubernetes API to
configure this default value.)"
One more ladder, for the granularity the post's whole-pod average could not express:
| stage | default | locked | releases |
|---|---|---|---|
| alpha | false |
— | v1.20 – v1.26 |
| beta | true |
— | v1.27 – v1.29 |
| stable | true |
— | v1.30 – v1.31 |
HPAContainerMetrics declares removed: true — retired because per-container targets became
unconditional, the same reason 06's NetworkPolicyEndPort
gate was retired, and the opposite reason from why NetworkPolicyStatus was.
Topology — pair, fresh. Two nodes is the post's own
shape and the reason is arithmetic: the worker has two cores, the pin's manifest requests 200m per
replica, and the post's --max=10 therefore cannot fit — so the Pending pods the post gets from a
five-node cloud cluster arrive on a two-node lab for free, and the wall is real rather than
contrived. What cannot arrive is the node that resolves them. Node autoscalers "need to interact
with cloud provider APIs to provision and consolidate Nodes […] they need to be explicitly
integrated with each supported cloud provider", and this lab has no cloud provider, so the second
half of the post's demonstration is unreachable here and unreachable anywhere in this curriculum.
That is the same line 06 draws at the policy plugin, drawn
one layer lower. Bring the topology up with
the five provision steps using topology=pair, install
Kubernetes with the node baseline procedure,
then ssh zain@10.10.10.130.
Do
- Before installing anything, ask the cluster for the measurement the post assumes it has:
Record what top says. This is the state the post's cluster was never in.
- Run the post's workload command exactly as the published page renders it, escapes included, into
a shell on the control plane. Then remove the four backslashes from the underscores and run it
again. Then remove
--requests=cpu=500m,memory=500Mand run it a third time, and inspect what you got:
Three attempts, three different outcomes. Name the kind that the third one created and say what the post's next command expects instead.
- Delete that and use the pin's manifest, reading it before you apply it:
kubectl delete pod php-apache --ignore-not-found
kubectl delete service php-apache --ignore-not-found
kubectl apply -f https://k8s.io/examples/application/php-apache.yaml
kubectl get deployment php-apache -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
Write down the image reference the pin ships and how it differs from the load-generator image on the same page.
- Create the autoscaler the post's way, then the pin's way, and then look at what was stored:
kubectl autoscale deployment php-apache --cpu-percent=50 --min=1 --max=10
kubectl autoscale deployment php-apache --cpu=50% --min=1 --max=10
kubectl get hpa php-apache -o yaml | head -20
The third command answers a question the first two do not raise: which API version the server returned, given that the pin's synopsis says the command "will attempt to use the autoscaling/v2 API first, in case of an error, it will fall back to autoscaling/v1 API."
- Read the autoscaler's own account of itself with no metrics available:
Compare your kubectl get hpa header against the post's and against the one the pin prints. Then
find the condition that is False and copy its reason and message verbatim.
- Install the metrics pipeline. It is out of tree and this exercise does not pin it: follow the link the pin itself gives, choose a release, and record which one you chose.
kubectl -n kube-system get deployment metrics-server
kubectl -n kube-system rollout status deployment/metrics-server --timeout=180s
If it does not become available, read its logs and record what it says — a kubeadm lab's kubelet serving certificates are the usual obstacle here, and the metrics-server README is the authority on it, not this repository.
- Re-ask both questions from steps 1 and 5, then start the load with both spellings:
kubectl top pods
kubectl describe hpa php-apache | sed -n '/Conditions/,$p'
kubectl run -i --tty service-test --image=busybox /bin/sh
kubectl run -i --tty load-generator --rm --image=busybox:1.28 --restart=Never -- /bin/sh -c "while sleep 0.01; do wget -q -O- http://php-apache; done"
The first run is the post's form and the second is the pin's. Record what the post's form does
before you use the pin's.
- In another shell, watch it climb into the wall:
kubectl get hpa php-apache --watch
kubectl get pods -l run=php-apache
kubectl describe pod -l run=php-apache | sed -n '/Events/,$p'
Copy the FailedScheduling message character for character. Then compare it with the post's
fit failure on node output and with the two other forms the pinned documentation prints, and
say which of the three it resembles most.
- Stop the load and time the descent, then take the brake off:
kubectl get hpa php-apache --watch
kubectl patch hpa php-apache --type merge -p '{"spec":{"behavior":{"scaleDown":{"stabilizationWindowSeconds":0}}}}'
Measure how long the first scale-down took against the documented 300 seconds, then raise the load, drop it, and measure again with the window at zero.
-
Ask for the two things the post could not ask for, and record which the API accepts:
kubectl patch hpa php-apache --type merge -p '{"spec":{"minReplicas":0}}' kubectl patch hpa php-apache --type merge -p '{"spec":{"behavior":{"scaleUp":{"tolerance":"0.05"}}}}' kubectl explain hpa.spec.behavior.scaleUp.toleranceOne of these is refused and the refusal is the point. Read the message and connect it to the reason the pin gives for the restriction.
Expect — step 1: two nodes Ready. kubectl top nodes fails, reporting that the metrics API is
not available; no metrics.k8s.io APIService is registered. The post has no equivalent moment
because its cluster arrived with the add-on.
Step 2: the escaped form fails before the API server — the shell sees google\_containers and
cpu=500m inside a flag the parser will reject anyway. Unescaped, the refusal is
unknown flag: --requests. Without that flag it succeeds and prints two lines, pod/php-apache
created and service/php-apache created. The kind is Pod, and kubectl autoscale deployment
php-apache will not find a Deployment.
Step 3: deployment.apps/php-apache created and service/php-apache created. The image is
registry.k8s.io/hpa-example with no tag; the load-generator image on the same page is
busybox:1.28.
Step 4: unknown flag: --cpu-percent. The pin's form returns
horizontalpodautoscaler.autoscaling/php-apache autoscaled. The stored object's apiVersion is
autoscaling/v2; note that the post's era's autoscaling/v1 is still served, and that the pin says
v2-only fields "are preserved as annotations when working with autoscaling/v1".
Step 5: the TARGET column reads <unknown>/50%. ScalingActive is False with a
FailedGetResourceMetric reason, and AbleToScale is True — the autoscaler can act and cannot
decide. Both the post's TARGET/CURRENT pair and the pin's single 0% / 50% cell differ from what
your kubectl prints; write down all three.
Step 6: whichever release you chose, the version is yours and not this repository's, which is the honest position for a component the pinned tree does not contain.
Step 7: kubectl top pods returns per-pod CPU; ScalingActive flips to True with
ValidMetricFound. The post's kubectl run … /bin/sh form is refused — the trailing command is not
separated by -- — and the pin's form gives you a shell that loops. Four differences separate those
two lines: the --, the --rm, the --restart=Never, and the pinned tag.
Step 8: the target climbs well past 50% and replicas rise toward 10. Some pods stay Pending. The
event aggregates its reasons with counts across both nodes and mentions the control plane's taint
alongside insufficient CPU; it names neither node individually. It does not resemble either sample
in the documentation. No TriggeredScaleUp event ever arrives, because nothing is watching for one:
the pods will stay Pending until you remove the load or the deployment.
Step 9: the first scale-down does not begin promptly; expect roughly five minutes of the highest recommendation in the window being held, which is the documented default. With the window at zero the descent starts on the next sync interval, which the pin gives as fifteen seconds.
Step 10: minReplicas: 0 is rejected by the API server, because this HPA's only metric is a
resource metric. The tolerance patch is accepted, and kubectl explain finds the field, because
the gate that carries it is stable and locked at this version.
Read on — the pin's
Horizontal Pod Autoscaling concept page,
the Algorithm details section. Read the paragraphs on missing metrics and not-yet-ready pods, and
answer one question with your step 8 output in hand: the controller "conservatively assumes that
the not-yet-ready pods are consuming 0% of the desired metric", and your Pending pods will never
become ready — so what does that do to the ratio, and does it push the replica count up or hold it?
Then read
Node Autoscaling
and answer a harder one: the page presents Cluster Autoscaler and Karpenter as interchangeable from
a cluster user's view, one working on pre-configured Node groups and the other auto-provisioning
individual machines — which of the post's four KUBE_AUTOSCALER_* variables has an equivalent in
each, and which has an equivalent in neither?
Teardown — kubectl delete hpa php-apache, then
kubectl delete -f https://k8s.io/examples/application/php-apache.yaml, then remove metrics-server
with whatever manifest you installed it from. Take the topology down with
teardown.