Bild von Stefan Lieser
Stefan Lieser

Konfiguration einer .NET Core App ohne appsettings.json in Docker

Seitdem Microsoft sich mit .NET Core dazu entschlossen hat, dass .NET auch Linux unterstützen soll, eignet sich .NET Core ganz hervorragend als Basis für Anwendungen, die in Docker ausgeführt werden. Damit wird das Deployment von .NET Core Anwendungen drastisch vereinfacht. In Kombination mit docker-compose besteht ein Deployment darin, den folgenden Befehl auszuführen:

				
					
docker-compose pull && docker-compose up -d

				
			

Der erste Befehl sorgt dafür, dass die benötigten Versionen der verwendeten Docker Container aus der Registry gezogen wird. Der zweite Befehl sorgt dafür, dass alle Container gestartet werden. Bei Bedarf werden aktualisierte Container neu gestartet. Die Option „-d“ sorgt für eine „detached“ Ausführung im Hintergrund. Einfacher kann es nicht sein. Die Konfiguration für docker-compose wird der Datei docker-compose.yaml entnommen. Dazu später mehr.

Konfiguration in Docker

Durch das Deployment einer .NET Anwendung in Docker stellt sich allerdings die Frage, wie man mit der Konfiguration der Anwendung umgeht. Der von Microsoft vorgeschlagene Weg ist der Einsatz der Datei appsettings.json. Konfigurationswerte können bspw. die Zugangsdaten zu einer Datenbank sein, die ebenfalls als Docker Container gehostet wird. Im Falle von bspw. MongoDB werden der Host, der Port, der Username und das Passwort benötigt, damit sich die Anwendung mit MongoDB verbinden kann. Über die appsettings.json sieht das wie folgt aus:

				
					"MongoDB": {
  "Host": "localhost",
  "Port": 27017,
  "User": "mongo",
  "Password": "secret"
},

				
			

Mit diesem Abschnitt in der Datei appsettings.json kann die Konfiguration wie folgt ausgelesen werden:

				
					var configurationBuilder = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json");
var configuration = configurationBuilder.Build();
var host = configuration.GetSection("MongoDb")["Host"];

Assert.That(host, Is.EqualTo("localhost"));

				
			

Die Konfiguration kann auch in den IoC Container gelegt werden, damit Klassen über Dependency Injection ihre Konfigurationsobjekte erhalten.

Warum die appsettings.json in Docker keine gute Idee ist

Mit dem Einsatz der appsettings.json verlässt man allerdings den Weg, der in Docker etabliert ist. Dort werden Umgebungsvariablen (Environment Variables) für die Konfiguration von Anwendungen verwendet. Dies ist insofern sehr komfortabel, weil diese in der docker-compose.yaml Datei angegeben werden können. Ferner können Umgebungsvariablen auch auf der Kommandozeile beim Befehl docker run übergeben werden, wenn ein Docker Container gestartet werden soll.
Die Datei appsettings.json ist eher ungeeignet, weil sie sich innerhalb des Containers befindet. Durch das Zusammenstellen eines Containers über die Datei Dockerfile landet die Datei mit im Container. Es stellt sich dann die Frage, wie man Änderungen an den Einstellungen vornimmt in einem laufenden Docker Container. Zwar kann man eine Shell in den laufenden Container öffnen und dort die Datei bearbeiten, doch ist dies mühsam. Vor allem überstehen die Änderungen kein Redeployment des Containers, denn die appsettings.json wird dann erneut vom Entwicklerrechner in den Container übertragen.
Viel einfacher ist es daher, die Konfigurationswerte in der Datei docker-compose.yaml oder einer .env Datei abzulegen. Diese kann auf dem Host System geändert werden. Anschließend wird der Container neu gestartet. Wir halten also fest: eine in Docker gehostete .NET Anwendung wird am besten über Umgebungsvariablen konfiguriert. Das könnte für das Beispiel einer MongoDB wie folgt aussehen:

				
					MONGODB_HOST=127.0.0.1
MONGODB_PORT=27017
MONGODB_USER=mongo
MONGODB_PASSWORD=secret

				
			

Aus .NET Core auf eine Environment Variable (Umgebungsvariable) zugreifen

Unter .NET kann auf folgende Weise auf eine Umgebungsvariable zugegriffen werden:

				
					var host = Environment.GetEnvironmentVariable("MONGODB_HOST");
				
			

Dies würde den Wert der Variable „MONGODB_HOST“ lesen, sofern diese in der Umgebung definiert ist. Andernfalls ist der Wert null. Damit hätten wir bereits das erste Problem identifiziert. Wir müssten bei jedem Zugriff auf null prüfen. Der Abruf der Umgebungsvariable kann dazu mit dem Null Coalescing Operator mit einem Defaultwert versehen werden:

				
					var result = Environment.GetEnvironmentVariable("UNKNOWN_ENV_VARIABLE") ?? "default value";
				
			

Trotzdem bleibt ein weiteres Problem, welches auch beim Einsatz der appsettings.json besteht: verteilt man diese Aufrufe über seine Codebasis, sind die Umgebungsvariablen, im Beispiel UNKNOWN_ENV_VARIABLE, schwer zu finden. Aus diesem Grund sammle ich gerne alle Konfigurationswerte in eine Klasse Config. Doch dazu später. Zunächst sprechen wir noch über eine bessere Lösung, die Umgebungsvariablen einzulesen.

Das DotEnv NuGet Paket und das .env File

Um alle Umgebungsvariablen, die eine Anwendung benötigt, zusammenzufassen, kann eine .env Datei verwendet werden. Der Dateiname ist tatsächlich „.env“. Darin werden Zeile für Zeile Umgebungsvariablen abgelegt. Mit Hilfe des NuGet Pakets DotEnv kann die Datei dann in einem Rutsch eingelesen werden. Dabei werden die Variablen in die Umgebung der Anwendung übertragen und stehen dort wieder zur Verfügung, um sie mit Environment.GetEnvironmentVariable einzulesen. Die .env Datei kann auch direkt in der docker-compose.yaml Datei referenziert werden.

				
					env_file: .env
				
			

Alternativ können die einzelnen Variablen wie folgt in der docker-compose.yaml Datei definiert werden:

				
					environment:
  MONGO_INITDB_ROOT_USERNAME: mongo
  MONGO_INITDB_ROOT_PASSWORD: secret
  MONGO_INITDB_DATABASE: customers

				
			

Damit haben wir die Möglichkeit, alle Konfigurationsdaten in einer Datei zusammenzufassen. Für den Zugriff innerhalb der Anwendung erstellen wir uns dann eine spezifische Config Klasse, welche das Auslesen der Umgebungsvariablen erledigt und die Werte als Properties anbietet.

Eine eigene Config Klasse

Das folgende Listing zeigt beispielhaft eine solche Config Klasse:

				
					public static class Config
{
    public static void Load() {
        DotEnv.Fluent().Load();
    }

    public static void Load(string filename) {
        DotEnv.Fluent().WithEnvFiles(filename).Load();
    }

    public static class MongoDb
    {
        public static string Host => GetEnvironmentVariable("MONGODB_HOST");
     
        public static string Port => GetEnvironmentVariable("MONGODB_PORT");
        
        public static string User => GetEnvironmentVariable("MONGODB_USER");
        
        public static string Password => GetEnvironmentVariable("MONGODB_PASSWORD");
    }
    
    private static string GetEnvironmentVariable(string variable) {
        var value = Environment.GetEnvironmentVariable(variable);
        if (value != null) {
            return value;
        }
        Load();
        value = Environment.GetEnvironmentVariable(variable);
        if (value != null) {
            return value;
        }
        return "";
    }
}

				
			

Die Klasse wird dann wie folgt verwendet:

				
					var host = Config.MongoDb.Host;
				
			

Die Idee ist hier folgende: innerhalb der statischen Klasse Config werden lokal, ebenfalls statische, Klassen angelegt. Im Beispiel MongoDB. Innerhalb dieser Unterklassen werden dann für alle Umgebungsvariablen die von der Anwendung verwendet werden, jeweils Properties angelegt. Die Getter der Properties verwenden jeweils die Methode GetEnvironmentVariable, die lokal in der Klasse Config definiert ist.

Hier ist der Ablauf dann wie folgt: zunächst wird versucht, die Umgebungsvariable zu lesen. Ist diese im System definiert, kommt ein String ungleich null zurück. Wenn das der Fall ist, wird dieser Wert verwendet. Ist noch keine Umgebungsvariable mit dem entsprechenden Namen definiert, wird DotEnv angewiesen, die .env Datei mit Load zu laden. anschließend wird erneut versucht, die Variable zu lesen. Ist jetzt ein Wert vorhanden, wird dieser zurückgegeben. Andernfalls wird ein leerer String geliefert, um die null Werte zu eliminieren.

Auf diese Weise kann an den benötigten Stellen auf die statischen Properties zugegriffen werden. Dies ist eine andere Vorgehensweise als die Verwendung von Konfigurationsobjekten im IoC Container. Und ich weiß, dass einige Kollegen allergisch auf statische Klassen reagieren. Deshalb widme ich dem Thema in den nächsten Tagen einen eigenen Artikel.

Beispiel ASP.NET Core Anwendung

Die ASP.NET Core Anwendung

Die in den Beispielen verwendete Anwendung ist mit ASP.NET Core 8 entwickelt. Es handelt sich um einen simplen Web Service, der Kundendaten entgegennimmt und in eine MongoDB Datenbank speichert. Das Beispiel wurde bereits im Artikel zu Integrationstests mit Docker und dem TestContainers Projekt vorgestellt.

Die Datei Dockerfile

Um eine ASP.NET Core Anwendung in ein Docker Image zu verpacken, erstellt man ein Dockerfile. Den Mechanismus vollständig zu erklären würde hier zu weit führen. Sie finden die hier dargestellte Beispielanwendung inkl. Dockerfileunter folgendem Link:

https://gitlab.com/ccd-akademie-gmbh/testcontainers

Liegt das Dockerfile vor und kann die Anwendung damit erfolgreich in ein Image verpackt werden, kann dieser Buildprozess im folgenden Schritt verwendet werden. Für ein Produktionsprojekt wird das Image allerdings durch den CI Prozess in eine private Registry hochgeladen und von dort deployed.

Die Datei docker-compose.yaml

Nachdem man ein Docker Image seiner Anwendung erstellt hat, kann Docker Compose verwendet werden, um alle benötigten Container zu starten. In unserem Beispiel wird eine MongoDB Datenbank verwendet. Daher erstelle ich folgende docker-compose.yaml:

				
					version: "3.9"
services:
  mongodb:
    image: mongo:latest
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: mongo
      MONGO_INITDB_ROOT_PASSWORD: secret
      MONGO_INITDB_DATABASE: customers
    volumes:
      - ./docker/mongodb/db:/data/db
      - ./docker/mongodb/dump:/data/db/dump
      - ./docker/mongodb/mongod.conf:/etc/mongod.conf
    restart: always
  
  testcontainerexample:
    build:
      context: .
      dockerfile: testcontainerexample/Dockerfile
    ports:
      - "80:8080"
      - "443:443"
    environment:
      MONGODB_HOST: mongodb
      MONGODB_PORT: 27017
      MONGODB_USER: mongo
      MONGODB_PASSWORD: secret
    restart: always
    depends_on:
      - mongodb

				
			

Die Datei enthält im Abschnitt services zwei Einträge: mongodb und testcontainerexample. Beide definieren jeweils einen zu startenden Docker Container. Im Falle von mongodb wird mit dem Eintrag image angegeben, welches Image verwendet werden soll. In diesem Fall „mongo:latest“. Da keine Registry explizit angegeben ist, wird implizit die Docker Registry verwendet. Alle Details zu diesem Image sind unter https://hub.docker.com/_/mongo zu finden. Wichtig sind in diesem Abschnitt die Umgebungsvariablen zur Konfiguration. Mit MONGO_INITDB_ROOT_USERNAME wird bspw. festgelegt, welcher User in der Instanz angelegt werden soll.

Der Abschnitt testcontainerexample definiert den Container für die ASP.NET Core Anwendung. Hier wird kein Image aus einer Registry runtergeladen, sondern mit build ein Image erstellt. Dazu ist der Pfad zum Dockerfile angegeben. Hier weist der relative Pfad in das ASP.NET Projekt. Dort wird dann docker build aufgerufen und somit das Image erstellt.

Auch zu diesem Container sind die Konfigurationswerte über Umgebungsvariablen angegeben. Eine davon will ich besonders hervorheben: „MONGODB_HOST: mongodb„. Auf diese Weise wird der Hostname der MongoDB Instanz angegeben, zu der sich die ASP.NET Anwendung verbinden soll. Der Eintrag „mongodb“ entspricht dem Namen des services Abschnitt für MongoDB. Dies ist ein Detail der Netzwerkkonfiguration von Docker. Hier wird durch Docker Compose für jeden Container ein Hostname angelegt, damit die Container untereinander über ein eigens dazu eingerichtetes (virtuelles) Netzwerk kommunizieren können. Spätestens an dieser Stelle sollte deutlich werden, dass es keine gute Idee ist, diese Information in der appsettings.json Datei unterzubringen. Man müsste dann sicherstellen, dass der Bezeichner über die beiden Dateien hinweg konsistent gleich bleibt. Da ist es viel einfacher, wenn alles in der Datei docker-compose.yaml zusammensteht.

Die Container starten

Mit folgendem Befehl werden nun alle benötigten Container gestartet:

				
					docker-compose pull && docker-compose up -d
				
			

Die Docker Images werden durch „pull“ bei Bedarf heruntergeladen bzw. mit „build“ erzeugt. Der ganze Vorgang kann lokal im Development Environment ausgeführt werden oder auch in Produktion. Das stellt sicher, dass lokal während der Entwicklung die gleiche Umgebung getestet wird, die später in Produktion läuft.

Fazit

Es ist leicht, sich von der Microsoft Dokumentation und den Beispielen in die Irre führen zu lassen. Verwendet man den Standardweg der Datei appsettings.json, wird der Einsatz von Docker unnötig kompliziert. Der hier vorgestellte Weg, die Konfiguration über Umgebungsvariablen vorzunehmen, passt besser zu Docker.

Ja, man kann auch Umgebungsvariablen in Kombination mit dem ConfigurationBuilder von .NET Core verwenden. Hierarchien werden dann mit doppelten Unterstrichen angelegt. Mir ist das zu viel Magie hinter den Kulissen. Einfacher erscheint es mir, die Umgebungsvariablen selbst so zu benennen, wie man es für richtig hält und dann eine Config Klasse bereitstellt, die das Einlesen erledigt. Für welchen Weg Sie sich auch immer entscheiden: konfigurieren Sie Ihre .NET Anwendungen mit Umgebungsvariablen, dann ist es leicht, sie in Docker zu konfigurieren.

Unsere Seminare

course
Clean Code Developer Basics

Prinzipien und Tests – Das Seminar wendet sich an Softwareentwickler, die gerade beginnen, sich mit dem Thema Softwarequalität auseinanderzusetzen. Es werden die wichtigsten Prinzipien und Praktiken der Clean Code Developer Initiative vermittelt.

zum Seminar »
course
Clean Code Developer Advanced

Mit Flow Design von den Anforderungen zum Clean Code – Lernen Sie mit Flow Design einen Softwareentwicklungsprozess kennen, der Sie flüssig von den Anforderungen zum Clean Code führt.

zum Seminar »
course
Clean Code Developer Trainer

Seminare als Trainer durchführen – Dieses Seminar wendet sich an Softwareentwickler, die ihr Wissen über die Clean Code Developer Prinzipien und Praktiken bzw. über Flow Design als Trainer an andere weitergeben möchten.

zum Seminar »
course
Clean Code Developer CoWorking

Online CoWorking inkl. Coaching –
Wir werden häufig gefragt, was man als Entwickler tun könne, um kontinuierlich dran zu bleiben am Thema Clean Code Developer. Unsere Antwort: Treffen Sie sich regelmäßig wöchentlich online mit anderen Clean Code Developern.

zum Seminar »

Kommentar verfassen

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

de_DEGerman