Summary

kubeadm is a setup tool, not a daemon. It generates config and hands off to the kubelet. On the CKA it shows up most in Cluster Architecture, Installation & Configuration (25%) — upgrade workflow and token regeneration are near-certain tasks.

Key concepts

The mental model: kubeadm writes files; the kubelet acts on them. Everything the control plane “runs” — apiserver, etcd, scheduler, controller-manager — the kubelet runs as static pods by watching /etc/kubernetes/manifests/. kubeadm writes those manifests and the PKI certs they reference, then exits. This explains most of kubeadm’s behavior: why bouncing static pods requires touching the manifests directory, why cert renewal doesn’t restart anything automatically, why kubeadm itself isn’t present after init.

Almost every subcommand supports phase-level execution (kubeadm <cmd> phase --help), which lets you run one step in isolation. Useful when something fails mid-init or mid-upgrade.

kubeadm init

Bootstraps the first control-plane node. Under the hood: generates the CA and all component certs under /etc/kubernetes/pki/, writes static pod manifests to /etc/kubernetes/manifests/ (kubelet picks these up immediately), creates the kubelet-config ConfigMap in kube-system, and prints a join command.

sudo kubeadm init \
  --pod-network-cidr=10.244.0.0/16 \
  --apiserver-advertise-address=10.0.0.10 \
  --control-plane-endpoint=10.0.0.10:6443 \
  --kubernetes-version=v1.35.0 \
  --upload-certs \
  --cri-socket=unix:///var/run/containerd/containerd.sock

--control-plane-endpoint should point to a load balancer or stable DNS — not the node IP — if there’s any chance of adding more control-plane nodes later. It can’t be cleanly retrofitted after init.

--upload-certs stores the control-plane certs encrypted in a Secret in kube-system so additional control-plane nodes can pull them during join --control-plane. The encryption key expires in ~2h.

Post-init, copy the kubeconfig so kubectl works as your user:

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

kubeadm join

Joins a node to the cluster. The mechanism differs between worker and control-plane nodes. For a worker: the kubelet presents the bootstrap token to the apiserver, gets a CSR signed (TLS bootstrapping), and from that point authenticates with its own client cert — the token is scaffolding it no longer needs. For an additional control-plane node: same flow, plus it pulls the control-plane certs from the Secret --upload-certs created, so it can serve the same CA-signed apiserver cert.

# Worker
sudo kubeadm join 10.0.0.10:6443 \
  --token abcdef.0123456789abcdef \
  --discovery-token-ca-cert-hash sha256:<hash>

# Additional control-plane node
sudo kubeadm join 10.0.0.10:6443 \
  --token <token> \
  --discovery-token-ca-cert-hash sha256:<hash> \
  --control-plane \
  --certificate-key <cert-key>

The CA cert hash pins which cluster the joining node is allowed to trust — it prevents a rogue apiserver from hijacking the join. Compute it manually if you lost the init output:

openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt \
  | openssl rsa -pubin -outform der 2>/dev/null \
  | openssl dgst -sha256 -hex | sed 's/^.* //'

kubeadm token

Bootstrap tokens are Kubernetes Secrets in kube-system with names like bootstrap-token-<id>. The apiserver has a token authentication plugin that reads these Secrets — no separate token service. They exist only to get a new kubelet through TLS bootstrapping; once it has a client cert, the token is irrelevant. That’s why 24h TTL is the right default: long enough to bootstrap, short enough to minimize the exposure window.

kubeadm token list
sudo kubeadm token create --ttl 2h
sudo kubeadm token delete <token>

The one to memorize — prints a complete, ready-to-paste join command:

sudo kubeadm token create --print-join-command

kubeadm upgrade

The upgrade sequence is ordered by dependency: the apiserver must be upgraded before any node-level component can talk to the new API. So control-plane first, then workers. The kubeadm binary must be upgraded before running it on each node because kubeadm reads its own version to know what to install and what manifests to generate — it can’t upgrade to a version it doesn’t know.

sudo kubeadm upgrade plan                 # read-only; shows current vs available
sudo kubeadm upgrade apply v1.35.1        # first control-plane node only — upgrades static pod manifests
sudo kubeadm upgrade node                 # every other node (additional control-plane nodes + workers)

upgrade apply rewrites the static pod manifests in /etc/kubernetes/manifests/. The kubelet notices and restarts the control-plane pods. upgrade node does the same for secondary control-plane nodes, plus syncs the kubelet-config ConfigMap down to /var/lib/kubelet/config.yaml (which upgrade apply doesn’t do on the first node, because it knows it’s writing that ConfigMap as part of the upgrade and will sync it on the next upgrade node call on workers).

Full upgrade sequence (memorize this rhythm):

# === First control-plane node ===

# Upgrade the kubeadm binary first
sudo apt-mark unhold kubeadm
sudo apt-get update && sudo apt-get install -y kubeadm=1.35.1-*
sudo apt-mark hold kubeadm

sudo kubeadm upgrade plan
sudo kubeadm upgrade apply v1.35.1

# Drain, upgrade kubelet, uncordon
kubectl drain <cp-node> --ignore-daemonsets
sudo apt-mark unhold kubelet kubectl
sudo apt-get update && sudo apt-get install -y kubelet=1.35.1-* kubectl=1.35.1-*
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload && sudo systemctl restart kubelet
kubectl uncordon <cp-node>

# === Each worker node (SSH into each) ===

sudo apt-mark unhold kubeadm
sudo apt-get update && sudo apt-get install -y kubeadm=1.35.1-*
sudo apt-mark hold kubeadm

sudo kubeadm upgrade node   # syncs kubelet config, upgrades local control-plane components if present

# Back on the control-plane node (or wherever kubectl is configured):
kubectl drain <worker> --ignore-daemonsets

# Back on the worker:
sudo apt-mark unhold kubelet kubectl
sudo apt-get update && sudo apt-get install -y kubelet=1.35.1-* kubectl=1.35.1-*
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload && sudo systemctl restart kubelet

# Back on control-plane:
kubectl uncordon <worker>

kubeadm certs

kubeadm is the CA for the cluster — it generated /etc/kubernetes/pki/ca.key and signed all component certs from it. certs renew re-signs certs against the same CA (which has a 10-year lifetime; component certs default to 1 year). Auto-renewal happens on upgrade apply.

sudo kubeadm certs check-expiration       # shows kubeadm-managed PKI only
sudo kubeadm certs renew all
sudo kubeadm certs renew apiserver        # renew a specific cert

After renewing, you must restart the static-pod control plane. The running apiserver/etcd/etc. processes opened their cert files at startup and hold those file descriptors — the renewed cert files on disk don’t take effect until the processes restart. The cleanest way is to move manifests out and back:

cd /etc/kubernetes/manifests
sudo mkdir -p /tmp/m && sudo mv *.yaml /tmp/m/ && sleep 20 && sudo mv /tmp/m/*.yaml .

Note: check-expiration only shows the kubeadm-managed PKI. Kubelet client/serving certs rotate separately via the CSR mechanism and won’t appear here.

kubeadm config

Mostly useful for templating and pre-pulling images before an air-gapped init:

kubeadm config print init-defaults        # print a full InitConfiguration/ClusterConfiguration template
sudo kubeadm config images list           # images kubeadm needs for this version
sudo kubeadm config images pull           # pre-pull them

kubeadm reset

Undoes what init/join did — cleans up manifests, certs, etcd member, kubelet state. Use before re-joining a broken node. Note it doesn’t clean CNI state or iptables rules, so do that manually:

sudo kubeadm reset
sudo rm -rf /etc/cni/net.d $HOME/.kube/config
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X

etcd backup & restore

Not a kubeadm subcommand, but kubeadm owns the etcd PKI so the certs are in a known place. etcdctl needs TLS creds to talk to etcd; these are always the same paths on a kubeadm cluster:

# Backup
sudo ETCDCTL_API=3 etcdctl snapshot save /opt/snapshot.db \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key

# Verify
sudo ETCDCTL_API=3 etcdctl snapshot status /opt/snapshot.db --write-out=table

# Restore to a new data dir
sudo ETCDCTL_API=3 etcdctl snapshot restore /opt/snapshot.db \
  --data-dir=/var/lib/etcd-restore

After restore, point the etcd static pod at the new data dir: edit /etc/kubernetes/manifests/etcd.yaml, update --data-dir and the hostPath volume to /var/lib/etcd-restore. The kubelet restarts etcd on the new data.

Paths reference

Path What it is
/etc/kubernetes/manifests/ Static pod manifests — kubelet watches this
/etc/kubernetes/pki/ Control-plane certs and keys (kubeadm’s CA lives here)
/etc/kubernetes/pki/etcd/ etcd-specific certs
/etc/kubernetes/admin.conf Cluster-admin kubeconfig
/var/lib/kubelet/config.yaml kubelet runtime config (synced from kubelet-config ConfigMap)
/var/lib/etcd/ etcd data directory (default)

Gotchas

Upgrade kubeadm binary before running upgrade commands on each node. kubeadm bootstraps itself — it needs to know the target version to generate correct manifests.

upgrade apply is first-control-plane-node-only; every subsequent node uses upgrade node. apply writes the new kubelet-config ConfigMap; upgrade node reads it. Running apply on a second control-plane node targets the wrong entrypoint and will either fail or mismatch state.

Drain before upgrading the kubelet, uncordon after. Restarting kubelet disrupts pods running on that node — drain ensures they’ve already been rescheduled elsewhere.

After certs renew, you must bounce the static pods. Renewing writes new files to disk. The running processes still have old file descriptors. They don’t notice the new files until they restart. A kubelet restart alone doesn’t help — the static pods are child processes of the kubelet that need their own restart.

--control-plane-endpoint must be set at init time. There’s no clean way to add it after the fact because it’s baked into multiple kubeconfigs and the apiserver cert SAN list.

Local edits to /var/lib/kubelet/config.yaml don’t survive an upgrade. The kubelet-config phase overwrites it from the ConfigMap. The ConfigMap is the source of truth.

Open questions

  • Kubelet serving certs rotate via CSR — what controls the rotation interval, and how does the kubelet signal readiness to rotate?
  • In air-gapped clusters, kubeadm config images list covers control-plane images. What about CNI and CoreDNS — are those included, or is that a separate pre-pull step?

References