Wenn Sie planen, eine moderne Anwendung mit all ihren Komponenten auf einem Server produktiv zu betreiben, stellt Docker dafür einen guten Ansatz dar. Vielfach ist es etwa bei geschäftlichen Anwendungen nicht notwendig, sich viele Gedanken über Skalierung zu machen – die Größe der Anwenderschaft steht von vornherein recht genau fest. Daher erscheint es unnötig, sofort über Lösungen wie Kubernetes nachzudenken. Aber selbst in solchen Fällen, wo Kubernetes nicht überdimensioniert erscheint, bietet Docker trotzdem die Werkzeuge, die zunächst zur “Containerisierung” der Anwendungsmodule notwendig sind.
In diesem Artikel geht es Schritt für Schritt darum, ein kleines fiktives Anwendungssystem mit Docker zu paketieren und für ein Deployment mit docker-compose vorzubereiten. Die Beispiele, die in den folgenden Beschreibungen verwendet werden, stehen in einem GitHub-Repository unter github.com/oliversturm/hosteurope-docker-tutorial-1 zu Ihrer Verfügung. So können Sie den Schritten selbst folgen und ein eigenes funktionierendes Deployment erstellen.
Ein Serverdienst auf .NET-Basis
Das erste Teilprojekt des Beispiels ist ein Serverdienst, der auf ASP.NET Core 6.0 basiert und eine einfache Web API verfügbar macht. Sie können die Kernfunktionalität der Anwendung in der Datei DotNetApp/Controllers/CalculationController.cs sehen: Vier unterschiedliche Rechenarten sind dort implementiert, die jeweils ein Resultat aus zwei Eingabewerten berechnen. Zugegeben, dies ist ein extrem einfacher Serverdienst! Allerdings gibt es tatsächlich für komplexere Dienste keine Besonderheiten für Docker-Deployments zu beachten, zumindest nicht, solange die Komplexität sich in der Anzahl und Implementation der Dienstfunktionen ausdrückt.
In der Datei DotNetApp/Properties/launchSettings.json können Sie sehen, dass der Dienst zu Entwicklungszwecken auf dem lokalen System über Port 5150 angesprochen werden kann. Wenn Sie .NET 6 auf Ihrem Computer installiert haben, können Sie mit dotnet run das Projekt starten. Falls Sie .NET 6 nicht installiert haben, können Sie etwas später trotzdem den Anleitungen zu Docker folgen – es ist Ihnen überlassen, ob Sie zu Illustrationszwecken den Dienst gern ausprobieren möchten.
Hier sehen Sie einen Testlauf. Zunächst wird der Dienst gestartet:
Dann können Sie mit curl oder einem vergleichbaren Werkzeug eine URL abrufen, deren Parameter für die Berechnung verwendet werden. Die Ausgabe auf der zweiten Zeile stellt das Ergebnis dar. +
Als Detail zum Beispielprojekt sei erwähnt, dass es zu Testzwecken eine CORS-Konfiguration verwendet, die Aufrufe von beliebigen Quellen zulässt. Die zuständige Codezeile ist Zeile 23 in DotNetApp/Project.cs. Dies ist in einem echten Projekt natürlich je nach Umständen zu bewerten, und für das fertige Deployment am Ende dieses Artikels ist es nicht mehr notwendig.
Da der Beispieldienst keine externen Abhängigkeiten hat, kann er sehr einfach in einem Docker-Image gekapselt werden. Microsoft bietet für ASP.NET Core 6.0 eine Vorlage für ein Dockerfile an, auf deren Basis die Konfigurationsdatei für dieses Beispiel erstellt wurde.
In meinem Artikel zum Bau eigener Docker-Images habe ich bereits erklärt, wie Sie Images in mehreren Schritten bauen können, und das wird in diesem Fall getan. Zusätzlich wird beachtet, dass Docker die Schichten eines Images nur dann neu erzeugt, wenn sie sich geändert haben. Hier die ersten Zeilen des Dockerfile:
Zunächst kopiert die COPY-Anweisung lediglich die Projektdatei allein, bevor dotnet restore ausgeführt wird. Das führt dazu, dass bei zukünftigen docker build-Kommandos diese Schritte nur dann wiederholt werden müssen, wenn sich die Projektdatei seit der vorherigen Ausführung geändert hat. Mit sauberer Planung lässt sich so verhindern, dass die mehrfache Erzeugung eines Image besonders während der Testphase länger dauert als nötig.
Die folgenden drei Zeilen vervollständigen den ersten Schritt der Imageerzeugung:
Nun werden alle anderen Dateien des Projekts in das Image kopiert. Das Kommando dotnet publish generiert eine kompilierte Laufzeitversion des Dienstprojektes im Verzeichnis /app des temporären Images. Bei einer Neuerstellung des Images müssen diese Kommandos normalerweise neu ausgeführt werden – sollten sich allerdings keine der Quelldateien geändert haben, würde Docker auch diese Schritte überspringen.
Achtung: Wenn Sie Dateien in ein Image kopieren, müssen Sie im Dockerfile die richtigen Pfade verwenden. In diesem Fall sind die Pfade so ausgelegt, dass der Aufruf außerhalb des Verzeichnisses DotNetApp erwartet wird. Beim Aufruf docker build muss immer ein Pfad für die Wurzel der Dateien und Verzeichnisse im Build-Kontext angegeben werden, und meistens ist das der Pfad . (ein Punkt), also das Aufrufverzeichnis. Es ist oft sinnvoll, die Dateien zu beschränken, die zum Bestandteil des Kontexts werden und somit bei Kopieroperationen erfasst werden. In diesem Beispiel finden Sie die beiden folgenden Zeilen in der Datei .dockerignore, so dass die eventuell umfangreichen Ordner mit Kompilaten aus Testläufen des Dienstes nicht mit übertragen werden.
Zweiter Schritt: Das Laufzeit-Image
Die letzten Zeilen des Dockerfile erzeugen das endgültige Image:
Beachten Sie, dass in diesem letzten Schritt ein anderes Basisimage verwendet wird als zuvor für den Kompiliervorgang. Das Laufzeitimage benötigt nicht mehr die verschiedenen Werkzeuge, die unter .NET für das Kompilieren notwendig sind. Daher können Sie gegenüber dem SDK-Basisimage build Platz in Ihrem Image sparen. Die COPY-Anweisung überträgt die fertig kompilierte Dienstanwendung in das finale Image.
Das Dockerfile für das Beispiel finden Sie unter dem Namen DotNetApp.Dockerfile. Sie können nun den Build-Vorgang auslösen:
Um das fertige Image zu testen, können Sie es starten und dabei die Abbildung des Port 80 im Container auf das lokale System konfigurieren. Beachten Sie, dass das .NET-Programm im Production-Modus nun Port 80 anstelle von 5150 verwendet. Angenommen, dass Sie lokal ebenfalls Port 80 für einen Testlauf verfügbar haben, starten Sie den Container so:
Der Container initialisiert sich mit einigen protokollierten Zeilen und wartet dann auf Verbindungen. Sie können mit einer weiteren curl-Anweisung testen, dass der Dienst arbeitet wie zuvor. Beachten Sie nur den geänderten (bzw. fehlenden) Port!
Nginx als öffentliches Frontend
Sollte Ihr Anwendungssystem so einfach sein, wie das Beispiel es bisher ist, könnten Sie nun bereits an die Publikation des Dienstes auf einem öffentlichen Server denken. Oft empfiehlt es sich auch unter solch einfachen Umständen, dem nativen .NET Server ein Frontend vorzuschalten, typischerweise etwa mit nginx. Das ist nicht zwingend erforderlich, schafft aber Flexibilität bei zukünftigen strukturellen Änderungen des Anwendungssystems und sorgt gleichzeitig dafür, dass Anfragen aus aller Welt zuerst vom millionenfach getesteten nginx verarbeitet werden, statt direkt vom .NET-eigenen Server. In einem zukünftigen zweiten Teil dieses Artikels werden Sie auch lernen, wie Sie einen nginx-Proxy SSL-fähig machen können!
Um ein Anwendungssystem aus den beiden Bestandteilen .NET-Dienst und nginx zu erstellen, brauchen Sie eine Konfigurationsdatei für docker-compose. Im Beispielprojekt finden Sie die Datei unter dem Namen docker-compose.dotnet.dev.yml. Darin ist ein Dienst für das .NET-Projekt selbst definiert:
Diese Deklaration ist so einfach, wie sie nur sein kann. Es wird angenommen, dass das verwendete Image auf dem Zielsystem unter dem Namen dotnetapp bereits verfügbar ist, oder aus einem bekannten Repository geladen werden kann – das ist für den Moment nicht weiter wichtig, sollte aber für spätere Deployments auf anderen Systemen im Kopf behalten werden. Weiterhin wird dem Container ein Name zugewiesen und das Neustartverhalten gesetzt. Mehr brauchen Sie für eine so einfache Definition nicht.
Der zweite Eintrag für den nginx-Dienst ist etwas komplizierter:
Drei Elemente kommen hier zu denen hinzu, die bereits für den Dienst dotnetapp beschrieben wurden. Zunächst wird mit depends_on eine Abhängigkeit definiert, aus der sich für docker-compose die Startreihenfolge der Dienste herleiten lässt.
Die Angabe zu den Ports ist einfach: Port 80 im Container wird als Port 80 des Hosts abgebildet. Dabei fällt allerdings auf, dass der eigene Dienst so eine Angabe gar nicht enthielt – haben wir da etwas vergessen? Nein, das ist absichtlich so! Da nämlich für den Dienst nginx als Abhängigkeit dotnetapp angegeben ist, ist vom laufenden Container nginx automatisch ein Netzwerksystem namens dotnetapp ansprechbar, das von docker-compose verfügbar gemacht und korrekt in Routing und Namensauflösung eingerichtet wird. So kann der Proxy auf den Dienst-Container zugreifen, ohne dass der Dienst selbst von außen zugängliche Ports öffnen muss, die eventuell missbraucht werden könnten.
Schließlich wird für nginx noch eine Konfigurationsdatei per Readonly-Mount in den Container angebunden. Diese Datei kann so extern gepflegt werden. Sie enthält den folgenden Block zur Einrichtung des Proxies für die URL /dotnetservices/:
Diese Proxyeinstellungen für nginx sollten für viele Fälle ausreichend sein, aber sie können natürlich mithilfe verfügbarer Dokumentation für nginx selbst sowie für .NET (etwa hier bei Microsoft) verfeinert werden. Das führt im Detail an dieser Stelle zu weit und sei Ihrer Eigeninitiative überlassen.
Im Kern ist lediglich wichtig, dass URLs, die mit /dotnetservices/ beginnen, auf die interne URL http://dotnetapp:80/ abgebildet werden. Dabei wird hier der bereits vorher erwähnte Name dotnetapp verwendet, der für den gleichnamigen Dienstcontainer steht.
Starten Sie nun die erste Version der Docker-Komposition:
Die beiden Container werden im Vordergrund gestartet, und an der Konsole können Sie Logausgaben anhand der Farben auseinander halten. Die korrekte Funktion der beiden Dienste lässt sich wiederum mit curl testen, diesmal unter Verwendung einer URL, die mit /dotnetservices/ beginnt. Wenn Sie auf dieses Präfix testweise verzichten, sehen Sie in der Ausgabe die Fehlermeldung von nginx.
Ein zusätzlicher Dienst mit eigenen Abhängigkeiten
Nun ist es an der Zeit, das Beispiel durch die Einführung neuer Dienste komplizierter und somit realistischer zu gestalten. Das Repository enthält ein zweites Anwendungsprojekt namens NodeApp, das mithilfe einer Standardvorlage des Frameworks Svelte Kit erzeugt wurde. Es handelt sich um eine Webanwendung, die mit einem MongoDB-Backend arbeitet und den bereits vorgestellten .NET-Dienst als Backend zur Berechnung von Werten verwendet. Die Implementation ist sehr einfach gehalten, aber die Bausteine des Gesamtsystems sind durchaus stellvertretend für eine echte Geschäftsanwendung.
Um die Anwendung lokal zu testen, stellen Sie sicher, dass sowohl der .NET-Dienst als auch eine Instanz von MongoDB läuft. Dies kann natürlich mithilfe von Docker geschehen! Anschließend starten Sie dann die Anwendung selbst mit npm run dev und probieren Sie die Funktionen im Browser mit Zugriff auf http://localhost:3000 aus. Sie können dort fiktive „Bestellposten“-Datensätze erzeugen, die mit einer Anzahl und einem Preis versehen werden. Wenn alles richtig funktioniert, sollten die Zeilen für die vorhandenen Datensätze jeweils einen vom .NET-Dienst berechneten Gesamtwert anzeigen.
Bitte beachten Sie die Kommentare im folgenden Konsolenbeispiel, wenn Sie selbst das Testszenario nachstellen möchten.
In diesem Beispielprojekt wurde ein Schritt gemacht, der für den .NET-Dienst nicht notwendig war. Die Webanwendung greift von ihrem serverseitigen Teil aus auf eine MongoDB-Instanz zu, und dazu muss sie wissen, wo der MongoDB-Server zu finden ist. Wie bereits zuvor beschrieben, kann der MongoDB-Dienst im fertigen Deployment über den Namen seines Containers gefunden werden, aber während der Entwicklung arbeiten Programmierer vermutlich noch nicht in solch einer Umgebung. Daher können Sie in der Datei NodeApp/src/routes/index.js sehen, dass zwei Umgebungsvariablen namens MONGODB_URL und MONGODB_NAME ausgewertet werden, um die Verbindungsparameter für den Datenbankzugriff extern einstellbar zu machen. Wie Sie wissen, ist das Setzen von Umgebungsvariablen beim Start eines Docker-Containers einfach zu bewerkstelligen, und die Auswertung solcher Variablen im Programmcode ist ebenfalls kurz und bündig – die Methode bietet sich an! Es ist lediglich Ihnen als Programmierer überlassen, diejenigen Programmparameter herauszuarbeiten, die extern einstellbar sein müssen.
Im Beispiel gibt es einen zweiten Punkt, der in ähnlicher Weise überlegt sein will: der Zugriff auf den .NET-Dienst für Berechnungen. Das Problem liegt hier darin, dass dieser Zugriff logisch von der Client-Anwendung ausgeführt wird, also vom Browser des Anwenders aus. Allerdings wird bei Verwendung des Frameworks Svelte der Code für die Client-Anwendung in einem Kompilationsschritt vorbereitet – das ist auch bei anderen Webframeworks nicht ungewöhnlich.
Wenn sich also die URL des Dienstes erst beim Start des Docker-Containers durch eine Umgebungsvariable ergäbe, wie sollte dann die aktuelle URL noch den Weg in die Client-App finden? Das wäre natürlich technisch irgendwie machbar – hier soll in erster Linie das Problem aufgezeigt werden! – aber aufgrund der Komplexität des Problems habe ich in unserem Beispiel den Weg gewählt, diesen Zugriff ebenfalls mit einem serverseitigen Endpunkt zu implementieren. Svelte Kit macht das angenehm einfach, und die kurze Implementation können Sie in der Datei NodeApp/src/routes/calculator_[calcType]_[x]_[y].json.js finden. Falls Sie sich mit Svelte Kit nicht auskennen, sei kurz erwähnt, dass die Platzhalter im Dateinamen als Routenparameter verstanden werden, so dass bei Abfrage dieser URL Werte mit den Namen calcType, x und y übergeben werden können.
Mit diesen beiden Beispielen für Anwendungsdienste ist der wichtigste Schritt bei der Vorbereitung einer eigenen Anwendung für die Verwendung in einem Docker-Container beschrieben: die Anpassung an die Laufzeitumgebung. Wenn Ihr Code in einem Container läuft, muss er es verstehen, mit Infrastrukturelementen im Anwendungssystem sowie mit anderen Diensten dieses Systems zusammenzuarbeiten.
Das Dockerfile für die Node-basierte Webanwendung ist der Konsistenz halber ähnlich strukturiert wie das für den .NET-Dienst. Auch in diesem Fall wird in den ersten Zeilen ein Build-Image erstellt, in dem sorgfältig zunächst die Abhängigkeiten in einer eigenen Schicht geladen werden, bevor der Build-Vorgang ausgelöst wird.
In einem separaten Schritt erstellt das Dockerfile dann das endgültige Image, indem es die notwendigen Dateien aus dem Build-Image kopiert.
Das beschriebene Dockerfile finden Sie im Beispielrepository unter NodeApp.Dockerfile.
Natürlich ist es nicht immer notwendig, die beschriebenen zwei Schritte zur Erstellung eines Images zu machen. Im Beispielsfall ist es etwa so, dass bereits bei der Paketinstallation die Tools von Svelte Kit eine Warnung ausgeben, da die Routen des Anwendungsprojekts noch nicht existieren. Dies dürfen Sie ignorieren, aber Sie könnten es nur vollständig vermeiden, indem Sie auf die schichtenorientierte Vorgehensweise verzichten. Da es sich bei Node nicht um eine Laufzeitumgebung handelt, die mit binären Kompilaten arbeitet, sind auch bei der Übernahme ins finale Image insgesamt recht viele Dateien zu übertragen, einschliesslich der Paketabhängigkeiten im Unterordner node_module. Ein Vorteil der Aufteilung ist allerdings, dass zur Laufzeit später die Quelltexte des Projekts nicht im Image liegen bleiben. Ob dies allein tatsächlich den strukturellen Mehraufwand im Dockerfile wert ist, sei Ihnen für Ihre eigenen Projekte überlassen.
Wie zuvor ist dieses Image mit dem richtigen Kommando schnell gebaut:
Es ist allerdings nicht einfach, dieses Image unabhängig von der restlichen Infrastruktur zu testen. Mit Umgebungsvariablen können Sie dem Container sagen, wo die anderen Dienste verfügbar sind – aber natürlich müssen diese zunächst im Netzwerk erreichbar sein. Es ist zwar möglich, mit einer geeigneten Netzwerkkonfiguration oder mithilfe von host.docker.internal (siehe hier) von einem Container aus auf den Host zuzugreifen, aber dieses Randthema kann komplex sein und gehört nicht an diese Stelle.
Neue Dienste für die Komposition
Daher geht es stattdessen sofort mit dem nächsten Schritt weiter: der Anpassung des docker-compose Setups. Im Vergleich zum ersten Beispiel werden für docker-compose zwei zusätzliche Dienste benötigt. Der erste ist der MongoDB-Server, dessen Konfigurationsblock sehr einfach aussehen kann:
Beachten Sie allerdings bitte, dass für die meisten „echten“ MongoDB-Setups zumindest externe Volumes zur Ablage der Datenbanken nötig sind, sowie eine grundlegende Einrichtung eines Admin-Passwortes! In diesem Beispiel habe ich diese Details weggelassen, da die Verwendung von MongoDB nur ein Platzhalter ist. Die Dokumentation finden Sie im Docker Hub zum Image mongo.
Der zweite neue Dienst ist die Node-App selbst. Sie ist abhängig von mongo und vom bereits zuvor erzeugten Dienst dotnetapp. Außerdem werden an dieser Stelle die beiden wichtigen Umgebungsvariablen CALCULATOR_URL und MONGODB_URL speziell für diesen Container konfiguriert. Die URLs enthalten jeweils einen der beiden anderen Dienstnamen, so dass sich wie schon bei nginx die Container untereinander finden können.
Am Konfigurationsblock für nginx werden an dieser Stelle zwei Änderungen gegenüber dem vorherigen Stand angebracht. Erstens ist der neue Dienst nodeapp eine weitere Abhängigkeit, und zweitens wird eine neue Konfigurationsdatei für nginx selbst verwendet.
Diese Konfigurationsdatei hat allerdings weitgehend denselben Inhalt wie zuvor. Es gibt zwei Unterschiede: Erstens ist die location nun die Wurzel des Servers (also ein einfacher Slash – /), und zweitens kontaktiert der Proxy nun den Container nodeapp auf Port 3000, und nicht mehr dotnetapp auf Port 80.
Fertig – Docker kann gestartet werden!
Damit ist es endlich soweit: Das ganze Anwendungssystem ist komplett in Docker und docker-compose abgebildet und kann gestartet werden. Das Kommando ist ähnlich wie zuvor:
Die Protokollausgaben der vier Dienste beim Start sind nun recht lang, aber ich empfehle, trotzdem einmal nach oben zu scrollen und grob durchzulesen, was da steht – sonst sind etwaige Fehler leicht zu übersehen. Natürlich können Sie auch in einem separaten Fenster sicherstellen, dass die vier Container wie vorgesehen laufen:
Dann bleibt nur noch eins: die laufende Anwendung noch einmal auszuprobieren. Unter http://localhost
– ganz ohne besondere Portangabe – können Sie nun im Browser darauf zugreifen. Neu erzeugte Daten werden in MongoDB abgelegt, und der Rechendienst liefert die richtigen Ergebnisse, so dass die Gesamtwerte der einzelnen Zeilen korrekt angezeigt werden.
Im zweiten Teil dieses Artikels werde ich beschreiben, wie ein Anwendungssystem nach der Vorbereitung und Bereitstellung als Docker-Images auf einem virtuellen Server bei Host Europe in Betrieb genommen werden kann.
Hier finden Sie weitere Artikel zum Thema:
- Interaktive Arbeit mit Docker – Eine Einführung in den Betrieb von Docker-Containern Teil 1
- Containerdaten und Backups für Docker – Eine Einführung in den Betrieb von Docker-Containern Teil 2
- Eigene Docker-Images bauen – Einführung in den Betrieb von Docker-Containern – Teil 3
- Grundlagen der Orchestrierung – Eine Einführung in den Betrieb von Docker-Containern Teil 4
- Docker im Entwickleralltag
- Docker Debugging: Diagnose und Behebung von gängigen Container-Fehlern
Titelmotiv: Unsplash
- Eine Docker-Anwendung auf einem Virtual Ubuntu-Server von Host Europe betreiben – Teil 2 - 4. November 2022
- Eine Docker-Anwendung auf einem Virtual Ubuntu-Server von Host Europe betreiben – Teil 1 - 19. Oktober 2022
- Vorbereitung eines Anwendungssystems für ein Deployment mit Docker – Tutorial - 19. Mai 2022