• English icon
  • German icon
Erklärvideo

Warum Ihr Container auf Azure App Service einen 503 liefert

· Reihe: Container auf Azure

azureazure-app-servicedockercontainersdevops

Das Video ist auf Englisch.

Ein Container läuft auf dem Laptop einwandfrei. Dasselbe Image kommt auf Azure App Service, startet, und App Service wartet. Es schickt Anfragen an Port 80 und an Port 8080, bekommt keine Antwort, weil die App auf 8000 lauscht, gibt auf, versucht es erneut, und die Nutzer sehen einen 503.

Dieses Erklärvideo zeigt, was App Service rund um einen eigenen Container tut: wie es das Image zieht, wie der Traffic den richtigen Port erreicht, was einen Neustart übersteht, wie Einstellungen ohne neuen Build hineinkommen und wo Sie suchen, wenn etwas schiefgeht. Eine Idee verbindet alles: Das Image ändert sich nie, alles andere wird von außen angeschlossen.

Das Wichtigste

  • App Service findet nur die Ports 80 und 8080 von selbst. Jeder andere Port braucht die App-Einstellung WEBSITES_PORT, und die App muss auf 0.0.0.0 lauschen, nicht auf localhost.
  • Ziehen Sie das Image mit einer Managed Identity. Die Identität der Web-App bekommt die Rolle AcrPull auf der Registry; nirgends wird ein Passwort gespeichert.
  • Das Dateisystem des Containers ist temporär. Nur /home übersteht Neustarts, und nur mit aktiviertem persistentem Speicher (App-Einstellung WEBSITES_ENABLE_APP_SERVICE_STORAGE auf true).
  • Die Konfiguration kommt von außen. App-Einstellungen kommen als Umgebungsvariablen an, Slot-Einstellungen bleiben bei ihrem Slot, Geheimnisse kommen über Key-Vault-Verweise. So bedient ein Image jede Umgebung.

Das Image ziehen

Beim Anlegen einer Web-App für Container wählen Sie, woher das Image kommt. Azure Container Registry ist die empfohlene Quelle. Andere Registries funktionieren auch: Docker Hub, GitHub Container Registry oder jede Registry, die die Docker Registry API spricht.

Eine private Registry braucht eine Anmeldung, und dafür gibt es zwei Wege:

Managed IdentityAdmin-Benutzer
Was es isteine Identität in Microsoft Entra ID, die zur Web-App gehörtBenutzername und Passwort der Registry
Zugriffdie Rolle AcrPull auf der Registryder Admin-Benutzer muss in der Registry aktiviert sein
Geheimnissenirgends gespeichertdas Passwort liegt in der Konfiguration der App
Rotationnichts zu rotierenvon Hand

Eine systemzugewiesene Identität lebt und stirbt mit der App. Eine benutzerzugewiesene Identität ist eine eigene Ressource, die sich mehrere Apps teilen können.

az webapp create -g rg -p plan -n docproc \
--container-image-name myacr.azurecr.io/docprocessor:v1
az webapp identity assign -g rg -n docproc
az role assignment create --assignee $PRINCIPAL_ID \
--role AcrPull --scope $ACR_ID
az webapp config set -g rg -n docproc \
--acr-use-identity true --acr-identity [system]

Wann App Service zieht: Beim ersten Deployment zieht es jede Schicht, bei einem Neustart nur die geänderten Schichten, und beim Hochskalieren zieht jede neue Instanz das Image.

Neue Versionen: Zeigen Sie die App auf einen neuen Tag, dann startet App Service neu und zieht ihn. Pushen Sie dagegen ein neues Image unter demselben Tag, bemerkt App Service das nicht. Starten Sie die App neu oder schalten Sie Continuous Deployment ein: az webapp deployment container config --enable-cd true liefert eine Webhook-URL, und sobald Sie sie als Push-Webhook in der Registry eintragen, startet jeder Push die App neu.

Traffic und der Port

Anfragen aus dem Internet kommen bei den Frontends von App Service an. Die Frontends beenden die HTTPS-Verbindung und leiten reines HTTP in den Container weiter, an einen einzigen Port.

Diesen Port sucht App Service von selbst: 80 oder 8080. ASP.NET-Core-Container-Images lauschen seit .NET 8 auf 8080 und funktionieren deshalb ohne Einstellung. Jeder andere Port, etwa gunicorn auf 8000 oder Express auf 3000, braucht die App-Einstellung WEBSITES_PORT. Diese Einstellung liest App Service selbst; sie wird nicht als Umgebungsvariable an den Container weitergegeben.

Beim Start schickt App Service Warm-up-Anfragen und wartet auf irgendeine Antwort, selbst auf einen Fehler. Antwortet innerhalb des Start-Zeitlimits niemand, schlägt der Start fehl und App Service versucht es erneut. Das zeigt sich als 503-Fehler.

Noch eine Falle: Die App muss auf 0.0.0.0 lauschen, also auf allen Netzwerkschnittstellen. Lauscht sie nur auf localhost, erreichen Anfragen von außerhalb des Containers sie nie.

Ein Startbefehl ersetzt die CMD-Anweisung des Dockerfiles, der ENTRYPOINT bleibt:

az webapp config appsettings set -g rg -n docproc \
--settings WEBSITES_PORT=8000
az webapp config set -g rg -n docproc \
--startup-file "gunicorn --bind=0.0.0.0:8000 app:app"

Dateien und Neustarts

Standardmäßig ist eine Datei, die die App schreibt, nach dem nächsten Neustart verschwunden. Das Dateisystem des Containers ist temporär: Startet die App neu oder zieht sie auf eine andere Maschine um, geht alles verloren, was im Container geschrieben wurde.

Für Dateien, die bleiben müssen, kann App Service persistenten Speicher unter /home einbinden. Bei eigenen Linux-Containern ist das standardmäßig aus; setzen Sie WEBSITES_ENABLE_APP_SERVICE_STORAGE auf true. Dann überstehen Dateien in /home Neustarts, jede Instanz einer hochskalierten App sieht dieselben Dateien, und der Platz zählt zum Speicherkontingent des App-Service-Plans.

/home ist eine Netzwerkfreigabe. Für Uploads und Ausgabedateien ist das in Ordnung, nicht aber für eine dateibasierte Datenbank wie SQLite, die Dateisperren braucht, die die Freigabe nicht bietet. Für große Datenmengen binden Sie Azure Storage ein oder nutzen eine echte Datenbank.

Einstellungen von außen

Das Image für Entwicklung, Staging und Produktion jeweils neu zu bauen, würde den Sinn eines Containers verfehlen. App-Einstellungen sind Name-Wert-Paare, die App Service beim Start des Containers als Umgebungsvariablen einspielt. Das Image bleibt gleich, nur die Variablen ändern sich.

import os
log_level = os.environ.get("LOG_LEVEL", "WARNING")
storage = os.environ["STORAGE_ACCOUNT_NAME"]
api_key = os.environ["API_KEY"]
  • Verschlüsselt gespeichert. Alle App-Einstellungen werden verschlüsselt abgelegt.
  • Namen: Buchstaben, Ziffern und Unterstriche. Ein verschachtelter .NET-Schlüssel wie ConnectionStrings:Default wird als ConnectionStrings__Default geschrieben.
  • Verbindungszeichenfolgen bekommen ein Präfix für den Datenbanktyp vor den Namen, zum Beispiel SQLAZURECONNSTR_Default für Azure SQL. Das hilft .NET-Apps; für Python oder Node sind normale App-Einstellungen einfacher.
  • Slot-Einstellungen: Mit Deployment-Slots markieren Sie die Werte, die zu einer Umgebung gehören, als Slot-Einstellungen. Beim Tausch von Staging in die Produktion wandert der Code, die Slot-Einstellungen bleiben bei ihrem Slot.

Für Geheimnisse nutzen Sie einen Key-Vault-Verweis als Wert der Einstellung. App Service holt das Geheimnis mit der Managed Identity der App, und der Code liest weiterhin nur eine Umgebungsvariable:

API_KEY = @Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/api-key)

Nach einer Rotation kommt der neue Wert innerhalb von 24 Stunden an, oder sofort, wenn die App neu startet.

Warm und gesund

Ohne Traffic kann App Service eine untätige App nach etwa 20 Minuten entladen. Die nächste Anfrage wartet dann auf einen Kaltstart, und ein großes Image macht ihn langsamer. Always On verhindert das: App Service schickt alle fünf Minuten eine Anfrage an die App, sodass sie nie untätig wird. Dafür braucht es mindestens den Basic-Tarif.

Health Checks gehen einen Schritt weiter. Sie geben App Service einen Pfad, etwa /health. Jede Minute ruft App Service diesen Pfad auf jeder Instanz auf und erwartet einen Statuscode zwischen 200 und 299.

Was passiertWann
Die Instanz verlässt den Load Balancernach zehn fehlgeschlagenen Checks in Folge (Standard)
Sie kehrt in den Load Balancer zurücksobald sie wieder gesund antwortet
Sie wird ersetztnach einer Stunde im ungesunden Zustand

Das hilft nur mit zwei oder mehr Instanzen: Bei einer einzigen Instanz gibt es keinen anderen Ort für den Traffic. Ein guter Health-Endpunkt prüft, was die App wirklich braucht, und liefert einen Fehler, wenn er es nicht erreicht:

@app.route("/health")
def health():
try:
db.execute("SELECT 1")
except Exception:
return {"status": "unhealthy"}, 503
return {"status": "healthy"}, 200
az webapp config set -g rg -n docproc --always-on true
az webapp config set -g rg -n docproc \
--generic-configurations '{"healthCheckPath": "/health"}'

Logs und wo Sie suchen

App Service fängt alles auf, was der Container auf die Standardausgabe und die Standardfehlerausgabe schreibt. Schalten Sie das Container-Logging ein, dann landen diese Zeilen unter /home/LogFiles. Der Log-Stream zeigt sie live, von jeder Instanz, jede Zeile mit ihrer Instanz-ID:

az webapp log config -g rg -n docproc \
--docker-container-logging filesystem
az webapp log tail -g rg -n docproc

Für einen Blick hinter die Kulissen hat die Kudu-Seite (auch SCM-Seite, <app>.scm.azurewebsites.net) eine Umgebungsseite, die jede Variable auflistet, die die App bekommt. Kudu läuft in einem eigenen Container und sieht deshalb die Prozesse in Ihrem nicht. Dafür gibt es SSH: Das Image muss einen SSH-Server auf Port 2222 mitbringen, mit dem Root-Passwort, das App Service erwartet, und er ist nur über Azure erreichbar, nicht aus dem Internet. Für längere Aufbewahrung schicken Sie die Logs in einen Log-Analytics-Arbeitsbereich und fragen sie mit KQL ab.

SymptomWo Sie suchen
Der Container startet nieder Port (WEBSITES_PORT, 0.0.0.0) und der Log-Stream
Eine Einstellung scheint zu fehlendie Umgebungsseite in Kudu
Das neue Image erscheint nichtder Tag, oder die App neu starten

Weiterlesen

Video-Transkript (Englisch)

Your container runs fine on your laptop. You deploy the same image to Azure App Service. It starts, and App Service waits. It knocks on port 80, then on port 8080. No answer, because your app listens on port 8000. App Service gives up, tries again, and your users get a 503.

In this video, you will see how App Service runs your container: how it pulls the image, how traffic finds the right port, what survives a restart, how settings get in without a rebuild, and how to find out what went wrong. One idea connects all of it. The image never changes. Everything else plugs in from the outside.

Start with the pull. Before App Service can run anything, it has to get your image. You choose where the image comes from. Azure Container Registry is the recommended source. Other registries work too: Docker Hub, GitHub Container Registry, or any registry that speaks the Docker Registry API. A private registry needs a login. The recommended way is a managed identity: an identity in Microsoft Entra ID that belongs to your web app. You give it the AcrPull role on the registry, and App Service pulls with it. No password is stored anywhere. A system-assigned identity lives and dies with the app. A user-assigned identity is its own resource, and several apps can share it. The other way is the registry's admin user, with a username and a password. That is fine for a quick test, but the password sits in your app's configuration, and you have to rotate it yourself. In the Azure CLI, az webapp create takes the full image name, with a tag. Then you turn on the identity, give it AcrPull on the registry, and tell the app to pull with that identity. So when does App Service pull? On the first deployment, it pulls every layer. On a restart, only the layers that changed. And when you scale out, every new instance pulls the image. To release a new version, point the app to a new tag. App Service restarts and pulls it. But if you push a new image under the same tag, App Service doesn't notice. Restart the app, or turn on continuous deployment: then a webhook from the registry restarts it for you.

The image is pulled, and the container starts. Now the requests have to reach it. Requests from the internet arrive at the App Service front ends. The front ends end the HTTPS connection, and forward plain HTTP into your container, to a single port. Which port? App Service looks for one on its own: port 80, or port 8080. ASP.NET Core images listen on 8080 since .NET 8, so they work without any setting. Any other port needs one app setting: WEBSITES_PORT. An app with gunicorn on port 8000, or Express on port 3000, sets it to that number. App Service reads this setting itself. Your app never sees it. At startup, App Service sends warm-up requests and waits for any answer, even an error. If nothing answers within the start time limit, the start fails, and App Service tries again. One more trap: your app must listen on 0.0.0.0, on all network interfaces. If it only listens on localhost, the requests from outside never reach it. You can also change how the container starts. A startup command replaces the CMD instruction of your Dockerfile, while the ENTRYPOINT stays. For example, gunicorn, bound to 0.0.0.0 on port 8000.

What happens to a file your app writes? By default, it is gone after the next restart. The container's file system is temporary. When the app restarts, or moves to another machine, everything written inside the container is lost. For files that must survive, App Service can mount persistent storage at /home. For custom Linux containers, it is off by default. Set WEBSITES_ENABLE_APP_SERVICE_STORAGE to true. Now files in /home survive restarts, and every instance of a scaled-out app sees the same files. The space counts against the storage quota of your App Service plan. The /home folder is a network share. That is fine for uploads and output files, but not for a file-based database like SQLite, which needs file locks the share can't give. For large data, mount Azure Storage, or use a real database.

Your app needs different values in development, in staging and in production. Rebuilding the image for each one would defeat the point of a container. App settings solve this. They are name and value pairs that App Service injects as environment variables when the container starts. The image stays the same. Only the variables change. Your code reads them like any environment variable. In Python, that is os.environ. All app settings are encrypted at rest. Their names can use letters, numbers and underscores. So a nested .NET key like ConnectionStrings:Default is written with a double underscore instead of the colon. Connection strings are a special kind of setting. App Service puts a prefix for the database type in front of the name, for example one for Azure SQL. That helps .NET apps. For Python or Node, plain app settings are simpler. With deployment slots, mark the values that belong to an environment as slot settings. When you swap staging into production, the code moves, but slot settings stay with their slot. And for secrets, use a Key Vault reference. The setting's value points to a secret in Azure Key Vault, and App Service fetches it with the app's managed identity. Your code still just reads an environment variable. After a rotation, the new value arrives within 24 hours, or right away when the app restarts.

Settings are in. Now keep the app running well. Without traffic, App Service can unload an idle app after about 20 minutes. The next request then waits for a cold start, and a large image makes that slower. Always On prevents this. App Service then sends a request to your app every five minutes, so it never goes idle. It needs the Basic tier or higher. Health checks go one step further. You give App Service a path, like /health. Every minute, it calls that path on every instance, and expects a status code between 200 and 299. By default, if an instance fails ten checks in a row, App Service takes it out of the load balancer. If it stays unhealthy for an hour, App Service replaces it. This only helps with two or more instances. With a single instance, there is nowhere else to send the traffic. A good health endpoint checks what your app really needs, like its database, and returns an error when it can't reach it.

And when something still goes wrong, you need to see it. App Service captures everything your container writes to standard output and standard error. Turn on container logging, and these lines are saved under /home/LogFiles. The log stream shows them live, from every instance, and each line carries its instance ID. In the CLI, that is az webapp log tail. For a look behind the scenes, open the Kudu site, also called the SCM site. Its environment page lists every variable your app receives. But Kudu runs in its own container, so it can't see the processes inside yours. For that, use SSH. Your image has to bring an SSH server on port 2222, with the root password App Service expects. It is reachable only through Azure, not from the internet. For longer retention, send the logs to a Log Analytics workspace, and query them with KQL. Now most problems have a clear place to look. The container never starts: check the port and the log stream. A setting seems to be missing: check the environment page in Kudu. The new image doesn't show up: check the tag, or restart the app.

To sum it up. App Service pulls your image, best with a managed identity and the AcrPull role. It sends traffic to one port: 80, 8080, or the one you set in WEBSITES_PORT. Files survive only in /home, and only when persistent storage is on. Settings arrive as environment variables, so one image serves every environment. Always On and health checks keep it warm and healthy, and the logs show you what went wrong.

The image stays the same. Everything else plugs in from the outside. If this was useful, subscribe for more practical .NET and Azure.

Brauchen Sie das in Ihrer eigenen Azure-Umgebung?

Ich entwickle, migriere und betreibe .NET-Plattformen auf Azure, mit Azure DevOps und Terraform. Wenn Ihr Team das sauber aufsetzen möchte, sehen Sie sich mein Angebot an.

Zu den Leistungen