Skip to content

Instantly share code, notes, and snippets.

@PatrickLang
Last active March 4, 2023 05:49
Show Gist options
  • Save PatrickLang/28a05cfd6cf4322d519d04cff0996585 to your computer and use it in GitHub Desktop.
Save PatrickLang/28a05cfd6cf4322d519d04cff0996585 to your computer and use it in GitHub Desktop.
Setting up a multi-arch Kubernetes cluster on ODroid HC-1 and Pine64 Rock64

Setting up a Multi-Arch, Multi OS cluster

I wanted to build a home lab to get some experience with how Kubernetes works in a multi-architecture, multi-OS environment. Here's the worklog. I'll probably turn it into a blog later :)

First node - ODroid HC-1

I chose this platform because it had 2GB RAM which is the recommended minimum from the Kubeadm docs

Initial setup of the ODroid

  1. Log in - found "odroid" dhcp client IP on Mikrotik router

  2. Login as root / odroid. Other docs that say odroid/odroid are wrong https://wiki.odroid.com/odroid-xu4/os_images/linux/ubuntu/20170731 (still wrong)

  3. Moving root to SSD

  1. Reboot, make sure mount shows /dev/sda1 mounted at /

  2. Make a user useradd -m -s /bin/bash patrick, passwd patrick , usermod -aG sudo patrick

  3. Log in as new user, make sure sudo works

  4. Lock out root sudo passwd -l root

  5. Update everything apt update ; apt upgrade

I was presented with this:

	                           lqk
	                           x A new boot.ini is installed.                                     x
	                           x Any changes to boot.ini is lost, such as display configuration   x
	                           x Persistent custom settings from /media/boot/boot.ini.default     x
	                           x have been restored                                               x
	                           x For reference your old boot.ini is saved to                      x
	                           x /media/boot/boot.ini.old                                         x
	                           tqu
	                           x                             <  OK  >                             x
	                           mqj

Sure enough, it did overwrite it and I had to fix boot.ini. I also tried writing the line to boot.ini.default as it suggested. If I had rebooted, it would have booted from the root filesystem on the microSD card, not the attached SATA SSD. In this case I would have been able to log back in as root/odroid and fix things up. I'll have to remember that's there in case I need it later.

ODroid frequency scaling (optional)

The ODroid device was running warmer than expected at 73C, but not overheating (+85C). This page has steps to enable cpu frequency scaling based on current demand. https://wiki.odroid.com/odroid-xu4/application_note/software/cpufrequtils_cpufreq_govornor

This was enabled by default on the image I used on the Rock64.

Installing Docker CE

I wanted to use the latest Docker CE (18.09) so it would be closer to the version used on Windows Server. The convenience script generally works, but isn't recommended for production. This is pretty dangerous as it's running untrusted code as root.

Running curl -sSL https://get.docker.com | sudo sh will set up the package manager to use Docker's package repo and install for your distro.

$ curl -sSL https://get.docker.com | sudo sh
# Executing docker install script, commit: 4957679
+ sh -c apt-get update -qq >/dev/null
+ sh -c apt-get install -y -qq apt-transport-https ca-certificates curl >/dev/null
+ sh -c curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" | apt-key add -qq - >/dev/null
Warning: apt-key output should not be parsed (stdout is not a terminal)
+ sh -c echo "deb [arch=arm64] https://download.docker.com/linux/ubuntu bionic edge" > /etc/apt/sources.list.d/docker.list
+ sh -c apt-get update -qq >/dev/null
+ sh -c apt-get install -y -qq --no-install-recommends docker-ce >/dev/null
+ sh -c docker version
Client:
 Version:           18.09.0
 API version:       1.39
 Go version:        go1.10.4
 Git commit:        4d60db4
 Built:             Wed Nov  7 00:52:51 2018
 OS/Arch:           linux/arm64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.0
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.4
  Git commit:       4d60db4
  Built:            Wed Nov  7 00:17:01 2018
  OS/Arch:          linux/arm64
  Experimental:     false
If you would like to use Docker as a non-root user, you should now consider
adding your user to the "docker" group with something like:

  sudo usermod -aG docker your-user

Remember that you will have to log out and back in for this to take effect!

WARNING: Adding a user to the "docker" group will grant the ability to run
         containers which can be used to obtain root privileges on the
         docker host.
         Refer to https://docs.docker.com/engine/security/security/#docker-daemon-attack-surface
         for more information.

Then I ran sudo usermod -aG docker patrick so I could easily run containers. A log out, then back in, and then tested it with docker run busybox uname -a

Installing Kubernetes

Read https://kubernetes.io/docs/setup/independent/install-kubeadm/#installing-kubeadm-kubelet-and-kubectl

Another approach - if you're going to be building and replacing Kubernetes binaries with your own, you could just download them from the v1.13.1 release notes, and copy them to /usr/local/bin.

Setting up the cluster

I followed Creating a single master cluster from the docs, using Flannel.

The YAML file for Flannel includes a daemonset for most Linux architectures (but not Windows... yet) so it started right up.

$ kubectl -n kube-system get ds
NAME                      DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR                     AGE
kube-flannel-ds-amd64     0         0         0       0            0           beta.kubernetes.io/arch=amd64     7d2h
kube-flannel-ds-arm       1         1         1       1            1           beta.kubernetes.io/arch=arm       7d2h
kube-flannel-ds-arm64     0         0         0       0            0           beta.kubernetes.io/arch=arm64     7d2h
kube-flannel-ds-ppc64le   0         0         0       0            0           beta.kubernetes.io/arch=ppc64le   7d2h
kube-flannel-ds-s390x     0         0         0       0            0           beta.kubernetes.io/arch=s390x     7d2h
kube-proxy                1         1         1       1            1           <none>                            7d2h

Deploying Tiller

helm init didn't create a running tipper-deploy deployment. I had to modify it (kubectl edit -n kube-system tiller-deploy) to:

kubectl get deploy -n kube-system tiller-deploy -o yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: "3"
  creationTimestamp: "2018-12-22T21:33:29Z"
  generation: 3
  labels:
    app: helm
    name: tiller
  name: tiller-deploy
  namespace: kube-system
  resourceVersion: "2606"
  selfLink: /apis/extensions/v1beta1/namespaces/kube-system/deployments/tiller-deploy
  uid: 3900c67f-0631-11e9-bd4a-001e06328e68
spec:
  progressDeadlineSeconds: 2147483647
  replicas: 1
  revisionHistoryLimit: 2147483647
  selector:
    matchLabels:
      app: helm
      name: tiller
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
    type: RollingUpdate
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: helm
        name: tiller
    spec:
      automountServiceAccountToken: true
      containers:
      - env:
        - name: TILLER_NAMESPACE
          value: kube-system
        - name: TILLER_HISTORY_MAX
          value: "0"
        image: jessestuart/tiller:v2.12.1
        imagePullPolicy: IfNotPresent
        livenessProbe:
          failureThreshold: 3
          httpGet:
            path: /liveness
            port: 44135
            scheme: HTTP
          initialDelaySeconds: 1
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 1
        name: tiller
        ports:
        - containerPort: 44134
          name: tiller
          protocol: TCP
        - containerPort: 44135
          name: http
          protocol: TCP
        readinessProbe:
          failureThreshold: 3
          httpGet:
            path: /readiness
            port: 44135
            scheme: HTTP
          initialDelaySeconds: 1
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 1
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
      tolerations:
      - effect: NoSchedule
        key: node-role.kubernetes.io/master
status:
  availableReplicas: 1
  conditions:
  - lastTransitionTime: "2018-12-22T21:33:29Z"
    lastUpdateTime: "2018-12-22T21:33:29Z"
    message: Deployment has minimum availability.
    reason: MinimumReplicasAvailable
    status: "True"
    type: Available
  observedGeneration: 3
  readyReplicas: 1
  replicas: 1
  updatedReplicas: 1

Setting up second node - Rock64

First boot

Originally I wanted ARM64 / AARCH64, not 32-bit ARM71 / ARMHF. With a bit more research, I found the cheap Rock64 which should work and offers up to 4GB of RAM - double the ODroid HC-1.

@ayufan has a nice set of minimal images ready to run, so I went to the latest release for the Rock64, and downloaded bionic-minimal-rock64-0.7.11-1075-arm64.img.xz. I used Etcher again to set up a 16GB SD Card. I plugged it in, and it booted up within seconds.

The default user+pass is rock64/rock64. I set up a new user (see same steps 5-8 from the ODroid, except I locked out both root and rock64 users) to lock it down.

Swap was also enabled, which isn't supported for Kubernetes.

$ free
              total        used        free      shared  buff/cache   available
Mem:        4083804      133788     2585832        3540     1364184     3914192
Swap:       2041888           0     2041888
patrick@rock64:/$ free
              total        used        free      shared  buff/cache   available
Mem:        4083804      133272     2586352        3540     1364180     3914708
Swap:       2041888           0     2041888
patrick@rock64:/$ mount | grep swap
patrick@rock64:/$ cat /etc/fstab
LABEL=boot /boot/efi vfat defaults,sync 0 0

The Rock64 image has zram-config installed which creates one compressed ramdisk per core. These are then used as "swap" space. That means less used memory pages will be compressed. That would probably be ok in a lab setup, but I disabled it anyway.

  1. To disable it immediately, run sudo swapoff -a
  2. Disable it permanently with sudo apt purge zram-config

Booting from a USB drive

You can continue to boot from the SD card at this point, but you can actually boot directly from a USB drive without any SD card at all.

Ayufan's image has a script to install U-Boot in the 128MB SPI flash on the Rock64 itself. If you're booted from the SD card, you can just run sudo /usr/local/sbin/rock64_write_spi_flash.sh.

I still don't know how to control the boot order from this point. It seems to boot from the SD card if it's plugged in and use it as the root. I wasn't able to move the root, but I found another workaround.

Now that U-Boot is installed, it can enumerate USB devices and boot the kernel + root filesystem. If you plug a USB drive (including a SSD/HDD in a USB enclosure) into a PC - you can use Etcher to write the same bionic-minimal-rock64-...-arm64.img.xz file to that drive.

Power off the Rock64, Remove the SD card, and plug in the USB drive. Power it on, and now it will boot directly from USB.

Moving the root to SSD (optional, not recommended)

Initially, I decided to leave the boot partition on the SD card, as well as the old root partition so I can boot without the USB SSD attached if I need to repurpose the device later. This didn't work out as expected, but I'll leave the steps here for reference. I recommend the steps in Booting from a USB drive above.

The steps are similar to the ODroid, but the disk layout and boot configuration is different. The ODroid images have U-Boot on the SD card, but the Rock64 uses extlinux.

This guide had most of the steps right, but I copied my exact steps below: https://forum.pine64.org/showthread.php?tid=4971.

  1. Make a single large GPT volume with fdisk. Run p to check the volume type, g to change it if needed, and n to create a new partition. Finish with w to save it.
$ sudo fdisk /dev/sda                                                                                                         [426/1866]
[sudo] password for patrick:

Welcome to fdisk (util-linux 2.31.1).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.

Device does not contain a recognized partition table.
Created a new DOS disklabel with disk identifier 0xa7f773cc.

Command (m for help): p
Disk /dev/sda: 238.5 GiB, 256060514304 bytes, 500118192 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 33553920 bytes
Disklabel type: dos
Disk identifier: 0xa7f773cc

Command (m for help): g
Created a new GPT disklabel (GUID: 16C6A535-2FA8-4E40-ABA4-B298D99DD69A).

Command (m for help): p
Disk /dev/sda: 238.5 GiB, 256060514304 bytes, 500118192 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 33553920 bytes
Disklabel type: gpt
Disk identifier: 16C6A535-2FA8-4E40-ABA4-B298D99DD69A

Command (m for help): n
Partition number (1-128, default 1):
First sector (65535-500118158, default 65535):
Last sector, +sectors or +size{K,M,G,T,P} (65535-500118158, default 500118158):

Created a new partition 1 of type 'Linux filesystem' and of size 238.5 GiB.

Command (m for help): w
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.
  1. Format it ext4, then mount it to /mnt/ssd
$ sudo mkfs.ext4 /dev/sda1
mke2fs 1.44.1 (24-Mar-2018)
Creating filesystem with 62506578 4k blocks and 15630336 inodes
Filesystem UUID: 8d305b55-4638-4c54-87b9-e39df4c04279
Superblock backups stored on blocks:
        32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
        4096000, 7962624, 11239424, 20480000, 23887872

Allocating group tables: done
Writing inode tables: done
Creating journal (262144 blocks): done
Writing superblocks and filesystem accounting information: done

$ sudo mkdir -p /mnt/ssd

$ sudo mount /dev/sda1 /mnt/ssd
  1. Copy the existing root over to the SSD
$ sudo rsync -axv /
  1. Get the UUID for the new root partition.
$ lsblk -f /dev/sda
NAME   FSTYPE LABEL UUID                                 MOUNTPOINT
sda
`-sda1 ext4         8d305b55-4638-4c54-87b9-e39df4c04279 /mnt/ssd
  1. Modify /boot/extlinux.conf and change root= to root=UUID=<UUID from previous step>

  2. Unmount the new filesystem sudo umount /dev/sda1

  3. sudo reboot

After the reboot, double check that it worked. sda1 should be mounted to /

$ lsblk -f
NAME        FSTYPE LABEL      UUID                                 MOUNTPOINT
sda
`-sda1      ext4              8d305b55-4638-4c54-87b9-e39df4c04279 /
mtdblock0
mmcblk1
|-mmcblk1p1
|-mmcblk1p2
|-mmcblk1p3
|-mmcblk1p4
|-mmcblk1p5
|-mmcblk1p6 vfat   boot       226D-5878                            /boot/efi
`-mmcblk1p7 ext4   linux-root d6d72015-76f9-4662-9bb9-6effe2437f69
zram0                                                              [SWAP]
zram1                                                              [SWAP]
zram2                                                              [SWAP]
zram3                                                              [SWAP]

Joining the node

The same doc also shows how to join a node to the existing cluster.

I can't get another computer delivered by Tardis, so my kubeadm token expired by the time the next machine arrived.

On the existing cluster leader, I ran

  1. kubeadm token create to create a new token
  2. openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex | sed 's/^.* //' to get the cert
  3. ip addr show eth0 to double check the IP

Then on the new node, I ran

kubeadm join --token <token> <master-ip>:<master-port> --discovery-token-ca-cert-hash sha256:<hash>

And now it's in the cluster

$ kubectl get node
NAME     STATUS     ROLES    AGE    VERSION
odroid   Ready      master   7d3h   v1.13.1
rock64   NotReady   <none>   40s    v1.13.1

After a few seconds, the status will change to Ready.

Changing Flannel to host-gw mode

  1. Get the existing Flannel configuration with kubectl get configmap -n kube-system kube-flannel-cfg -o yaml > kube-flannel-cfg.yaml

  2. Modify it with vim kube-flannel-cfg.yaml

Change the contents of net-conf.json so that the Backend type is host-gw

  net-conf.json: |
    {
      "Network": "10.244.0.0/16",
      "Backend": {
        "Type": "host-gw"
      }
    }
  1. Replace the old configmap with the new one kubectl replace configmap -n kube-system kube-flannel-cfg -f kube-flannel-cfg.yaml

  2. Find and kill all of the Flannel DaemonSet pods with kubectl get pod -n kube-system and kubectl delete pod -n kube-system kube-flannel-ds-...

More reading

Here's some stuff I should link to when I make this a blog:

  • Some explanation of why Linux, golang, and Docker have different names for the ARM architecture variants
  • How to use ContainerD instead of Docker CE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment