Automatische Image-Erstellung

System um Linux-Images automatisch zu erstellen (einfacher und schneller als OpenEmbedded, Puppet, Ansible etc).

In Beiträgen der Kategorie Job trage ich Projekte zusammen, die ich im Rahmen meiner beruflichen Karriere federführend durchgeführt habe. Ich gehe dabei mit Absicht nicht allzu sehr auf Details an: die Interessen meiner Arbeitgeber sollen ja nicht berührt werden.

Projekt-Info

Idee & Umsetzung: ich

Nutzung: 2012 bis heute

Implementierung: Make, Bash, Python

Effizienzgewinn:

  • jeder Änderung via "git log" / "git blame" verifizierbar
  • Directory-basierte Images lassen sich per "rsync" in Sekundenschnelle auf Geräte übertragen und testen
  • extrem schneller Image build (600 MB Image in 23 Sekunden) bedeutet einen schnellen Turnaround bedeutet das neue Features schneller getestet werden können

Anforderungen

  • das Ergebnis sollte eine Standard-Distribution sein (hier: Debian), nicht etwas spezielles wie beispielsweise bei TODO(Artikel schreiben) "OpenEmbedded"
  • die Image-Erstellung sollte ausgesprochen schnell gehen
  • Images sollten reproduzierbar sein
  • geringe Komplexität (also deutlich einfach wie weiland TODO(Artikel schreiben) "OpenEmbedded"
  • keine Client/Server-Architektur: das Image soll in einem Verzeichnis gebaut werden
  • keine Artefakte im Image vom eigentlichen Build-Prozesses — Kunden mögen es nicht, wenn irgendwelche Daemons offene Ports haben (außer vielleicht SSH).

Vorgehensweise

#
digraph G {
        layout=fdp;
        debootstrap [
            pos="0.5,3!"
            shape=cylinder
        ]
        kernel [
            pos="3,3!"
            label=<<table border="0" cellborder="1" cellspacing="0" cellpadding="4">
                <tr><td>linux-6.x.tar.xz</td></tr>
                <tr><td>patch-6.x.y.xz</td></tr>
                <tr><td>*.kconfig</td></tr>
                <tr><td>*.patch</td></tr>
                </table>>
            shape=plain
        ]
        debs [
            pos="5.5,3!"
            label=<<table border="0" cellborder="1" cellspacing="0" cellpadding="4">
                <tr><td>Debian Pakete</td></tr>
                <tr><td>Konfigurationsdateien</td></tr>
                </table>>
            shape=plain
        ]
        config [
            pos="7.5,3!"
            shape=box
        ]
        image [
            pos="3,1!"
            shape=cylinder
        ]
        downloads [
            pos="3,4.3!"
            shape=ellipse
            color="#a0a0a0"
            label="download Cache"
        ]
        cache [
            pos="3,6!"
            shape=ellipse
            color="#a0a0a0"
            label=".deb Cache"
        ]
        systemd [
            pos="5.5,6!"
            shape=box
        ]
        wpa [
            pos="5.5,5!"
            shape=box
        ]

        debootstrap -> image;
        kernel -> image;
        debs -> image;
        config -> image;
        systemd -> cache;
        wpa -> cache;

        downloads -> kernel   [dir=both, color="#a0a0a0"];
        cache -> debs         [dir=both, color="#a0a0a0"];
        cache -> debootstrap  [dir=both, color="#a0a0a0"];
}

mkimage.png

debootstrap<<debootstrap>>

Debootstrap erzeugt ein Debian-Basissystem in einem Unterverzeichnis. Es braucht keine Installations-CD, sondern lediglich Zugriff auf die Debian-Repositories.

So ein Basissystem ist selbst nicht bootbar — man kann es aber schon z.B. in Docker nutzen. Ich nutze es, um aus diesem minimalen Basis-System dann das zu erstellen.

~/d/mkimage$ time make debootstrap
sudo make debootstrap
rm -rf image.debootstrap.bookworm.amd64
mkdir -p downloads/debootstrap.bookworm.amd64
mkdir -p downloads/apt.bookworm.amd64
eatmydata -- debootstrap \
    --arch amd64 \
    --variant=minbase \
    --no-check-gpg \
    --cache-dir=downloads/apt.bookworm.amd64 \
    --exclude [weggelassen]... \
    --include apt-utils,procps,xz-utils \
	bookworm image.debootstrap.bookworm.amd64 https://deb.debian.org/debian/
...
touch --no-create image.debootstrap.bookworm.amd64/etc/debian_version

real    0m27.615s
user    0m0.034s
sys     0m0.036s
Erwähnenswert
  • eatmydata reduziert das exzessive "fsync()" von "dpkg". Das Filesystem syncen ist gänzlich unnötig, wenn man sich das
  • ein Cache-Directory "downloads/apt.bookworm.amd64" schont die Debian-Server und erhöht die Geschwindigkeit — dies ist einer der Bereich, die Docker bis heute nicht gut gelöst hat.
  • 28 Sekunden ist ein durchaus netter Wert
Ergebnis

Anschließend hat man eine minimales Debian in einem Directory, welches man für

  • TODO(Artikel schreiben) Linux Restore Stick
  • TODO(Artikel schreiben) Teststick UEFI

einsetzen kann.

~/d/mkimage$ ls image.debootstrap.bookworm.amd64/
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
~/d/mkimage$ sudo du -hs image.debootstrap.bookworm.amd64/
182M	image.debootstrap.bookworm.amd64/

Kernel-Erstellung

Auch die Erstellung des Kernels ist automatisiert. Möchte man diesen Schritt einzeln ausführen, kann man jederzeit z.B.

~/d/mkimage$ make cleankernel
...
~/d/mkimage$ time make -j8 compkernel
...
real	2m58.199s
user	18m18.395s
sys	2m40.577s

ausführen. Das ganze dauert also nur 3 Minuten.

Kernel-Source

Auf den Geräten läuft ein selbst-kompilierter Kernel, basierend auf https://kernel.org. Das liegt daran, das der Standard-Debian-Kernel eher für Desktop- und Serverumgebungen gemacht ist, weniger für Embedded.

Also nehme ich jeweils einen aktuellen, stabilen Upstream-Kernel von kernel.org, füge AUFS für den hinzu. Anschließend werden noch jede Menge Patches mit Hilfe von quilt angewandt. Diesen haben oft diese Zwecke:

  • Unterdrücken von harmlosen Kernel-Warnungen, die aber den Kunden beim Booten des Images verunsichern würden
  • TODO Optimierungen des Linux mac80211 Layers und diverser WLAN-Treiber (z.B. Atheros) zum besseren Roaming
  • Geschwindigkeitsoptimierungen für ein schnelleres Booten / Filesystem

Die Kernel-Sourcen werden direkt von kernel.org heruntergeladen und in "downloads/" gecacht.

Kernel-Konfiguration

Neben den Patches gibt es auch noch in "*.kconfig" Files eine Kernelkonfiguration. Die wichtigste ist natürlich "default.kconfig". Sie macht nur Dinge an, die wir brauchen. Ein Beispiel: Debian hat "Hot CPU Swap" an – aber das wird bei Industrie-Geräten nie der Fall sein. Man müßte das Gerät komplett zerlegen, um an die CPU zu kommen.

Neben der Default-Konfiguration gibt es noch für jedes unterstützte Gerät eine "device-XXXX.kconfig" Datei, welches Treiber für dieses spezifische Gerät aktiviert. Ein Beispiel:

#00:02.0 VGA compatible controller [0300]: Intel Corporation Atom Processor Z36xxx/Z37xxx Series Graphics & Display [8086:0f31] (rev 11)
CONFIG_DRM_I915=m

#00:14.0 USB controller [0c03]: Intel Corporation Atom Processor Z36xxx/Z37xxx Series USB xHCI [8086:0f35] (rev 11)
CONFIG_USB_XHCI_HCD=y
externe Treiber

Es gibt (oder gab) noch diverse externen Kernel-Module, beispielsweise zur Hardware-Erkennung, BIOS-Updates, Penmount-Treiber, diverse externe USB-Geräte.

Erwähnenswert
  • die schnelle Kompilationszeit kommt daher, das viele Kernel-Subsysteme erst gar nicht kompiliert werden. Warum sollte ein Image für die Industrie Treiber für Graphics-Tablets oder DVB-S (Satelittenfernsehen) habe?

systemd

Wir nutzen nicht den systemd des Debian-Projektes, denn dieser ist eher für Rechenzentren gedacht. Er enthält viele Dinge, die man auf einem Embedded-Device eher nicht braucht. Beispiele: quotacheck, importd, timedated, localed …

Außerdem werden viel mehr .deb Pakete erzeugt, insgesamt 54. Installiert werden davon aber nur wenige. Die meisten werden nur vorgehalten, sollte ein Kunde das jemals brauchen. Beispiele: rfkill, cgls, cgtop, kernelinstall, journal-gatwayd, journal-remote …

wpasupplicant

Wir compilieren auch unseren eigenen WPA-Supplicant, um das Roamingverhalten zu verbessern. Sie dazu den Artikel

  • TODO(Artikel schreiben) Schnelles WLAN-Roaming

Pakete, Konfigurationsdateien

Nun ist es Zeit, das eigentliche Image zu erstellen. Dies geschieht mit diesen Komponenten:

  • "bin/run" enthält viele Shell-Funktionen und kann Shell-Scriptlets sourcen
  • "base/*" enthält viele dieser Shell-Scriptlets, beispielsweise "base/kernel" oder "base/tool-rsync" die jeweils eine Sache installieren bzw. konfigurieren
  • "conf/base-image.conf" definiert, welche von den "base/*" Scripten genutzt werden sollen
  • "conf/base-config.imgconf" definiert, welches Debian wir verwenden (also beispielsweise "bookworm" für die Architektur "amd64")

Lassen wir das doch einfach mal ablaufen:

$ time make image
make checkconfig                                           (ref:checkconfig)
make[1]: Entering directory '/home/holger/d/mkimage'
make[1]: Leaving directory '/home/holger/d/mkimage'
sudo make image CUST="" IMAGE=image                        (ref:sudo)
umount -f image/proc 2>/dev/null                           (ref:umount)
make: [Makefile.image:36: image] Error 32 (ignored)
umount -f image/sys 2>/dev/null
make: [Makefile.image:37: image] Error 32 (ignored)
umount -f image/dev 2>/dev/null
make: [Makefile.image:38: image] Error 32 (ignored)
rm -rf image                                               (ref:rmimage)
bin/run                                                    (ref:run)
Info : using conf/image.imgconf

running base/image                                         (ref:runimage)
running base/eatmydata                                     (ref:eatmydata)
running base/firmware-radeon
running base/firmware-realtek
running base/kernel
running base/systemd
...
running base/wireless
-> get ftp.de.debian.org/debian/pool/main/libn/libnl3/libnl-genl-3-200_3.7.0-0.2+b1_amd64.deb    (ref:deb)
-> get ftp.de.debian.org/debian/pool/main/libn/libnl3/libnl-3-200_3.7.0-0.2+b1_amd64.deb
-> get ftp.de.debian.org/debian/pool/main/libn/libnl3/libnl-route-3-200_3.7.0-0.2+b1_amd64.deb
-> get ftp.de.debian.org/debian/pool/main/p/pcsc-lite/libpcsclite1_1.9.9-2_amd64.deb
running base/lib-x11
...
running base/rm                                           (ref:runrm)
finished !!!

real    0m23.599s
user    0m0.090s
sys     0m0.019s
  • in Zeile (checkconfig) prüfen wir, ob z.B. in den in C++/Qt geschrieben config-Tool noch Debugausgaben sind
  • Zeile (sudo) erkennt, das wir noch ein normaler User sind. Es wird dann automatisch nach Root gewechselt.
  • Zeile (umount) versucht, Mounts zu löschen. Diese können entstehen, wenn man einen vorherigen "make image" mit Ctrl-C abbricht – dies lässt sich in Makefiles nicht abfangen (die Bash könnte es).
  • Zeile (rmimage) bedeutet, das wir mit ein vorheriges "image/" Directory löschen. Dort hinein wird unser Image generiert. Das ist ähnlich wie oben bei Debootstrap, das ein "image.debootstrap.bookworm.amd/" erstellt hatte.
  • Zeile (run) schließlich führt das "bin/run" Programm aus, welches rekursive Scriptlets in "base/*" ausführen kann
  • das erste Scriptlet wird in Zeile (runimage) ausgeführt. Es legt ein frisches "image/" Directory an und kopiert erst mal das Debootstrap-Image dort hinein.
  • danach werden viele weitere Scriptslets ausgeführt auf die ich nicht weiter eingehe
  • die meisten Schritte hatten schon die nötigen Debian-Pakete im .deb Cache ("downloads/deb.bookworm.amd64/". Aber in Zeile (deb) kann man sehen, das noch fehlende Debian-Pakete automatisch heruntergeladen werden. Das erledigt ein kleines Python-Script, "bin/get_deb.py".
  • am Schluss wird es in Zeile (runrm) wieder etwas besonders: da wir Images für Embedded Devices erstellen, können wir viele Dinge löschen. Beispiel: es ist sowieso kein "man" Binary installiert. Also kann man auch einfach alle Manpages löschen. Sie könnten ja doch nicht angeschaut werden.
Erwähnenswert

Wer jemals ein Docker-Image auf Debian-Basis erstellt hat, wird sich evtl. die Augen reiben: wie kann man ein Image in gerade mal 23 Sekunden bauen? Allein die Docker-Zeile "apt-get update; apt-get install foo bar baz; apt-get clean" braucht wesentlich länger?

Der Trick ist hier ist:

  • einerseits die Installation von "eatmydata" ins Image hinein (siehe Zeile (eatmydata) oben. Es wird dann beim Installieren von ".deb"-Paketen kein "fsync()" or "open(...,O_SYNC)" ausgeführt.
  • manuelles Dependency-Resolving. Hier in Beispiel: um BlueZ (den Linux-Bluetooth-Daemon) zu installieren, braucht man vorher einige Libraries. Statt "apt" das herausfinden zu lassen, finde ich es einmalig heraus und fordere die explizit. Die Datei "base/bluez" sieht dann z.B. so aus:
need base/user                               (ref:user)
need base/systemd
need base/lib-glib                           (ref:glib)

install_debian_deb bluez                     (ref:bluez)
install_debian_deb libbluetooth3             (ref:lib1)
install_debian_deb libdw1
install_debian_deb libasound2
install_debian_deb libasound2-data
install_debian_deb libreadline8
install_debian_deb readline-common           (ref:lib7)

do_run()                                     (ref:dorun)
{
    mkdir -p ${IMAGE_DIR}/etc/systemd/system/bluetooth.service.d/
    copy_file less-services.conf etc/systemd/system/bluetooth.service.d/  (ref:dropin)

    ...
    in_image adduser --quiet dlog bluetooth >/dev/null    (ref:adduser)
}

Das bedeutet im einzelnen:

  • bevor "base/bluez" ausgeführt werden kann, muss (Zeile (user)) ein Standard-User angelegt werden. Das liegt daran, das wir diesen User mit "addgroup" in die Gruppe "bluetooth=" aufnehmen wollen (Zeile (adduser)).
  • dann brauchen wir noch systemd vorher im Image, da wir ein Drop-In Konfigurationsfile installieren, welches Teile des Debian-BlueZ-Unit überschriebt (Zeile (dropin)).
  • BlueZ braucht wie viele andere Programm die glib. Statt also alle .deb unten anzuführen, die glib installieren, habe ich das in ein eigenes "base/lib-glib" ausgelagert (Zeile (glib)).
  • "need" ist übrigens eine Shell-Funktion, definiert in "bin/run". Dasselbe gilt für "install_debian_deb" und "in_image".
  • nun wird BlueZ selbst in Zeile (bluez) und alle benötigten Libraries installiert (Zeilen (lib1) bis (lib7)).
  • Wenn alle Debian-Pakete installiert sind, wird die Funktion "do_run" in Zeile (dorun) aufgerufen. Sie kann beliebige Programme aufrufen, einmal außerhalb des neu erstellten Images, aber auch innerhalb mit Hilfe von "in_image". Außerdem sind diverse Environment-Variablen wie "$RUN_HOME", "$IMAGE_DIR" und "$DATA_DIR" definiert.
Ergebnis
~/d/mkimage$ ls image
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
~/d/mkimage$ sudo du -hs image
639M	image

Diese Image kann nun mit "cd image; tar cvzf ../combined-linux.tar.xz ." eingepackt werden. Dieses File würde man dann auf einen TODO(Artikel schreiben) "Linux Restore Stick" kopieren und damit Geräte initialisieren.

Man kann es auch mir "rsync" direkt per Ethernet oder WLAN auf ein Gerät syncen — das ist erheblich schneller: nach wenigen Sekunden ist das neue erstellte Image testbar.

Verwandte Projekte

Die folgenden Projekte verwenden mkimage direkt oder ähnlich:

  • TODO(Artikel schreiben) Linux-Image auf Basis von i.MX& RISC Prozessor für den Tagebau
  • TODO(Artikel schreiben) Linux Restore Stick
  • TODO(Artikel schreiben) Hardware-Teststick für DLT-V73