Arm-Images auf dem Raspberry Pi mit K3S und Gitlab-Runner bauen

Für mein LED-Matrix IoT-Projekt schreibe ich die Steuerungssoftware selbst und möchte diese im gewohnten Workflow via Docker und Kubernetes auf meinem Raspberry Pi laufen lassen. Aber geht das überhaupt? Die Antwort lautet ja, aber der Weg dort hin hat sich als steinig herausgestellt, denn beim Raspberry Pi ist man plötzlich mit einer anderen Prozessor-Architektur konfrontiert, als man das mit x64 tagtäglich gewohnt ist. Ich habe mich der Herausforderung angenommen, eine Build-Pipeline für diese Architektur zu erstellen, um somit meine eigenen Arm Images bauen zu können. Schließlich gewinnt die Arm-Plattform spätestens mit der Einführung des M1 Chip von Apple immer mehr an Bedeutung und wird früher oder später seinen Weg in den Alltag vieler Softwareentwickler finden. Hier möchte ich gerne meine Erfahrungen, die ich dabei gemacht habe, teilen.

1. Voraussetzungen

Raspberry Pi 3 oder 4 mit mindestens 4GB RAM

K3s ist eine sehr schlanke Kubernetes-Implementierung, die von Rancher extra für den Betrieb auf IoT Geräten entwickelt und optimiert wurde. Das Paket ist nur ca. 40MB groß und für die Arm-Plattform optimiert, was den Einsatz auf Raspberry Pi Geräten ermöglich. Allerdings ist für die Ausführung der Container-Platform doch ein Mindestmaß an Rechenleistung und Arbeitsspeicher nötig, was die älteren Raspberry Pi Modelle 1 und 2 ausschließt. Theoretisch würde das Setup mit 512 GB RAM wohl laufen, aber je mehr, desto besser.

Schnelle microSD Karte mit wenigstens 16GB Kapazität

Bei meinen ersten Gehversuchen habe ich eine 16 GB microSD-Karte von Kingston verwendet. Das Betriebssystem ließ sich erfolgreich flashen und starten, auch war die Installation der Software kein Problem. Nach der Installation von K3S ist mir aufgefallen, dass der Service ständig neu startet. Dies hat sich darin bemerkbar gemacht, dass der Kubernetes-API-Server sporadisch nicht erreichbar war und nach einigen Sekunden wieder funktioniert hat. In den Logdateien fand ich keinen Hinweis auf einen Fehler. In diversen Foren berichteten Nutzer von ähnlichen Problemen, und brachten das Verhalten mit der IO-Performance der SD-Karte in Verbindung. Also beschloss ich, eine bessere SD-Karte zu bestellen. Ich habe mich für das Modell Extreme microSDHC 32GB von SanDisk entschieden. Diese hat mit 100MB/s Lese- und 60 MB/s Schreibgeschwindigkeit die 10-fache Geschwindigkeit meiner alten Karte. Die Neustarts sind mit dieser SD-Karte nicht mehr passiert. Anscheinend hat es die Kingston Karte mit 10MB/s Schreibgeschwindigkeit einfach nicht geschafft, den IO-Ansprüchen des Betriebssystems und des K3S Services nachzukommen.

Eine bereits laufende Gitlab-Instanz

Auf dem Raspberry Pi wird im K3S-Kubernetes-Cluster kein „vollwertiges“ Gitlab, sondern nur ein Runner ausgeführt. Dieser ist ein Agent, welcher sich bei einer bestehenden Gitlab-Instanz registriert und periodisch abfragt, ob für ihn entsprechende Build-Jobs in der Warteschlange sind. Falls ja, greift der Agent den Build-Job auf und führt diesen aus, indem er einen Build-Pod im gleichen Cluster erstellt. Dies hat den Vorteil, dass die Last auf dem Raspberry gering gehalten wird. Eine komplette Gitlab-Instanz zu betreiben wäre prinzipiell wahrscheinlich auch möglich, beansprucht aber einiges mehr an Ressourcen.

2. Überblick über Arm-Prozessor-Typen und Architekturen

Wie eingangs bereits beschrieben, wird man bei der Raspberry Pi Plattform mit Prozessoren der Firma Arm konfrontiert. Arm-Prozessoren benötigen verhältnismäßig wenig Energie und liefern trotzdem eine hohe Leistung, was sie für den Einsatz auf Mobilgeräten oder System-on-a-Chip Geräten geeignet macht. Dabei ist das Phänomen Arm nicht neu, bereits im Jahre 1985 startete mit dem ARMv1 die erste Version der Prozessorfamilie. Während heute in fast jedem Mobiltelefon und Tablet ein Prozessor der Firma Arm steckt, sind die Prozessoren in der Notebook und Desktop-Umgebung noch nicht verbreitet, hier sind CPUs der Firmen AMD und Intel noch immer marktbeherrschend. Mit der Einführung des M1-Chips von Apple ändert sich dies grundlegend, sodass die Arm-Prozessorfamilie Einzug in den Consumer-Rechner Bereich hält und schließlich auch Software-Entwickler mit diesem Thema konfrontiert.

Der Raspberry Pi wurde seit der ersten Version im Jahre 2012 mit CPUs der Firma Arm bestückt, anfangs noch mit der Familie ARM11 in der Architektur ARMv6, welche 32 Bit hat. Ab dem Raspberry Pi Modell 2 ist heißt die CPU-Familie Arm Cortext-A und ab dem Raspberry Pi Modell 2 Mod.B v1.2 kommt die Armv8-Architektur zum Einsatz, welche mit 64 Bit läuft:

Modell2 Modell B2 Modell B v1.23 Modell A+3 Modell B3 Modell B+4 Modell B
CPU-FamilieArm-Cortex-AArm-Cortex-AArm-Cortex-AArm-Cortex-AArm-Cortex-AArm-Cortex-A
CPU-TypCortex-A7Cortex-A53Cortex-A53Cortex-A53Cortex-A53Cortex-A72
CPU-ArchitekturArmv7Armv8Armv8Armv8Armv8Armv8
Busbreite32 Bit64 Bit64 Bit64 Bit64 Bit64 Bit
Anzahl der Kerne444444
Taktfrequenz900 MHz900 MHz1400 MHz1200 MHz1400 MHz1500 MHz
Übersicht über die CPU-Konfigurationen aktueller Raspberry Pi Modelle,
Quelle: https://de.wikipedia.org/wiki/Raspberry_Pi

Das bedeutet, dass für x86 oder x64 kompilierte Binaries und Docker-Images, die für diese Architekturen kompiliert worden sind, nicht auf einem Raspberry Pi laufen.

3. Multi-Arch Docker Images

Das Publizieren von Docker-Images wurde in der offiziellen Registry von Docker anfänglich so gelöst, dass man für Images, welche nicht für die x64 Architektur gebaut worden sind, spezielle Präfixe bzw. Vendor-Namen verwendet hat. So findet man zum Beispiel für den Raspberry 2 Modell B, der ja noch auf 32 Bit und der Armv7 Architektur läuft, Docker Images in einenem eigenen Namensraum arm32v7: https://hub.docker.com/u/arm32v7/. Armv8 Images, welche für aktuelle Raspberry Pi Generationen interessant sind, finden sich im Namespace arm64v8: https://hub.docker.com/u/arm64v8/

Da sich diese Herangehensweise für eine Vielzahl von Architekturen als nicht praktikabel herausgestellt hat, ist man im Jahre 2017 dazu übergegangen, die Präfixe wegzulassen und stattdessen auf Multi-Arch Images zu setzen. Das bedeutet, dass ein Docker-Image für verschiedene Ziel-Architekturen gebaut und veröffentlicht wird. Ob ein Image für die gewünschte Architektur verfügbar ist, lässt sich auf zwei Varianten herausfinden:

a) Recherche im Docker Hub

Unter der Beschreibung des alpine-Images sieht man Tags, welche die Architekturen verraten, für welche dieses Docker-Image gebaut wurde.

b) Das Image Manifest

Jedes OCI kompatible Container Image hat ein Manifest, in welchem Metadaten wie Versionen, Layer und Hashes gespeichert werden. Bei einem Multi-Arch Container Image gibt es nun zusätzlich noch einen „Image Index“, welcher die Manifeste der Images für verschiedene Architekturen referenziert. Das klingt etwas kompliziert, aber wenn man ein

docker manifest inspect alpine:3.13.5
Code language: Bash (bash)

ausführt, so bekommt man die verschiedenen Manifeste für dieses Image angezeigt und kann gut erkennen, welche Architekturen enthalten sind:

{ "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", "manifests": [ { "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "size": 528, "digest": "sha256:def822f9851ca422481ec6fee59a9966f12b351c62ccb9aca841526ffaa9f748", "platform": { "architecture": "amd64", "os": "linux" } }, { "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "size": 528, "digest": "sha256:ea73ecf48cd45e250f65eb731dd35808175ae37d70cca5d41f9ef57210737f04", "platform": { "architecture": "arm", "os": "linux", "variant": "v6" } }, { "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "size": 528, "digest": "sha256:9663906b1c3bf891618ebcac857961531357525b25493ef717bca0f86f581ad6", "platform": { "architecture": "arm", "os": "linux", "variant": "v7" } }, { "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "size": 528, "digest": "sha256:8f18fae117ec6e5777cc62ba78cbb3be10a8a38639ccfb949521abd95c8301a4", "platform": { "architecture": "arm64", "os": "linux", "variant": "v8" } }, ... ] }
Code language: JSON / JSON with Comments (json)

Hier kann man gut erkennen, dass zwischen architecture, os und variant unterschieden wird, um die genaue Zielarchitektur und Plattform zu spezifizieren.

Schaut man sich hingegen das Manifest des Images arm32v7/alpine:3.13.5 an, so erkennt man, dass es sich nicht um einen Index handelt und nur ein einzelnes Image, für eben genau diese Plattform, spezifiziert ist:

docker manifest inspect arm32v7/alpine:3.13.5 { "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "size": 1468, "digest": "sha256:b6342193e7c053c0c2dc48cc0b72b02c42c83f763f02ada10c5ca48a9e7ca788" }, "layers": [ { "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "size": 2424145, "digest": "sha256:e160e00eb35d5bc2373770873fbc9c8f5706045b0b06bfd1c364fcf69f02e9fe" } ] }
Code language: JavaScript (javascript)

Weiterführende Informationen zum OCI Image Manifest und der Index-Spezifikation finden sich hier:

Welchen Vorteil haben nun Multiarch-Images?

Der große Vorteil von Multiarch-Images besteht darin, dass man auf die Spezifikation der Architektur im Namen des Images verzichten kann. Befinde ich mich also auf einer arm64v8 Plattform und weise die Container-Engine an, das Image alpine:3.13.5 zu pullen, so wird automatisch das für dieses Betriebssystem und die Prozessorarchitektur korrekte Image heruntergeladen.

Weiterführende Informationen zu Multi-Arch Images finden sich hier: https://github.com/docker-library/official-images#multiple-architectures

Was passiert, wenn ich auf einer Architektur ein Image ausführen möchte, welches es für die Architektur nicht gibt?

Für den Fall, dass die Container-Engine angewiesen wird, ein Image zu pullen, das entweder ein Multi-Arch Image ist, aber keinen passenden Eintrag für die aktuelle Kombination aus Betriebssystem und Architektur enthält oder ein Image zu pullen, welches kein Multi-Arch Image ist und nur für eine andere Kombination aus Betriebssystem und Architektur erhältlich ist, dann tut die Container-Engine genau dieses. Es gibt keinen eingebauten Mechanismus, der dies vorher prüft und ggf. die Ausführung verweigert.

Erst, wenn die Binaries in einem Image zur Ausführung gebracht werden, „merkt“ die Container Runtime, dass diese für eine andere Kombination aus Betriebssystem und/oder Architektur kompiliert worden sind und quittiert die Ausführung wie folgt:

standard_init_linux.go:211: exec user process caused "exec format error"
Code language: CSS (css)

4. Setup des Raspberry Pi

Das Default-Image, welches das Tool “Raspberry Pi Imager“ auf eine SD-Karte schreibt, ist die 32 Bit-Version des Raspberry Pi OS (vormals: „Raspbian“). Eine 64 Bit-Version von Raspberry Pi OS wird in dem Tool nicht angeboten, denn dieses wird von der Raspberry Pi Foundation noch nicht offiziell unterstützt. So findet sich auch auf der Download-Seite kein Hinweis darauf, lediglich in einem Foren-Eintrag, der sein letztes Update im November 2020 erfahren hat und ausdrücklich die Nutzung des 32 Bit-Images empfiehlt, verlinkt auf die 64 Bit ISO-Dateien zum Download. Schade, dass die Raspberry Pi Foundation das Thema 64 Bit OS so stiefmütterlich behandelt, schließlich hat man schon eine 64 Bit Architektur und möchte diese gerne auch voll ausreizen.

Da die Armv8 Architektur 32 Bit abwärtskompatibel ist, ist es aber möglich, die 32 Bit Version von Raspberry Pi OS und auch für 32 Bit kompilierte Anwendersoftware auszuführen.

Standardmäßig bietet der Raspberry Pi Image das 32 Bit Raspberry Pi OS mit Desktop-Umgebung an – welches für das K3S-Setup ungeeignet ist.

Da auf meinem Raspberry keine Desktop-Umgebung laufen lassen möchte und das Basis-System so schlank, wie möglich sein soll, entscheide ich mich für Raspberry Pi OS 32 Lite (32 Bit).

WLAN und SSH vor dem ersten Boot konfigurieren

Mein Raspberry dient als Server und wird ohne Peripherie wie Maus, Tastatur oder Bildschirm betrieben. Deshalb muss ich dafür sorgen, dass sich der Raspberry bereits beim ersten Booten von alleine mit dem gewünschten WLAN verbindet und den SSH-Daemon startet. Nach dem Flashen der SD-Karte erstelle ich eine Datei namens wpa_supplicant.conf und kopiere diese mit folgendem Inhalt in das Root-Verzeichnis der boot-Partition der SD-Karte:

ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev update_config=1 country=DE network={ ssid="SSID" psk="PSK" }
Code language: JavaScript (javascript)

Unter SSID wird der Name des WLANS angegeben, PSK ist der Preshared Key, also das „WLAN-Passwort“. Um den SSH-Daemon bei Systemstart zu aktivieren und eine Verbindung aufzubauen, muss im Root-Verzeichnis der SD-Karte eine Datei namens SSH existieren, welche keinen Inhalt haben muss:

touch /boot/ssh

Anschließend kann die SD-Karte in den Raspberry Pi gesteckt und dieser gestartet werden.

Wie finde ich die IP meines Raspberry Pi?

Im Netzwerk sollte es einen DHCP-Server geben, der dem startenden Raspberry eine IP-Adresse zuweist. Wie findet man diese heraus? Man kann:

  • Im Router nachsehen, welche IP-Adresse zugewiesen wurde, dort gibt es meistens eine Liste aktiver Netzwerk-/DCHP-Clients
  • raspberrypi.local anpingen (Namensauflösung via multicast DNS)
  • Mit nmap -sn 10.0.0.0/24 das ganze Subnetz absuchen (Netzmaske ggf. anpassen)

Wenn alles geklappt hat, kann ich mich per SSH verbinden:

ssh pi@raspberrypi.local
Code language: Bash (bash)

Das Passwort des pi Users lautet raspberry. Nach dem erstmaligen Login editiere ich die Datei /etc/passwd und setze die Shell des pi Users auf /usr/sbin/nologin, da ich diesen Nutzer nicht benötige und das schwache Passwort sonst ein Sicherheitsrisiko darstellt. Man kann den User genauso gut löschen oder ein starkes Passwort setzen und diesen verwenden:

sudo vim /etc/passwd
_apt:x:103:65534::/nonexistent:/usr/sbin/nologin pi:x:1000:1000:,,,:/home/pi:/usr/sbin/nologin messagebus:x:104:110::/nonexistent:/usr/sbin/nologin
Code language: Bash (bash)

Anschließend erstelle ich mir einen neuen User mit meinem Namen:

sudo adduser matthias

füge ihn der Sudo-Gruppe hinzu:

usermod -a -G sudo matthias

und kopiere meinen SSH-Public-Key von meinem Client auf den Raspberry Pi:

ssh-copy-id matthias@10.0.0.1
Code language: Bash (bash)

Mit dem tool raspi-config stelle ich unter 5. Localisation Options meine System-Locale auf UTF-8 und meine Zeitzone auf Europe/Berlin, zudem setze ich unter 1. System Options einen Hostnamen:

sudo raspi-config

cgroups aktivieren

Die Ausführung von Containern setzt voraus, dass das Betriebssystem folgende cgroups aktiviert hat:

  • cpuset
  • memory
cgroups sind ein Kernel-Funktionalität, mit welcher Linux-Prozessen bestimmte Ressourcen wie RAM, CPU, Netzwerk-Bandbreite, IO zugeteilt werden können. Auch ist es möglich, diese Ressourcen einzuschränken. Das hilft später bei der Ausführung von Containern zu verhindern, dass Containerprozesse beispielsweise sämtlichen Arbeitsspeicher belegen oder die CPU übermäßig beanspruchen.

Die erforderlichen cgroups können per Eintrag in der Datei /boot/cmdline.txt aktiviert werden, indem man folgende Parameter hinzufügt:

cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory

Die gesamte /boot/cmdline.txt sieht bei mir anschließend so aus:

console=serial0,115200 console=tty1 root=PARTUUID=4e528420-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait quiet splash plymouth.ignore-serial-consoles cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory
Code language: Bash (bash)

Nach einem Reboot ist der Raspberry Pi fertig konfiguriert und per SSH im Netzwerk unter hostname.local erreichbar.

5. K3S auf dem Raspberry Pi installieren

K3S ist eine sehr schlanke Kubernetes-Implementierung, welche aufgrund ihres geringen Ressourcenbedarfs besonders für IoT-Geräte geeignet ist. Als Container-Runtime kommt containerd zum Einsatz. Im Gegensatz zu einer gewöhnlichen Kubernetes-Installation gibt es auf dem Node keine separaten Prozesse für kubelet, etcd usw. – stattdessen wird nur ein einziger Service ausgeführt. Es ist möglich, ein beliebig großes Cluster aufzubauen und viele Worker-Nodes zu joinen, ich jedoch belasse es vorerst bei einem Single-Node Cluster.

Installation des Control Plane Node („Server“)

Der K3S-Server ist schnell installiert:

sudo curl -sfL https://get.k3s.io | sh -
Code language: Bash (bash)

Wenn man dem Distributor und der Netzwerkverbindung dorthin blind vertraut, ansonsten lohnt es sich auch immer, das sh-Skript vorher herunterzuladen und zu inspizieren. Ein systemctl status k3s zeigt an, ob der Service erfolgreich gestartet wurde:

systemctl status k3s ● k3s.service - Lightweight Kubernetes Loaded: loaded (/etc/systemd/system/k3s.service; enabled; vendor preset: enabled) Active: active (running) since Mon 2021-05-24 15:26:58 CEST; 1 weeks 2 days ago Docs: https://k3s.io Process: 11209 ExecStartPre=/sbin/modprobe br_netfilter (code=exited, status=0/SUCCESS) Process: 11210 ExecStartPre=/sbin/modprobe overlay (code=exited, status=0/SUCCESS) Main PID: 11211 (k3s-server) Tasks: 161 Memory: 2.5G CGroup: /system.slice/k3s.service
Code language: Bash (bash)

Optional: Installation von Worker Nodes

An dieser Stelle ist es möglich, weitere Nodes in das Cluster einzubinden. Um dies zu bewerkstelligen, installiert man auf einem weiteren Host zunächst den K3S-Service. Anschließend holt man sich vom „Server“, also dem zuerst installierten Control-Plane Node, das Node-Token:

cat /var/lib/rancher/k3s/server/node-token
Code language: Bash (bash)

Dieses wird benötigt, um den Worker Node zum Server zu verbinden und somit in das Kubernetes Cluster zu joinen:

sudo k3s agent --server https://matrix.local:6443 --token <TOKEN>
Code language: HTML, XML (xml)

Kommunikation mit dem Cluster

Um nun via kubectl mit dem Cluster zu kommunizieren, benötigt man die kubeconfig, welche die Installationsroutine auf dem Server unter

/etc/rancher/k3s/k3s.yaml
Code language: Bash (bash)

ablegt. Diese kann man sich lokal abspeichern und dann die KUBECONFIG Umgebungsvariable auf diese Datei zeigen lassen:

export KUBECONFIG=~/.kube/matrix
Code language: Bash (bash)

Anschließend kann man überprüfen, ob die Kommunikation mit der Control Plane funktioniert und die Nodes als Healthy und Ready angezeigt werden:

kubectl get nodes NAME STATUS ROLES AGE VERSION matrix Ready control-plane,master 13d v1.20.7+k3s1
Code language: Bash (bash)

6. Gitlab-Runner installieren

Der Gitlab Runner ist die Komponente, welche die Haupt-Instanz von Gitlab regelmäßig nach für sie bestimmte Build-Jobs abfragt und diese ggf. aufgreift und ausführt. Dafür startet der Gitlab Runner einen oder mehrere Build-Pods, in welchen der Build schließlich ausgeführt wird. Für das Deployment verwende ich das Helm-Chart gitlab/gitlab-runner in der Version 0.28.0 und setze dabei folgende von den Defaults abweichenden values:

image: gitlab/gitlab-runner:alpine-v13.12.0 gitlabUrl: https://<GITLAB_DOMAIN> runnerRegistrationToken: "<REGISTRATION_TOKEN>" rbac: create: true metrics: enabled: false runners: executor: kubernetes config: | [[runners]] [runners.kubernetes] image = "alpine:3.13.5" locked: false tags: "arm" name: "arm64 builds (raspberry pi 4)" helpers: image: "gitlab/gitlab-runner-helper:arm64-9f7e09db"
Code language: YAML (yaml)

Erläuterung:

imagegitlab/gitlab-runner:alpine-v13.12.0 wähle ich passend zu meiner Gitlab Version
gitlabUrlDie URL, unter welcher mein Gitlab erreichbar ist. Diese muss vom Runner aus per HTTPS erreichbar sein.
runnerRegistrationTokenDamit sich der Runner registrieren kann, benötigt er ein Token. Dieses ist in Gitlab unter Admin Area -> Runners -> Set up a shared Runner manually zu finden.
rbac.createDamit der Gitlab Runner im Kubernetes Cluster Ressourcen wie z.B. Pods erstellen kann, wird ein ServiceAccount erstellt, welcher per RoleBinding an eine Role gebunden wird.
metrics.enabledMetrics möchte ich im ersten Schritt nicht aktivieren.
runners.executorBuilds sollen in Kubernetes-Pods ausgeführt werden.
runners.configRunner Konfiguration im TOML Format. Es wird das Image konfiguriert, welches in einem Build-Pod standardmäßig startet, soweit in der .gitlab-ci.yml nicht anders definiert. Das Image steht standardmäßig auf ubuntu:16.04. Da mir das zu veraltet und „schwergewichtig“ ist, verwende ich alpine in einer aktuellen Version.
runners.lockedfalse, damit der Runner nicht an ein spezifisches Projekt gebunden, sondern von mehreren Projekten verwendet werden kann.
runners.tagsDer Runner wird mit arm getaggt. Dies ermöglicht es, in der .gitlab-ci.yml im Build-Step per tags den Arm-Runner zu selektieren.
runners.nameMenschenlesbarer Name für den Runner
runners.helpers.imageDas Image des Helper-Containers wird auch explizit auf eine arm64 Version gesetzt, da es zum aktuellen Zeitpunkt kein Multiarch-Image gibt.

Diesen Stack installiere ich via:

helm install -n gitlab gitlab-runner -f values.yml gitlab/gitlab-runner

Schnell folgt die Ernüchterung: Die Pods starten nicht. Ich bekomme bei der Ausführung des Runner-Pods einen exec format error. Aber warum?

64 Bit Container Images sind auf einem 32 Bit Kernel nicht ausführbar

Wie sieht unser aktueller Stack aus?

Hardware (CPU)64 Bit
Betriebssystem (Kernel)32 Bit
Applikation (K3S)32 Bit
Container-Image (Binaries)64 Bit

Im Gegensatz zur Virtualisierung (wie zum Beispiel mit KVM/QEMU) nutzen Container den bereits vorhandenen Kernel zum Starten von Prozessen, welche dann mittels cgropus und Namespaces entsprechend eingeschränkt werden. Alle Prozesse (auch die aus dem Container) laufen also auf dem gleichen Linux-Kern. Somit ist klar, dass ein 64 Bit Container Image nicht auf einem 32 Bit Kernel ausgeführt werden kann.

Warum wird aber ein 64 Bit Image heruntergeladen? Die Applikation, also K3S läuft doch unter 32 Bit? Ich vermute, dass die Container Runtime die zugrunde liegende Hardware (CPU) abfragt, dort 64 Bit zurückbekommt und folglich ein 64 Bit Image herunterlädt und startet.

Kein Problem, dann nutze ich einfach die 32 Bit Version von gitlab-runner und gitlab-runner-helper. Mit der Syntax

image: gitlab/gitlab-runner@sha256:1eb6e0cbcb0a360f0db83d2cfc1000c3afbab710e455bb3a7c044c9c78d474f8
Code language: HTTP (http)

kann man ja schließlich spezifizieren, welche exakte Checksumme eines Container-Images man verwenden möchte und überlässt die Wahl somit nicht mehr der Container-Runtime.

Das Problem: Es gibt keine offiziellen 32 Bit Container Images von Gitlab. Weder für gitlab/gitlab-runner , noch für gitlab-runner-helper. Nun könnte ich mir 32 Bit Container Images selbst bauen, aber dafür würde ich wiederum einen Gitlab-Runner benötigen, der in auf auf meinem Raspberry im K3S Cluster laufen muss (um ein Arm Image bauen zu können). Das bringt mich zu einer zyklischen Abhängigkeit, über die ich nicht länger nachdenken möchte.

7. Also doch 64 Bit Raspberry Pi OS

Es bleibt mir nichts weiter, als meinen kompletten Stack auf 64 Bit laufen zu lassen. Wie unter Punkt 4 zu lesen ist, ist die Unterstützung dafür sehr dürftig, was der Grund dafür ist, dass ich davon zunächst abgesehen habe.

Nach einiger Recherche entdecke ich einen Download-Mirror der Lite Version von Raspberry Pi OS 64 Bit: https://downloads.raspberrypi.org/raspios_lite_arm64/images/. Ich lade das Image herunter und schreibe es mit Hilfe des Raspberry Pi Imagers auf die SD-Karte und wiederhole alle Schritte von Punkt 4. Das System startet, verbindet sich mit dem vorkonfigurierten WLAN und ich verbinde mich via SSH. Bei der Anlage meines Benutzers friert mitten im Dialog die SSH-Session ein. Ich kann mich auch nach mehreren Neustarts nicht mehr auf den Host verbinden. Selbst nach mehrmaligem Neuflashen der SD-Karte gelingt es mir ab diesem Zeitpunkt nie wieder, eine SSH-Verbindung aufzubauen, auch ist der Host im Netzwerk nicht mehr sichtbar. Vermutlich gerät der Kernel beim Boot in Panic und es kommt gar nicht zur vollständigen Betriebssystem-Initialisierung. Ich mounte mir die Datenpartition der SD-Karte unter macOS mittels ext4fuse und macfuse, aber in keiner Log-Datei finde ich einen Hinweis auf ein Problem.

Resigniert suche ich nach einer Lösung und stoße auf einen Artikel, der beschreibt, dass man aus einem 32 Bit Raspberry Pi OS mittels eines einzigen Flags eine 64 Bit Version machen kann. Auf den Punkt gebracht, kann man in der Datei /boot/config.txt das folgende Flag setzen:

arm_64bit=1

Dies bewirkt, dass nach einem Reboot anstatt des 32 Bit der 64 Bit Kernel geladen wird. Somit sind nun auch 64 Bit Container Images ausführbar.

7. Test-Pipeline erstellen

Mein Softwareprojekt ist eine Go-Anwendung. Die Aufgabe der Pipeline ist, den Quellcode der Anwendung aus dem Git-Repository herunterzuladen, zu kompilieren und daraus ein Arm kompatibles Container Image zu bauen, und in die Gitlab Container-Registry zu pushen.

Ein weit verbreiteter Ansatz, um Pipelines, welche in einem Kubernetes-Build-Pod laufen ein Container Image bauen zu lassen, besteht in der Verwendung von DinD. Das steht für Docker in Docker und beschreibt die Vorgehensweise, dass der Docker-Socket des Hosts in Build-Pods gemountet wird. Somit sind Build-Pods in der lage, den Docker-Daemon des Host-Betriebssystems zu verwenden und somit zum Beispiel ein docker build auszuführen.

Dieses Vorgehen möchte ich vermeiden, denn

  1. Es stellt ein erhebliches Sicherheitsrisiko dar:
    Das Mounten des Docker-Sockets in einen Build Pod bedeutet Root-Rechte auf dem Host. Der Docker Daemon läuft auf dem Host mit root Rechten. Startet man nun im Buildpod durch diese „Hintertür“ einen privilegierten Container und mountet sich zum Beispiel / des Host-Dateisystems in den Container, hat man auf sämtliche Dateien des Hosts Zugriff.
  2. Es bedeutet zusätzliche Komplexität und weniger Transparenz:
    Auf dem Host läuft eine Container-Runtime, die von Kubernetes bzw. K3S genutzt wird. Das heißt, dass K3S den Lifecycle sämtlicher laufender Container verwaltet und überwacht. Mountet man nun den Docker Socket in einen Build-Pod werden auf dem Host Container gestartet, die K3S nicht kennt. Ein Managen von Ressourcen und Limits via Vorgabe im Build-Pod ist so zum Beispiel nicht möglich, was es theoretisch möglich macht, den Host per DoS außer Betrieb zu setzen, weil ein Container übermäßig viel CPU oder RAM konsumiert.

Build mit Kaniko

Google hat ein Framework namens Kaniko veröffentlicht, mit dem man Container Images bauen kann ohne den Docker Daemon zu benötigen. Die Verwendung in einer Gitlab-Pipelinen sieht zum Beispiel so aus:

stages: - build build: stage: build image: name: gcr.io/kaniko-project/executor:v1.6.0-debug entrypoint: [""] script: - mkdir -p /kaniko/.docker - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG tags: - arm
Code language: YAML (yaml)

Zum Glück gibt es seit Kurzem ein Arm64 kompatibles Docker Image grc.io/kaniko-project/executor:v.1.6.0-debug. Warum wird das debug Image verwendet? Der Gitlab-Runner benötigt im Container eine Shell, z.B. sh. Diese ist im gehärteten Image gcr.io/kaniko-project/executor:v1.6.0 nicht vorhanden.

Es wird im script Block des Build-Steps zunächst ein Verzeichnis erstellt, in welches anschließend eine Docker-Konfiguration im JSON-Format geschrieben wird, in dem Authentifizierungs-Informationen aus Gitlab Umgebungsvariablen ausgelesen werden. Dies ist für die Authentifizierung nötig, wenn das Image in die Registry gepushed wird.

kaniko-executor bekommt bei mir drei Argumente:

--contextBuild Kontext, analog docker build
--dockerfileSpezifikation des Dockerfiles
--destinationPfad und Tagging des Images

Für weiterführende Informationen zu Builds mit Kaniko unter Gitlab siehe: https://docs.gitlab.com/ee/ci/docker/using_kaniko.html

Schreiben Sie einen Kommentar

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.