Docker für .NET, bevor es normal war
Eine kleine Nancy-API unter Mono in einen Docker-Container zu packen war im Frühjahr 2015 ein Umweg über Linux – und hat mir mehr über .NET-Deployment beigebracht als jedes IIS-Handbuch.
Gut 400 Megabyte. Das meldet docker images für ein Image, dessen eigentliche Anwendung eine einzige kompilierte Datei von wenigen Kilobyte ist – eine kleine HTTP-API, geschrieben in C#. Die Zahl wirkt absurd, und sie ist es auch. Aber die interessantere Messung ist eine andere: docker start demo1 braucht unter einer Sekunde, und danach antwortet der Dienst. Ein vergleichbares Deployment auf einem Windows-Server mit IIS hat mich in Projekten regelmäßig einen halben Nachmittag gekostet – Framework-Version prüfen, App-Pool anlegen, Rechte setzen, Dateien kopieren, beten.
Diese beiden Zahlen nebeneinander – 400 Megabyte Platz gegen eine Sekunde Start – beschreiben ziemlich genau, worauf man sich einlässt, wenn man im Frühjahr 2015 .NET-Code in einen Docker-Container steckt. Man tauscht Plattenplatz gegen Wiederholbarkeit. Ich halte das für einen guten Tausch, und ich will erzählen, warum.
Frühjahr 2015: .NET heißt offiziell Windows
Wer heute „.NET" sagt, meint praktisch immer Windows, IIS und den Microsoft-Stack von oben bis unten. Docker dagegen ist ein Linux-Thema: Docker 1.6 ist gerade erschienen, auf dem Mac läuft der Docker-Daemon in einer kleinen VirtualBox-VM namens boot2docker, und Compose ist eben erst aus Fig hervorgegangen. Die beiden Welten berühren sich kaum.
Ganz stimmt das Bild allerdings nicht mehr. Im November hat Microsoft angekündigt, .NET Core als Open Source zu entwickeln, seit Februar liegt die CoreCLR auf GitHub, und das kommende ASP.NET 5 soll ausdrücklich auch auf Linux und OS X laufen. Das ist eine bemerkenswerte Wende – aber im April 2015 ist es eine Ankündigung mit frühen Betas, kein Fundament. Wer heute .NET-Code in einem Linux-Container ausführen will, landet bei Mono, der freien Laufzeitumgebung, die es seit über zehn Jahren gibt und die inzwischen erstaunlich viel vom .NET Framework abdeckt.
Genau das wollte ich ausprobieren. Nicht, weil ein Projekt es verlangt hätte, sondern weil mich die Frage nicht losließ: Wie viel vom üblichen .NET-Deployment-Ritual ist eigentlich nötig – und wie viel davon ist nur Gewohnheit, die am IIS hängt? Ein Wochenende, ein Repository, ein bewusst kleiner Dienst. Wenn das Experiment scheitert, ist wenig verloren; wenn es trägt, weiß ich etwas, das sich mit keinem Blogartikel von anderen ersetzen lässt.
Der Versuch: eine Nancy-API im Container
Das Ergebnis liegt als Repository unter dotnet-docker auf GitHub: eine kleine HTTP-API mit dem Nancy-Framework, selbst gehostet über Nancy.Hosting.Self, mit LiteDB als eingebetteter Datenbank. Kein IIS, kein Windows-Dienst, keine Installation – ein Prozess, der einen Port öffnet. Das README nennt es einen „Docker + DotNet Microservice", und so klein wie das klingt, ist es auch: ein Modul, zwei ernsthafte Endpunkte, eine Datei Datenbank.
Das Herzstück ist das Dockerfile. Es beschreibt vollständig, was diese Anwendung zum Laufen braucht – und zwar so, dass keine stille Annahme übrig bleibt:
FROM mono:3.10
# Update SSL certs
RUN mozroots --import --sync
# Copy source files
ADD src ./src
WORKDIR /src
# Install dependencies via NuGet
RUN mono /src/NuGet.exe install Nancy
RUN cp /src/Nancy.1.1/lib/net40/Nancy.dll /src
RUN mono /src/NuGet.exe install Nancy.Hosting.Self
RUN cp /src/Nancy.Hosting.Self.1.1/lib/net40/Nancy.Hosting.Self.dll /src
RUN mono /src/NuGet.exe install LiteDB
RUN cp /src/LiteDB.1.0.0/lib/net40/LiteDB.dll /src
# Compile the web service
RUN mcs nancy.cs modules/webhooks.cs \
-reference:Nancy.dll \
-reference:Nancy.Hosting.Self.dll \
-reference:LiteDB.dll
EXPOSE 8080
# Run the web service
CMD ["mono", "/src/nancy.exe"]
Man sieht dem Dockerfile die Epoche an, und das ist Absicht. Es gibt kein offizielles Microsoft-Image, also startet alles auf mono:3.10. Es gibt keine eingebaute Paketverwaltung im Build, also liegt NuGet.exe mit im Repository und wird per mono aufgerufen. Und weil NuGet die Pakete in Versionsordner legt, werden die DLLs anschließend von Hand an die Stelle kopiert, an der der Compiler sie erwartet. Kompiliert wird mit mcs, dem Mono-C#-Compiler, direkt im Image – kein Visual Studio, keine MSBuild-Projektdatei, nur Quelldateien und Referenzen.
Das ist unbequem. Aber jede dieser Zeilen ersetzt eine Annahme, die sonst irgendwo unsichtbar auf einem Server läge: welche Laufzeitumgebung, welche Paketversionen, welche Zertifikate, welcher Port. Mir ist beim Schreiben aufgefallen, wie viele dieser Annahmen ich in Windows-Projekten nie aufgeschrieben, sondern immer nur gewusst habe.
flowchart LR A[mono:3.10<br/>Basis-Image] --> B[mozroots<br/>SSL-Zertifikate] B --> C[NuGet.exe install<br/>Nancy + LiteDB] C --> D[mcs<br/>kompiliert nancy.exe] D --> E[Image dotnet<br/>ca. 400 MB] E --> F[docker run demo1<br/>mono nancy.exe]
Der Code: ein Prozess, der einen Port öffnet
Die Anwendung selbst ist bewusst schlicht. Der Einstiegspunkt startet einen NancyHost und hält den Prozess am Leben:
using System;
using Nancy;
using Nancy.Hosting.Self;
class Program
{
static void Main()
{
using (var host = new NancyHost(new Uri("http://localhost:8080")))
{
host.Start();
Console.WriteLine("Nancy host (http://localhost:8080) started.");
Console.ReadLine();
}
}
}
Dazu ein Nancy-Modul, das Webhooks in LiteDB ablegt – eine Datenbank, die als einzelne Datei neben der Anwendung liegt und ohne Server auskommt:
public class WebHookModule : NancyModule
{
public WebHookModule() : base("/webhooks")
{
Get["/"] = _ =>
{
using (var db = new LiteDatabase(@"MyData.db"))
{
return db.GetCollection<WebHook>("webhooks")
.FindAll()
.ToArray();
}
};
Post["/"] = _ =>
{
using (var db = new LiteDatabase(@"MyData.db"))
{
db.GetCollection<WebHook>("webhooks").Insert(new WebHook());
return HttpStatusCode.Created;
}
};
}
}
Gebaut und gestartet wird mit drei Kommandos, so stehen sie auch im README:
docker build -t dotnet .
docker run --name demo1 -p 8080:8080 -i -t dotnet
docker start demo1
Wo es hakte
Glatt lief das nicht, und die Stolpersteine sind lehrreicher als der glückliche Pfad. Der erste Start endete mit einem Container, der sich sofort wieder beendete. Der Grund steht oben im Code: Console.ReadLine() hält den Prozess nur am Leben, solange es ein Stdin gibt. Ohne die Flags -i -t liefert Docker keins, ReadLine kehrt sofort zurück, Main läuft aus, der Container ist tot. Deshalb stehen die beiden Flags im README – nicht aus Sorgfalt, sondern als Narbe. Die eigentliche Lektion: Ein Container lebt genau so lange wie sein Prozess. Es gibt keinen IIS, der die Anwendung im Hintergrund festhält; die Anwendung ist selbst dafür zuständig, nicht fertig zu werden.
Der zweite Stolperstein war die Erreichbarkeit. Auf dem Mac läuft der Docker-Daemon in der boot2docker-VM, also antwortet der Dienst nicht auf localhost, sondern auf der IP der VM – boot2docker ip verrät sie. Und dass die Bindung an http://localhost:8080 im Container überhaupt von außen erreichbar ist, verdankt der Code einer Eigenheit von Nancys Self-Hosting: Die HostConfiguration hat eine Option RewriteLocalhost, die standardmäßig aktiv ist und aus localhost eine Wildcard-Bindung macht. Das habe ich erst verstanden, als ich im Nancy-Quelltext nachgelesen habe. Wer strikt an 127.0.0.1 bindet, sieht von außen: nichts.
Der Rest war Kleinkram, der sich summiert:
- Frische Mono-Images kennen keine SSL-Stammzertifikate; ohne
mozroots --import --syncscheitert schon der NuGet-Zugriff auf HTTPS-Quellen. - Die Pakete müssen als net40-Assemblies vorliegen, damit Mono 3.10 sie sauber lädt – bei Nancy 1.1 und LiteDB 1.0 kein Problem, bei exotischeren Paketen durchaus.
- Jedes
RUNerzeugt eine Image-Schicht; wer beim Experimentieren oft baut, lernt den Build-Cache zu lieben und die Reihenfolge der Zeilen zu respektieren. - Die 400 Megabyte bleiben. Ein Basis-Image mit kompletter Mono-Installation ist kein schlankes Fundament, und ein schlankeres gibt es für .NET-Code derzeit nicht.
Was der Umweg lehrte: Prozess statt IIS
Der eigentliche Gewinn des Experiments liegt nicht im Container, sondern in dem, was der Container erzwingt. Eine .NET-Anwendung, die unter IIS läuft, ist keine Anwendung, sondern ein Gast: Der Webserver besitzt den Prozess, das Konfigurationssystem, den Lebenszyklus. Die XML-Konfigurationsdatei des IIS beschreibt weniger die Anwendung als ihr Verhältnis zum Wirt. Im Container dreht sich das um. Die Anwendung ist ein gewöhnlicher Prozess, der einen Port öffnet, auf die Standardausgabe schreibt und beendet werden kann, ohne dass etwas aufzuräumen wäre.
Damit ändert sich auch, wo Konfiguration hingehört. Ein Image, das für jede Umgebung neu gebaut werden muss, weil die Einstellungen einkompiliert sind, verfehlt den Zweck. Die Konfiguration muss von außen kommen, und der natürliche Weg dafür sind Umgebungsvariablen – docker run -e reicht dann sowohl das Können als auch die Disziplin. In der nächsten Iteration des Einstiegspunkts sah das so aus:
static void Main()
{
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
var uri = new Uri("http://localhost:" + port);
using (var host = new NancyHost(uri))
{
host.Start();
Console.WriteLine("Nancy host ({0}) started.", uri);
Console.ReadLine();
}
}
Das wirkt banal, ist aber ein Bruch mit Jahren von IIS-Konfigurations-Transformationen und Registry-Einträgen. Interessanterweise beschreibt die Twelve-Factor-Liste von Heroku – seit 2011 öffentlich – genau diese Denkweise: Konfiguration in der Umgebung, Prozesse zustandslos, Logs als Ereignisstrom auf die Standardausgabe, Abhängigkeiten explizit deklariert. Ich hatte das Manifest gelesen und für ein Heroku-Werbedokument gehalten. Erst der Container hat mir gezeigt, dass es eine Beschreibung dessen ist, was übrig bleibt, wenn man einer Anwendung den Wirt wegnimmt.
flowchart TD
subgraph W[Gewohnter Windows-Pfad]
W1[Server mit IIS<br/>vorbereiten] --> W2[Framework + App-Pool<br/>konfigurieren]
W2 --> W3[XML-Konfiguration<br/>transformieren]
W3 --> W4[Dateien kopieren<br/>und hoffen]
end
subgraph C[Container-Pfad]
C1[Dockerfile<br/>beschreibt alles] --> C2[docker build<br/>reproduzierbar]
C2 --> C3[docker run -e PORT=8080<br/>Konfiguration von außen]
end
Dazu kommt die Wiederholbarkeit. Das Image, das auf meinem Rechner entsteht, ist bit für bit das, was auf jedem anderen Docker-Host läuft. „Bei mir geht's" hört auf, ein Argument zu sein, weil „bei mir" jetzt eine Datei ist, die man weitergeben kann. Für jemanden, der .NET-Deployments bisher als Abfolge manueller Schritte auf gepflegten Servern kannte, ist das der eigentliche Kulturschock – nicht die Technik, sondern der Wegfall der Ausreden.
Die ehrliche Einordnung
Man muss trotzdem nüchtern bleiben. Mono ist nicht das .NET Framework. Für Nancy, LiteDB und eine überschaubare API trägt es problemlos; wer WCF-Altlasten, WPF oder tiefe Windows-Abhängigkeiten mitbringt, kommt nicht weit. Wer im Betrieb auf Windows-Servern ausliefert, testet mit einem Mono-Container außerdem eine andere Laufzeitumgebung als die produktive – das darf man wissen und muss es einpreisen. Und die Werkzeugkette ist spürbar zweite Reihe: kein Debugging im Container, Paketverwaltung von Hand, Fehlermeldungen, die Linux-Grundwissen voraussetzen.
In ein Kundenprojekt würde ich dieses Setup heute deshalb nicht tragen. Dafür ist zu viel Handarbeit im Spiel, und ein Team, das seit Jahren Windows-Server betreibt, wechselt nicht wegen eines gelungenen Prototyps das Betriebsmodell. Als Lernvehikel dagegen ist es kaum zu schlagen: Jeder Handgriff, den das Dockerfile sichtbar macht, ist ein Handgriff, den der IIS bisher versteckt hat.
Die Richtung stimmt trotzdem. Microsoft meint die Öffnung offenbar ernst: CoreCLR und der Compiler liegen öffentlich auf GitHub, ASP.NET 5 wird ausdrücklich für Linux entwickelt, und die frühen Betas laufen heute schon auf Mono. Wenn das hält, was es verspricht, ist der Mono-Umweg ein Übergangspfad – aber eben ein gangbarer, heute, nicht irgendwann. Ich vermute, dass sich die Fertigkeiten aus diesem Experiment eins zu eins übertragen werden: Dockerfiles schreiben, Prozesse statt Wirte denken, Konfiguration über die Umgebung reichen. Die Laufzeitumgebung darunter ist austauschbar; die Denkweise nicht.
Was hängen geblieben ist
Das Repository ist ein Wegwerf-Experiment, und als solches behandle ich es auch. Trotzdem hat es meine Sicht auf .NET-Deployment dauerhaft verschoben, und zwar an drei Stellen:
- Ein Dockerfile ist die vollständige, ausführbare Liste aller Laufzeitannahmen einer Anwendung – was dort nicht steht, wird stillschweigend vom Host geliehen.
- Eine Anwendung, die als schlichter Prozess laufen kann, ist die flexiblere Anwendung – jeder Wirt, vom IIS bis zum Container, lässt sich darüberstülpen, umgekehrt gilt das nicht.
- Konfiguration gehört in die Umgebung, nicht ins Artefakt – ein Build, viele Deployments.
Nichts davon setzt Docker voraus. Aber Docker hat mich gezwungen, es aufzuschreiben statt zu wissen, und aufgeschriebene Annahmen kann man diskutieren, prüfen und weitergeben. Die 400 Megabyte zahle ich dafür gern – Plattenplatz ist billig, verlorene Nachmittage vor dem IIS-Manager sind es nicht.
Weiterführende Quellen
- Das Repository zum Artikel: MikeBild/dotnet-docker
- Microsofts Ankündigung: .NET Core is Open Source
- Das offizielle Mono-Image auf dem Docker Hub
- Das Nancy-Framework auf GitHub
- The Twelve-Factor App – die Denkweise hinter Prozess, Umgebung und Logs
Wie fandest du diesen Beitrag?
Kommentare