In the previous lesson we brought up disposable clusters on the laptop and discovered, almost in passing, that kind uses kubeadm internally: that kubeadmConfigPatches field in the configuration file was no accident. Now we go to the tool directly.

kubeadm is the Kubernetes project's official tool for bootstrapping a conformant cluster on machines you control. It is the foundation on which nearly every distribution is built, and understanding it gives you something no other route does: seeing with your own eyes where the certificates are, where the control plane's static pods live and exactly what happens when a node joins the cluster. Even if Rutas Norte S.L. ends up choosing a managed cluster (10-06), this knowledge is what will let you debug a broken control plane and pass the CKA certification (12-01).

Contents

  1. What kubeadm is and what it is not
  2. Self-managed versus managed: when it makes sense
  3. Preparing the machines
  4. Installing containerd
  5. Installing kubeadm, kubelet and kubectl
  6. kubeadm init with a configuration file
  7. What kubeadm creates on the node exactly
  8. Installing the CNI and why the nodes are NotReady
  9. Joining worker nodes
  10. A highly available control plane
  11. Certificate management
  12. Version upgrades
  13. etcd backup and restore
  14. kubeadm reset and clean-up
  15. Common mistakes and tips
  16. Exercises
  17. Conclusion

  1. What kubeadm is and what it is not

kubeadm does one thing well: given machines that are already prepared, it bootstraps a control plane on them and joins worker nodes, producing a cluster that passes the Kubernetes conformance tests.

kubeadm DOES kubeadm DOES NOT
Generate the whole certificate hierarchy (CA, apiserver, etcd, kubelet) Provision virtual or physical machines
Write the manifests for the control plane's static pods Install the container runtime
Start etcd (stacked) or connect to an external one Install the network plugin (CNI)
Configure the kubelet and generate its kubeconfigs Configure the operating system (swap, sysctl, firewall)
Issue join tokens and join nodes Install Ingress, monitoring, storage
Upgrade the control plane's version Manage etcd backups
Renew certificates Provide high availability for the API balancer

That right-hand list is the key. Plenty of people run kubeadm init, see the success message, and are then surprised that kubectl get nodes says NotReady. There is no error: the CNI is missing, and that job is not kubeadm's.

flowchart TB
    subgraph yours["Your responsibility"]
        A[Machines and network]
        B[Operating system:<br/>swap, sysctl, firewall]
        C[containerd]
        E[CNI: Calico, Cilium...]
        F[Add-ons: Ingress,<br/>storage, monitoring]
        G[etcd backups, patching,<br/>node rotation]
    end
    subgraph kubeadm["kubeadm's responsibility"]
        D[Certificates, control plane,<br/>tokens, node joining,<br/>upgrades]
    end
    A --> B --> C --> D --> E --> F --> G
    style kubeadm fill:#e8f4ff
    style yours fill:#fff4e8

  1. Self-managed versus managed: when it makes sense

Before writing a single command, the honest question: should Rutas Norte S.L. operate its own cluster?

Criterion kubeadm (self-managed) Managed (10-06)
Control plane cost Your machines (3 control nodes minimum for HA) €0-75/month per cluster, depending on provider
Who fixes a corrupted etcd at 3 in the morning Your team The provider
Version upgrades Manual, node by node, with a maintenance window One button, though it still demands planning
Control over apiserver flags Total Limited
Running on your own machines or in a private data centre Yes No (except hybrid variants)
Regulatory data sovereignty requirements Achievable Depends on the provider and the region
Staff required At least 2 people with deep knowledge and on-call duty One person part-time
Time to the first production cluster Weeks Hours

A serious warning: operating your own cluster in production demands a dedicated team. It is not a task you can bolt onto somebody else's job description. You need out-of-hours incident cover, a tested etcd restore procedure, an operating system patching policy, and someone who understands the certificates when they expire. If your organisation cannot sustain that, a managed cluster is not a convenience: it is a risk-management decision.

When kubeadm genuinely is the right answer: your own data centre or specific hardware (GPUs, low latency, regulatory compliance); a need for control plane configurations no managed offering allows; cost savings with many nodes; and learning and certification, because the CKA (12-01) assesses exactly this.

For Rutas Norte we will build a kubeadm cluster in a lab in order to learn and practise, with the reasoned production conclusion reserved for 10-06.

  1. Preparing the machines

We start from three Ubuntu 24.04 LTS machines in the Rutas Norte lab:

Name Role vCPU RAM IP
rn-control-1 Control plane 2 4 GB 10.10.0.11
rn-worker-1 Worker node 2 4 GB 10.10.0.21
rn-worker-2 Worker node 2 4 GB 10.10.0.22

Official minimum requirements: 2 CPUs and 2 GB of RAM per control node, a unique host name, a unique MAC address and a unique product_uuid (sudo cat /sys/class/dmi/id/product_uuid). If two cloned machines share a product_uuid, the cluster may confuse them: regenerate it from the hypervisor.

3.1 Disabling swap

All the following steps are run on all three machines.

sudo swapoff -a                                # now
sudo sed -i '/ swap / s/^/#/' /etc/fstab       # and permanently
free -h                                        # check that Swap is 0

Why must swap be disabled? Because it breaks the resource model we studied in 03-04 and 03-05. The scheduler decides where a pod goes based on the memory requested and available; the kubelet evicts pods when the node is under memory pressure, following the QoS classes. With swap, a pod that exceeds its memory limit does not die: it starts paging to disk, becomes a thousand times slower, its liveness probes start failing erratically and the whole node degrades without any metric explaining it. It is a far worse failure than a clean OOMKilled.

Kubernetes 1.30 has experimental swap support (NodeSwap), but it is in beta with plenty of caveats. For a production cluster, swap off.

3.2 Kernel modules

# Declare the modules that must be loaded at every boot
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

# Load them now without rebooting
sudo modprobe overlay
sudo modprobe br_netfilter

# Verify
lsmod | grep -E 'overlay|br_netfilter'

What each one does:

  • overlay: the layered file system containerd uses to mount container images. Without it, the runtime cannot create containers.
  • br_netfilter: makes traffic crossing a Linux network bridge (the one connecting the node's containers) visible to iptables. Without it, kube-proxy writes rules that are never applied to pod-to-pod traffic, and Services simply do not work.

3.3 sysctl parameters

cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF
sudo sysctl --system                                          # apply
sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables # verify
  • ip_forward = 1: the node must forward packets between interfaces. Without this, a pod on rn-worker-1 cannot talk to one on rn-worker-2.
  • bridge-nf-call-iptables = 1: switches on what br_netfilter makes possible.

If sysctl tells you the parameter does not exist, it means br_netfilter is not loaded. The order matters.

3.4 Firewall and ports

Node Port/range Protocol Component Who accesses it
Control 6443 TCP kube-apiserver All nodes, kubectl, the balancer
Control 2379-2380 TCP etcd (client and peer) Control nodes only
Control 10250 TCP kubelet API Control plane (logs, exec)
Control 10257 TCP kube-controller-manager Local
Control 10259 TCP kube-scheduler Local
Worker 10250 TCP kubelet API Control plane
Worker 10256 TCP kube-proxy (health) Balancers
Both 30000-32767 TCP NodePort range As needed
Both CNI-dependent UDP/TCP Pod network All nodes

About the last one: every CNI uses its own. Calico with VXLAN needs UDP 4789; with IP-in-IP it needs IP protocol 4; Cilium with VXLAN, UDP 8472. Check the documentation of the CNI you pick.

For the lab it is enough to open those ports with ufw and allow all internal network traffic: sudo ufw allow from 10.10.0.0/24. Verify from another node with nc -zv 10.10.0.11 6443.

Lab tip: if you are learning and something will not connect, temporarily disable the firewall (sudo ufw disable) to rule it out as the culprit. In production, never; there every rule is documented.

  1. Installing containerd

Kubernetes does not run containers directly: it talks to a runtime through the CRI interface. Since 1.24, Docker is not a valid runtime directly; containerd is the standard option.

# 1. Docker's repository (containerd is distributed from there)
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# 2. Install ONLY containerd (not the Docker engine)
sudo apt-get update && sudo apt-get install -y containerd.io

4.1 SystemdCgroup: the most common mistake

Here is failure number one in kubeadm clusters. containerd ships by default with SystemdCgroup = false, whereas the kubelet in Kubernetes 1.30 uses the systemd cgroup driver. If they do not match, the cluster comes up apparently fine and then pods restart at random under memory pressure, because there are two cgroup managers fighting over the same node.

# 1. Generate the default configuration (containerd ships without a full config.toml)
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml > /dev/null

# 2. Change SystemdCgroup from false to true
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

# 3. VERIFY that the change was applied (do not take it on trust) and restart
grep SystemdCgroup /etc/containerd/config.toml     # must say: SystemdCgroup = true
sudo systemctl restart containerd && sudo systemctl enable containerd

If the grep returns nothing or returns false, the option's path has changed in your version. Edit the file by hand and look for the [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] section.

4.2 Checking that containerd answers CRI

sudo apt-get install -y cri-tools
printf 'runtime-endpoint: unix:///run/containerd/containerd.sock\n' | sudo tee /etc/crictl.yaml
sudo crictl info | head -20

If this responds, the runtime is ready. crictl is your node-level debugging tool: crictl ps, crictl images, crictl logs. When the apiserver is down, kubectl is useless and crictl is all you have.

  1. Installing kubeadm, kubelet and kubectl

All three are installed from the official Kubernetes repository, which since 1.28 has been segmented by minor version. This matters: the v1.30 repository only contains 1.30 patches, which prevents accidental minor version jumps.

curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | \
  sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | \
  sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update

# Install a SPECIFIC patch version, not the latest one
sudo apt-get install -y kubelet=1.30.4-1.1 kubeadm=1.30.4-1.1 kubectl=1.30.4-1.1

5.1 Pinning the versions with apt-mark hold

sudo apt-mark hold kubelet kubeadm kubectl
apt-mark showhold                      # check

# Enable the kubelet (it will still fail and retry: that is normal, there is no
# configuration until kubeadm init or join is run)
sudo systemctl enable --now kubelet

Why the hold is essential: without it, a routine system-maintenance apt upgrade would upgrade the kubelet on some random node. That new kubelet could be incompatible with the control plane, or simply restart mid-afternoon and evict every pod on that node. Kubernetes upgrades are a planned procedure (section 12), never a side effect.

  1. kubeadm init with a configuration file

You could bootstrap the cluster with a string of flags (kubeadm init --pod-network-cidr=... --control-plane-endpoint=... --apiserver-cert-extra-sans=...), and it would work. But the problem is obvious after everything we have covered so far: it is not versioned, nobody remembers which flags were used a year ago, and adding a control node means repeating them exactly. The professional way is a configuration file, kept alongside the manifests.

# kubeadm/rn-cluster.yaml
---
apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
localAPIEndpoint:                    # how THIS node advertises the apiserver
  advertiseAddress: "10.10.0.11"
  bindPort: 6443
nodeRegistration:
  name: "rn-control-1"
  criSocket: "unix:///run/containerd/containerd.sock"
  taints:                            # standard control plane taint (06-05)
    - { key: "node-role.kubernetes.io/control-plane", effect: "NoSchedule" }
---
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: "v1.30.4"
clusterName: "rutas-norte-lab"

# STABLE entry point. Declared from the very first moment even though
# today there is only one node: changing it later forces a certificate regeneration.
controlPlaneEndpoint: "api-k8s.rutasnorte.example:6443"

networking:
  # MUST match whatever you configure in Calico/Cilium, and it CANNOT
  # overlap with the physical network (10.10.0.0/24) or with serviceSubnet.
  podSubnet: "10.244.0.0/16"
  serviceSubnet: "10.96.0.0/12"
  dnsDomain: "cluster.local"

apiServer:
  # Valid names and IPs on the apiserver's certificate. Reaching it through a
  # name not listed here gives "certificate is valid for ..., not ...".
  certSANs: ["api-k8s.rutasnorte.example", "10.10.0.10", "10.10.0.11",
             "localhost", "127.0.0.1"]
  extraArgs:                          # audit log (08-06)
    audit-log-path: "/var/log/kubernetes/audit.log"
    audit-log-maxage: "30"

etcd:
  local: { dataDir: "/var/lib/etcd" }  # stacked etcd, a static pod right here
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: "systemd"               # MUST match SystemdCgroup=true
# Resources reserved for the system and the kubelet, which are NOT offered to
# the scheduler. Without this, a saturated node runs out of memory for sshd.
systemReserved: { cpu: "200m", memory: "256Mi" }
kubeReserved:   { cpu: "200m", memory: "256Mi" }
evictionHard:   { memory.available: "200Mi", nodefs.available: "10%" }
serverTLSBootstrap: true

We run:

# First, pull the images in advance (this avoids timeouts
# during init if the network is slow)
sudo kubeadm config images pull --config kubeadm/rn-cluster.yaml

# Bootstrap
sudo kubeadm init --config kubeadm/rn-cluster.yaml --upload-certs

--upload-certs uploads the control plane certificates to a temporary, encrypted cluster Secret so that other control nodes can join without copying them by hand. It expires after 2 hours.

Output (heavily abridged):

[certs] Generating "ca" certificate and key
[certs] apiserver serving cert is signed for DNS names [api-k8s.rutasnorte.example
  kubernetes kubernetes.default rn-control-1] and IPs [10.96.0.1 10.10.0.11 10.10.0.10]
[control-plane] Creating static Pod manifest for "kube-apiserver"
[etcd] Creating static Pod manifest for local etcd
[apiclient] All control plane components are healthy after 12.503 seconds

Your Kubernetes control-plane has initialized successfully!
You should now deploy a pod network to the cluster.

You can now join any number of control-plane nodes:
  kubeadm join api-k8s.rutasnorte.example:6443 --token abcdef.0123456789abcdef \
    --discovery-token-ca-cert-hash sha256:1a2b3c... \
    --control-plane --certificate-key 8f3c1a...

Then you can join any number of worker nodes:
  kubeadm join api-k8s.rutasnorte.example:6443 --token abcdef.0123456789abcdef \
    --discovery-token-ca-cert-hash sha256:1a2b3c...

Save that output. The two join commands at the end are what you need in sections 9 and 10.

# Configure kubectl for 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

kubectl get nodes
NAME           STATUS     ROLES           AGE   VERSION
rn-control-1   NotReady   control-plane   62s   v1.30.4

NotReady. That is correct, and we explain it in section 8.

6.1 The fields that matter most

Field What it does What happens if you get it wrong
podSubnet The pods' IP range If it overlaps the physical network or serviceSubnet, routing breaks in ways that are extremely hard to debug
serviceSubnet Range of the Services' virtual IPs Likewise; also the .1 IP of this range belongs to the kubernetes Service
controlPlaneEndpoint Stable name for the control plane Without it, you cannot add control nodes later without regenerating certificates
certSANs Valid names on the apiserver's certificate Reaching it through an unlisted name gives a certificate error
kubernetesVersion The version to install If it does not match the installed binaries, init warns you
cgroupDriver The kubelet's cgroup manager If it does not match containerd, pods are unstable under pressure
criSocket The runtime's socket With several runtimes installed, kubeadm does not know which to use

About controlPlaneEndpoint: even if today you have a single control node and no balancer, declare it with a DNS name from the outset. You can have that name point at 10.10.0.11 today and at the balancer's virtual IP tomorrow. If you do not, the certificate and every kubeconfig will point at the node's IP, and building HA later demands regenerating certificates across the whole cluster.

  1. What kubeadm creates on the node exactly

This section connects directly with the architecture we saw in 01-02. Now we are going to see it on disk.

7.1 The control plane's static pods

ls -l /etc/kubernetes/manifests/
-rw------- 1 root root 2405 Sep 12 10:02 etcd.yaml
-rw------- 1 root root 3891 Sep 12 10:02 kube-apiserver.yaml
-rw------- 1 root root 3320 Sep 12 10:02 kube-controller-manager.yaml
-rw------- 1 root root 1463 Sep 12 10:02 kube-scheduler.yaml

This is a static pod: the kubelet watches that directory and starts any pod it finds there, without going through the apiserver. It is the answer to the chicken-and-egg problem: how does the apiserver start, if creating a pod requires an apiserver? Answer: it is not created as a normal pod, the kubelet reads it straight from disk.

Some very important practical consequences:

  • If you edit /etc/kubernetes/manifests/kube-apiserver.yaml, the kubelet detects the change and restarts the apiserver automatically within seconds. That is how you add a flag by hand.
  • If you delete that file, the apiserver vanishes and you lose the cluster. Take a backup before touching anything.
  • kubectl delete pod kube-apiserver-rn-control-1 -n kube-system does not really remove it: the kubelet recreates it, because the source of truth is the file.
  • If the apiserver will not start, kubectl does not work. Debug it with crictl ps -a and crictl logs, or with journalctl -u kubelet.
# Static pods appear as "mirror" objects in the API,
# with the node's name as a suffix: that is their distinguishing mark.
kubectl get pods -n kube-system -l tier=control-plane
etcd-rn-control-1                      1/1   Running   0   3m
kube-apiserver-rn-control-1            1/1   Running   0   3m
kube-controller-manager-rn-control-1   1/1   Running   0   3m
kube-scheduler-rn-control-1            1/1   Running   0   3m

7.2 Certificates

sudo ls -1 /etc/kubernetes/pki/
apiserver.crt  apiserver.key  apiserver-etcd-client.crt  apiserver-etcd-client.key
apiserver-kubelet-client.crt  apiserver-kubelet-client.key  ca.crt  ca.key  etcd/
front-proxy-ca.crt  front-proxy-ca.key  front-proxy-client.crt  front-proxy-client.key
sa.key  sa.pub
File Purpose
ca.crt / ca.key The cluster's root certificate authority. It is the most valuable secret: whoever holds it can issue administrator credentials
apiserver.crt The apiserver's server certificate, with the certSANs
apiserver-kubelet-client.* What the apiserver identifies itself with when talking to the kubelets (kubectl logs, exec)
apiserver-etcd-client.* What the apiserver identifies itself with to etcd
etcd/ etcd's own CA and certificates (server, peer, health)
front-proxy-* For the API aggregator (custom metrics from 09-01)
sa.key / sa.pub The key pair used to sign ServiceAccount tokens (03-06)

To inspect a certificate's valid names: sudo openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -text | grep -A2 'Subject Alternative Name'.

7.3 kubeconfig files

In /etc/kubernetes/*.conf there is one per component: controller-manager.conf, scheduler.conf, kubelet.conf (this node's credentials, with automatic rotation) and two administrative ones:

  • admin.conf: the one you copied to ~/.kube/config. Since 1.29 it belongs to the kubeadm:cluster-admins group, subject to RBAC (08-01).
  • super-admin.conf: bypasses RBAC entirely (system:masters). It is the break-glass option when you have broken RBAC and cannot even fix it. Do not hand it around.

7.4 etcd data

In /var/lib/etcd/member/ (with its snap and wal directories) lives the complete state of your cluster: every object, every Secret, everything. That is what has to be backed up (section 13).

  1. Installing the CNI and why the nodes are NotReady

kubectl get nodes
kubectl describe node rn-control-1 | grep -A5 Conditions
  Type             Status  Reason                       Message
  Ready            False   KubeletNotReady              container runtime network not
                                                        ready: NetworkReady=false
                                                        reason:NetworkPluginNotReady
                                                        message:Network plugin returns
                                                        error: cni plugin not initialized

The message is explicit. The kubelet refuses to declare itself Ready while there is no network plugin able to assign IPs to pods. Remember from 04-01: Kubernetes defines the contract (every pod has an IP, all pods see each other without NAT) but does not implement it. That is the CNI's job.

You will also see CoreDNS in Pending, and that is normal: CoreDNS is a pod and it needs the network to start.

We install Calico (the CNI detail is in 04-01; here only the essentials):

kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.1/manifests/tigera-operator.yaml

cat <<EOF | kubectl apply -f -
apiVersion: operator.tigera.io/v1
kind: Installation
metadata: { name: default }
spec:
  calicoNetwork:
    ipPools:
      # MUST match networking.podSubnet from the kubeadm file
      - { cidr: 10.244.0.0/16, encapsulation: VXLANCrossSubnet, natOutgoing: Enabled }
EOF

kubectl get nodes -w      # NotReady -> Ready in under a minute

And CoreDNS starts on its own. The full sequence is:

sequenceDiagram
    participant K as kubeadm init
    participant Kl as kubelet
    participant A as apiserver
    participant C as CNI (Calico)
    K->>Kl: writes static pods + config
    Kl->>A: starts apiserver, etcd, scheduler, cm
    Kl->>A: registers the node (NotReady: no network)
    Note over A: CoreDNS stays Pending
    K-->>C: (you apply the CNI manifests)
    C->>Kl: installs the CNI binary in /opt/cni/bin
    Kl->>A: NetworkReady=true → node Ready
    A->>A: CoreDNS is scheduled and starts

  1. Joining worker nodes

On rn-worker-1 and rn-worker-2, already prepared per sections 3, 4 and 5:

sudo kubeadm join api-k8s.rutasnorte.example:6443 \
  --token abcdef.0123456789abcdef \
  --discovery-token-ca-cert-hash sha256:1a2b3c4d5e6f...
[preflight] Running pre-flight checks
[preflight] Reading configuration from the cluster...
[kubelet-start] Starting the kubelet
[kubelet-start] Waiting for the kubelet to perform the TLS Bootstrap...

This node has joined the cluster.

What the two parameters mean:

  • --token: a temporary credential (it expires after 24 hours) authorising the node to request its certificate. It is a Secret of type bootstrap.kubernetes.io/token in kube-system.
  • --discovery-token-ca-cert-hash: the SHA-256 hash of the public key of the cluster's CA. Its purpose is for the node to verify the apiserver, not the other way round. Without it, an attacker could impersonate the control plane and capture the token. Never use --discovery-token-unsafe-skip-ca-verification outside a lab.

9.1 Generating a new token when it expires

This is the most common situation: you want to add a node three weeks later and the original token is no longer valid.

# On a control node: a single command that prints everything you need
sudo kubeadm token create --print-join-command
kubeadm join api-k8s.rutasnorte.example:6443 --token 7t8u9i.qwertyuiopasdfgh \
  --discovery-token-ca-cert-hash sha256:1a2b3c4d5e6f...

Related commands:

sudo kubeadm token list                            # live tokens and expiry
sudo kubeadm token create --ttl 2h --print-join-command
sudo kubeadm token delete 7t8u9i.qwertyuiopasdfgh

# Recompute the CA hash by hand, 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/^.* //'

9.2 Labelling the nodes

kubeadm does not set the role label on worker nodes for security reasons (a kubelet cannot assign itself role labels). It is done from the control plane:

kubectl label node rn-worker-1 node-role.kubernetes.io/worker=
kubectl label node rn-worker-2 node-role.kubernetes.io/worker=
# And Rutas Norte's topology labels (09-05)
kubectl label node rn-worker-1 topology.kubernetes.io/zone=lab-a
kubectl label node rn-worker-2 topology.kubernetes.io/zone=lab-b
kubectl get nodes                 # all three Ready, with their roles

  1. A highly available control plane

With a single control node, if rn-control-1 goes down the cluster keeps serving traffic (the pods keep running, kube-proxy keeps routing) but you lose all management capability: no pod rescheduling, no scaling, no deployments, no HPA. And if etcd's disk is lost, you lose the entire cluster.

10.1 The stable entry point

Every control node publishes the same port 6443. You need something in front to spread the load:

flowchart TB
    K[kubectl / the nodes'<br/>kubelets] --> LB["api-k8s.rutasnorte.example:6443<br/>(virtual IP 10.10.0.10)"]
    LB --> C1[rn-control-1<br/>:6443]
    LB --> C2[rn-control-2<br/>:6443]
    LB --> C3[rn-control-3<br/>:6443]
    C1 -.-> E[(stacked etcd<br/>quorum of 3)]
    C2 -.-> E
    C3 -.-> E

Options: HAProxy + keepalived with a floating virtual IP (the most common choice in your own data centre), the cloud layer-4 balancer if you are on virtual machines, or kube-vip as a static pod if you want no external infrastructure. What does not work is DNS with several A records: the client caches and does not notice failures.

A minimal HAProxy configuration:

# /etc/haproxy/haproxy.cfg
frontend k8s-api
    bind *:6443
    mode tcp
    default_backend k8s-control-plane
backend k8s-control-plane
    mode tcp
    option tcp-check
    balance roundrobin
    server rn-control-1 10.10.0.11:6443 check fall 3 rise 2
    server rn-control-2 10.10.0.12:6443 check fall 3 rise 2
    server rn-control-3 10.10.0.13:6443 check fall 3 rise 2

It is pure TCP (tcp mode), not HTTP: the connection to the apiserver is end-to-end TLS and the balancer must not terminate it. With check, if a node stops answering on 6443, HAProxy automatically stops sending it traffic.

10.2 Stacked etcd versus external etcd

Aspect Stacked etcd External etcd
Where it runs As a static pod on each control node On dedicated machines
Machines required 3 (control + etcd together) 3 control + 3 etcd = 6
Configuration Automatic with kubeadm Manual: certificates, systemd units
Failure isolation Losing a node loses a control plane and an etcd member Independent
Performance under load etcd competes with the apiserver for I/O and CPU etcd has its disk to itself
Recommendation Default, for most cases Very large clusters or strict requirements

etcd needs quorum: 1 member tolerates 0 failures, 2 members also tolerate 0 (worse than 1!), 3 tolerate 1, 4 tolerate 1, and 5 tolerate 2. That is why the number of control nodes is always odd: 1 (lab), 3 (normal production) or 5 (large clusters).

For external etcd, you declare it in the kubeadm file:

apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
etcd:
  external:
    endpoints:
      - https://10.10.0.31:2379
      - https://10.10.0.32:2379
      - https://10.10.0.33:2379
    caFile: /etc/kubernetes/pki/etcd/ca.crt
    certFile: /etc/kubernetes/pki/apiserver-etcd-client.crt
    keyFile: /etc/kubernetes/pki/apiserver-etcd-client.key

10.3 Joining additional control nodes

# On rn-control-2 and rn-control-3 (already prepared per 3, 4 and 5)
sudo kubeadm join api-k8s.rutasnorte.example:6443 \
  --token abcdef.0123456789abcdef \
  --discovery-token-ca-cert-hash sha256:1a2b3c... \
  --control-plane \
  --certificate-key 8f3c1a...

The --certificate-key comes from --upload-certs. If more than 2 hours have passed, regenerate it:

# On rn-control-1
sudo kubeadm init phase upload-certs --upload-certs
[upload-certs] Using certificate key:
f0e1d2c3b4a5968778695a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d

To check the health of the etcd cluster, kubectl -n kube-system exec etcd-rn-control-1 -- etcdctl ... endpoint status --cluster -w table must show all three members, with the same version, a similar database size and exactly one with IS LEADER = true.

  1. Certificate management

Here is the trap that catches out most teams: the certificates kubeadm generates expire after a year. A cluster built in March stops working in March the following year, with no warning, with a message like this:

Unable to connect to the server: x509: certificate has expired or is not yet valid

Kubernetes renews the certificates automatically during kubeadm upgrade. Since many teams upgrade at least once a year, they never see it. Whoever does not upgrade gets the fright.

11.1 Checking the expiry

sudo kubeadm certs check-expiration
CERTIFICATE                 EXPIRES                  RESIDUAL TIME
admin.conf                 Sep 12, 2026 10:02 UTC    364d
apiserver                  Sep 12, 2026 10:02 UTC    364d
apiserver-etcd-client      Sep 12, 2026 10:02 UTC    364d
apiserver-kubelet-client   Sep 12, 2026 10:02 UTC    364d
etcd-server, etcd-peer, scheduler.conf, controller-manager.conf ... 364d

CERTIFICATE AUTHORITY   EXPIRES                  RESIDUAL TIME
ca, etcd-ca, front-proxy-ca   Sep 10, 2035 10:02 UTC   9y

CAs last 10 years; the certificates they sign, 1 year.

Operational recommendation: do not leave this to a human calendar. The apiserver exposes apiserver_client_certificate_expiration_seconds_bucket, on which you can build an alert in Alertmanager (07-04). Simpler and even more reliable: a CronJob (06-03) or a system task running kubeadm certs check-expiration weekly and warning if less than a month remains.

11.2 Renewing

# ON EVERY CONTROL NODE
sudo cp -r /etc/kubernetes/pki /root/pki-backup-$(date +%F)     # back up first
sudo kubeadm certs renew all              # or just one: kubeadm certs renew apiserver

# Restart the static pods: moving them out and back is enough
sudo mkdir -p /tmp/stopped-manifests
sudo mv /etc/kubernetes/manifests/*.yaml /tmp/stopped-manifests/
sleep 20
sudo mv /tmp/stopped-manifests/*.yaml /etc/kubernetes/manifests/

# Update your personal kubeconfig, which was also renewed
sudo cp /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

sudo kubeadm certs check-expiration && kubectl get nodes

A note about the kubelet: its certificate rotates on its own, automatically, if rotateCertificates: true (the default). The certificates you have to renew by hand are the control plane's.

  1. Version upgrades

12.1 The one-minor-version-at-a-time rule

You can only jump one minor version at a time. You cannot go straight from 1.28 to 1.30: you have to do 1.28 → 1.29 → 1.30. Each jump is a complete procedure.

In addition, the version skew policy requires:

Component Skew allowed relative to the apiserver
kube-controller-manager, kube-scheduler Up to 1 minor below
kubelet Up to 3 minors below
kube-proxy Up to 3 minors below
kubectl 1 above or 1 below

Hence the mandatory order: the control plane first, the worker nodes afterwards. Never the other way round.

12.2 Before you start

Three things, in this order: an etcd backup (section 13, non-negotiable), reading the release notes, and checking that nothing uses APIs that are going away.

kubectl get --raw /metrics | grep apiserver_requested_deprecated_apis
apiserver_requested_deprecated_apis{group="flowcontrol.apiserver.k8s.io",
  removed_release="1.32",resource="flowschemas",version="v1beta3"} 1

Any line here is work that has to be done before upgrading. Tools such as kubent (kube-no-trouble) or pluto scan your manifests looking for the same thing.

12.3 The first control node

# --- ON rn-control-1 ---

# 1. Release the hold and upgrade ONLY kubeadm
sudo apt-mark unhold kubeadm
sudo apt-get update

# Point the repository at the new minor version
sudo sed -i 's|v1.30|v1.31|' /etc/apt/sources.list.d/kubernetes.list
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | \
  sudo gpg --dearmor --yes -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
sudo apt-get update

sudo apt-get install -y kubeadm=1.31.1-1.1
sudo apt-mark hold kubeadm
kubeadm version
# 2. See the plan: what is going to be upgraded and to what
sudo kubeadm upgrade plan
COMPONENT                 CURRENT   TARGET
kube-apiserver            v1.30.4   v1.31.1
kube-controller-manager   v1.30.4   v1.31.1
kube-scheduler            v1.30.4   v1.31.1
kube-proxy                v1.30.4   v1.31.1
CoreDNS                   v1.11.1   v1.11.3
etcd                      3.5.14    3.5.15

Components that must be upgraded manually: kubelet on the 3 nodes.
You can now apply the upgrade by executing:  kubeadm upgrade apply v1.31.1
# 3. Apply it. This updates the static pod manifests and
#    RENEWS THE CERTIFICATES along the way.
sudo kubeadm upgrade apply v1.31.1
[upgrade/successful] SUCCESS! Your cluster was upgraded to "v1.31.1".
# 4. Drain the node: move its pods elsewhere and accept no new ones.
#    This honours the PodDisruptionBudgets from 09-05.
kubectl drain rn-control-1 --ignore-daemonsets --delete-emptydir-data

# 5. Upgrade kubelet and kubectl
sudo apt-mark unhold kubelet kubectl
sudo apt-get install -y kubelet=1.31.1-1.1 kubectl=1.31.1-1.1
sudo apt-mark hold kubelet kubectl

sudo systemctl daemon-reload
sudo systemctl restart kubelet

# 6. Put the node back in service
kubectl uncordon rn-control-1
kubectl get nodes
NAME           STATUS   ROLES           AGE   VERSION
rn-control-1   Ready    control-plane   3d    v1.31.1
rn-worker-1    Ready    worker          3d    v1.30.4
rn-worker-2    Ready    worker          3d    v1.30.4

12.4 The remaining nodes, one at a time

For the other control nodes and for the worker nodes, the procedure is the one from the previous section with a single difference: you use sudo kubeadm upgrade node instead of upgrade apply.

# On each remaining node, one at a time:
sudo apt-mark unhold kubeadm && sudo apt-get install -y kubeadm=1.31.1-1.1 && sudo apt-mark hold kubeadm
sudo kubeadm upgrade node                                    # <-- the difference
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=300s
sudo apt-mark unhold kubelet kubectl
sudo apt-get install -y kubelet=1.31.1-1.1 kubectl=1.31.1-1.1
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload && sudo systemctl restart kubelet
kubectl uncordon <node>
kubectl get nodes    # confirm Ready and v1.31.1 BEFORE moving to the next one

One at a time, verifying in between. If drain hangs, that is a PodDisruptionBudget doing its job: bookings-postgres cannot be left without replicas. Investigate with kubectl get pdb -A before forcing anything.

  1. etcd backup and restore

In 05-06 we backed up the applications' data with Velero. This is a different thing: it is a backup of the cluster's definition. Every Deployment, Secret, ConfigMap, RBAC rule, CRD... it all lives in etcd.

Without an etcd backup, a disk failure on the control node forces you to recreate the cluster from scratch and reapply every manifest. With GitOps (10-05) that would be recoverable, but you would lose everything that is not in Git.

13.1 Taking the backup

sudo apt-get install -y etcd-client

# On a control node
sudo ETCDCTL_API=3 etcdctl snapshot save /var/backups/etcd-$(date +%F-%H%M).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 (an unverified backup is not a backup!)
sudo etcdutl --write-out=table snapshot status /var/backups/etcd-2026-09-12-1042.db
+----------+----------+------------+------------+
|   HASH   | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| 4f2a91bc |   184920 |       1847 |      28 MB |
+----------+----------+------------+------------+

That same command in /etc/cron.d/etcd-backup, at 03:15 daily and chained with a find /var/backups -name 'etcd-*.db' -mtime +14 -delete, covers automation with a two-week retention.

Three essentials: also back up /etc/kubernetes/pki/ (a snapshot without the CA is useless), take them off the node (a backup on the disk that is going to fail is not a backup) and encrypt them, because they contain every Secret in rutas-norte-pro, including the bookings-postgres credentials and those of the payment gateway: remember from 03-02 that Secrets are base64-encoded, not encrypted.

13.2 Restoring

An emergency procedure, for when the control plane is unrecoverable:

# 1. Stop the control plane by moving the static pods out
sudo mkdir -p /tmp/stopped-manifests
sudo mv /etc/kubernetes/manifests/*.yaml /tmp/stopped-manifests/
sudo crictl ps                      # wait until nothing is left

# 2. Set the current data aside (do NOT delete it: just in case)
sudo mv /var/lib/etcd /var/lib/etcd-broken-$(date +%F)

# 3. Restore the snapshot into a new directory
sudo etcdutl snapshot restore /var/backups/etcd-2026-09-12-1042.db \
  --data-dir=/var/lib/etcd --name=rn-control-1 \
  --initial-cluster=rn-control-1=https://10.10.0.11:2380 \
  --initial-advertise-peer-urls=https://10.10.0.11:2380

# 4. Put the static pods back and verify
sudo mv /tmp/stopped-manifests/*.yaml /etc/kubernetes/manifests/
sleep 60 && kubectl get nodes && kubectl get pods -A

Critical points:

  • On an HA cluster, you have to restore on all three control nodes, with the identical snapshot and each one with its own --name and URLs. If the data differs, etcd will not form quorum.
  • The state goes back to the moment of the backup. Everything created afterwards is lost.
  • Pods that were running keep running (the kubelet has not noticed), but "ghost" objects can appear: pods that exist on the node but not in etcd, or the other way round. After restoring, check kubectl get pods -A against crictl ps on every node.

Rehearse the restore. A backup that has never been restored is not a backup: it is a file. Do it in the lab, time yourself, and write the procedure down in a runbook (11-06).

  1. kubeadm reset and clean-up

When something goes wrong and you want to start over, or to retire a node:

# 1. From the control plane: drain the node and remove it from the cluster
kubectl drain rn-worker-2 --ignore-daemonsets --delete-emptydir-data
kubectl delete node rn-worker-2

# 2. On the node itself: undo what kubeadm did
sudo kubeadm reset -f
[reset] Deleting contents of directories: [/etc/kubernetes/manifests
  /var/lib/kubelet /etc/kubernetes/pki]
The reset process does not clean CNI configuration. To do so, you must
remove /etc/cni/net.d
The reset process does not reset or clean up iptables rules.

reset is honest about what it does not clean up. You have to finish the job by hand:

sudo rm -rf /etc/cni/net.d /opt/cni/bin/calico*        # CNI configuration
sudo iptables -F && sudo iptables -t nat -F            # kube-proxy rules
sudo iptables -t mangle -F && sudo iptables -X
sudo ipvsadm -C 2>/dev/null || true                    # if it was using IPVS mode
for i in cni0 flannel.1 vxlan.calico; do               # virtual interfaces
  sudo ip link delete "$i" 2>/dev/null || true
done
rm -rf $HOME/.kube

If you skip steps 3, 4 and 5, the next kubeadm init or join on that machine may appear to work and then produce inexplicable network failures: stale iptables rules routing to pod IPs that no longer exist. It is one of the most frequent causes of "I reinstalled and it still fails".

Common Mistakes and Tips

1. SystemdCgroup = false in containerd. This is mistake number one. The cluster starts, everything looks fine, and days later pods restart under memory pressure with no explanation. Always verify with grep SystemdCgroup /etc/containerd/config.toml and restart containerd after changing it.

2. Forgetting swapoff -a in /etc/fstab. You disable it, it works, you reboot the machine a month later and the kubelet will not start. Always comment out the line in /etc/fstab.

3. A podSubnet that overlaps the physical network. If your lab uses 10.10.0.0/24 and you declare podSubnet: 10.0.0.0/8, routing breaks in ways that look like black magic. Pick ranges that collide with nothing: not the node network, not serviceSubnet, not the corporate network.

4. A CNI range different from podSubnet. You declare 10.244.0.0/16 in kubeadm and install Calico with its default value 192.168.0.0/16. Pods get IPs from the CNI's range, but the controller-manager allocates blocks from the other one. They must match.

5. Not declaring controlPlaneEndpoint from the outset. Adding high availability later forces a certificate regeneration across the whole cluster. Declare a DNS name from day one, even if it points at a single IP.

6. Certificates expiring after a year. Real and very common. Put an alert in the calendar, or better, in your monitoring.

7. Jumping two minor versions, or upgrading the nodes before the control plane. kubeadm upgrade apply v1.32.0 from 1.30 is rejected; and a 1.31 kubelet against a 1.30 apiserver is outside the skew policy. One minor at a time, control plane first.

8. kubectl drain hanging and forcing it with --force. What is blocking you is usually a PDB (09-05) protecting bookings-postgres or redis-cache. Forcing it can cause data loss. Investigate first with kubectl get pdb -A.

9. etcd backups without /etc/kubernetes/pki. A snapshot without the CA will not give you back a working cluster. Back up both, together, encrypted and off the node.

10. kubeadm reset without cleaning iptables and the CNI. Leftovers cause network failures on reinstall. Run the full clean-up from section 14.

11. Not setting apt-mark hold. A routine apt upgrade that upgrades the kubelet in production is an incident waiting to happen.

Exercises

Exercise 1: node preparation script

Write an idempotent prepare-node.sh script (one that can be run twice without breaking anything) that leaves an Ubuntu machine ready for kubeadm join: swap permanently disabled, modules and sysctl, containerd with a verified SystemdCgroup=true, and kubeadm/kubelet/kubectl 1.30.4 pinned. The script must fail with a clear message if any check does not pass.

Exercise 2: a configuration file for HA

Write the kubeadm configuration file for the rutas-norte-lab cluster with these requirements: three control nodes behind api-k8s.rutasnorte.example (virtual IP 10.10.0.10), podSubnet 172.16.0.0/16, stacked etcd, auditing enabled, and the kubelet reserving 500m of CPU and 512Mi of memory for the system. Explain why you chose 172.16.0.0/16 and what you would check beforehand.

Exercise 3: upgrade and recovery plan

Write out the complete procedure, in order and with the exact commands, to upgrade the cluster from 1.30.4 to 1.31.1 with one control node and two workers, including the prior backup. Add the "point of no return" criterion and what you would do if the apiserver will not start after upgrade apply.

Solutions

Solution 1

#!/usr/bin/env bash
# prepare-node.sh — idempotent
set -euo pipefail
VER="1.30.4-1.1"

sudo swapoff -a
sudo sed -i '/\sswap\s/ s/^\([^#]\)/#\1/' /etc/fstab     # does not re-comment what is already commented
[[ $(swapon --show | wc -l) -eq 0 ]] || { echo "ERROR: swap active"; exit 1; }

printf 'overlay\nbr_netfilter\n' | sudo tee /etc/modules-load.d/k8s.conf >/dev/null
sudo modprobe overlay; sudo modprobe br_netfilter
printf 'net.bridge.bridge-nf-call-iptables=1\nnet.bridge.bridge-nf-call-ip6tables=1\nnet.ipv4.ip_forward=1\n' \
  | sudo tee /etc/sysctl.d/k8s.conf >/dev/null
sudo sysctl --system >/dev/null
[[ $(sysctl -n net.ipv4.ip_forward) == 1 ]] || { echo "ERROR: ip_forward"; exit 1; }

command -v containerd >/dev/null || sudo apt-get install -y containerd.io
sudo mkdir -p /etc/containerd
[[ -s /etc/containerd/config.toml ]] || \
  containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
grep -q 'SystemdCgroup = true' /etc/containerd/config.toml || { echo "ERROR: cgroup"; exit 1; }
sudo systemctl restart containerd && sudo systemctl enable containerd

sudo apt-mark unhold kubelet kubeadm kubectl 2>/dev/null || true
sudo apt-get install -y kubelet="$VER" kubeadm="$VER" kubectl="$VER"
sudo apt-mark hold kubelet kubeadm kubectl
sudo systemctl enable --now kubelet

The keys to idempotency: the sed that does not re-comment already-commented lines, the [[ -s ]] before regenerating containerd's configuration, and the unhold before installing.

Solution 2

apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: "v1.30.4"
clusterName: "rutas-norte-lab"
controlPlaneEndpoint: "api-k8s.rutasnorte.example:6443"
networking:
  podSubnet: "172.16.0.0/16"
  serviceSubnet: "10.96.0.0/12"
apiServer:
  certSANs: ["api-k8s.rutasnorte.example", "10.10.0.10",
             "10.10.0.11", "10.10.0.12", "10.10.0.13"]
  extraArgs:
    audit-log-path: "/var/log/kubernetes/audit.log"
    audit-policy-file: "/etc/kubernetes/audit-policy.yaml"
    audit-log-maxage: "30"
  extraVolumes:                       # mount the policy into the static pod
    - { name: audit-policy, hostPath: /etc/kubernetes/audit-policy.yaml,
        mountPath: /etc/kubernetes/audit-policy.yaml, readOnly: true, pathType: File }
etcd:
  local: { dataDir: "/var/lib/etcd" }
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: "systemd"
systemReserved: { cpu: "500m", memory: "512Mi" }
kubeReserved:   { cpu: "200m", memory: "256Mi" }
evictionHard:   { memory.available: "200Mi", nodefs.available: "10%" }

Why 172.16.0.0/16: it is a private range (RFC 1918) that collides neither with the lab network (10.10.0.0/24) nor with serviceSubnet (10.96.0.0/12), and it gives 65,536 IPs, which is plenty. Prior checks: that 172.16/16 is not used on the corporate network or the VPN, and configuring the CNI with that same CIDR.

Solution 3

# --- PHASE 0: prior backup (the point of return) ---
sudo etcdctl snapshot save /var/backups/pre-131.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
sudo tar czf /var/backups/pki-pre-131.tgz /etc/kubernetes/pki
sudo etcdutl --write-out=table snapshot status /var/backups/pre-131.db   # verify
kubectl get --raw /metrics | grep apiserver_requested_deprecated_apis     # deprecated APIs

# --- PHASE 1: rn-control-1 ---
sudo apt-mark unhold kubeadm
sudo sed -i 's|v1.30|v1.31|' /etc/apt/sources.list.d/kubernetes.list
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | \
  sudo gpg --dearmor --yes -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
sudo apt-get update && sudo apt-get install -y kubeadm=1.31.1-1.1 && sudo apt-mark hold kubeadm
sudo kubeadm upgrade plan
sudo kubeadm upgrade apply v1.31.1        # <-- POINT OF NO RETURN
kubectl drain rn-control-1 --ignore-daemonsets --delete-emptydir-data
sudo apt-mark unhold kubelet kubectl && sudo apt-get install -y kubelet=1.31.1-1.1 kubectl=1.31.1-1.1
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload && sudo systemctl restart kubelet
kubectl uncordon rn-control-1 && kubectl get nodes

# --- PHASES 2 and 3: rn-worker-1, then rn-worker-2 (one at a time) ---
# repository + kubeadm + `sudo kubeadm upgrade node` + drain + kubelet + uncordon

Point of no return: kubeadm upgrade apply, because it migrates etcd's schema. Before it, reverting the packages is enough; after it, going back demands restoring the snapshot.

If the apiserver will not start: sudo crictl ps -a | grep apiserver, sudo crictl logs <id> and journalctl -u kubelet -f. Typical causes: an unsupported new flag or a badly generated certificate. If it is not resolved within the maintenance window, restore pre-131.db and pki-pre-131.tgz using the procedure in section 13.2 and revert the packages to 1.30.4.

Conclusion

You now know how to build a Kubernetes cluster from scratch on your own machines. The essentials:

  • kubeadm bootstraps a conformant cluster; it does not provision machines and does not install the CNI. That boundary explains 90% of the initial confusion, including the NotReady node.
  • Preparing the machines — swap, overlay and br_netfilter, ip_forward, ports, and above all SystemdCgroup = true in containerd — is where the hardest-to-diagnose failures are born.
  • A versioned configuration file (ClusterConfiguration, InitConfiguration, KubeletConfiguration) is infinitely better than a string of flags; controlPlaneEndpoint and certSANs set correctly from day one save you regenerating certificates later.
  • kubeadm leaves on the node static pods in /etc/kubernetes/manifests, the certificates in /etc/kubernetes/pki and the kubeconfigs: the architecture from 01-02, turned into files.
  • Nodes join with a token and the CA hash; the token expires after 24 hours and is regenerated with kubeadm token create --print-join-command. High availability demands a stable entry point and an odd number of etcd members, normally 3 stacked.
  • Certificates expire after a year, upgrades go one minor version at a time with the control plane before the worker nodes, and etcd backups (snapshot + pki, encrypted, off the node, with a rehearsed restore) are the difference between a bad day and the end of the company.

And the warning that bears repeating: operating this in production demands a dedicated team with on-call duty. In 10-06 we will see what a managed cluster saves you and at what price.

But first we have to solve the problem this module started with. We now know how to create clusters, both local and our own; what remains unsolved is Rutas Norte's 120 duplicated YAML files. In the next lesson, Helm, we will meet the Kubernetes package manager: charts, values, templates and releases. It is the tool we already used to install cert-manager (04-05) and kube-prometheus-stack (07-03) without explaining it; time to understand it properly and use it to package the Rutas Norte platform into a single chart with one values file per environment.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved