Docker Compose: Speicherlimits gegen OOM und Exit 137
Setzen Sie in Docker Compose Speicher- und CPU-Limits, damit ein Container Ihren VPS nicht beendet. Erfahren Sie, wann mem_limit, deploy.resources und Swap greifen.
Was ein Docker-Compose-Speicherlimit bewirkt
Ein Docker-Compose-Speicherlimit ist eine harte Obergrenze, die der Linux-Kernel für die cgroup (Control Group, eine Kernel-Funktion zur Ressourcenmessung für eine Gruppe von Prozessen) eines Containers festlegt. Setzen Sie deploy.resources.limits.memory für einen Dienst, kann dieser Container niemals mehr als den angegebenen Wert verwenden. Beim Überschreiten beendet der Kernel einen Prozess innerhalb des Containers. Der Container wird dann normalerweise mit dem Exit-Code 137 beendet.
Das ist besonders auf einem VPS relevant, dessen Arbeitsspeicher fest begrenzt ist und der keinen zusätzlichen Speicher vom Host ausleihen kann. Ein Container mit einem Speicherleck oder einer fehlerhaften Abfrage kann auf einem System mit 8GB jede freie Speicherseite belegen. Der Kernel beendet dann den Prozess, den er als problematischsten einstuft. Das ist häufig eine Datenbank oder Ihre SSH-Sitzung und nicht der Container, der das Problem verursacht hat. Limits machen aus einem Ausfall des gesamten Servers einen einzelnen Dienst, der neu gestartet wird.
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256mWenden Sie das Limit an und prüfen Sie, ob es aktiv ist:
docker compose up -d
docker stats --no-streamIn der Spalte MEM USAGE / LIMIT sollte ein Wert wie 142MiB / 1GiB stehen. Wenn die Limitspalte den gesamten Arbeitsspeicher des Hosts anzeigt, wurde die Einstellung nicht übernommen. Der restliche Teil dieser Anleitung hilft erst, wenn das Limit aktiv ist. Falls Ihnen die Compose-Datei selbst noch nicht vertraut ist, erklärt die Docker-Compose-Grundlagen für einen VPS den Dateiaufbau, auf dem diese Konfiguration aufsetzt.
deploy.resources.limits oder mem_limit: Welche Einstellung gilt?
Für dieselbe Funktion gibt es zwei Schreibweisen. Das sorgt häufig für Verwirrung.
mem_limit, mem_reservation, memswap_limit, cpus und cpu_shares sind Service-Schlüssel auf der obersten Ebene. Sie stammen aus älteren Compose-Dateiformaten. deploy.resources stammt aus dem Swarm-Schema und ist heute Teil der Compose Specification. Dieses Format liest docker compose.
Beide Varianten funktionieren auf einem einzelnen Host. Compose V2, das docker compose-Plugin, wendet deploy.resources.limits und deploy.resources.reservations an, wenn Sie docker compose up ausführen. Dafür ist kein Swarm-Cluster erforderlich. Die nur für Swarm relevanten Teile des Blocks deploy sind die übrigen Schlüssel: mode, placement, update_config und endpoint_mode haben für docker stack deploy eine Bedeutung und werden von docker compose up ignoriert. Die verbreitete Aussage, dass „deploy Swarm benötigt“, ist für den Abschnitt resources daher falsch. Wenn Sie dieser Aussage folgen, laufen Ihre Services ohne jegliches Limit.
Verwenden Sie pro Projekt nur eine Schreibweise. Wenn Sie mem_limit: 512m und deploy.resources.limits.memory: 1g für denselben Service eintragen, ist die Datei auf den ersten Blick nicht eindeutig lesbar. Fragen Sie statt zu raten, welcher Wert gilt, den Daemon:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1Speicherwerte werden in Bytes angegeben. Daher gibt 1g den Wert als 1073741824 aus. Die CPU wird in Nano-CPUs angegeben. Daher gibt 1.5 den Wert als 1500000000 aus. Ein 0 in einem Feld bedeutet, dass kein Limit gesetzt wurde. Das kleinste von Docker akzeptierte Speicherlimit ist 6m. Unterhalb dieses Werts startet der Container nicht.
Was passiert, wenn ein Container das Limit erreicht?
Der Container wird nicht langsamer. Er beendet sich.
Wenn ein Prozess eine Speicherseite anfordert und die cgroup bereits ihr memory.max erreicht hat, gibt der Kernel zuerst Speicher innerhalb dieser cgroup frei: sauberen Page Cache und anschließend Seiten, die ausgelagert werden können. Gibt die Speicherbereinigung nicht genügend Speicher frei, wählt der OOM-Killer (Out of Memory) der cgroup einen Prozess innerhalb des Containers aus und sendet ihm SIGKILL. Wird die PID 1 des Containers beendet, endet der Container. Der Exit-Code 137 ist einfach 128 plus Signal 9. Daher ist 137 ein Kennzeichen für jedes SIGKILL, aber allein kein Beweis für einen OOM.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 ist ein OOM-Kill. false 137 bedeutet, dass etwas anderes SIGKILL gesendet hat. Die übliche Ursache ist, dass docker compose stop sein Zeitlimit von zehn Sekunden erreicht, weil die Anwendung SIGTERM ignoriert hat. Diese Unterscheidung spart Stunden, weil die beiden Probleme nichts miteinander zu tun haben.
Zwei weitere Stellen protokollieren das Ereignis. Überwachen Sie den Daemon live:
docker events --filter event=oomLesen Sie anschließend das Kernel-Log. Es bleibt auch nach einem Neustart erhalten:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'Ein cgroup-Kill schreibt eine Zeile, die mit Memory cgroup out of memory: Killed process 24713 (node) beginnt. Eine Zeile ohne das Präfix Memory cgroup weist auf einen OOM des Hosts hin. Das bedeutet, dass dem Rechner selbst der RAM ausgegangen ist. Genau das sollen die Limits verhindern. Wenn dieser Fehler auftritt, ist die Summe Ihrer Limits zu hoch oder einige Dienste haben überhaupt kein Limit.
Mit restart: unless-stopped bleibt eine OOM-Schleife leicht unbemerkt, weil der Dienst bereits eine Sekunde nach seinem Beenden wieder unter docker compose ps angezeigt wird. Prüfen Sie die Spalte mit der Laufzeit und den Neustartzähler. Kombinieren Sie das Limit außerdem mit einem Healthcheck, der die Anwendung als fehlerhaft meldet, damit ein Container, der ständig beendet wird, auch ohne laufende Überwachung sichtbar bleibt.
Eine Reservation ist ein Hinweis, das Limit ist die Regel
reservations.memory (das ältere mem_reservation) ist eine weiche Untergrenze. Docker beschreibt sie als Soft-Limit, das aktiviert wird, wenn der Daemon eine Ressourcenknappheit oder zu wenig Arbeitsspeicher auf dem Host erkennt. Sie verhindert nie, dass ein Container darüber hinaus Speicher verwendet, und sie garantiert nie, dass der Speicher verfügbar ist, wenn der Container ihn anfordert. Sie bewirkt lediglich, dass der Kernel zuerst Speicher von Containern zurückfordert, die über ihrer Reservation liegen.
Eine Reservation schützt daher allein nichts. Verwenden Sie sie, um einen Dienst zu kennzeichnen, der unter hoher Last bevorzugt behandelt werden soll, und verlassen Sie sich für die Begrenzung auf das Limit. Die Reservation muss unter dem Limit liegen, andernfalls startet der Container nicht: Docker lehnt die Konfiguration mit Minimum memory limit can not be less than memory reservation limit ab.
Swap-Accounting, ehrlich betrachtet
Die meisten VPS-Images werden ganz ohne Swap-Datei ausgeliefert. Führen Sie swapon --show und free -h aus. Wenn die gesamte Swap-Größe null ist, haben alle folgenden Swap-Einstellungen keine Wirkung. Ihr Speicherlimit ist dann eine reine RAM-Begrenzung.
memswap_limit ist nicht die Swap-Größe. Der Wert umfasst den gesamten Speicher aus RAM und Swap. Mit mem_limit: 1g und memswap_limit: 2g erhält der Container 1GB RAM und 1GB Swap. Wenn Sie beide Werte gleich setzen, hat der Container überhaupt keinen Swap. Wenn Sie mem_limit setzen und memswap_limit nicht festlegen, kann der Container wieder bis zur Größe seines Speicherlimits auslagern.
Ubuntu 24.04 und Debian 13 verwenden standardmäßig cgroup v2. Dort ist Swap ein eigener Zähler (memory.swap.max), und diese Konfiguration funktioniert ohne zusätzliche Einrichtung. Die alte Meldung Your kernel does not support swap limit capabilities stammt von cgroup-v1-Hosts, die ohne swapaccount=1 gestartet wurden. Auf diesen Hosts gilt das Speicherlimit weiterhin, während der Swap-Anteil ignoriert wird.
Seien Sie sich darüber im Klaren, was Swap tatsächlich leistet. Swap führt nicht seltener zu einem OOM-Kill, sondern verzögert ihn nur, weil ein Prozess mit Speicherleck den Swap genauso füllt wie den RAM. Gleichzeitig verlangsamt ein Container, der auf gemeinsam genutztem VPS-Speicher ständig auslagert, jeden anderen Dienst auf dem Host. Für Anwendungen mit hohen Latenzanforderungen ist ein korrekt gesetztes Limit ohne Swap die schnellere und besser vorhersehbare Fehlerbehandlung.
Warum der Speicherverbrauch schlimmer aussieht, als er ist
Der Wert MEM USAGE in docker stats enthält den Seiten-Cache. Deshalb steigt ein Container, der große Dateien liest, bis zu seinem Limit und bleibt dort. Das ist normal und kein Leak, weil freier Cache zurückgefordert wird, bevor der OOM-Killer überhaupt aufgerufen wird. Ein Dienst wie ein selbst gehosteter Jellyfin-Medienserver wirkt genau aus diesem Grund dauerhaft nahezu ausgelastet.
Teilen Sie den Wert innerhalb des Containers in Cache und tatsächlich benötigte Speichermenge auf:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon ist anonymer Speicher, also der nicht freigebbare Arbeitssatz. file ist Seiten-Cache, der freigegeben werden kann. Legen Sie das Limit anhand von anon plus einer Reserve fest, nicht anhand des Gesamtwerts. Die Datei memory.events liefert eine eindeutige Antwort: Ein oom_kill-Zähler über 0 bedeutet, dass der Kernel seit dem Start des Containers etwas beendet hat. Ein steigender max-Zähler bedeutet, dass der Container gerade an seinem Limit gehalten wird. Beide Befehle benötigen eine Shell und coreutils im Image. Daher schlagen sie bei einem distroless- oder scratch-Image fehl.
Größenlimits auf einem 8GB-VPS
Beginnen Sie beim Host, nicht bei den Anwendungen. Lassen Sie auf einem 8GB-VPS etwa 1GB für den Kernel, den Docker-Daemon, sshd, journald und Ihre eigene Login-Shell frei. Damit bleiben ungefähr 7GB zur Verteilung. Die Summe aller Containerlimits sollte darunter bleiben. Überbelegung funktioniert genau bis zu dem Tag, an dem zwei Dienste gleichzeitig ihre Spitzenlast erreichen.
Eine praktikable Aufteilung auf einem 8GB-System:
- Reverse Proxy: Limit von 128m. Er ist ein kleiner Prozess. Ein so knappes Limit erkennt eine außer Kontrolle geratene Konfigurationsneuladung sofort.
- PostgreSQL: Limit von 2g. Setzen Sie
shared_buffersin der Datenbankkonfiguration auf etwa 512MB. - Anwendungscontainer: Limit von 1g.
- Hintergrund-Worker: Limit von 512m.
- Medien- oder Dateidienst: Limit von 2g. Der größte Teil davon wird für den Page Cache verwendet.
Übernehmen Sie diese Werte nicht unverändert für Ihren eigenen Stack. Lassen Sie die Dienste einen Tag lang unter realer Last laufen. Überwachen Sie docker stats, ermitteln Sie für jeden Container den Spitzenwert anon und addieren Sie ungefähr die Hälfte dieses Werts als Reserve. Ein zu knapp gesetztes Limit ist schlechter als gar kein Limit, weil es einen gesunden Dienst bei einer normalen Verkehrsspitze beendet.
Eine Falle verdient einen eigenen Hinweis. Die meisten Laufzeitumgebungen erkennen das Limit nicht automatisch. Sie müssen ihnen das Limit mitteilen. PostgreSQL dimensioniert shared_buffers und work_mem problemlos über das Containerlimit hinaus und wird dann beendet. Eine JVM (Java virtual machine) benötigt -XX:MaxRAMPercentage=75, damit sie ihren Heap anhand des cgroup-Limits statt anhand des Arbeitsspeichers des Hosts dimensioniert. Node.js benötigt --max-old-space-size in Megabyte. Setzen Sie den Wert unter das Containerlimit. Andernfalls lässt der Garbage Collector den Heap wachsen, bis der Kernel eingreift. Bei Ollama gilt dasselbe mit einem anderen Parameter: Das Erhöhen von num_ctx vergrößert den KV-Cache um Hunderte Megabyte, und der Container wird während eines langen Prompts beendet. Die cgroup verhandelt nicht. Sie beendet den Prozess.
CPU-Limits funktionieren grundlegend anders
cpus: "1.5" entspricht 150 % eines CPU-Kerns und wird als CFS-Quota (Completely Fair Scheduler) durchgesetzt. Der Container erhält in jedem Zeitraum von 100 ms insgesamt 150 ms CPU-Zeit, verteilt auf alle seine Threads. Sobald dieses Kontingent aufgebraucht ist, lässt der Kernel ihn bis zum Beginn des nächsten Zeitraums warten.
Das ist der entscheidende Unterschied. Ein Container, der sein Speicherlimit überschreitet, wird beendet. Ein Container, der sein CPU-Limit überschreitet, wird gedrosselt und läuft mit geringerer Geschwindigkeit weiter. Daher können Sie ein CPU-Limit relativ großzügig festlegen. Ein Speicherlimit benötigt dagegen Reserven.
cpu_shares ist ein anderes Werkzeug: eine relative Gewichtung, die nur relevant ist, wenn die CPUs tatsächlich ausgelastet sind. Zwei Container mit Shares von 1024 und 512 teilen sich einen ausgelasteten CPU-Kern ungefähr im Verhältnis zwei zu eins. Auf einem nicht ausgelasteten System wird keiner der beiden eingeschränkt. Verwenden Sie Shares, um Dienste nach ihrer Wichtigkeit zu priorisieren. Verwenden Sie cpus, wenn Sie eine echte Obergrenze benötigen, beispielsweise um zu verhindern, dass ein nächtlicher Transcodierungsjob Ihren Webserver ausbremst.
FAQ
Funktioniert deploy.resources.limits ohne Docker Swarm?
Ja. Compose V2 wendet deploy.resources.limits und deploy.resources.reservations an, wenn Sie docker compose up auf einem einzelnen Host ausführen. Prüfen Sie dies mit docker inspect --format '{{.HostConfig.Memory}}' <container>. Der Befehl gibt das Limit in Byte aus und 0, wenn kein Limit angewendet wurde. Die Schlüssel innerhalb von deploy, die tatsächlich Swarm erfordern, sind mode, placement, update_config und endpoint_mode.
Was bedeutet der Exit-Code 137 in Docker Compose?
Der Hauptprozess hat SIGKILL empfangen, da 137 gleich 128 plus Signal 9 ist. Häufig ist der OOM-Killer des Kernels die Ursache. Derselbe Code entsteht jedoch auch bei einem Timeout während des Herunterfahrens, wenn eine Anwendung SIGTERM ignoriert. Führen Sie docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> aus, um die beiden Fälle zu unterscheiden. true 137 steht für eine Speicherbeendigung, false 137 hingegen nicht.
Sollte ich mem_limit oder deploy.resources.limits.memory setzen?
Beides funktioniert mit docker compose. deploy.resources.limits.memory ist die aktuelle Form der Compose Specification und für eine neue Datei die bessere Standardeinstellung. Behalten Sie mem_limit bei, wenn der übrige Dateiinhalt bereits die älteren Schlüssel auf oberster Ebene verwendet. Wenn Sie beides für denselben Dienst setzen, wird die Datei nur schwerer lesbar. Wählen Sie daher eine Variante und prüfen Sie das Ergebnis mit docker inspect.
Warum nutzt mein Container sein vollständiges Speicherlimit, ohne beendet zu werden?
Der Nutzungswert in docker stats umfasst den Page Cache. Der Kernel gibt diesen unter Speicherdruck frei, anstatt einen OOM-Kill auszulösen. Führen Sie docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat aus und lesen Sie den Wert anon. Dieser bezeichnet den Arbeitssatz, der nicht zurückgewonnen werden kann. Ein hoher Wert file neben einem niedrigen Wert anon weist auf einen Container mit Festplattenein- und -ausgaben hin, nicht auf einen Container, der kurz vor der Beendigung steht.
Wie viel RAM sollte ich auf einem 8GB-VPS nicht zuweisen?
Lassen Sie etwa 1GB für den Kernel, den Docker-Daemon, sshd, journald und Ihre eigene Shell frei. Halten Sie anschließend die Summe aller Containerlimits unter den verbleibenden 7GB. Überwachen Sie den Spitzenwert anon jedes Containers einen Tag lang unter realer Last, bevor Sie konkrete Werte festlegen. Betrachten Sie die Gesamtsumme als Budget und nicht als Zielwert, der vollständig ausgeschöpft werden muss.