
Azure Container Apps mit mehreren Containern
In diesem Artikel betrachten wir die reale Transformation einer Anwendung, bei der eine .NET Web API und Node.js zunächst gemeinsam in einem einzigen Docker-Container liefen und später auf zwei Container innerhalb derselben Azure Container App aufgeteilt wurden.
Dabei betrachten wir folgende Themen:
- die ursprüngliche Architektur mit einem Container;
- die Transformation der Anwendung;
- die notwendigen Infrastrukturänderungen;
- die Kommunikation zwischen den Containern;
- das Ergebnis des Refactorings und die Auswirkungen auf den Ressourcenverbrauch.
Ausgangsarchitektur: ein Container
Ursprünglich bestand die Anwendung auf Infrastrukturebene aus einem einzigen Docker-Container, in dem gleichzeitig zwei Dienste liefen:
- .NET Web API;
- eine Node.js-Anwendung, die für das Angular Server-Side Rendering zuständig war.
Das Ziel dieser Lösung war einfach: die Anwendung so schnell wie möglich in einer produktiven Umgebung zum Laufen zu bringen.
Dieses Ziel wurde erfolgreich erreicht. Nachdem die Anwendung stabil lief, war es jedoch an der Zeit, die entstandenen technischen Schulden zu reduzieren und die Verantwortlichkeiten auf mehrere Container aufzuteilen.
Da die Anwendung gleichzeitig zwei Prozesse benötigte — .NET und Node.js —, wurde für deren Start ein separates Shell-Skript verwendet. Dieses Skript fungierte als Einstiegspunkt des Docker-Containers und startete beide Prozesse.
Ein solcher Ansatz ist insbesondere in einer frühen Projektphase durchaus praktikabel. Mit der Zeit wird jedoch deutlich, dass ein einzelner Container für mehrere voneinander unabhängige Prozesse verantwortlich ist und seine Konfiguration dadurch zunehmend komplexer wird.
Transformation der Anwendung
Nach der Entscheidung, die Anwendung aufzuteilen, stellte sich schnell heraus, dass das gemeinsame Startskript nicht mehr benötigt wurde. Jeder Container kann nun eigenständig genau den Prozess starten, für den er verantwortlich ist.
Alles, was zum Frontend und zu Angular SSR gehört, wurde in einen eigenen Dockerfile verschoben. Der Backend-Container ist dagegen ausschließlich für die Web API zuständig.
Dadurch wurden die Verantwortlichkeiten deutlich klarer:
- Backend-Container — .NET Web API;
- Frontend-Container — Angular SSR auf Basis von Node.js.
Der Dockerfile für die Web API sieht nun folgendermaßen aus:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY serdg-api/ ./
RUN dotnet restore "SD/SD.csproj"
RUN dotnet publish "SD/SD.csproj" \
--configuration Release \
--output /out/api \
--no-restore \
/p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /out/api ./
EXPOSE 8080
ENTRYPOINT ["dotnet", "SD.dll"]
Auch das Frontend erhielt einen eigenen Dockerfile. Seine Hauptaufgabe besteht darin, Angular Server-Side Rendering bereitzustellen:
FROM node:22-bookworm-slim AS build
WORKDIR /src/ui
COPY serdg-ui/package*.json ./
RUN npm ci --legacy-peer-deps
COPY serdg-ui/ ./
RUN npm run build
RUN npm prune --omit=dev --legacy-peer-deps
FROM node:22-bookworm-slim AS runtime
WORKDIR /app/ui
ENV NODE_ENV=production
ENV PORT=4000
ENV HOST=0.0.0.0
ENV API_BASE_URL=http://127.0.0.1:8080
COPY --from=build /src/ui/dist ./dist
COPY --from=build /src/ui/node_modules ./node_modules
COPY --from=build /src/ui/package*.json ./
EXPOSE 4000
ENTRYPOINT ["node", "dist/serdg-ui/server/server.mjs"]
Nach diesen Änderungen muss nur noch sichergestellt werden, dass beide Docker-Images korrekt gebaut und erfolgreich in die Azure Container Registry übertragen werden, damit sie anschließend bereitgestellt werden können.
Infrastrukturänderungen
Ursprünglich ging ich davon aus, dass mit der Aufteilung der Dockerfiles bereits der größte Teil der Arbeit erledigt wäre und die Anwendung anschließend fast sofort in der neuen Konfiguration funktionieren würde.
Doch erneut bestätigte sich eine bekannte Regel:
Die ersten 80 % einer Aufgabe sind längst nicht so schwierig wie die zweiten 80 %.
Während die Anpassung der Dockerfiles nur wenige Stunden dauerte, benötigten Deployment und Infrastrukturkonfiguration noch einmal mehrere Stunden.
Folgende Aufgaben mussten gelöst werden:
- einen zweiten Container zur Azure Container App hinzufügen und die notwendigen Einstellungen für jeden Container konfigurieren;
- Backend und Frontend so anpassen, dass jede Komponente ausschließlich für ihren eigenen Verantwortungsbereich zuständig ist;
- das Routing des eingehenden HTTP-Traffics anpassen;
- die Kommunikation zwischen Frontend- und Backend-Container konfigurieren.
Eine besonders praktische Funktion von Azure Container Apps ist die Möglichkeit, mehrere Container innerhalb einer einzigen Container App auszuführen.
Dieser Ansatz eignet sich gut für Komponenten, die eng miteinander verbunden sind und gemeinsam gestartet werden sollen.
Gleichzeitig besitzt jeder Container seine eigene Konfiguration:
- Docker-Image;
- Umgebungsvariablen;
- CPU-Einstellungen;
- Arbeitsspeicher;
- Startkommando.
Gleichzeitig teilen sich die Container jedoch einen gemeinsamen Lebenszyklus. Sie gehören zur selben Revision der Azure Container App, werden gemeinsam gestartet und als eine Einheit skaliert.
Für das aktuelle Projekt ist das vollkommen akzeptabel: Das Frontend ist ohne Backend kaum sinnvoll nutzbar, weshalb eine unabhängige Skalierung der beiden Komponenten derzeit nicht erforderlich ist.
Ein weiterer wichtiger Vorteil besteht darin, dass Container innerhalb derselben Container App
dieselbe Netzwerkumgebung teilen und über localhost miteinander kommunizieren können.
Deshalb kann der Frontend-Container auf die API beispielsweise so zugreifen:
ENV API_BASE_URL=http://127.0.0.1:8080
Das Backend muss dabei nicht separat nach außen veröffentlicht werden.
Änderung der Backend-Verantwortlichkeiten
Durch die Aufteilung der Container haben sich auch die Verantwortlichkeiten der einzelnen Anwendungen verändert.
Zuvor stellte die .NET-Anwendung nicht nur die API bereit, sondern war auch an der Auslieferung des Frontends beteiligt.
Zum Beispiel wurde folgender Fallback verwendet:
app.MapFallbackToFile("index.html");
Nachdem das Frontend in einen eigenen Container verschoben wurde, ist diese Logik nicht mehr erforderlich.
Das Backend wird damit zu dem, was es eigentlich sein soll: einem reinen Backend. Seine Aufgaben sind die Bereitstellung der API, die Verarbeitung von Daten, die Auslieferung serverseitiger Ressourcen und die übrige Backend-Logik.
Das ist vermutlich einer der wichtigsten Vorteile des Refactorings: Jede Komponente ist nun nur noch für ihren eigenen Bereich verantwortlich.
Frontend als zentraler Einstiegspunkt
Nach der Trennung der Container gelangen alle externen HTTP-Anfragen zunächst an die Node.js-Anwendung.
Der Frontend-Container ist nun der Einstiegspunkt für den Azure Container Apps Ingress.
Normale Benutzeranfragen werden von Angular SSR verarbeitet. Anfragen an die API sowie an bestimmte serverseitige Ressourcen müssen dagegen an den zweiten Container weitergeleitet werden.
Dafür war eine kleine Anpassung an server.ts erforderlich.
Die Liste der Routen, die an das Backend weitergeleitet werden sollen, sieht folgendermaßen aus:
const proxiedRequestPaths = [
'/api',
'/documents',
'/images',
'/mnt',
];
/**
* Proxy API-related requests to the API container.
*/
for (const requestPath of proxiedRequestPaths) {
app.use(requestPath, proxyToApi);
}
Der resultierende Request-Flow sieht nun ungefähr so aus:
Internet
│
▼
Azure Container Apps Ingress
│
▼
Node.js / Angular SSR :4000
│
├── Angular pages
│
└── /api, /documents, /images, /mnt
│
▼
.NET Web API :8080
Dadurch wird ein externer Ingress nur noch für den Frontend-Container benötigt.
Der Backend-Container ist für das Frontend über das interne Netzwerk der Azure Container App erreichbar und wird nicht direkt ins Internet veröffentlicht.
Ergebnis
Nach dem Refactoring ist die Architektur deutlich sauberer geworden.
Anstelle eines Containers mit zwei unabhängigen Prozessen gibt es nun zwei Container mit klar definierten Verantwortlichkeiten:
Azure Container App
│
├── Frontend container
│ └── Node.js + Angular SSR :4000
│
└── Backend container
└── .NET Web API :8080
Dadurch konnte das zusätzliche Skript zum Starten beider Prozesse entfernt, die Dockerfiles vereinfacht und die Gesamtarchitektur der Anwendung übersichtlicher gestaltet werden.
Gleichzeitig bleiben beide Container weiterhin Teil derselben Azure Container App und können eng miteinander kommunizieren. Das passt gut zu den Anforderungen des aktuellen Projekts.
Neben den architektonischen Verbesserungen brachte das Refactoring auch einen praktischen Vorteil: Der Verbrauch von Arbeitsspeicher und CPU-Zeit ist spürbar gesunken. Der Unterschied beim Ressourcenverbrauch ist im folgenden Screenshot gut zu erkennen.
Mindestens genauso wichtig ist jedoch, dass die Architektur nun wesentlich besser den tatsächlichen Verantwortlichkeiten der einzelnen Komponenten entspricht.
Und natürlich gibt es noch einen weiteren Vorteil jedes erfolgreich abgeschlossenen Refactorings: das gute Gefühl, dass das Projekt nun ein kleines Stück weniger technische Schulden enthält.