TypeScript in einem Docker-Container auf dem Raspberry Pi ausführen (Einsteiger-Tutorial)

TypeScript in einem Docker-Container auf dem Raspberry Pi ausführen (Einsteiger-Tutorial)

Docker sowie Docker-Compose sind auch für die ARM-Architektur und damit den Raspberry Pi verfügbar. Meist werden sie zwar auf x86-Hardware eingesetzt. Doch auch auf dem Raspberry hat man zunehmend mehrere Anwendungen im Einsatz: Beispielsweise einen Anwendungs- oder API-Server, der in vielen Fällen nicht ohne Datenbank auskommt. Nicht selten möchte man diese Daten auch noch grafisch darstellen, sodass eine Benutzerschnittstelle dazu kommt. Schnell haben wir mehrere Container, die bisher direkt auf dem Pi installiert wurden – wie auf einem klassischen Linux-Server.

Durch die Verwendung von Containern können wir diese Anwendungen besser voneinander isolieren. Die Vorteile sind identisch wie auch auf klassischen x86 Systemen.

Voraussetzungen

  • Raspberry Pi 3 oder neuer (Je neuer das Modell, um so besser die Performance)
  • SSH-Zugang eingerichtet
  • Docker und Docker-Compose installiert

Anlegen der Ordnerstruktur

Zunächst legen wir einen Basisordner für das Projekt an und navigieren mit cd dort hin

mkdir my-project
cd my-project

Die Trennung von Quellcode und weiteren Dateien wie Konfigurationsdateien hat sich bewährt. Daher ist zu empfehlen, einen weiteren Unterordner für den Source Code anzulegen. Oft wird er src genannt. Ich empfehle diese Konvention zu übernehmen. Grundsätzlich kann er aber frei benannt werden.

In diesem Szenario handelt es sich um eine einzelne Anwendung, die wir selbst entwickeln. Dazu kommen Drittanbieter-Abhängigkeiten wie z.B. eine Datenbank. An dieser Stelle sollte man sich überlegen, welche Komponenten die eigene Anwendung benötigt. Möchte man beispielsweise Backend und Frontend trennen (Eigenentwicklung der grafischen Oberfläche) oder benötigt zusätzliche Dienste wie einen Cron, macht es Sinn, diese direkt in Unterordnern aufzuteilen. Im folgenden Beispiel halten wir es einfach und legen nur einen src-Ordner an, der mit index.ts den Einstiegspunkt der Anwendung enthält.

mkdir src
echo "console.log('Hello World')" > src/index.ts

Docker-Compose Datei erstellen

In der ersten Ordnerebene (hier my-project) legen wir zunächst eine docker-compose.yml Datei mit einem frei wählbaren Texteditor (z.B. vim) an. Sie definiert alle Dienste, die zu unserer Anwendung gehören und in getrennten Docker-Containern gestartet werden.

version: '2'
services:
  app:
    build:
      context: src
    mem_limit: 256MB
    ports:
        - 80:80
        - 443:443

Hier definieren wir zunächst nur einen Dienst für die Anwendung selbst. Diese könnte z.B. eine API bereitstellen. Weitere Container wie beispielsweise Datenbanken können natürlich hinzugefügt werden, je nachdem was man benötigt.

TypeScript mittels package.json installieren

NodeJS unterstützt TypeScript nicht automatisch. Zunächst muss der TypeScript-Kompiler installiert werden. Er erzeugt JavaScript, das NodeJS ausführen kann. Der Kompiler ist im NPM-Paket typescript zu finden. Man kann ihn entweder mit

npm i -g typescript

global innerhalb des Containers installieren. Oder über die lokale package.json. Ich empfehle letzeres, da man die package.json oft ohnehin für weitere Bibliotheken benötigt. Dadurch befinden sich alle Abhängigkeiten zentral an einer Stelle, statt in package.json und Dockerfile.

Eine package.json Datei lässt sich einfach mit npm init anlegen:

npm init -y

Durch den Schalter -y überspringen wir die zahlreichen Nachfragen nach diversen Metadaten. Diese sind im Moment nicht wichtig und werden erst relevant, wenn ein NPM-Paket in einer Registry publiziert werden soll. Für Bibliotheken ist das Standard, bei Anwendungen eher die Ausnahme. Soll dies später dennoch geschehen, können die betroffenen Felder einfach händisch mit einem Texteditor in der erzeugten package.json Date ergänzt werden.

Nachdem die Datei angelegt ist, können wir TypeScript hinzufügen. Im einfachsten Falle ist auf dem Entwicklungssystem npm installiert, sodass wir einfach folgenden Befehl im gleichen Verzeichnis ausführen:

npm i --save typescript

Ist das nicht der Fall, einfach auf npmjs.com nach der aktuellsten stabilen Version des Paketes suchen. Zum Erstellungszeitpunktes dieses Artikels ist das 3.9.6 . Im Bereich dependencies der package.json folgende Zeile einfügen:

"typescript": "^3.9.6"

Dadurch wird TypeScript in der angegebenen Version installiert, wenn wir später innerhalb unseres Containers npm install zur Auflösung der Abhängigkeiten ausführen.

Dockerfile für TypeScript/NodeJS

Das Herzstück unseres Containers ist das selbst erstellte Dockerfile. Es erzeugt die Umgebung, installiert alle notwendigen Abhängigkeiten und richtet diese passend zu unserer Anwendung ein. Im src Ordner öffnen wir dazu die gleichnamige Datei ohne Endung und fügen folgendes ein:

FROM node:lts-alpine
WORKDIR /app
COPY package.json .
RUN npm install

COPY src .
RUN npx tsc index.ts
ENTRYPOINT ["node", "index.js"]

Zeile 1 legt das offizielle NodeJS-Image als Basis fest. Die LTS-Version ist stabil und erhält am längsten Updates. Wer möchte, kann alternativ auch natürlich auch die neuste stabile Version einsetzen. Gerade für ein RPI-Projekt würde ich jedoch besser zur LTS-Version raten, sofern man nicht zwingend die dort fehlenden brandneuen Funktionen benötigt. Oft ist dies nicht der Fall.

Darüber hinaus verwende ich nicht das auf Debian bzw. Raspbian aufbauende Image, sondern Alpine. Dabei handelt es sich um ein minimalistisches Linux. Es ist mit wenigen MB deutlich schlanker als die anderen Distributionen. Das spart Speicher, verkürzt Ladezeiten und reduziert auch die potenzielle Angriffsfläche. Auch hier würde ich Alpine dem Vorzug geben, sofern nicht aufgrund von Abhängigkeiten explizit Debian/Raspbian benötigt wird.

In Zeile 2 definieren wir das aktuelle Arbeitsverzeichnis im Container – vergleichbar mit dem cd Verzeichniswechsel. Der Ordner /app hat sich als Quasi-Standard etabliert. Grundsätzlich kann man den Pfad frei wählen. Er beeinflusst den Host nicht, da er nur innerhalb des Images und damit dem Container zur Verfügung steht.

Zeile 3 kopiert die zuvor angelegte package.json Datei mit den Abhängigkeiten, während Zeile 4 diese installiert. Das geschieht bewusst getrennt, weil Docker in mehreren Schichten arbeitet. Würden wir es uns einfacher machen und den gesamten Ordner mit COPY . . kopieren, funktioniert das zwar. Hätte allerdings folgenden Seiteneffekt: Sobald eine Quellcode-Datei geändert wird, müssten auch die NPM-Pakete neu installiert werden – obwohl die package.json Datei unverändert ist. Als Folge erhöht sich die Erstellungszeit deutlich. Den gesamten src-Ordner kopieren wir daher erst im Anschluss.

In der vorletzten Zeile wird der TypeScript-Kompiler ausgeführt. Er erzeugt aus index.ts eine JavaScript-Datei, die von NodeJS gestartet werden kann. NPX ist ein praktisches Werkzeug, um CLI-Pakete aus dem lokalen node_modules Ordner auszuführen. Es erspart uns die händische Suche im gleichnamigen Ordner. Außerdem kann es als Fallback auf global installierte Pakete zurückgreifen, falls das gewünschte nicht lokal verfügbar ist.

Die letzte Zeile legt fest, welcher Befehl beim Start des Containers ausgeführt werden soll. Standardmäßig erzeugt der TypeScript-Kompiler gleichnamige Dateien, die auf .js statt .ts enden.

Image erzeugen und Container starten

Mithilfe des Dockerfiles können wir nun ein Image bauen. Dies enthält alle Abhängigkeiten und Dateien, so wie von uns definiert. Es kann daher zum Starten eines (oder auch mehrerer identischer) Container verwendet werden. Hierzu führen wir folgenden Befehl aus:

docker-compose up --build

Es dauert einen Moment, bis alle Schichten erstellt und der Container gestartet wurde. Am Ende sollte man folgende Ausgabe sehen können:

Attaching to test_app_1
test_app_1     | Hello World
test_app_1 exited with code 0

An diesem Punkt wurde unsere index.ts Datei ausgeführt. Das anschließende Beenden des Containers ist kein Fehler: Unser Skript erhält keinerlei weitere Anweisungen. Oft startet man hier einen länger laufenden Dienst, wie etwa einen Webserver. Im Gegensatz zu einer VM läuft der Container nicht weiter, wenn es nichts zutun gibt. In dem Fall ist das kein Problem, weil es so beabsichtigt ist – der Container soll nur die Ausgabe tätigen, nicht mehr und nicht weniger.

Problematisch ist dies erst, wenn der Container einen lang laufenden Prozess starten sollte. Ein Datenbank-Container dürfte sich beispielsweise nicht beenden, sondern muss dauerhaft laufen. Beendet er sich dennoch, besteht ein Konfigurationsfehler.

Weitere Schritte und Verbesserungen

Nun haben wir eine einfache TypeScript-Anwendung containerisiert auf dem Raspberry Pi gestartet. Dies ist ein praktisches Beispiel für den Einstieg in Docker auf einem Einplatinencomputer.

Leave a Reply