How to Setup Kubernetes Cluster on Ubuntu 20.04 LTS with kubeadm

Kubeadm is the tool you use to build a Kubernetes cluster. It handles the bootstrap, and it sticks around afterwards for upgrades, downgrades and managing bootstrap tokens.

Before it does anything else it runs a batch of prechecks against the machine to confirm it’s actually ready to run Kubernetes. Worth paying attention to those. Then it downloads and installs the control plane components and configures the cluster resources they need.

The control plane pieces it puts in place:

  • kube-apiserver
  • kube-controller-manager
  • kube-scheduler
  • etcd

And the runtime side:

  • kubelet
  • kube-proxy
  • container runtime

Prerequisites

2 or 3 Ubuntu 20.04 LTS System with Minimal Installation
Minimum 2 or more CPU, 3 GB RAM.
Disable SWAP on All node
SSH Access with sudo privileges

AWS Security Group Inbound Traffic Ports for Kubernetes Cluster

Control-plane node(s)

Protocol Direction Port Range Purpose Used By:
TCP Inbound 6443* Kubernetes API server All
TCP Inbound 2379-2380 etcd server client API kube-apiserver, etcd
TCP Inbound 10250 Kubelet API Self, Control plane
TCP Inbound 10251 kube-scheduler Self
TCP Inbound 10252 kube-controller-manager Self

Worker node(s)

Protocol Direction Port Range Purpose Used By:
TCP Inbound 10250 Kubelet API Self, Control plane
TCP Inbound 30000-32767 NodePort Services† All
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release

Install Docker

sudo apt-get install docker.io
sudo usermod -aG docker $USER
sudo chmod 666 /var/run/docker.sock
sudo systemctl start docker.service
sudo systemctl status docker.service
sudo systemctl enable docker.service
sudo systemctl restart docker

Add Kubernetes GPG Key on All node
sudo curl -fsSLo /usr/share/keyrings/kubernetes-archive-keyring.gpg https://packages.cloud.google.com/apt/doc/apt-key.gpg

Add Kubernetes apt repository on all node for Ubuntu.
echo "deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list

sudo curl -fsSLo /usr/share/keyrings/kubernetes-archive-keyring.gpg https://packages.cloud.google.com/apt/doc/apt-key.gpg
echo "deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list
deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main

update the system packages
sudo apt-get update

Install Kubeadm, Kubelet, and Kubectl on All Nodes

Now for kubeadm, kubelet and kubectl. I’m using Kubernetes version 1.21.4-00 for this setup.
sudo apt-get install -y kubelet=1.21.4-00 kubeadm=1.21.4-00 kubectl=1.21.4-00 kubernetes-cni

How to Setup Kubernetes Cluster on Ubuntu 20.04 LTS with kubeadm

Time to initialise the master. This one runs on the master node:
sudo kubeadm init --pod-network-cidr 10.0.0.0/16

I0318 09:50:06.413354   20536 version.go:254] remote version is much newer: v1.23.5; falling back to: stable-1.21
[init] Using Kubernetes version: v1.21.11
[preflight] Running pre-flight checks
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [ip-172-31-21-67 kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local] and IPs [10.96.0.1 172.31.21.67]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [ip-172-31-21-67 localhost] and IPs [172.31.21.67 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [ip-172-31-21-67 localhost] and IPs [172.31.21.67 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s
[apiclient] All control plane components are healthy after 8.005780 seconds
[upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace
[kubelet] Creating a ConfigMap "kubelet-config-1.21" in namespace kube-system with the configuration for the kubelets in the cluster
[upload-certs] Skipping phase. Please see --upload-certs
[mark-control-plane] Marking the node ip-172-31-21-67 as control-plane by adding the labels: [node-role.kubernetes.io/master(deprecated) node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]
[mark-control-plane] Marking the node ip-172-31-21-67 as control-plane by adding the taints [node-role.kubernetes.io/master:NoSchedule]
[bootstrap-token] Using token: bo6g2v.1g5np9bs5c2yvg87
[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles
[bootstrap-token] configured RBAC rules to allow Node Bootstrap tokens to get nodes
[bootstrap-token] configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials
[bootstrap-token] configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token
[bootstrap-token] configured RBAC rules to allow certificate rotation for all node client certificates in the cluster
[bootstrap-token] Creating the "cluster-info" ConfigMap in the "kube-public" namespace
[kubelet-finalize] Updating "/etc/kubernetes/kubelet.conf" to point to a rotatable kubelet client certificate and key
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy

Your Kubernetes control-plane has initialized successfully!

Create new '.kube' configuration directory and copy the configuration 'admin.conf' from '/etc/kubernetes' directory.

To start using your cluster, you need to run the following as a 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

Alternatively, if you are the root user, you can run:

  export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
  https://kubernetes.io/docs/concepts/cluster-administration/addons/

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 172.31.21.67:6443 --token bo6g2v.1g5np9bs5c2yvg87 \
        --discovery-token-ca-cert-hash sha256:c095ea351cdff6eabbec851dced588052090b2595758a70d5d4772547f13571c
Copy the join token out of that output and keep it somewhere. You’ll need it in a moment to attach the worker to the master.

Worker Node

sudo kubeadm join 172.31.21.67:6443 --token bo6g2v.1g5np9bs5c2yvg87 \
    --discovery-token-ca-cert-hash sha256:c095ea351cdff6eabbec851dced588052090b2595758a70d5d4772547f13571c
[preflight] Running pre-flight checks
[preflight] Reading configuration from the cluster...
[preflight] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Starting the kubelet
[kubelet-start] Waiting for the kubelet to perform the TLS Bootstrap...

This node has joined the cluster:

* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.

Run 'kubectl get nodes' on the control-plane to see this node join the cluster.
Now check whether the worker has actually connected to the master:
sudo kubectl get nodes
NAME              STATUS     ROLES                  AGE    VERSION
ip-172-31-21-67   NotReady   control-plane,master   4m6s   v1.21.4

sudo kubectl get nodes
NAME              STATUS   ROLES                  AGE   VERSION
ip-172-31-20-59   Ready    <none>                7m2s   v1.21.4
ip-172-31-21-67   Ready   control-plane,master   6m6s   v1.21.4

Disable Swap

Turn off swap
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
sudo swapoff -a

Enable kernel modules and configure sysctl.

sudo modprobe overlay
sudo modprobe br_netfilter

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

Reload sysctl

sudo sysctl --system

Configure Kubernetes Network With Flannel

kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
Warning: policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+
podsecuritypolicy.policy/psp.flannel.unprivileged created
clusterrole.rbac.authorization.k8s.io/flannel created
clusterrolebinding.rbac.authorization.k8s.io/flannel created
serviceaccount/flannel created
configmap/kube-flannel-cfg created
daemonset.apps/kube-flannel-ds created

kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/k8s-manifests/kube-flannel-rbac.yml
clusterrole.rbac.authorization.k8s.io/flannel configured
clusterrolebinding.rbac.authorization.k8s.io/flannel unchanged

kubectl get pods --all-namespaces
NAMESPACE     NAME                                      READY   STATUS    RESTARTS   AGE
kube-system   coredns-558bd4d5db-dfq22                  1/1     Running   0          6m46s
kube-system   coredns-558bd4d5db-dtzwx                  1/1     Running   0          6m46s
kube-system   etcd-ip-172-31-5-181                      1/1     Running   0          6m59s
kube-system   kube-apiserver-ip-172-31-5-181            1/1     Running   0          6m59s
kube-system   kube-controller-manager-ip-172-31-5-181   1/1     Running   0          6m59s
kube-system   kube-flannel-ds-cfd9f                     1/1     Running   0          86s
kube-system   kube-flannel-ds-mqdqp                     1/1     Running   0          86s
kube-system   kube-proxy-jwmrf                          1/1     Running   0          6m47s
kube-system   kube-proxy-rp6jd                          1/1     Running   0          4m46s
kube-system   kube-scheduler-ip-172-31-5-181            1/1     Running   0          6m59s

kubectl get serviceaccounts
NAME      SECRETS   AGE
default   1         7m5s

kubectl -n kube-system get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE     IP             NODE              NOMINATED NODE   READINESS GATES
coredns-558bd4d5db-dfq22                  1/1     Running   0          7m14s   10.0.0.3       ip-172-31-5-181   <none>           <none>
coredns-558bd4d5db-dtzwx                  1/1     Running   0          7m14s   10.0.0.2       ip-172-31-5-181   <none>           <none>
etcd-ip-172-31-5-181                      1/1     Running   0          7m27s   172.31.5.181   ip-172-31-5-181   <none>           <none>
kube-apiserver-ip-172-31-5-181            1/1     Running   0          7m27s   172.31.5.181   ip-172-31-5-181   <none>           <none>
kube-controller-manager-ip-172-31-5-181   1/1     Running   0          7m27s   172.31.5.181   ip-172-31-5-181   <none>           <none>
kube-flannel-ds-cfd9f                     1/1     Running   0          114s    172.31.20.59   ip-172-31-20-59   <none>           <none>
kube-flannel-ds-mqdqp                     1/1     Running   0          114s    172.31.5.181   ip-172-31-5-181   <none>           <none>
kube-proxy-jwmrf                          1/1     Running   0          7m15s   172.31.5.181   ip-172-31-5-181   <none>           <none>
kube-proxy-rp6jd                          1/1     Running   0          5m14s   172.31.20.59   ip-172-31-20-59   <none>           <none>
kube-scheduler-ip-172-31-5-181            1/1     Running   0          7m27s   172.31.5.181   ip-172-31-5-181   <none>           <none>

kubectl -n kube-system get deployments
NAME      READY   UP-TO-DATE   AVAILABLE   AGE
coredns   2/2     2            2           7m38s

kubectl -n kube-system get configmap coredns -oyaml
apiVersion: v1
data:
  Corefile: |
    .:53 {
        errors
        health {
           lameduck 5s
        }
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
           pods insecure
           fallthrough in-addr.arpa ip6.arpa
           ttl 30
        }
        prometheus :9153
        forward . /etc/resolv.conf {
           max_concurrent 1000
        }
        cache 30
        loop
        reload
        loadbalance
    }
kind: ConfigMap
metadata:
  creationTimestamp: "2022-03-25T10:12:11Z"
  name: coredns
  namespace: kube-system
  resourceVersion: "241"
  uid: 8213dc7e-2026-4428-bfe7-a2c4a49e4084

kubectl get nodes
NAME              STATUS   ROLES                  AGE    VERSION
ip-172-31-20-59   Ready    <none>                 8m6s   v1.21.4
ip-172-31-5-181   Ready    control-plane,master   10m    v1.21.4

kubectl get serviceaccounts
kubectl get pods --all-namespaces
kubectl -n kube-system get pods -o wide
kubectl -n kube-system get deployments
kubectl -n kube-system get configmap coredns -o yaml

Deploy Nginx microservice on Kubernetes

Next, the Nginx manifests. A Service and a Deployment, which between them get the web server running on the cluster.
vim nginx-web.yaml

apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  type: NodePort
  selector:
    app: nginx-app
    type: front-end
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30011
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx-app
    type: front-end
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx-app
      type: front-end
  template:
    metadata:
      labels:
        app: nginx-app
        type: front-end
    spec:
      containers:
        - name: nginx-container
          image: nginx:latest

Let's create the nginx web server on kubeadm.

kubectl apply --filename nginx-web.yaml
service/nginx-service created
deployment.apps/nginx-deployment created

kubectl get pods
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-56cbd5d774-7n9z2   1/1     Running   0          9s
nginx-deployment-56cbd5d774-97hzg   1/1     Running   0          9s
nginx-deployment-56cbd5d774-kpkh2   1/1     Running   0          9s

kubectl get pods -o wide
NAME                                READY   STATUS    RESTARTS   AGE    IP            NODE              NOMINATED NODE   READINESS GATES
nginx-deployment-56cbd5d774-7n9z2   1/1     Running   0          6m2s   10.244.2.15   ip-172-31-20-59   <none>           <none>
nginx-deployment-56cbd5d774-97hzg   1/1     Running   0          6m2s   10.244.2.14   ip-172-31-20-59   <none>           <none>
nginx-deployment-56cbd5d774-kpkh2   1/1     Running   0          6m2s   10.244.2.16   ip-172-31-20-59   <none>           <none>

kubectl get deployments
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3/3     3            3           22s

kubectl get svc
NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
kubernetes      ClusterIP   10.96.0.1       <none>        443/TCP        27d
nginx-service   NodePort    10.110.241.41   <none>        80:30011/TCP   17s

Verify Deployed Services

kubectl exec nginx-deployment-56cbd5d774-7n9z2  -- curl http://localhost

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 <!DOCTYPE html>0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>
  615  100   615    0     0   150k      0 --:--:-- --:--:-- --:--:--  150k

Remove the Nginx App from kubeadm

kubectl delete --filename nginx-web.yaml
service "nginx-service" deleted
deployment.apps "nginx-deployment" deleted
$ kubectl get pods -n kube-system -o wide
Note: seeing this error during cluster setup? Add the docker daemon config to systemd as the cgroup driver, and the warning stops coming back.
[preflight] Running pre-flight checks
        [WARNING IsDockerSystemdCheck]: detected "cgroupfs" as the Docker cgroup driver. The recommended driver is "systemd". Please follow the guide at https://kubernetes.io/docs/setup/cri/

Add the Docker Daemon configurations to use systemd as the cgroup driver.

cat <<EOF | sudo tee /etc/docker/daemon.json
{
  "exec-opts": ["native.cgroupdriver=systemd"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m"
  },
  "storage-driver": "overlay2"
}
EOF

How to Remove Worker Node from Cluster:

Find the node with
kubectl get nodes

Drain it with 
kubectl drain mynode (Specify node name)

Delete it with 
kubectl delete node mynode

Conclusion

So that’s a working Kubernetes cluster on Ubuntu 20.04 LTS, built with kubeadm. A master, a worker joined to it, networking sorted with Flannel, and something real deployed on top. It’s a straight walkthrough rather than a deep dive, on purpose. Get the cluster standing up first, because everything else about Kubernetes is far easier to learn once you’ve got somewhere to run it.
Asif Khan

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.