Bauen eines Embedded Systems
In diesem Artikel geht es darum, ein Linux-System crashsicher zu machen. Wir bereiten eine Installation soweit vor, dass ein Debian Bullseye bootet und eine Beispiel-node.js-App startet.
Das Root-Dateisystem wird dabei in einem SquashFS-Archiv liegen. Bei Start wird dieses als readonly in den Arbeitsspeicher geladen, und ein weiteres virtuelles Dateisystem drüber gelegt, welches die Änderungen beinhaltet.
Sinn des ganzen: alles, was sich während der Laufzeit im Dateisystem ändert, passiert im RAM. Stürzt das Linux ab, verliert es die Verbindung zum Strom, oder was auch immer passiert – wir booten jedes mal wieder mit exakt dem selben Dateisystem.
Dazu brauchen wir erst mal ein Linux, mit welchem wir das andere Linux-System bauen. Ich selber nutze WSL unter Windows (in Kombination mit Hyper-V, um erstellte Images direkt zu testen) – ihr könnt euch aber auch einen eigenen kleinen Rechner aufsetzen. Zu der Konstellation WSL <-> Hyper-V ganz unten mehr.
Wir erstellen in diesem Tutorial eine ISO-Datei (DVD-Abbild), die eine Anwendung in einer kompletten Linux-Umgebung nach unserem Wunsch startet. Es wird nicht auf Updates o.ä. eingegangen, das wird vermutlich Thema eines anderen Posts 😅
Disclaimer vorweg
Der hier vorliegende Guide orientiert sich nicht nur zufällig sehr stark an folgendem:
https://willhaley.com/blog/custom-debian-live-environment/
Hier kommt eine abgeschlackte Version ohne GUI raus, und es wird noch mal darauf eingegangen, wie man nun z.B. eine App dort reingeknotet bekommt; dennoch möchte ich gerne 85% der Credits einmal abgeben. Im Prinzip habe ich ja „nur auf Deutsch übersetzt“ und ein paar Sachen weggestrichen, ein paar Sachen ergänzt, das auf meine Bedürfnisse angepasst und hier gedumpt.
Vorbereitung des Host-Systemes
Zum Bauen benötigt ihr folgende Software
sudo apt install debootstrap squashfs-tools xorriso isolinux syslinux-efi grub-pc-bin grub-efi-amd64-bin mtools
Und folgende Verzeichnisse
mkdir /build/
mkdir /build/{chroot,staging,staging/{EFI,EFI/boot,boot,boot/grub/,boot/grub/x86_64-efi,isolinux,live},tmp}
In /build/chroot/ werden wir später unser embedded Linux haben. /build/staging/ wird das sein, was bei dem Zielrechner auf der Platte landet. /build/tmp nutzen wir für alles, was weg kann.
Erstellen wir uns doch mal ein basis-Linux:
debootstrap \
--arch=amd64 \
--variant=minbase \
bullseye \
/build/chroot \
http://ftp.de.debian.org/debian/
Und chrooten dort rein:
chroot /build/chroot
In der chroot-Umgebung
Setzen wir als erstes einen Hostname für das Gerät:
echo "embedded-system" > /etc/hostname
Updaten unsere Paketlisten
apt-get update
Und installieren ein paar grundlegene Sachen:
apt-get install --no-install-recommends linux-image-amd64 live-boot systemd-sysv
Sowie etwas Netzwerk-Shizzle:
apt-get install --no-install-recommends network-manager net-tools iputils-ping
Dann vergeben wir ein Root-Kennwort:
passwd root
Die nächsten zwei Schritte sind optional: ich möchte nen SSH-Server auf der Box haben, um das aus der Ferne zu warten – sowie ein paar andere Kleinigkeiten, wie curl und nano
apt-get install --no-install-recommends curl openssh-server nano
Erlauben von Root-SSH-Verbindungen auf die Box:
nano /etc/ssh/sshd_config
Und ersetzen von #PermitRootLogin prohibit_password
zu PermitRootLogin yes
Vor dem Bauen bietet es sich an, den apt-Cache zu leeren, und verwaiste Pakete zu löschen – dann ist unser Image später auch schmaller.
apt autoremove
apt clean
Anschließend verlassen wir das chroot mit:
exit
Und schon haben wir ein sehr minimales Linux, mit einem SSH-Server, ohne eine Anwendung. Die bauen wir in den nächsten zwei Schritten zu einer ISO.
Vorbereiten des Boot-Loaders
Das hier wird nur einmalig nötig sein:
cat <<'EOF' >/build/staging/isolinux/isolinux.cfg
UI vesamenu.c32
MENU TITLE Boot Menu
DEFAULT linux
TIMEOUT 50
MENU RESOLUTION 640 480
MENU COLOR border 30;44 #40ffffff #a0000000 std
MENU COLOR title 1;36;44 #9033ccff #a0000000 std
MENU COLOR sel 7;37;40 #e0ffffff #20ffffff all
MENU COLOR unsel 37;44 #50ffffff #a0000000 std
MENU COLOR help 37;40 #c0ffffff #a0000000 std
MENU COLOR timeout_msg 37;40 #80ffffff #00000000 std
MENU COLOR timeout 1;37;40 #c0ffffff #00000000 std
MENU COLOR msg07 37;40 #90ffffff #a0000000 std
MENU COLOR tabmsg 31;40 #30ffffff #00000000 std
LABEL linux
MENU LABEL Debian Live [BIOS/ISOLINUX]
MENU DEFAULT
KERNEL /live/vmlinuz
APPEND initrd=/live/initrd boot=live
LABEL linux
MENU LABEL Debian Live [BIOS/ISOLINUX] (nomodeset)
MENU DEFAULT
KERNEL /live/vmlinuz
APPEND initrd=/live/initrd boot=live nomodeset
EOF
Und selbes für EFI:
cat <<'EOF' >/build/staging/boot/grub/grub.cfg
search --set=root --file /DEBIAN_CUSTOM
set default="0"
set timeout=5
# If X has issues finding screens, experiment with/without nomodeset.
menuentry "Debian Live [EFI/GRUB]" {
linux ($root)/live/vmlinuz boot=live
initrd ($root)/live/initrd
}
menuentry "Debian Live [EFI/GRUB] (nomodeset)" {
linux ($root)/live/vmlinuz boot=live nomodeset
initrd ($root)/live/initrd
}
EOF
Damit Grub die richtige Partition, von der er starten soll, findet, sagen wir ihm, er soll die nehmen, die eine Datei DEBIAN_CUSTOM direkt im Root-Verzeichnis hat
cat <<'EOF' >/build/tmp/grub-standalone.cfg
search --set=root --file /DEBIAN_CUSTOM
set prefix=($root)/boot/grub/
configfile /boot/grub/grub.cfg
EOF
Und erstellen diese Datei:
touch /build/staging/DEBIAN_CUSTOM
Nun bereiten wir unseren Bootloader vor:
cp /usr/lib/ISOLINUX/isolinux.bin /build/staging/isolinux/
cp /usr/lib/syslinux/modules/bios/* /build/staging/isolinux/
cp -r /usr/lib/grub/x86_64-efi/* /build/staging/boot/grub/x86_64-efi/
grub-mkstandalone \
--format=x86_64-efi \
--output=/build/tmp/bootx64.efi \
--locales="" \
--fonts="" \
"boot/grub/grub.cfg=/build/tmp/grub-standalone.cfg"
cd /build/staging/EFI/boot
dd if=/dev/zero of=efiboot.img bs=1M count=20
mkfs.vfat efiboot.img
mmd -i efiboot.img efi efi/boot
mcopy -vi efiboot.img /build/tmp/bootx64.efi ::efi/boot/
Bauen eines Releases
Nun juckeln wir aus unserem chroot alles, außer /boot, in ein SquashFS und kopieren vmlinuz / initrd noch mit in unser /build/staging:
mksquashfs /build/chroot /build/staging/live/filesystem.squashfs -noappend -e boot
cp /build/chroot/boot/vmlinuz-* /build/staging/live/vmlinuz
cp /build/chroot/boot/initrd.img-* /build/staging/live/initrd
Und bauen daraus eine ISO:
xorriso \
-as mkisofs \
-iso-level 3 \
-o "/build/release.iso" \
-full-iso9660-filenames \
-volid "DEBIAN_CUSTOM" \
-isohybrid-mbr /usr/lib/ISOLINUX/isohdpfx.bin \
-eltorito-boot \
isolinux/isolinux.bin \
-no-emul-boot \
-boot-load-size 4 \
-boot-info-table \
--eltorito-catalog isolinux/isolinux.cat \
-eltorito-alt-boot \
-e /EFI/boot/efiboot.img \
-no-emul-boot \
-isohybrid-gpt-basdat \
-append_partition 2 0xef /build/staging/EFI/boot/efiboot.img \
"/build/staging"
Die eigene Node-App hinzufügen
Wir wollen eine dumme node-app programmieren, die bei Start direkt auf Port 8080 läuft und einfach nur „hello world“ anzeigt.
Dazu gehen wir als erstes wieder in das chroot:
chroot /build/chroot
Und installieren uns nodejs. Dazu laden wir die bevorzugte Version von nodejs.org herunter, entpacken sie und verteilen etwas Kram in die entsprechenden Ordner:
cd /tmp
curl https://nodejs.org/dist/v16.15.0/node-v16.15.0-linux-x64.tar.xz --output node.tar.xz
tar -xf node.tar.xz
cd node-*
mv bin/* /usr/local/bin
mv lib/node_modules/ /usr/local/lib/
node -v
npm -v
Die letzten beiden Befehle checken nur, ob alles lief wie geplant.
Erstellen wir uns ein neues Verzeichnis und initialisieren unser Projekt:
mkdir /app
cd /app
npm init
… installieren express…
npm install --save express
… und erstellen eine /app/index.js mit folgendem Inhalt:
const express = require('express');
const app = express();
app.get('/', function(req, res) { res.send('Hello World!'); });
app.listen(8080, function() { console.log("Express running"); });
Testen, ob die App lokal läuft, indem wir (1) node /app/index.js
eingeben und (2.) mit einem Browser unserer Wahl http://localhost:8080
aufrufen – läuft. STRG+C zum Beenden der App. (alles hier geht davon aus, dass euer Build-System einen Browser integriert hat. Wie bereits angedeutet, ich nutze WSL. Für Windows gibt es ein paar Browser.)
Nun erstellen wir eine Datei /lib/systemd/system/my_app.service
mit folgendem Inhalt:
[Unit]
Description=Meine erste App
Documentation=https://www.tino-ruge.de/
After=network.target
[Service]
Environment
Type=simple
User=root
ExecStart=/usr/local/bin/node /app/index.js
Restart=on-failure
[Install]
WantedBy=multi-user.target
Und legen die in den Autostart:
systemctl enable my_app
Anschließend bauen wir ein neues Image, wie im Abschnitt „Bauen eines Releases“ beschrieben.
Windows <-> Hyper-V: schneller testen
Hatte ich oben versprochen, löse ich hier unten ein. Ich habe das WSL installiert – quasi ein Linux, innerhalb von Windows. Da habe ich leider kein Guide für, aber das haben die Jungs von Microsoft schon ganz gut hinbekommen.
Ebenfalls installiert ist bei mir Hyper-V. Dazu habe ich hier einen Guide.
In Hyper-V habe ich eine VM der Generation 1 erstellt und die ISO, die wir in diesem Artikel bauen, direkt in dem Pfad eingehängt, in dem diese erzeugt wird; bei mir ist es folgender:
C:\Users\tino.ruge.TINO-RUGE\AppData\Local\Packages\CanonicalGroupLimited.UbuntuonWindows_79rhkp1fndgsc\LocalState\rootfs\build\release.iso
Wenn ich einen Build in meinem Subsystem starte, schalte ich die VM aus (kann sie ja ab), lasse den Build durchlaufen, und schalte die VM wieder ein –> direkt testen, ob es passt, ohne jedes mal die ISO kopieren zu müssen o.ä.