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.

Every init flag is just a field of the InitConfiguration (node-specific) or ClusterConfiguration (cluster-wide) API — the flags are a convenience subset:

Flag What it sets (default) Config field
--kubernetes-version control-plane version (latest stable) ClusterConfiguration.kubernetesVersion
--control-plane-endpoint stable LB/DNS front for the API — set at init for HA ClusterConfiguration.controlPlaneEndpoint
--apiserver-advertise-address IP the apiserver advertises + binds (default-route iface) InitConfiguration.localAPIEndpoint.advertiseAddress
--apiserver-bind-port apiserver port (6443) InitConfiguration.localAPIEndpoint.bindPort
--apiserver-cert-extra-sans extra IPs/DNS names on the apiserver serving cert ClusterConfiguration.apiServer.certSANs
--pod-network-cidr pod IP range — must match your CNI ClusterConfiguration.networking.podSubnet
--service-cidr Service ClusterIP range (10.96.0.0/12) ClusterConfiguration.networking.serviceSubnet
--service-dns-domain cluster DNS suffix (cluster.local) ClusterConfiguration.networking.dnsDomain
--image-repository registry for CP images (registry.k8s.io) — air-gap/mirror ClusterConfiguration.imageRepository
--cert-dir where PKI is written (/etc/kubernetes/pki) ClusterConfiguration.certificatesDir
--certificate-key key that encrypts uploaded CP certs (with --upload-certs) InitConfiguration.certificateKey
--node-name this node’s registered name (hostname) InitConfiguration.nodeRegistration.name
--feature-gates toggle alpha/beta features ClusterConfiguration.featureGates
--ignore-preflight-errors skip named preflight checks (e.g. Swap) InitConfiguration.nodeRegistration.ignorePreflightErrors
--dry-run render everything to a temp dir, change nothing (CLI only)
--config supply all of the above as a YAML file (the file itself)

The declarative equivalent — a multi-document file passed with --config:

# kubeadm-config.yaml  →  sudo kubeadm init --config kubeadm-config.yaml --upload-certs
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: 10.0.0.10          # --apiserver-advertise-address
  bindPort: 6443                       # --apiserver-bind-port
certificateKey: "a1b2c3deadbeef..."    # --certificate-key (encrypts the uploaded CP certs)
nodeRegistration:
  name: cp-1                           # --node-name
  criSocket: unix:///var/run/containerd/containerd.sock
  ignorePreflightErrors:               # --ignore-preflight-errors
    - Swap
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.35.0             # --kubernetes-version
controlPlaneEndpoint: "10.0.0.10:6443" # --control-plane-endpoint
imageRepository: registry.k8s.io       # --image-repository
certificatesDir: /etc/kubernetes/pki   # --cert-dir
apiServer:
  certSANs:                            # --apiserver-cert-extra-sans
    - 10.0.0.10
    - k8s.example.com
networking:
  podSubnet: 10.244.0.0/16             # --pod-network-cidr
  serviceSubnet: 10.96.0.0/12          # --service-cidr
  dnsDomain: cluster.local             # --service-dns-domain
featureGates: {}                       # --feature-gates — kubeadm's own gates (see note below)

--config can’t be mixed with most other flags — kubeadm errors on conflicts; only a few operational ones (--upload-certs, --dry-run, --ignore-preflight-errors) are allowed alongside it. Bootstrap a template with kubeadm config print init-defaults. After init the config is persisted in the kubeadm-config ConfigMap in kube-system, and upgrade/join read it back — so that ConfigMap is the source of truth. Append KubeletConfiguration / KubeProxyConfiguration documents (more --- blocks) to set the kubelet cgroup driver or kube-proxy mode: ipvs.

The featureGates field takes kubeadm’s own gates (currently NodeLocalCRISocket, PublicKeysECDSA, RootlessControlPlane) — a separate set from the Kubernetes component feature gates. To toggle an apiserver/kubelet/scheduler gate you pass it through that component’s extraArgs (e.g. apiServer.extraArgs), not featureGates.

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/^.* //'

A control-plane join also needs the certificate-key from init --upload-certs, and the encrypted-cert Secret it points at expires ~2h after init. Regenerate it for a late control-plane join:

sudo kubeadm init phase upload-certs --upload-certs   # re-encrypts the certs, prints a fresh certificate-key

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 kubeconfig

Generate a kubeconfig for an additional user, signed by the cluster CA. Kubernetes has no user object — a user is just a cert CN, and its O fields become the groups — so this is the kubeadm-native way to mint one on a control-plane node. It reads the ClusterConfiguration from the kubeadm-config ConfigMap, signs against /etc/kubernetes/pki/ca.{crt,key}, and points the kubeconfig at the control-plane endpoint:

kubeadm kubeconfig user --client-name=jane --org=dev > jane.kubeconfig
kubeadm kubeconfig user --client-name=jane --org=dev --org=ops \
  --validity-period=168h > jane.kubeconfig     # --org repeatable → multiple groups

--client-name → the username (CN); each --org → a group (O). Those are exactly what an RBAC RoleBinding/ClusterRoleBinding binds against with --user / --group.

Regenerate the control plane’s own kubeconfigs (a lost or CA-rotated admin.conf) with init phases:

sudo kubeadm init phase kubeconfig admin      # just /etc/kubernetes/admin.conf
sudo kubeadm init phase kubeconfig all        # admin + kubelet + controller-manager + scheduler

kubeadm kubeconfig user only handles authentication, not authorization — the signed cert makes the apiserver trust the identity, but the user has zero permissions until you add a binding. Grant + verify are in the RBAC drills: CKA Operational Drills.

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. Two binaries since etcd 3.5, split by operation type: etcdctl for anything that hits a live server over the network (snapshot save, member list — needs --endpoints + TLS), etcdutl for offline operations on local files (snapshot status, snapshot restore, defrag --data-dir — no endpoint or TLS). The TLS cert paths are always the same on a kubeadm cluster:

# Backup — network op, stays in etcdctl (talks to the live server)
sudo 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

# Inspect + restore — offline file ops, so they moved to etcdutl (no endpoint/TLS needed)
sudo etcdutl snapshot status /opt/snapshot.db --write-out=table
sudo etcdutl snapshot restore /opt/snapshot.db --data-dir=/var/lib/etcd-restore

ETCDCTL_API=3 has been the default since etcd 3.4 — drop the old prefix, it’s a no-op now. etcdctl snapshot restore/status still work in 3.5 but are deprecated in favor of etcdutl (and removed in 3.6).

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.

etcd restore is etcdutl, not etcdctl (since 3.5). Muscle memory says etcdctl snapshot restore; that’s deprecated and gone in 3.6. snapshot save stays in etcdctl (it’s a network op); restore/status are offline file ops on etcdutl. And ETCDCTL_API=3 is now the default — no longer needed.

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