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
- What kubeadm is and what it is not
- Self-managed versus managed: when it makes sense
- Preparing the machines
- Installing containerd
- Installing kubeadm, kubelet and kubectl
kubeadm initwith a configuration file- What kubeadm creates on the node exactly
- Installing the CNI and why the nodes are NotReady
- Joining worker nodes
- A highly available control plane
- Certificate management
- Version upgrades
- etcd backup and restore
kubeadm resetand clean-up- Common mistakes and tips
- Exercises
- Conclusion
- 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
- 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.
- 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 0Why 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 toiptables. 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 # verifyip_forward = 1: the node must forward packets between interfaces. Without this, a pod onrn-worker-1cannot talk to one onrn-worker-2.bridge-nf-call-iptables = 1: switches on whatbr_netfiltermakes 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.
- 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.io4.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 containerdIf 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 -20If 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.
- 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.15.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 kubeletWhy 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.
kubeadm init with a configuration file
kubeadm init with a configuration fileYou 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: trueWe 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 nodesNotReady. 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.
- 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
-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.yamlThis 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-systemdoes not really remove it: the kubelet recreates it, because the source of truth is the file.- If the apiserver will not start,
kubectldoes not work. Debug it withcrictl ps -aandcrictl logs, or withjournalctl -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-planeetcd-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 3m7.2 Certificates
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 thekubeadm:cluster-adminsgroup, 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).
- Installing the CNI and why the nodes are NotReady
Type Status Reason Message
Ready False KubeletNotReady container runtime network not
ready: NetworkReady=false
reason:NetworkPluginNotReady
message:Network plugin returns
error: cni plugin not initializedThe 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 minuteAnd 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
- 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 typebootstrap.kubernetes.io/tokeninkube-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-verificationoutside 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-commandkubeadm 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
- 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 2It 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.key10.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:
[upload-certs] Using certificate key:
f0e1d2c3b4a5968778695a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4dTo 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.
- 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:
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
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 9yCAs 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 nodesA 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.
- 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.
apiserver_requested_deprecated_apis{group="flowcontrol.apiserver.k8s.io",
removed_release="1.32",resource="flowschemas",version="v1beta3"} 1Any 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 versionCOMPONENT 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# 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 nodesNAME 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.412.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 oneOne 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.
- 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 -ACritical points:
- On an HA cluster, you have to restore on all three control nodes, with the identical snapshot and each one with its own
--nameand 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 -Aagainstcrictl pson 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).
kubeadm reset and clean-up
kubeadm reset and clean-upWhen 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/.kubeIf 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 kubeletThe 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 + uncordonPoint 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
NotReadynode. - Preparing the machines — swap,
overlayandbr_netfilter,ip_forward, ports, and above allSystemdCgroup = truein 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;controlPlaneEndpointandcertSANsset 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/pkiand 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
