Kubernetes Examples on AlmaLinux 10: Build a kubeadm Cluster and Deploy Apps

Kubernetes is the standard way to run containers across more than one server, and kubeadm is the upstream tool for building a conformant cluster yourself. This guide builds a three-node Kubernetes cluster on AlmaLinux 10 with containerd and Calico, then walks through practical examples: Deployments, Services, ConfigMaps and Secrets, scaling, rolling updates and rollbacks, Jobs and CronJobs, and resource limits.

Versions at the time of writing: AlmaLinux 10.2, Kubernetes 1.37 from the official pkgs.k8s.io repository, containerd 2.4 and Calico 3.33. The same steps work on RHEL 10, Rocky Linux 10 and Oracle Linux 10, and with small changes on the EL9 family. If you are new to containers, start with Docker Examples on AlmaLinux 10; for the bigger picture, see Kubernetes in 2026.

Lab layout

  • k8s-cp1.example.com 192.168.1.51, control plane
  • k8s-w1.example.com 192.168.1.52, worker
  • k8s-w2.example.com 192.168.1.53, worker
  • Pod network 10.244.0.0/16 and Service network 10.96.0.0/12 (kubeadm default), chosen so they do not overlap the 192.168.1.0/24 LAN

Prerequisites

  • Three AlmaLinux 10 servers with at least 2 vCPUs and 2 GB RAM each (4 GB on the control plane is more comfortable) and 20 GB free disk
  • Unique hostnames, MAC addresses and /sys/class/dmi/id/product_uuid values on every node (watch out for cloned VMs)
  • Full network connectivity between the nodes and outbound internet access to pkgs.k8s.io, registry.k8s.io, download.docker.com, quay.io and GitHub
  • A user with sudo rights on every node

Step 1: Prepare every node

Run the commands in Steps 1 to 4 on all three nodes. Start with name resolution; skip the /etc/hosts entries if DNS already resolves the names.

sudo hostnamectl set-hostname k8s-cp1.example.com   # k8s-w1 / k8s-w2 on the workers
cat <<EOF | sudo tee -a /etc/hosts
192.168.1.51 k8s-cp1.example.com k8s-cp1
192.168.1.52 k8s-w1.example.com  k8s-w1
192.168.1.53 k8s-w2.example.com  k8s-w2
EOF

The kubelet refuses to start with swap enabled by default, so turn swap off now and on every boot:

sudo swapoff -a
sudo sed -i '/\sswap\s/ s/^/#/' /etc/fstab
free -h      # the Swap line should show 0B

Load the kernel modules and sysctls that container networking needs:

cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter

cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.ipv4.ip_forward                 = 1
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
sudo sysctl --system
sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables

The upstream kubeadm instructions for Red Hat-based systems put SELinux in permissive mode, because the kubelet and several network plugins need host file access that the default policy blocks:

sudo setenforce 0
sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config
getenforce

Step 2: Open the firewall ports

Kubernetes and Calico need these ports between the nodes. On the control plane:

sudo firewall-cmd --permanent --add-port={6443,2379-2380,10250,10257,10259}/tcp
sudo firewall-cmd --permanent --add-port={179,5473}/tcp --add-port=4789/udp
sudo firewall-cmd --permanent --zone=trusted --add-source={10.244.0.0/16,10.96.0.0/12}
sudo firewall-cmd --reload

On each worker:

sudo firewall-cmd --permanent --add-port={10250,10256}/tcp --add-port=30000-32767/tcp
sudo firewall-cmd --permanent --add-port={179,5473}/tcp --add-port=4789/udp
sudo firewall-cmd --permanent --zone=trusted --add-source={10.244.0.0/16,10.96.0.0/12}
sudo firewall-cmd --reload

Port 6443 is the API server, 2379-2380 etcd, 10250 the kubelet, 10256 kube-proxy health checks and 30000-32767 the NodePort range. Calico uses 179/tcp for BGP, 4789/udp for VXLAN and 5473/tcp for Typha. Trusting the Pod and Service ranges stops firewalld from rejecting forwarded container traffic.

Step 3: Install and configure containerd

containerd is the container runtime the kubelet talks to. The containerd.io package from Docker’s repository is current and well maintained on EL10. Remove the conflicting Podman packages first, as in the Docker guide:

sudo dnf remove -y podman buildah runc
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf -y install containerd.io

The packaged /etc/containerd/config.toml disables the CRI plugin that Kubernetes needs. Replace it with a full default configuration and switch runc to the systemd cgroup driver, which is what kubeadm configures the kubelet to use on cgroup v2 hosts:

sudo mv /etc/containerd/config.toml /etc/containerd/config.toml.orig
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
grep -n 'SystemdCgroup' /etc/containerd/config.toml
sudo systemctl enable --now containerd
sudo systemctl restart containerd

The grep should show SystemdCgroup = true under the [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options] section.

Step 4: Install kubeadm, kubelet and kubectl

Add the community-owned Kubernetes repository. Each minor version has its own repository, and the exclude line keeps a routine dnf upgrade from upgrading Kubernetes behind your back:

cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.37/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.37/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF

sudo dnf install -y kubelet kubeadm kubectl \
  --setopt=disable_excludes=kubernetes --setopt=install_weak_deps=False
sudo systemctl enable --now kubelet
kubeadm version -o short

The kubelet restarts every few seconds until kubeadm init or kubeadm join gives it a configuration. That is expected. Point crictl at containerd so you can debug containers on the node later:

cat <<EOF | sudo tee /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
EOF

Step 5: Initialize the control plane

On k8s-cp1 only, pre-pull the images and create the cluster. Setting --control-plane-endpoint to a DNS name now makes it possible to add more control-plane nodes or a load balancer later.

sudo kubeadm config images pull
sudo kubeadm init \
  --control-plane-endpoint=k8s-cp1.example.com:6443 \
  --apiserver-advertise-address=192.168.1.51 \
  --pod-network-cidr=10.244.0.0/16

When it finishes, kubeadm prints a kubeadm join command. Save it. Then set up kubectl for your regular 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

The node shows NotReady until a Pod network is installed.

Step 6: Install the Calico Pod network

Calico provides Pod networking and NetworkPolicy. Install its CRDs and the Tigera operator (Kubernetes 1.36 and later use the v3_projectcalico_org.yaml CRDs):

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

Download the default custom resources and change the IP pool to match the --pod-network-cidr you passed to kubeadm:

curl -O https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/custom-resources.yaml
sed -i 's#cidr: 192.168.0.0/16#cidr: 10.244.0.0/16#' custom-resources.yaml
grep cidr custom-resources.yaml
kubectl create -f custom-resources.yaml
watch kubectl get tigerastatus

Wait until every component reports AVAILABLE True, then press Ctrl+C. kubectl get nodes should now show the control plane as Ready.

Step 7: Join the worker nodes

On k8s-w1 and k8s-w2, run the join command printed by kubeadm init. It looks like this:

sudo kubeadm join k8s-cp1.example.com:6443 --token abcdef.0123456789abcdef \
  --discovery-token-ca-cert-hash sha256:<hash>

Tokens expire after 24 hours. To print a fresh join command later, run this on the control plane:

sudo kubeadm token create --print-join-command

Step 8: Verify the cluster

kubectl get nodes -o wide
kubectl get pods -A
kubectl cluster-info
kubectl label node k8s-w1.example.com k8s-w2.example.com node-role.kubernetes.io/worker=

All three nodes should be Ready, and every Pod in kube-system, calico-system and tigera-operator should be Running. The last command only adds a friendly role label to the workers. Enable shell completion while you are here:

echo 'source <(kubectl completion bash)' >> ~/.bashrc
source ~/.bashrc

Example 1: A namespace, a Deployment and a Service

Keep the examples in their own namespace so cleanup is one command. Save this as web.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:1.28
        ports:
        - containerPort: 80
        resources:
          requests:
            cpu: 100m
            memory: 64Mi
          limits:
            memory: 128Mi
        readinessProbe:
          httpGet:
            path: /
            port: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: demo
spec:
  type: NodePort
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 80
    nodePort: 30080
kubectl apply -f web.yaml
kubectl -n demo get deploy,rs,pods,svc -o wide
curl -I http://192.168.1.52:30080

The Deployment keeps two nginx Pods running and spreads them across the workers. The NodePort Service makes them reachable on port 30080 of every node, and the readiness probe keeps a Pod out of the Service until nginx answers.

Example 2: Scale, roll out an update and roll back

kubectl -n demo scale deployment web --replicas=4
kubectl -n demo get pods -l app=web

kubectl -n demo set image deployment/web nginx=nginx:1.29
kubectl -n demo rollout status deployment/web
kubectl -n demo rollout history deployment/web

kubectl -n demo rollout undo deployment/web
kubectl -n demo get deploy web -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'

A rolling update replaces Pods a few at a time, so the Service keeps answering throughout. rollout undo returns to the previous ReplicaSet. In production, change the image in the YAML file and re-apply it so Git stays the source of truth.

Example 3: ConfigMaps and Secrets

Keep configuration and credentials out of the image. Create a ConfigMap holding a custom page and a Secret holding a password:

kubectl -n demo create configmap web-content \
  --from-literal=index.html='<h1>Served by Kubernetes on AlmaLinux 10</h1>'
kubectl -n demo create secret generic db-cred \
  --from-literal=username=app --from-literal=password="$(openssl rand -base64 18)"

Mount the ConfigMap as files and expose the Secret as environment variables by patching the Deployment:

kubectl -n demo patch deployment web --type=strategic -p '
spec:
  template:
    spec:
      volumes:
      - name: content
        configMap:
          name: web-content
      containers:
      - name: nginx
        volumeMounts:
        - name: content
          mountPath: /usr/share/nginx/html
        envFrom:
        - secretRef:
            name: db-cred
'
kubectl -n demo rollout status deployment/web
curl http://192.168.1.52:30080
kubectl -n demo exec deploy/web -- printenv username

Secrets are only base64-encoded in etcd by default. Limit who can read them with RBAC, and consider enabling encryption at rest for Secrets on the API server for production clusters.

Example 4: Jobs and CronJobs

A Job runs Pods to completion; a CronJob creates Jobs on a schedule.

kubectl -n demo create job pi --image=perl:5.40 -- perl -Mbignum=bpi -wle 'print bpi(500)'
kubectl -n demo wait --for=condition=complete job/pi --timeout=120s
kubectl -n demo logs job/pi

kubectl -n demo create cronjob hello --image=busybox:1.37 \
  --schedule='*/5 * * * *' -- /bin/sh -c 'date; echo Hello from the cluster'
kubectl -n demo get cronjob hello
kubectl -n demo create job hello-now --from=cronjob/hello
kubectl -n demo logs job/hello-now

Example 5: Resource quotas and debugging

A ResourceQuota stops one namespace from consuming the whole cluster:

kubectl -n demo create quota demo-quota \
  --hard=requests.cpu=2,requests.memory=2Gi,limits.memory=4Gi,pods=20
kubectl -n demo describe quota demo-quota

The commands you will use most when something misbehaves:

kubectl -n demo describe pod <pod-name>          # events, probe failures, scheduling
kubectl -n demo logs <pod-name> --previous        # logs from the last crashed container
kubectl -n demo get events --sort-by=.lastTimestamp
kubectl -n demo exec -it deploy/web -- sh
kubectl run tmp --rm -it --image=busybox:1.37 --restart=Never -- \
  wget -qO- http://web.demo.svc.cluster.local

The last command starts a throwaway Pod and fetches the Service by its cluster DNS name, which proves CoreDNS and Pod-to-Service networking work. When you are finished, remove everything with kubectl delete namespace demo.

Troubleshooting

  • kubeadm preflight fails on swap. Run swapon --show. If anything is listed, repeat the swap commands in Step 1 and make sure no zram or swap unit is re-enabling it at boot (systemctl list-units --type swap).
  • container runtime is not running or CRI errors during init. The CRI plugin is still disabled. Check that /etc/containerd/config.toml was regenerated (it should not contain disabled_plugins = ["cri"]), restart containerd and run sudo crictl info | head.
  • Control-plane Pods keep restarting. This is the classic cgroup driver mismatch. Confirm SystemdCgroup = true in the containerd config and cgroupDriver: systemd in /var/lib/kubelet/config.yaml, then check sudo journalctl -u kubelet -b --no-pager | tail -50.
  • Nodes stay NotReady. The Pod network is not up. Run kubectl get tigerastatus and kubectl -n calico-system get pods, and make sure the cidr in custom-resources.yaml matches --pod-network-cidr.
  • Pods on different nodes cannot reach each other, or DNS times out. Usually the firewall. Re-check the Calico ports and trusted sources from Step 2 with sudo firewall-cmd --list-all --zone=public and --zone=trusted. Temporarily stopping firewalld on a lab cluster quickly confirms whether it is the cause.
  • kubeadm join fails with an expired token or a certificate hash error. Generate a new command with kubeadm token create --print-join-command, and check that the worker can resolve k8s-cp1.example.com and reach port 6443.
  • Pods stuck in ImagePullBackOff. Run kubectl describe pod to see the exact registry error, then test from the node with sudo crictl pull <image>. Typos in tags and Docker Hub rate limits are the usual causes.
  • Starting over. On a node you want to rebuild, run sudo kubeadm reset -f, then remove /etc/cni/net.d and $HOME/.kube/config before running init or join again.

Next steps

From here, add persistent storage with a CSI driver. If you built the cluster from Deploy a Ceph Storage Cluster on AlmaLinux 10 with cephadm, the Ceph CSI driver gives you RBD block volumes and CephFS shared volumes. An Ingress controller, metrics-server and a GitOps workflow are the other usual additions. The official kubeadm cluster guide, container runtime notes and Calico on-premises install guide cover every option used here.

Leave a Reply

Your email address will not be published. Required fields are marked *