Deploying my first cluster using kubeadm

After completing Mumshad Mannambeth’s Certified Kubernetes Administrator (CKA) with Practice Tests course it was time to deploy my first kubernetes cluster for practice. There was an easy (and recommended) way, using kubeadm, and a hard way: from scracth. I choose the easy, but there is a video playlist for those who want to give the hard way a try.

Considerations

  • Most of the documentation was based on Ubuntu systems
  • Ubuntu 20.04 LTS had a 5.4 kernel, ahead of CentOS' 4.0.18 kernel
  • Support of Docker will be removed on kubernetes 1.22

Thus I used Ubuntu 20.04 LTS this time, maybe I’ll try CentOS 8 Stream in the future, and I made the hard choice of using cri-o instead of container.d, maybe because I thought it was better because it was a CNCF incubating project supported by RedHat.

Base system

Again, I used Vagrant for provisioning the environment, almost the same configuration I used on my previous exercises, only a small change on my Vagrantfile to use a Ubuntu image:

  config.vm.box = "peru/ubuntu-20.04-server-amd64"
  config.vm.box_version = "20210701.01"

The ansible playbook used to provision the nodes with my automation user and corresponding ssh keys is the same as before.

I wanted to know every necessary step to bring up the cluster, so I ran away of ansible roles which set up the cluster, and made my own playbook.

The first step was to install prerequisites

      - name: Install prerequisites packages
        apt:
          name: 
            - apt-transport-https
            - curl
            - ca-certificates
            - gpg
          state: latest
          update_cache: yes

Then I had to load br_netfilter module, configure it to load at boot and tweak some kernel tunables to allow kubernetes network to work

      - name: configure br_netfilter module
      - name: load br_netfilter module
        modprobe:
          name: br_netfilter
          state: present

      - name: setup sysctl
        sysctl:
          name: "{{ item }}"
          value: '1'
          sysctl_file: /etc/sysctl.d/10-k8s.conf 
          reload: yes
        loop:
          - net.bridge.bridge-nf-call-ip6tables
          - net.bridge.bridge-nf-call-iptables
          - net.ipv4.ip_forward

The cri-o and kubernetes packages were spread among several repositories, I used variables to select software versions.

      - name: Fetch google\'s key
        get_url:
          url: https://packages.cloud.google.com/apt/doc/apt-key.gpg
          dest: /usr/share/keyrings/kubernetes-archive-keyring.gpg
          checksum: sha256:ff834d1e179c3727d9cd3d0c8dad763b0710241f1b64539a200fbac68aebff3e
    
      - name: Add google\'s apt repository
        apt_repository:
          repo: "deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main"
          filename: "kubernetes"

      - name: Fetch Suse\'s libcontainers key
        apt_key: 
          url: https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_{{ ansible_distribution_version }}/Release.key          
          state: present

      - name: Fetch Suse\'s cri-o key
        apt_key:
          url: https://download.opensuse.org/repositories/devel:kubic:libcontainers:stable:cri-o:{{ crio_version }}/xUbuntu_{{ ansible_distribution_version }}/Release.key
          state: present

      - name: Add opensuse\'s apt repository
        apt_repository:
          repo: "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_{{ ansible_distribution_version }}/ /"
          filename: "kubernetes"          
          state: present
      
      - name: Add opensuse\'s cri-o apt repository
        apt_repository:
          repo: "deb http://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/{{ crio_version }}/xUbuntu_{{ ansible_distribution_version }}/ /" 
          filename: "kubernetes"
          state: present

Once repositories were set, I installed the corresponding packages.

      - name: Install kubernetes packages
        apt:
          name:
            - cri-o
            - cri-o-runc
            - kubeadm={{ k8s_version }}-00
            - kubectl={{ k8s_version }}-00
            - kubelet={{ k8s_version }}-00
          state: present
          update_cache: yes

      - name: Enable cri-o service
        service:
          name: cri-o
          state: started
          enabled: yes

On Running CRI-O with kubeadm documentation, it suggested using yq to edit the kubeadm configuration, so I also installed it.

      - name: Install yq
        get_url:
          url: https://github.com/mikefarah/yq/releases/download/{{ yq_version }}/yq_linux_amd64
          dest: /usr/local/bin/yq
          mode: 0755
          checksum: sha256:8716766cb49ab9dd7df5622d80bb217b94a21d0f3d3dc3d074c3ec7a0c7f67ea

As NAT interfaces are mandatory when deploying VMs using Vagrant, I created a drop-in override for the kubelet service in order to use the correct ip.

      - name: Setup correct internalIp in kubelets
        template: 
          src: ./templates/09-extra-args.conf.j2
          dest: /etc/systemd/system/kubelet.service.d/09-extra-args.conf
        notify: reload systemd

The contents of the jinja2 template were:

# {{ ansible_managed }}
[Service]
Environment=KUBELET_EXTRA_ARGS="--address={{ ansible_facts[internalip_iface]['ipv4']['address'] }} --node-ip={{ ansible_facts[internalip_iface]['ipv4']['address'] }}"

As I had my nexus repository configured as hub.docker.com registry mirror, I also took advantage of it.

      - name: Setup custom registries
        template:
          src: ./templates/registries.conf.j2
          dest: /etc/containers/registries.conf
          backup: yes
        when: 'local_registry is defined'

I only wanted to mirror (at the moment) docker.com registry, so the contents of the registries.conf jinja2 template were:

#{{ ansible_managed }}
unqualified-search-registries = ["{{ local_registry }}"]

[[registry]]
prefix = "k8s.gcr.io"
insecure = false
blocked = false
location = "k8s.gcr.io"

I made a big mistake and didn’t read the docs for creating a HA enabled cluster, so I kept this for future use and went along with not having HA.


      - name: Fake a cluster load balancer for ha setups
        lineinfile:
          line: "10.255.255.201 apiserver.cluster.local"
          path: /etc/hosts

Did I mention I was doing this on Ubuntu VMs? Let’s prevent mistakes for now.

    - block:
      # ...
      # all above tasks were here
      # ...
      when: "ansible_distribution == 'Ubuntu'"

Deploying the cluster with kubeadm

I used the script from Running CRI-O with kubeadm documentation to generate a kubadm config file at /tmp/kubeadm.conf, but I had to perform additional changes on the file:

--- /tmp/kubeadm.conf.old	2021-08-23 14:26:55.769902995 +0000
+++ /tmp/kubeadm.conf	2021-08-23 14:27:42.577790034 +0000
@@ -9,7 +9,7 @@
       - authentication
 kind: InitConfiguration
 localAPIEndpoint:
-  advertiseAddress: 1.2.3.4
+  advertiseAddress: 10.255.255.101 
   bindPort: 6443
 nodeRegistration:
   criSocket: unix:///var/run/crio/crio.sock
@@ -27,11 +27,12 @@
 etcd:
   local:
     dataDir: /var/lib/etcd
 imageRepository: k8s.gcr.io
 kind: ClusterConfiguration
-kubernetesVersion: 1.21.0
+kubernetesVersion: 1.21.2
 networking:
   dnsDomain: cluster.local
   serviceSubnet: 10.96.0.0/12
+  podSubnet: 10.32.0.0/12
 scheduler: {}
 cgroupDriver: systemd
 ---

I fixed the advertiseAddress to my vm address and kubernetes version to match my kubeadm version. And as I wanted to use the weave network plugin, I set the podSubnet to weave’s default subnet: 10.32.0.0/12

Then I ran the kubeadm in --dry-run mode to check if everything was in place.

[root@k8snode1]$ kubeadm init --node-name=controlplane --config /tmp/kubeadm.conf --dry-run
0823 14:28:08.977716   83693 strict.go:54] error unmarshaling configuration schema.GroupVersionKind{Group:"kubeadm.k8s.io", Version:"v1beta2", Kind:"ClusterConfiguration"}: error unmarshaling JSON: while decoding JSON: json: unknown field "cgroupDriver"
[init] Using Kubernetes version: v1.21.2
[preflight] Running pre-flight checks
[preflight] Would pull the required images (like 'kubeadm config images pull')
[certs] Using certificateDir folder "/etc/kubernetes/tmp/
....

That threw 1124 lines of output, including a complaint about not recognizing the cgroupDriver property, but I thought it was ok as systemd was de default cgroup driver for cri-o.

Then I ran the command without the --dry-run option:

[root@k8snode1]$ kubeadm init --node-name=controlplane --config /tmp/kubeadm.conf 
W0823 14:35:30.182996   85471 strict.go:54] error unmarshaling configuration schema.GroupVersionKind{Group:"kubeadm.k8s.io", Version:"v1beta2", Kind:"ClusterConfiguration"}: error unmarshaling JSON: while decoding JSON: json: unknown field "cgroupDriver"
[init] Using Kubernetes version: v1.21.2
[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 [controlplane kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local] and IPs [10.96.0.1 10.255.255.101]
[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 [controlplane localhost] and IPs [10.255.255.101 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [controlplane localhost] and IPs [10.255.255.101 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 20.504492 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 controlplane 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 controlplane as control-plane by adding the taints [node-role.kubernetes.io/master:NoSchedule]
[bootstrap-token] Using token: abcdef.0123456789abcdef
[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!

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 10.255.255.101:6443 --token abcdef.0123456789abcdef \
	--discovery-token-ca-cert-hash sha256:ebd7518d5eed84695320bda99aa477f8edd1fc68f74bf15819d05a9799abc9cc 

It installed the control plane components and then I used ansible to run the command to join the nodes to the cluster.

ansible 'cluster_managers,!k8snode1' -a "kubeadm join 10.255.255.101:6443 --control-plane --token abcdef.0123456789abcdef --discovery-token-ca-cert-hash sha256:ebd7518d5eed84695320bda99aa477f8edd1fc68f74bf15819d05a9799abc9cc"

k8snode2 | FAILED | rc=1 >>
[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'error execution phase preflight: 
One or more conditions for hosting a new control plane instance is not satisfied.

unable to add a new control plane instance a cluster that doesn't have a stable controlPlaneEndpoint address

Please ensure that:
* The cluster has a stable controlPlaneEndpoint address.
* The certificates that must be shared among control plane instances are provided.


To see the stack trace of this error execute with --v=5 or highernon-zero return code

At this point I realized I made a mistake planning my cluster, no HA for me! 😭.

So in order to preserve resources, I disposed the additional control plane nodes. Then I joined the worker nodes

ansible 'cluster_workers' -a "kubeadm join 10.255.255.101:6443 --token abcdef.0123456789abcdef --discovery-token-ca-cert-hash sha256:ebd7518d5eed84695320bda99aa477f8edd1fc68f74bf15819d05a9799abc9cc"

At first try I got the wrong InternalIP of the worker nodes because the default route is bound to the VirtualBox NAT interface, that was the reason to include the fix in the playbook.

root@k8snode1:/home/vagrant# kubectl get nodes -o wide
NAME           STATUS   ROLES                  AGE     VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION     CONTAINER-RUNTIME
controlplane   Ready    control-plane,master   5m19s   v1.21.2   10.255.255.101   <none>        Ubuntu 20.04.3 LTS   5.4.0-77-generic   cri-o://1.21.2
k8snode4       Ready    <none>                 88s     v1.21.2   10.0.2.15        <none>        Ubuntu 20.04.3 LTS   5.4.0-77-generic   cri-o://1.21.2
k8snode5       Ready    <none>                 88s     v1.21.2   10.0.2.15        <none>        Ubuntu 20.04.3 LTS   5.4.0-77-generic   cri-o://1.21.2

I had to delete the nodes and recreate them with the fix applied:

root@k8snode1:/home/vagrant# kubectl get nodes -o wide
NAME           STATUS   ROLES                  AGE     VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION     CONTAINER-RUNTIME
controlplane   Ready    control-plane,master   7h50m   v1.21.2   10.255.255.101   <none>        Ubuntu 20.04.3 LTS   5.4.0-77-generic   cri-o://1.21.2
k8snode4       Ready    <none>                 6h51m   v1.21.2   10.255.255.104   <none>        Ubuntu 20.04.2 LTS   5.4.0-77-generic   cri-o://1.21.2
k8snode5       Ready    <none>                 7h      v1.21.2   10.255.255.105   <none>        Ubuntu 20.04.2 LTS   5.4.0-77-generic   cri-o://1.21.2

The weave network plugin

One is supposed to install the network plugin before joining other nodes, I did it after but it seemed to work. I installed weave network addon for two reasons:

  1. It supported network policies, which flannel didn’t
  2. It was very easy to install

Running this command was what I did to install it:

kubectl apply -f "https://cloud.weave.works/k8s/net?k8s-version=$(kubectl version | base64 | tr -d '\n')"

Installing kubernetes dashboard

Once having my cluster running I installed the kubernetes dashboard.

It was as simple as deploying a manifest from an url:

[root@k8snode1]$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.3.1/aio/deploy/recommended.yaml
namespace/kubernetes-dashboard created
serviceaccount/kubernetes-dashboard created
service/kubernetes-dashboard created
secret/kubernetes-dashboard-certs created
secret/kubernetes-dashboard-csrf created
secret/kubernetes-dashboard-key-holder created
configmap/kubernetes-dashboard-settings created
role.rbac.authorization.k8s.io/kubernetes-dashboard created
clusterrole.rbac.authorization.k8s.io/kubernetes-dashboard created
rolebinding.rbac.authorization.k8s.io/kubernetes-dashboard created
clusterrolebinding.rbac.authorization.k8s.io/kubernetes-dashboard created
deployment.apps/kubernetes-dashboard created
service/dashboard-metrics-scraper created
deployment.apps/dashboard-metrics-scraper created

And created a service account following the instructions on Creating a sample user guide.

I used this definition file:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: admin-user
  namespace: kubernetes-dashboard

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: admin-user
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  name: admin-user
  namespace: kubernetes-dashboard

Then after getting the account’s token

kubectl -n kubernetes-dashboard get secret $(kubectl -n kubernetes-dashboard get sa/admin-user -o jsonpath="{.secrets[0].name}") -o go-template="{{.data.token | base64decode}}"

I was able to access the dashboard running a kubectl proxy and an ssh tunnel through my control plane node using the url http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/

kubernetes dashboard

NOTE: I also set up a service to expose the dashboard as a NodePort service, then I could access it directly at https://[any-node-ip]:30443/

The service definition to do that was:

apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    k8s-app: kubernetes-dashboard
  name: kubernetes-dashboard-fwd
  namespace: kubernetes-dashboard
spec:
  ports:
  - port: 30443
    nodePort: 30443
    protocol: TCP
    targetPort: 8443

  selector:
    k8s-app: kubernetes-dashboard
  type: NodePort 

Conclusions

Even the course is supposed to be enough to pass the CKA exam if one masters the labs, I feel like I’m not ready yet. For the moment I had my first experience deploying a cluster.

The following exercises should be:

  • Deploy an ingress controller
  • Run some workloads
  • Gain understanding of how storage works
  • Deploy a high availability enabled cluster

References