How To Create a Kubernetes Cluster on Ubuntu 18.04

What is Kubernetes

Running one container is easy. Running two hundred of them across a fleet of machines, restarting the ones that die, and adding more when traffic climbs, is a different job entirely. That job is called orchestration, and Kubernetes is what most teams reach for to do it.

What an orchestrator gives you is a way to describe what you want running, and then let something else worry about keeping it that way. Containers get placed on hosts for you. A crashed container comes back without anyone being paged. Load goes up, more replicas appear. Load drops, they go away again.

Kubernetes has won this space fairly decisively, which matters practically: it’s what the tooling targets, what the cloud providers offer as a managed service, and what job descriptions ask for. If you want the full picture, the official Kubernetes documentation is genuinely good. This guide is the hands-on half. Three Ubuntu 18.04 servers, one control plane, two workers, and a cluster you can actually schedule work on by the end.

Installing Docker

Kubernetes doesn’t run containers itself. It schedules them and hands the actual work to a container runtime, so that has to be in place before anything else. Docker is the one this guide uses.

All three servers need it, not just the master. A worker with no runtime will join the cluster and then sit there, unable to start a single pod.

Here are the commands we are going to use to setup docker:

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

sudo add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"

sudo apt-get update

sudo apt-get install -y docker-ce=18.06.1~ce~3-0~ubuntu

$ sudo apt-mark hold docker-ce
docker-ce set on hold.

Check it took, on every server:

$ sudo docker version
Client:
 Version:           18.06.1-ce
 API version:       1.38
 Go version:        go1.10.3
 Git commit:        e68fc7a
 Built:             Tue Aug 21 17:24:51 2018
 OS/Arch:           linux/amd64
 Experimental:      false

Server:
 Engine:
  Version:          18.06.1-ce
  API version:      1.38 (minimum version 1.12)
  Go version:       go1.10.3
  Git commit:       e68fc7a
  Built:            Tue Aug 21 17:23:15 2018
  OS/Arch:          linux/amd64
  Experimental:     false

Installing Kubeadm, Kubelet, and Kubectl

With the runtime sorted, the Kubernetes packages come next. Three of them, and all three go on all three servers including the workers, which catches people out. Here is what each one is for.

Here are the commands used to install the Kubernetes components in this lesson. Run these on all three servers.

Kubeadm: Does the heavy lifting of standing the cluster up and joining nodes to it. Without it you’d be generating certificates and writing manifests by hand.
Kubelet: The agent that actually runs containers on a node. Every server that will run containers needs kubelet, workers included.
Kubectl: The command line client you talk to the cluster with. Strictly it only needs to be where you work, but having it everywhere saves a lot of hopping between shells.

NOTE: There are some issues being reported when installing version 1.12.2-00 from the Kubernetes ubuntu repositories. You can work around this by using version 1.12.7-00 for kubelet, kubeadm, and kubectl. Pinning the version like this is worth doing deliberately rather than taking whatever the repository offers, because a version mismatch between nodes causes problems that are tedious to track down later.

$ curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -
OK

$ cat << EOF | sudo tee /etc/apt/sources.list.d/kubernetes.list
> deb https://apt.kubernetes.io/ kubernetes-xenial main
> EOF

sudo apt-get update

sudo apt-get install -y kubelet=1.15.7-00 kubeadm=1.15.7-00 kubectl=1.15.7-00

sudo apt-mark hold kubelet kubeadm kubectl

Confirm kubeadm is there and working before going any further:

$ kubeadm version
kubeadm version: &version.Info{Major:"1", Minor:"15", GitVersion:"v1.15.7", GitCommit:"6c143d35bb11d74970e7bc0b6c45b6bfdffc0bd4", GitTreeState:"clean", BuildDate:"2019-12-11T12:40:15Z", GoVersion:"go1.12.12", Compiler:"gc", Platform:"linux/amd64"}

$ systemctl status kubelet
● kubelet.service - kubelet: The Kubernetes Node Agent
   Loaded: loaded (/lib/systemd/system/kubelet.service; enabled; vendor preset: enabled)
  Drop-In: /etc/systemd/system/kubelet.service.d
           └─10-kubeadm.conf
   Active: active (running) since Sat 2020-09-12 22:41:06 UTC; 2h 9min ago
     Docs: https://kubernetes.io/docs/home/
 Main PID: 13315 (kubelet)
    Tasks: 17 (limit: 2313)
   CGroup: /system.slice/kubelet.service
           └─13315 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.co

Making Active the Cluster

Now the cluster itself. The master gets initialised first, which sets up the control plane components, then each worker is joined to it. Nothing here is reversible in a satisfying way, so read the output rather than scrolling past it.

On the Kube master node, initialize the cluster:

$ sudo kubeadm init --pod-network-cidr=10.244.0.0/16
I0912 22:40:22.039846   12698 version.go:248] remote version is much newer: v1.19.1; falling back to: stable-1.15
[init] Using Kubernetes version: v1.15.12
[preflight] Running pre-flight checks
---
Your Kubernetes control-plane has initialized successfully!

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

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.24.191:6443 --token ei5f3h.ffmlt3ube5qb2b76 \
    --discovery-token-ca-cert-hash sha256:45252cd1ebf00694e5b9f0084363702741d6ed6f1bad026b3ef37df393254d39

Give it a few minutes. It pulls the control plane images and waits for them to come up, so a slow connection makes this the longest step in the whole guide. When it finishes, set up the local kubeconfig:

$ mkdir -p $HOME/.kube
$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
$ sudo chown $(id -u):$(id -g) $HOME/.kube/config

That kubeconfig is what tells kubectl where the cluster is and how to authenticate. Skip it and every command afterwards fails with a connection refused, which sends people hunting for a networking problem that doesn’t exist. Check it worked. You should get a Server Version as well as a Client Version, something like this:

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"15", GitVersion:"v1.15.7", GitCommit:"6c143d35bb11d74970e7bc0b6c45b6bfdffc0bd4", GitTreeState:"clean", BuildDate:"2019-12-11T12:42:56Z", GoVersion:"go1.12.12", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"15", GitVersion:"v1.15.12", GitCommit:"e2a822d9f3c2fdb5c9bfbe64313cf9f657f0a725", GitTreeState:"clean", BuildDate:"2020-05-06T05:09:48Z", GoVersion:"go1.12.17", Compiler:"gc", Platform:"linux/amd64"}

$ kubectl get nodes
NAME     STATUS     ROLES    AGE     VERSION
master   NotReady   master   8m48s   v1.15.7

The kubeadm init command should output a kubeadm join command containing a token and hash. Copy that command and run it with sudo on both worker nodes. It should look something like this:

Worker Node 1

$ sudo kubeadm join 172.31.24.191:6443 --token ei5f3h.ffmlt3ube5qb2b76 \
>     --discovery-token-ca-cert-hash sha256:45252cd1ebf00694e5b9f0084363702741d6ed6f1bad026b3ef37df393254d39
[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 -oyaml'
[kubelet-start] Downloading configuration for the kubelet from the "kubelet-config-1.15" ConfigMap in the kube-system namespace
[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] Activating the kubelet service
[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.

Worker Node 2

$ sudo kubeadm join 172.31.24.191:6443 --token ei5f3h.ffmlt3ube5qb2b76 \
>     --discovery-token-ca-cert-hash sha256:45252cd1ebf00694e5b9f0084363702741d6ed6f1bad026b3ef37df393254d39
[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 -oyaml'
[kubelet-start] Downloading configuration for the kubelet from the "kubelet-config-1.15" ConfigMap in the kube-system namespace
[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] Activating the kubelet service
[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.

Verifying on Master

Back on the master, confirm both workers actually arrived:

$ kubectl get nodes
NAME     STATUS     ROLES    AGE     VERSION
master   NotReady   master   12m     v1.15.7
node1    NotReady   <none>   3m1s    v1.15.7
node2    NotReady   <none>   2m20s   v1.15.7

Configuring Networking with Flannel

At this point the cluster exists but it can’t do very much. Pods have no way to talk to each other across nodes, because Kubernetes deliberately leaves networking to a plugin rather than shipping one. Until you install a network add-on, nodes stay NotReady and nothing schedules properly.

Flannel is the one used here. It isn’t the most featureful option, and it’s the least fiddly, which is what you want the first time. You can find more information on Flannel at the project’s home on GitHub: github.com/flannel-io/flannel. The old coreos.com documentation link now redirects somewhere unrelated, since CoreOS was acquired and the project moved.

On all three nodes, run the following:

$ echo "net.bridge.bridge-nf-call-iptables=1" | sudo tee -a /etc/sysctl.conf
net.bridge.bridge-nf-call-iptables=1

$ sudo sysctl -p
net.bridge.bridge-nf-call-iptables = 1

Install Flannel in the cluster by running this only on the Master node:

$ kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml
clusterrole.rbac.authorization.k8s.io/flannel created
clusterrolebinding.rbac.authorization.k8s.io/flannel created
serviceaccount/flannel created
configmap/kube-flannel-cfg created
daemonset.extensions/kube-flannel-ds-amd64 created
daemonset.extensions/kube-flannel-ds-arm64 created
daemonset.extensions/kube-flannel-ds-arm created
daemonset.extensions/kube-flannel-ds-ppc64le created
daemonset.extensions/kube-flannel-ds-s390x created

Then watch the nodes flip over:

$ kubectl get nodes

You should see all three of your servers listed, and all should have a STATUS of Ready. It should look something like this:

$ kubectl get nodes
NAME     STATUS   ROLES    AGE   VERSION
master   Ready    master   33m   v1.15.7
node1    Ready    <none>   24m   v1.15.7
node2    Ready    <none>   23m   v1.15.7

Note: it may take a few moments for all nodes to enter the Ready status, so if they are not all Ready, wait a few moments and try again. That’s normal rather than a sign something’s gone wrong.

Worth checking the Flannel pods themselves too, since a node can report Ready while its networking is still coming up:

$ kubectl get pods -n kube-system
NAME                             READY   STATUS    RESTARTS   AGE
coredns-5d4dd4b4db-tpqjx         1/1     Running   0          35m
coredns-5d4dd4b4db-wk8qs         1/1     Running   0          35m
etcd-master                      1/1     Running   0          34m
kube-apiserver-master            1/1     Running   0          34m
kube-controller-manager-master   1/1     Running   0          34m
kube-flannel-ds-amd64-5dm6b      1/1     Running   0          2m26s
kube-flannel-ds-amd64-cxll8      1/1     Running   0          2m26s
kube-flannel-ds-amd64-s8b94      1/1     Running   0          2m26s
kube-proxy-fw9fm                 1/1     Running   0          35m
kube-proxy-ncgsj                 1/1     Running   0          25m
kube-proxy-swx4g                 1/1     Running   0          25m
kube-scheduler-master            1/1     Running   0          34m

You should have three pods with flannel in the name, and all three should have a status of Running. If one’s stuck in CrashLoopBackOff, that’s almost always the pod network CIDR not matching what Flannel expects.

Conclusion

That’s a working cluster. Three nodes, all Ready, networking in place, and kubectl talking to it from the master.

What you have built here is a learning cluster rather than a production one. There’s a single control plane node, so losing it loses the cluster. There’s no ingress, no persistent storage configured, and no monitoring. Each of those is a topic in its own right and worth taking one at a time.

Get something scheduled on it first, though. Deploy a pod, watch it land on a worker, delete it and watch it come back. Most of what feels abstract about Kubernetes stops feeling that way once you’ve watched the scheduler actually do its job.

Avatar photo

Asif Khan

I have spent over 10 years working across IT systems, open source software, DevOps, Linux administration and cloud operations. Three things drive most of what I do: automation, security and resilience. Much of that work involves planning and building the platforms that sit behind services people rely on daily, which means designing for failure just as carefully as for load. Cloud computing held my attention early on, largely for its flexibility. Being able to scale up and then back down again means far less guessing about how much capacity you will need. Across projects I work with the full DevOps toolchain, from provisioning, orchestration and configuration management through to release management and microservices architecture.