The Node API still carries the field this post is about, still documents its four status subfields in the present tense, and the kubelet stopped reading any of it three years ago¶
Post — Dynamic Kubelet Configuration,
2018-07-11, by Michael Taufen (Google). Kubernetes 1.11, part of the 1.11 in-depth series. Fifty
lines and 4,447 bytes, the smallest walk in this year.
This is also the only post in the 2018 census whose first line is a retraction. The pinned copy opens with an editor's note that was added later: "The feature has been removed in the version 1.24 after deprecation in 1.22." So unlike every other exercise here, the reader is told the answer before the argument. What the note does not tell them is that the API is still there.
As written — a problem, a mechanism, four features and a pointer.
The problem is stated in one paragraph and it is a real one. "Most Kubernetes installations, however, run the Kubelet as a native process on each host, outside the scope of standard Kubernetes APIs." So administrators "could not rely on Kubernetes APIs to reconfigure Kubelets in a live cluster," and were left with three bad options: "either ssh into machines to perform manual reconfigurations, use third-party configuration management automation tools, or create new VMs with the desired configuration already installed, then migrate work to the new machines. These approaches are environment-specific and can be expensive."
The mechanism builds on something that had just shipped. "Kubernetes v1.10 made it possible to configure the Kubelet via a beta [config file] API. Kubernetes already provides the ConfigMap abstraction for storing arbitrary file data in the API server." So: put the config file in a ConfigMap, and "extend the Node object so that a Node can refer to a ConfigMap that contains the same type of config file."
Four core features are listed:
- the kubelet uses the dynamically assigned configuration;
- it "checkpoints" that configuration to local disk, "enabling restarts without API server access";
- it "reports assigned, active, and last-known-good configuration sources in the Node status";
- and on invalid configuration it "automatically falls back to a last-known-good configuration and reports errors in the Node status."
Then the workflow, which is the post's central instruction and the thing the exercise turns on: post a ConfigMap, "then set each Node.Spec.ConfigSource.ConfigMap reference to refer to the new ConfigMap. Operators can update these references at their preferred rate, giving them the ability to perform controlled rollouts of new configurations."
The rest is the state machine. Each kubelet watches its own Node object. On a change to
Node.Spec.ConfigSource.ConfigMap it writes the ConfigMap's files to local disk, "will then exit,
and the OS-level process manager will restart it." On restart it validates; on success it updates
Node.Status.Config; on failure it "will fall back to its last-known-good configuration and report
an error in Node.Status.Config." And if the reference is not set at all, "the Kubelet uses the set
of flags and config files local to the machine it is running on."
One precedence rule is stated explicitly, and it is worth holding onto: "Command-line flags that overlap with the config file always take precedence over both the local configuration file and dynamic configurations, for backwards-compatibility."
The post closes with a diagram and a single pointer: "Please see the official tutorial at /docs/tasks/administer-cluster/reconfigure-kubelet/, which contains more in-depth details on user workflow, how a configuration becomes 'last-known-good,' how the Kubelet 'checkpoints' config, and possible failure modes."
As it runs now — the feature is gone, the API is not, and the API's documentation has not caught up in either direction.
node.spec.configSource is still a field in the core v1 API. It is at
node-v1.md:72-73, typed NodeConfigSource, in the Node spec table, and its description is a single
sentence: "Deprecated: Previously used to specify the source of the node's configuration for the
DynamicKubeletConfig feature. This feature is removed." Past tense, no ambiguity, and still
present in the schema of the pin's newest release. That is the whole exercise in one line: the
post's central instruction — set this field — still applies cleanly and does nothing.
The status half of the same feature was never marked deprecated at all. Fifty-seven lines down
the same file, node-v1.md:129-130 documents node.status.config, typed NodeConfigStatus, and its
description reads: "Status of the config assigned to the node via the dynamic Kubelet config
feature." Present tense. No "Deprecated:" prefix. No note. One page, two halves of one feature,
and only the half you write to says the feature is gone.
Three different deprecation phrasings appear on that page for one feature. :73 says "This
feature is removed." ConfigMapNodeConfigSource at :224 says "This API is deprecated since 1.22"
and links the KEP. NodeConfigSource at :353 says "This API is deprecated since 1.22" with no
link. And :130 says nothing. Removed, deprecated-since, deprecated-since, and silent — for four
parts of the same thing, in one generated reference.
The state machine is still documented in full working detail. NodeConfigStatus at
node-v1.md:368-391 is not a stub. It carries all four subfields the post's third bullet promises
— active, assigned, lastKnownGood, error — each with a paragraph in the present tense
describing behaviour that no longer occurs. assigned explains that "When Node.Spec.ConfigSource
is updated, the node checkpoints the associated config payload to local disk, along with a record
indicating intended config." error explains which failures roll back and which do not: "Earlier
errors (e.g. download or checkpointing errors) will not result in a rollback to LastKnownGood, and
may resolve across Kubelet retries. Later errors (e.g. loading or validating a checkpointed config)
will result in a rollback to LastKnownGood." And lastKnownGood supplies a number the post did not
give: "This is currently implemented as a 10-minute soak period starting when the local record of
Assigned config is updated. If the Assigned config is Active at the end of this period, it becomes
the LastKnownGood."
That last paragraph then gives advice about the future: "You should not make assumptions about the node's method of determining config stability and correctness, as this may change or become configurable in the future." It is warning the reader against depending on an implementation detail of a feature that has no implementation. This is a better and more complete description of how dynamic kubelet configuration worked than the post itself contains, and it is sitting in the current API reference of a release that cannot do it.
The post's one pointer is dead, and it never worked as a link. The path
/docs/tasks/administer-cluster/reconfigure-kubelet/ does not exist in the pinned tree. That is
expected for a removed feature. What is not expected: grep the whole pinned checkout — docs and
every blog post from 2015 to 2026 — for the string reconfigure-kubelet and you get exactly one
hit, this post's own line 50. The tutorial the post calls "official" has left no trace anywhere,
including in the redirect and reference machinery that usually catches this. And look at how the
post wrote it: as bare text in a sentence, not as a markdown link. It was never clickable, so it
never showed up as a broken link to any checker. A pointer that was already only a sentence became
a sentence about nothing.
What replaced it is a flag and a directory on the node. kubelet.md:186-189 documents
--config-dir: "Path to a directory containing drop-in configuration files that override settings
from defaults and the --config file… Drop-in files must have a '.conf' suffix (e.g.,
'99-kubelet-address.conf') and are processed in lexical order." The task page for it is
kubelet-config-file.md:103-144, with the example --config-dir=/etc/kubernetes/kubelet.conf.d
(:107) and a transition note that dates it: "For Kubernetes v1.28 to v1.29, you can only specify
--config-dir if you also set the environment variable KUBELET_CONFIG_DROPIN_DIR_ALPHA for the
kubelet process (the value of that variable does not matter)" (:109-111).
Set that against the post's opening. The problem it names is that operators had to "ssh into
machines to perform manual reconfigurations" or "use third-party configuration management
automation tools." The pin's answer to the same problem is a directory of .conf files on each
machine, populated by whatever put them there. The project accepted the problem and rejected the
API-shaped solution: reconfiguring a kubelet at the pin means changing files on the host, exactly
as it did before v1.11, with the improvement being that the files now compose.
The pin contradicts itself, twice bolded, about how those files compose.
kubelet-config-file.md:117-118 says: "The kubelet processes files in its config drop-in directory
by sorting the entire file name alphanumerically." Twenty-five lines later, :142-143, inside a
note comparing the mechanism to kubeadm's: "The kubelet determines the order of merges based on
sorting the suffixes alphanumerically, and replaces every field present in a higher priority
file." Entire file name, or suffixes. Both emphasised in the source. Both on the same page.
The tree cannot settle it. kubelet.md:189 — the flag's own help text — says only "processed in
lexical order" and declines to say lexical in what, while adding a fact the task page never
mentions: "All .conf files in the directory and its subdirectories are processed in lexical order."
And both statements on the task page point the reader at
/docs/reference/node/kubelet-config-directory-merging/ for "more information"; that page exists,
runs 155 lines, and covers only how data types merge — structure fields, lists, maps. It contains
no occurrence of "sort", "order", "suffix" or "file name". The two contradictory sentences send you
to a page that does not discuss ordering.
The post's precedence rule is still right, with one carve-out it could not have predicted. Post:
command-line flags "always take precedence over both the local configuration file and dynamic
configurations." kubelet-config-file.md:133-136 gives the pin's merge order, lowest first:
- "Feature gates specified over the command line (lowest precedence)."
- "The kubelet configuration."
- "Drop-in configuration files, according to sort order."
- "Command line arguments excluding feature gates (highest precedence)."
So command-line arguments are still highest — the post's "always" holds — except for
--feature-gates, which is now the lowest precedence input, below the config file it used to
override. The word doing the work in that list is "excluding". One flag was carved out of a rule
the post states without exception, and the carve-out is only visible from the parenthetical.
One artifact of the feature is still in the kubelet's own config API.
kubelet-config.v1beta1.md:1820-1838 documents SerializedNodeConfigSource, whose stated purpose
is "SerializedNodeConfigSource allows us to serialize v1.NodeConfigSource" — the type that exists
to write the removed field to disk during the checkpointing step. Its source field links out to
kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#nodeconfigsource-v1-core. That
version in the path is not a comment on this type: all four of the page's cross-references into the
core API name v1.36, in a pin whose newest release is v1.37, so the generated page trails the
tree it ships in by one release across the board.
And the thing the post was built on won outright. The post's second section credits "a beta
config file API" shipped in v1.10. At the pin, that config file is how the kubelet is configured:
kubelet.md marks 105 flags DEPRECATED, and 100 of those 105 carry the same appended sentence —
"(DEPRECATED: This parameter should be set via the config file specified by the Kubelet's --config
flag. See https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/ for more
information.)" — and --config itself (:179-182) is the flag everything else defers to. The ladder below has what
happened to its gate, which is not what happened to the file.
The diff, and why — this is a plan the project abandoned, and it is the cleanest example of that case in the year, because the abandonment is so precisely partial. The problem was accepted. The mechanism was rejected. The API surface was left behind.
Take the three pieces separately, because they came apart.
The problem — kubelets are configured outside the API, so fleet reconfiguration is
environment-specific and expensive — was never disputed and is still real. The pin's answer to it
exists and is documented: --config-dir, a drop-in directory, a defined merge order. That answer
is not API-centric. It requires something to place files on each host, which is the third-party
configuration management the post lists as one of the bad options. So the project agreed the
problem was worth solving and disagreed that the API server should be the mechanism.
The mechanism — a Node field pointing at a ConfigMap, checkpointed to disk, with a
lastKnownGood fallback and a 10-minute soak — was built, shipped as beta, defaulted on for
eleven releases, deprecated, and removed from the kubelet. The ladder below shows that the whole
arc took twenty-two releases. This is not a feature that failed to land.
The API surface is still here, and this is where the post becomes a trap rather than a
historical note. node.spec.configSource exists in the current v1 Node schema. It is writable.
kubectl patch node will accept it, the API server will store it, kubectl get node -o yaml will
show it back to you, and no kubelet anywhere will read it. The post's central instruction — "set
each Node.Spec.ConfigSource.ConfigMap reference to refer to the new ConfigMap" — succeeds, returns
no error, produces no warning, and has no effect. A field that still applies and does nothing is
worse than a field that was deleted, because a deleted field tells you.
So the mechanism by which this post misleads is not that the docs are out of date. It is that the
docs are out of date in one direction only. The spec field says the feature is removed; the
status field describes it in the present tense; the status subfields describe it in more detail
than the post does, including a number the post omits, plus advice about how it might change in
future. If you read the pin from the status side — which is what you do when you are debugging a
node and run kubectl get node -o yaml — everything you find says this works.
Release facts and the pressure behind each are in
research/blog-era-translation.md.
The ladder — two gates. Unlike the IPVS exercise next door, this post's own gate is not transcribed anywhere else in the archive, so it gets it here, along with the gate for the config file it was built on.
DynamicKubeletConfig
| stage | default | locked | releases |
|---|---|---|---|
| alpha | false |
— | v1.4 – v1.10 |
| beta | true |
— | v1.11 – v1.21 |
| deprecated | false |
— | v1.22 – v1.25 |
KubeletConfigFile
| stage | default | locked | releases |
|---|---|---|---|
| alpha | false |
— | v1.8 – v1.9 |
| deprecated | — | — | v1.10 – v1.10 |
Both files declare removed: true. Neither declares former_titles. KubeletConfigFile's single
deprecated row has no defaultValue at all, which is why the default column is empty
rather than false; the transcription prints the absence rather than guessing at it.
Four readings.
The default flipped inside the deprecation, and that is the whole shape of a retreat.
DynamicKubeletConfig is beta with defaultValue: true for eleven releases, v1.11 through
v1.21, and this post is written in the first of them. Then one row later it is deprecated with
defaultValue: false. A repeated stage in a gate file marks a default change; here the change and
the stage change land together, so the same table records both "we turned this on for everyone" and
"we turned it off for everyone" in adjacent rows. Eleven releases is a long time to be on by
default. This was not an experiment that never caught on; it was a shipped default that was
withdrawn.
The gate file's body disagrees with the gate file's own stages. The body reads: "Enable the
dynamic configuration of kubelet. The feature is no longer supported outside of supported skew
policy. The feature gate was removed from kubelet in 1.24." The stages: list carries the
deprecated row to toVersion: "1.25". Cite both and say they disagree: by the body the kubelet
stopped accepting the gate at v1.24, by the stages the gate is documented as existing through
v1.25. The census row for this post takes the body's number, and the table above takes the stages'.
Both are in the same file, eleven lines apart.
The alpha ran for seven releases before the beta this post announces. v1.4 to v1.10 is longer than the whole life of most gates in the tree. Read that against the post's confidence: the four core features are stated as accomplished facts, and by v1.11 they had been in the tree, off by default, for seven releases. The post is not announcing new code. It is announcing that a default changed.
KubeletConfigFile shows the opposite outcome in two rows. The gate for the config file went
alpha at v1.8, and at v1.10 its next and last row is deprecated with no default value. The post
says "Kubernetes v1.10 made it possible to configure the Kubelet via a beta config file API" — and
what actually happened at v1.10 is that the gate was retired, because the config file stopped
being optional. A gate deprecated at v1.10 and a mechanism that is now the only supported way to
configure a kubelet are the same event seen from two sides. DynamicKubeletConfig's gate went to
deprecated because the feature was being taken away; KubeletConfigFile's went to deprecated
because the feature had won. The stage name does not tell you which. That is the reading worth
carrying out of this exercise: deprecated in a gate file is a statement about the switch, not
about the feature, and the two gates in this one table point in opposite directions.
The KEP for the removal is linked from the pinned tree itself, at node-v1.md:224:
git.k8s.io/enhancements/keps/sig-node/281-dynamic-kubelet-configuration. That number is quoted
from the tree, not from memory, and it is the only KEP link on the Node reference page.
Topology — pair: a control-plane node at
10.10.10.130 and a worker at 10.10.10.131. Two nodes are the point. The post's selling
proposition is not "configure a kubelet"; it is "update these references at their preferred rate,
giving them the ability to perform controlled rollouts." A controlled rollout needs somewhere to
roll to. With two nodes you can set the dead field on one and not the other and watch both kubelets
behave identically, and then do the replacement properly with --config-dir and see exactly how
much per-host work the API was meant to remove — twice, by hand, over SSH.
Bring it up with the five provision steps, substituting
topology=pair, then the node baseline on
both, then ssh zain@10.10.10.130.
What this cannot show: whether the feature ever worked. Nothing in the lab can exercise the
checkpoint, the soak period or the lastKnownGood rollback, because no kubelet in any release you
can install from pkgs.k8s.io still contains that code. Every step below that touches
configSource is testing the API server's willingness to store the field, which is a different
and much smaller claim than the post's. Say so in your notes; the exercise is about the gap between
a schema and an implementation, not about the implementation.
Do
- Confirm the field is still in the schema your cluster is serving. Three ways of asking, because they can disagree:
kubectl explain node.spec.configSource
kubectl explain node.status.config
kubectl get --raw /openapi/v2 | python3 -c 'import sys,json; d=json.load(sys.stdin)["definitions"]; print(json.dumps(d["io.k8s.api.core.v1.NodeConfigSource"], indent=2))'
- Read what your cluster says about each half, and compare the wording to the pin:
kubectl explain node.spec.configSource | sed -n '1,12p'
kubectl explain node.status.config | sed -n '1,12p'
kubectl explain node.status.config.lastKnownGood
kubectl explain node.status.config.error
- Ask the kubelet whether it still has the gate. Do this on both nodes:
sudo journalctl -u kubelet --no-pager | grep -ci 'DynamicKubeletConfig' || true
ps -o args= -C kubelet | tr ' ' '\n' | grep -- '--config\|--feature-gates'
sudo kubelet --feature-gates=DynamicKubeletConfig=true --help 2>&1 | head -3
- Build the post's ConfigMap exactly as instructed — a kubelet config file stored in the API. This part still works, because it is only a ConfigMap:
sudo cat /var/lib/kubelet/config.yaml | head -20
kubectl -n kube-system create cm kubelet-config-attempt \
--from-literal=kubelet="$(printf 'apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nmaxPods: 42\n')"
kubectl -n kube-system get cm kubelet-config-attempt -o jsonpath='{.data.kubelet}{"\n"}'
kubectl -n kube-system get cm kubelet-config-attempt -o jsonpath='{.metadata.uid}{"\n"}'
- Now the post's central step, on the worker only. Record what the API server does with it:
UID=$(kubectl -n kube-system get cm kubelet-config-attempt -o jsonpath='{.metadata.uid}')
kubectl patch node k8s-worker --type merge -p "$(printf '{"spec":{"configSource":{"configMap":{"name":"kubelet-config-attempt","namespace":"kube-system","uid":"%s","kubeletConfigKey":"kubelet"}}}}' "$UID")"
echo "exit=$?"
kubectl get node k8s-worker -o jsonpath='{.spec.configSource}{"\n"}'
- Look for every signal the API is supposed to give you when you write to something dead:
kubectl patch node k8s-worker --type merge -p '{"spec":{"configSource":null}}' >/dev/null
kubectl patch node k8s-worker --type merge -p "$(printf '{"spec":{"configSource":{"configMap":{"name":"kubelet-config-attempt","namespace":"kube-system","uid":"%s","kubeletConfigKey":"kubelet"}}}}' "$(kubectl -n kube-system get cm kubelet-config-attempt -o jsonpath='{.metadata.uid}')")" 2>&1 | tee /tmp/patch-out
grep -ci 'warn\|deprecat' /tmp/patch-out || echo 'no warning of any kind'
kubectl get --raw '/api/v1/nodes/k8s-worker' -v=6 2>&1 | grep -i 'warning' || echo 'no Warning header'
- Wait out the post's state machine and see whether any of it happens:
kubectl get node k8s-worker -o jsonpath='{.status.config}{"\n"}'
kubectl get node k8s-worker -o jsonpath='{.status.config.assigned}{"\n"}'
kubectl get node k8s-worker -o jsonpath='{.status.config.error}{"\n"}'
kubectl get node k8s-worker -o jsonpath='{.status.nodeInfo.kubeletVersion}{"\n"}'
kubectl get node k8s-worker -o jsonpath='{.status.capacity.pods}{"\n"}'
- Confirm the field survives a kubelet restart untouched — the post says a change triggers a checkpoint and an exit, so a restart is the moment the mechanism would fire:
Then on the worker:
ssh zain@10.10.10.131 'sudo systemctl restart kubelet; sleep 15; sudo journalctl -u kubelet --since "-1min" --no-pager | grep -ci "configSource\|checkpoint\|lastKnownGood" || echo 0'
kubectl get node k8s-worker -o jsonpath='{.spec.configSource.configMap.name}{"\n"}'
kubectl get node k8s-worker -o jsonpath='{.status.capacity.pods}{"\n"}'
- Prove the ConfigMap is inert by breaking it. If anything were reading it, this is where the
lastKnownGoodfallback andstatus.config.errorwould appear:
kubectl -n kube-system patch cm kubelet-config-attempt --type merge \
-p '{"data":{"kubelet":"this is not yaml at all: [["}}'
ssh zain@10.10.10.131 'sudo systemctl restart kubelet; sleep 15; systemctl is-active kubelet'
kubectl get node k8s-worker -o jsonpath='{.status.config.error}{"\n"}'
kubectl get node k8s-worker -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'
-
Now do it the way the pin says to. Set
maxPodson the worker through the replacement mechanism, and count the steps it takes:ssh zain@10.10.10.131 'sudo mkdir -p /etc/kubernetes/kubelet.conf.d && \ printf "apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nmaxPods: 42\n" \ | sudo tee /etc/kubernetes/kubelet.conf.d/99-maxpods.conf' ssh zain@10.10.10.131 'sudo grep -n "config-dir" /var/lib/kubelet/kubeadm-flags.env /etc/systemd/system/kubelet.service.d/*.conf 2>/dev/null || echo "--config-dir is not set anywhere"' -
Set the flag, since nothing set it for you. This is the per-host manual step the post's opening paragraph exists to eliminate:
ssh zain@10.10.10.131 'sudo sed -i "s|KUBELET_KUBEADM_ARGS=\"|KUBELET_KUBEADM_ARGS=\"--config-dir=/etc/kubernetes/kubelet.conf.d |" /var/lib/kubelet/kubeadm-flags.env && cat /var/lib/kubelet/kubeadm-flags.env' ssh zain@10.10.10.131 'sudo systemctl restart kubelet; sleep 20; systemctl is-active kubelet' kubectl get node k8s-worker -o jsonpath='{.status.capacity.pods}{"\n"}' kubectl get node k8s-cp -o jsonpath='{.status.capacity.pods}{"\n"}' -
Settle the ordering contradiction on your own node, since the tree will not. Two files that differ in the prefix and two that differ in the suffix, and see which pair changes the outcome:
ssh zain@10.10.10.131 'cd /etc/kubernetes/kubelet.conf.d && \ printf "apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nmaxPods: 11\n" | sudo tee 10-aaa.conf >/dev/null && \ printf "apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nmaxPods: 22\n" | sudo tee 20-aaa.conf >/dev/null && \ sudo rm -f 99-maxpods.conf && ls' ssh zain@10.10.10.131 'sudo systemctl restart kubelet; sleep 20' kubectl get node k8s-worker -o jsonpath='{.status.capacity.pods}{"\n"}'Then invert it, so the prefixes sort one way and the suffixes the other:
ssh zain@10.10.10.131 'cd /etc/kubernetes/kubelet.conf.d && \ sudo rm -f 10-aaa.conf 20-aaa.conf && \ printf "apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nmaxPods: 33\n" | sudo tee 10-zzz.conf >/dev/null && \ printf "apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nmaxPods: 44\n" | sudo tee 20-aaa.conf >/dev/null && \ sudo systemctl restart kubelet; sleep 20' kubectl get node k8s-worker -o jsonpath='{.status.capacity.pods}{"\n"}' -
Test the other undocumented half of the flag's help text — subdirectories, which the task page never mentions:
ssh zain@10.10.10.131 'cd /etc/kubernetes/kubelet.conf.d && sudo rm -f *.conf && sudo mkdir -p sub && \ printf "apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nmaxPods: 55\n" | sudo tee sub/50-nested.conf >/dev/null && \ sudo systemctl restart kubelet; sleep 20' kubectl get node k8s-worker -o jsonpath='{.status.capacity.pods}{"\n"}' -
Test the precedence carve-out. A flag beats the config file, and
--feature-gatesdoes not:ssh zain@10.10.10.131 'sudo grep -o "\-\-[a-z-]*" /var/lib/kubelet/kubeadm-flags.env | sort -u' ssh zain@10.10.10.131 'cd /etc/kubernetes/kubelet.conf.d && \ printf "apiVersion: kubelet.config.k8s.io/v1beta1\nkind: KubeletConfiguration\nfeatureGates:\n CustomCPUCFSQuotaPeriod: true\n" | sudo tee 60-gates.conf >/dev/null && \ sudo sed -i "s|KUBELET_KUBEADM_ARGS=\"|KUBELET_KUBEADM_ARGS=\"--feature-gates=CustomCPUCFSQuotaPeriod=false |" /var/lib/kubelet/kubeadm-flags.env && \ sudo systemctl restart kubelet; sleep 20; systemctl is-active kubelet' kubectl get --raw "/api/v1/nodes/k8s-worker/proxy/configz" | python3 -m json.tool | grep -A4 featureGates -
Read the config the kubelet actually ended up with, which is the one answer none of the twelve steps above could give you directly:
kubectl get --raw "/api/v1/nodes/k8s-worker/proxy/configz" | python3 -c 'import sys,json; c=json.load(sys.stdin)["kubeletconfig"]; print("maxPods:", c.get("maxPods")); print("featureGates:", c.get("featureGates"))' kubectl get --raw "/api/v1/nodes/k8s-cp/proxy/configz" | python3 -c 'import sys,json; c=json.load(sys.stdin)["kubeletconfig"]; print("maxPods:", c.get("maxPods"))' kubectl get node k8s-worker -o jsonpath='{.spec.configSource}{"\n"}'
Expect
Step 1 is the exercise in three lines. kubectl explain node.spec.configSource returns the field
and its "This feature is removed." description; kubectl explain node.status.config returns the
field and describes it as live. The OpenAPI definition for NodeConfigSource is present with its
configMap subfield, because it has to be — the field is part of the served v1 schema, and
removing it would be a breaking API change. That is why it is still here, and it is the honest
reason: the feature was removable, the field was not.
Step 2 should show you the asymmetry in your own cluster's output rather than in the pin's markdown.
lastKnownGood will hand you the 10-minute soak paragraph. Read it and note that it is a claim
about running code, delivered by a v1.37 API server, about behaviour last present in a v1.23
kubelet.
Step 3 should find zero mentions of the gate in the journal and a kubelet whose --help either
rejects DynamicKubeletConfig as an unrecognised gate or ignores it. Either outcome makes the same
point; record which one you got, because "unrecognised" and "silently ignored" are different
failure modes and the gate file's own body claims the former from v1.24.
Step 4 all works. Nothing in it touches the removed feature: it is a ConfigMap with some YAML in it,
and ConfigMaps are fine. Keep the UID — step 5 needs it, because ConfigMapNodeConfigSource
requires uid and kubeletConfigKey, which is itself worth noticing: the API still validates the
shape of a reference nothing will follow.
Step 5 is the moment. kubectl patch prints node/k8s-worker patched, exits 0, and
{.spec.configSource} reads the value back to you in full. If your node is named differently,
substitute; kubectl get nodes has the names. This is the post's central instruction and it has
just succeeded.
Step 6 is the search for a warning, and it comes up empty. No Warning: line in the patch output,
no Warning header in the raw request, no deprecation notice, no admission rejection. Compare this
to what the API server does when you use a genuinely deprecated API version — it emits a
Warning header the client prints — and note that a deprecated field gets none of that
machinery. That asymmetry is the whole reason this post is a trap rather than a curiosity.
Step 7: status.config is empty. Not "reporting an error", not "assigned", empty — the kubelet
never writes it, because the code that wrote it is gone. capacity.pods is whatever your kubelet's
real config says, which for a kubeadm node is 110, not the 42 in the ConfigMap.
Step 8 finds no checkpoint directory under /var/lib/kubelet and no journal lines about
checkpointing, and configSource is still set on the Node afterwards, unchanged. The post says a
change to this reference makes the kubelet write files to disk and exit. The kubelet restarts
cleanly and does neither. capacity.pods is unchanged.
Step 9 is the negative control and the most useful step for a sceptical reader. You have now put
syntactically invalid YAML in the ConfigMap that the Node's configSource points at. If any part
of the mechanism were live, the kubelet would fail validation, roll back to lastKnownGood, and
write status.config.error. Instead: kubelet active, status.config.error empty, Node Ready.
Nothing read the file.
Step 10 shows that --config-dir is not set on a kubeadm node — kubeadm does not configure a
drop-in directory — so the file you just placed does nothing either, for a completely different
reason. Two inert config sources on the same node, one because the feature was removed and one
because the flag was never set.
Step 11 is where the real work happens, and where you should count. You edited
kubeadm-flags.env, restarted a service, and did it over SSH, on one host. capacity.pods on the
worker becomes 42; on the control-plane node it stays at its default. That difference is the
"controlled rollout" the post promised through the API, achieved by doing exactly the thing the
post's first paragraph describes as expensive. The feature was removed; the need was not.
Step 12 is the experiment the documentation cannot answer for you. With 10-aaa.conf and
20-aaa.conf both rules agree and you get 22. With 10-zzz.conf and 20-aaa.conf they disagree:
sorting by entire file name processes 10-zzz then 20-aaa and yields 44; sorting by
suffix processes aaa then zzz and yields 33. Whichever number you get, you have just
settled which of kubelet-config-file.md:117-118 and :142-143 is correct at your version, and
the other one is a documentation bug. Write down the number and the version, because that is a
finding, not an exercise answer.
Step 13 should give you 55, matching kubelet.md:189's "directory and its subdirectories" — and if
it does, note that the task page's entire drop-in section describes a flat directory and never
mentions subdirectories at all. The flag's generated help text is more accurate than the
hand-written task page.
Step 14 tests the parenthetical. CustomCPUCFSQuotaPeriod is set true in a drop-in file and
false on the command line. By the post's rule the flag wins and configz reports false; by
kubelet-config-file.md:133-136 the command line is the lowest precedence input for feature
gates, so the file's true should win. Read the answer out of configz.
That gate is chosen for three reasons, and they are the reasons to check before substituting
another one. Its defaultValue is false and it is still alpha at the pin, with a stages: list
of one row — alpha, false, from v1.12, no toVersion — so true is the non-default value and
a true in configz can only have come from the drop-in file. It is kubelet-scoped, so the
kubelet will accept it and the API server never sees it. And it is inert: it only permits setting
cpuCFSQuotaPeriod away from the kernel default, which nothing here does, so enabling it changes
no behaviour on the node. If the file's true survives, the post's "always take precedence" now
has an exception, and that is the finding.
Step 15 is the reconciliation. configz is the only interface that tells you what the kubelet is
actually running, and it exists on the Node proxy — reachable through the API server, which is
mildly ironic given the post's thesis. Compare its maxPods against the four places you tried to
set it: the ConfigMap in configSource (ignored), the drop-in files (honoured, once the flag was
set), the flags file, and the control-plane node's untouched default. And read .spec.configSource
one last time: still set, still stored, still doing nothing.
Read on — three questions the pinned tree can answer, and one it cannot.
node-v1.md:72says "This feature is removed" and:129says nothing at all, for two halves of one feature in one generated reference. Both descriptions come from Go doc comments on thecore/v1types, so the difference is in the source, not in the site build. Read the two descriptions againstConfigMapNodeConfigSourceat:224andNodeConfigSourceat:353, both of which say "This API is deprecated since 1.22" — and ask which of the four phrasings a client-side deprecation checker could act on.kubelet-config-file.md:117-118and:142-143contradict each other about drop-in ordering, and the reference page they both cite covers only data-type merging. Read/docs/reference/node/kubelet-config-directory-merging/in full — it is 155 lines, three sections, all about structure fields, lists and maps — and ask what the page would need to say for either of the two contradictory sentences to be checkable against it.DynamicKubeletConfig's gate body says the gate "was removed from kubelet in 1.24" while itsstages:list runs thedeprecatedrow through v1.25. Compare that againstKubeletConfigFile, whose two-row ladder ends atdeprecatedfor the opposite reason, and ask whatdeprecatedin a gate file's stage list is a statement about — the switch, the feature, or the support commitment.- The unanswerable one. The pinned tree does not say why
node.spec.configSourceis still in the schema. The API-compatibility rule is the obvious explanation and it is a good one:v1is stable, so a field cannot be removed, only emptied of meaning. But that explanation predicts a symmetric treatment — both halves of the feature marked the same way, with the same words, at the same time — and that is not what happened. There is no page in the tree recording a decision about how to document a field that must remain but must not be used, no convention stated for the wording, and no index of such fields. So what cannot be settled here is whether the asymmetry between:72and:129is a deliberate distinction (you write to spec, so spec is where the warning belongs) or simply the half that nobody edited.SerializedNodeConfigSourceatkubelet-config.v1beta1.md:1820-1838, still documented and still pointing at a v1.36 URL, is weak evidence for the second reading, and this exercise cannot get past weak.
Sibling exercises worth reading first, both backward. The kubelet config file that this post builds
on, and where kubeadm actually writes it on a node, is Do-step material in the node-dashboard
exercise
(../2016/12-visualize-kubelet-performance-with-node-dashboard.md),
which is the fastest way to get oriented on the file before step 4 here. And the pattern of a
deprecated gate row that means two opposite things is the same reading problem as the two IPVS
gates facing opposite directions, worked out in the exercise next door
(04-ipvs-in-cluster-load-balancing.md) — read the two
ladders together and the stage names stop being self-explanatory.
Teardown — undo the node edits before the objects, because a kubelet with a bad drop-in file and no drop-in directory is harder to reason about than one that never had either:
ssh zain@10.10.10.131 'sudo rm -rf /etc/kubernetes/kubelet.conf.d && \
sudo sed -i "s|--config-dir=/etc/kubernetes/kubelet.conf.d ||; s|--feature-gates=CustomCPUCFSQuotaPeriod=false ||" /var/lib/kubelet/kubeadm-flags.env && \
cat /var/lib/kubelet/kubeadm-flags.env && sudo systemctl restart kubelet'
kubectl patch node k8s-worker --type merge -p '{"spec":{"configSource":null}}'
kubectl -n kube-system delete cm kubelet-config-attempt --ignore-not-found
kubectl get node k8s-worker -o jsonpath='{.status.capacity.pods}{"\n"}'
rm -f /tmp/patch-out
Check capacity.pods is back to the kubeadm default and both nodes agree before you finish; if the
worker is still reporting 42 or 55, the sed did not match and kubeadm-flags.env still has
--config-dir in it. Then
tear the topology down.