Alle Systeme betriebsbereit 6 Offshore-Regionen No-KYC-Bezahlvorgang
Hands-on Praxisleitfaden

Docker auf dem VPS: die Firewall, die er still aushebelt

Sie haben die erste Stunde richtig gemacht: ein benannter Benutzer, ausschließlich schlüsselbasiertes SSH, Default-Deny auf beiden IP-Familien, automatische Sicherheitsupdates. Dann haben Sie einen Container-Port veröffentlicht und eine Datenbank ins öffentliche Internet gestellt — denn Docker stellt diesen Port über eine Regel zu, die längst läuft, bevor Ihre Firewall je zu Rate gezogen wird, und ufw meldet weiterhin, dass alles verweigert wird. Dies ist der Guide zu dieser Lücke: warum sie existiert, wie Sie auslesen, was Ihre Container gerade tatsächlich exponieren, und die Handvoll Änderungen, die dafür sorgen, dass die Firewall, die Sie geschrieben haben, tatsächlich das bedeutet, was Sie sich dabei gedacht haben.

Aktualisiert 2026-09-14 · 15 Min. Lesezeit · Flottenbetrieb
Auf dieser Seite
  1. Die Regel, die Sie geschrieben haben, und die Regel, die Docker geschrieben hat
  2. Wie ein exponierter Port von außen aussieht
  3. Lesen Sie, was Sie tatsächlich veröffentlichen
  4. Loopback-Bindung, und der Unterschied zwischen ports und expose
  5. DOCKER-USER: die Chain, die genau dafür existiert
  6. Die andere Tür: der Socket, und was als root läuft
  7. Fremde Images, auf einer Box, die zu verlieren Sie sich nicht leisten können
  8. Logs, Volumes, und der Zustand, der den Container überlebt
  9. Schritt für Schritt
SP·01

Die Regel, die Sie geschrieben haben, und die Regel, die Docker geschrieben hat

Zwei Programme bearbeiten dieselbe Firewall mit unterschiedlichen Annahmen, und nur eines der beiden sagt Ihnen davon. ufw schreibt seine Regeln in die INPUT-Chain der filter-Tabelle — den Pfad, den ein Paket nimmt, wenn es für den Host selbst bestimmt ist. Docker schreibt in die nat-Tabelle und in FORWARD — den Pfad, den ein Paket nimmt, wenn es für irgendwo anders bestimmt ist.

Verfolgen Sie ein einzelnes Paket, und die Lücke wird offensichtlich. Jemand in einem anderen Land öffnet eine Verbindung zu Ihrer Adresse auf Port 5432. Sie kommt an, und das Erste, worauf sie trifft, ist nat PREROUTING, das sie an Dockers DOCKER-Chain weiterreicht. Dort schreibt eine DNAT-Regel das Ziel auf 172.17.0.2:5432 um — den Container. Das Paket ist nicht mehr an Ihren Server adressiert, also routet der Kernel es, statt es lokal zuzustellen: Es durchläuft FORWARD, wo Docker bereits ein ACCEPT für Traffic in Richtung eines veröffentlichten Ports installiert hat. An keiner Stelle dieser Reise durchläuft das Paket INPUT, die einzige Chain, die ufw filtert. Ihre Regel war nie falsch. Sie wurde nie zu Rate gezogen.

Das ist auch, warum das Symptom so verwirrend ist. ufw status verbose meldet unablässig Default: deny (incoming), mit nichts erlaubt außer 80, 443 und Ihrem SSH-Port, während ein Scanner auf einem anderen Kontinent eine offene Sitzung zu Ihrer Datenbank hält. Beide Aussagen sind wahr. Die Firewall tut exakt das, was Sie konfiguriert haben, mit dem Traffic, den sie bekommt — und der Traffic, auf den es ankommt, bekommt sie nicht.

Nichts davon ist ein Bug, und nichts davon ist Nachlässigkeit von Docker. Eine Container-Engine muss NAT- und Forwarding-Regeln programmieren, sonst erreichen Container das Netzwerk überhaupt nicht, und sie kann nicht sicher erraten, welche Regeln Ihrer Host-Firewall auf sie gemünzt waren. Also tut sie das Ehrliche: Sie verwaltet ihre eigenen Chains und reicht Ihnen eine dedizierte Chain — DOCKER-USER, ausgewertet vor allem anderen in FORWARD — und verspricht, deren Inhalt nie zu überschreiben. Die Lücke ist nicht das Vorhandensein dieses Mechanismus. Die Lücke ist, dass -p 5432:5432 sich liest wie "mach das verfügbar", aber bedeutet: "veröffentliche das auf jeder Adresse, auf der diese Maschine antwortet, vorbei an der Firewall, die zu schreiben Sie eine Stunde gebraucht haben".

SP·02

Wie ein exponierter Port von außen aussieht

Das Internet bemerkt es schneller, als man denkt. Hosting-Bereiche werden kontinuierlich und erschöpfend gescannt — nicht von jemandem, der sich für Sie persönlich interessiert, sondern von kommerziellen Crawlern, Forschungsprojekten und opportunistischen Botnetzen, die jede routbare Adresse auf jedem interessanten Port abklappern und die Ergebnisse veröffentlichen oder verkaufen. Das Intervall zwischen docker compose up -d und der ersten unaufgeforderten Verbindung zu einem frisch veröffentlichten Datenbank-Port wird in Stunden gemessen. Niemand musste Ihren Hostnamen erraten. Niemand musste Ihren Namen kennen. Die Adresse lag im Scanbereich.

Was sie finden, hängt vollständig davon ab, was Sie veröffentlicht haben, und die üblichen Fälle sind trostlos. Ein PostgreSQL- oder MySQL-Container, gestartet aus dem Quick-Start-Snippet eines Images, mit dem trivialen Passwort aus eben diesem Snippet noch in Kraft. Eine Elasticsearch- oder MongoDB-Instanz, die nie auf Authentifizierung konfiguriert wurde, weil sie ohnehin nur vom Anwendungs-Container aus erreichbar sein sollte. Ein memcached, das jedem antwortet — was nicht nur ein Datenleck ist, sondern ein Verstärker, der auf irgendjemand anderen gerichtet werden kann und Ihren Server zum Teilnehmer an genau der Art von Flut macht, die in unserem DDoS-Runbook für die erste Stunde beschrieben wird. Ein Admin-Panel oder Message-Queue-Dashboard, das ein privates Netzwerk vorausgesetzt hat. Ein Metrics-Endpunkt, der still und leise jeden internen Dienst, Hostnamen und jede Version auflistet, die Sie betreiben.

Es lohnt sich, auch den Schaden zweiter Ordnung zu benennen, denn der Schaden erster Ordnung ist nicht die ganze Rechnung. Eine aus dem Internet erreichbare Datenbank ist nicht nur lesbar — in den meisten Engines ist sie auch schreibbar, was bedeutet, dass der Eindringling keinen weiteren Exploit braucht, um Persistenz zu installieren, und mehrere Engines lassen sich aus einer privilegierten Sitzung heraus dazu bewegen, Dateien zu schreiben oder Befehle auf dem Host auszuführen. Auf einer Box, die Sie gerade deshalb gewählt haben, weil sie keine Identität trägt, ist ein unauthentifizierter Fuß in der Tür zugleich eine Verbindung zurück zu allem anderen, was diese Box berührt: das Backup-Ziel, das sie erreichen kann, die Schlüssel in ihren Umgebungsvariablen, die anderen Container auf ihrer Bridge.

Das Unangenehme daran: Nichts davon kündigt sich an. Es gibt keine Log-Zeile, die sagt "Ihre Firewall wurde umgangen". Der Dienst funktioniert, die Anwendung verbindet sich, die Site ist erreichbar, und das einzige äußere Signal ist ein Verbindungszähler, den niemand beobachtet. Die Exposition wird entweder von Ihnen entdeckt, bewusst, in den nächsten zehn Minuten — oder von jemand anderem, nach dessen Zeitplan.

SP·03

Lesen Sie, was Sie tatsächlich veröffentlichen

Beginnen Sie damit, was die Engine glaubt zu tun. docker ps druckt eine PORTS-Spalte, und die Unterscheidung darin ist das ganze Thema dieses Guides: 0.0.0.0:5432->5432/tcp bedeutet jede Adresse auf der Maschine, 127.0.0.1:5432->5432/tcp bedeutet ausschließlich Loopback, und ein nacktes 5432/tcp ohne Pfeil bedeutet, der Port ist anderen Containern exponiert und nirgends veröffentlicht. Lesen Sie jede Zeile dieser Spalte bei jedem Container, bevor Sie irgendetwas ändern.

Dann schauen Sie sich mit ss -tulpen die Sockets an. Bei einer Standardinstallation sehen Sie docker-proxy, der die veröffentlichten Ports hält, weil Docker weiterhin pro veröffentlichtem Port einen kleinen Userland-Prozess startet. Hier ist die Falle, die Leute einen ganzen Nachmittag kostet: Ist userland-proxy auf Ihrem Daemon deaktiviert — eine verbreitete Tuning-Änderung, manche Distributionen liefern es sogar so aus —, gibt es überhaupt keinen lauschenden Socket auf dem Host. ss zeigt nichts, lsof zeigt nichts, und der Port steht weiterhin weit offen, weil die DNAT-Regel des Kernels die Arbeit erledigt, ohne dass irgendein Prozess die Adresse halten müsste. Eine stille ss-Ausgabe ist kein Beweis für einen geschlossenen Port.

Lesen Sie also die Regeln selbst. iptables -t nat -S DOCKER listet eine DNAT-Zeile pro veröffentlichtem Port, und jede Zeile trägt die Antwort, die Sie suchen: Eine Regel mit -d 127.0.0.1/32 ist eine Loopback-Veröffentlichung, und eine Regel ohne Ziel-Einschränkung gilt für jede Adresse, die die Maschine hält. Machen Sie dasselbe mit ip6tables, denn die beiden Familien werden unabhängig voneinander konfiguriert, und eine Box kann auf der einen dicht und auf der anderen offen sein.

Und schließlich — der einzige Schritt, der tatsächlich etwas beweist — schauen Sie sich die Maschine von woanders an. Jeder Befehl oben läuft auf dem Host und erbt dessen eigene Sicht auf sein Netzwerk. Loopback-Traffic überspringt die Chains, auf die es ankommt, sodass ein erfolgreiches curl 127.0.0.1:5432 Ihnen nichts darüber sagt, ob ein Fremder dasselbe kann, und ein gescheitertes noch weniger. Der maßgebliche Test ist ein Scan von einer anderen Maschine in einem anderen Netzwerk, auf beiden IP-Familien. Alles davor ist eine Hypothese.

SP·04

Loopback-Bindung, und der Unterschied zwischen ports und expose

Die kleinste nützliche Korrektur ist elf Zeichen lang. -p 127.0.0.1:5432:5432 weist Docker an, seine DNAT-Regel mit einer Ziel-Einschränkung zu schreiben, sodass die Umschreibung nur noch auf Traffic zutrifft, der ohnehin schon lokal war. Ein entferntes Paket, das auf Ihre öffentliche Adresse zielt, passt nicht mehr, wird nicht an den Container weitergeleitet und kommt endlich dort an, wo Sie es immer vermutet hatten: bei INPUT, wo ufw es verweigert. In einer Compose-Datei ist dasselbe ports: ["127.0.0.1:5432:5432"], und die Anführungszeichen zählen — ein unquotierter Wert mit Doppelpunkten ist ein Parsing-Unfall, der nur darauf wartet zu passieren.

Aber die bessere Frage ist, warum der Port überhaupt veröffentlicht wird. Container im selben benutzerdefinierten Netzwerk erreichen sich gegenseitig direkt, über den Servicenamen, auf dem eigenen Port des Containers, ganz ohne jede Veröffentlichung. Ihre Anwendung verbindet sich nicht zu 127.0.0.1:5432; sie verbindet sich zu postgres:5432, aufgelöst durch Dockers eingebettetes DNS zu einer Adresse auf der privaten Bridge. Eine Datenbank in dieser Anordnung braucht keine ports:-Zeile — keine Loopback-Zeile, gar keine. Der sicherste veröffentlichte Port ist der, den Sie gelöscht haben. Behalten Sie ports: für den einen oder zwei Dienste, die tatsächlich öffentlich auftreten, und lassen Sie alles andere im privaten Netzwerk sprechen.

Das ist auch, wo expose: missverstanden wird. Es veröffentlicht nichts und öffnet nichts; es ist Dokumentation, die festhält, auf welchem Port ein Dienst lauscht, und es hat keine Wirkung auf die Firewall, in keine Richtung. Leute fügen es hinzu in der Hoffnung, es sei die sichere Version von ports:, was es auch ist — im selben Sinn, in dem ein Kommentar die sichere Version von Code ist. Wenn Sie einen Dienst wollen, der nur von seinen Nachbarn erreichbar ist, brauchen Sie nicht expose:; Sie brauchen die Abwesenheit von ports:.

Zwei ehrliche Grenzen der Loopback-Bindung. Erstens schützt sie die Grenze des Hosts, nicht die Nachbarschaft: Container im selben Bridge-Netzwerk erreichen sich weiterhin ungehindert, sodass ein kompromittierter Frontend-Container einen klaren Weg zu einer Datenbank hat, die nichts veröffentlicht. Teilen Sie Dienste auf getrennte Netzwerke auf und markieren Sie das Backend-Netzwerk als internal: true, wenn der Schadensradius zählt. Zweitens ist 127.0.0.1 eine IPv4-Adresse und schränkt nur IPv4 ein; falls der Host ein routbares /64 hat — jeder Plan hier liefert eines —, denken Sie über v6 separat nach, und testen Sie es separat.

SP·05

DOCKER-USER: die Chain, die genau dafür existiert

Loopback-Bindung repariert die Container, an die Sie sich erinnern. DOCKER-USER ist, wie Sie aufhören, nur ein -p vom nächsten Vorfall entfernt zu sein. Docker installiert sie als ersten Sprung in FORWARD, noch vor seinen eigenen Accept-Regeln, und lässt — anders als alles sonst in seinen Chains — deren Inhalt unangetastet, über Neustarts, Upgrades und neue Container hinweg. Sie ist der vorgesehene Ort für die Richtlinie, die die Engine nicht erraten kann: welche Quellen überhaupt Container auf diesem Host erreichen dürfen.

Das Muster sind drei Regeln auf dem öffentlichen Interface, und die Reihenfolge ist die ganze Sache. Erstens: etablierten und verwandten Traffic zurückgeben, damit Antworten auf Verbindungen, die Ihre Container geöffnet haben, weiter fließen. Zweitens: die Quellen zurückgeben, die Sie tatsächlich hereinlassen wollen — eine Büroadresse, ein Monitoring-Host, ein befreundeter Server. Drittens: alles andere verwerfen, was aus dem Internet ankommt. Die Conntrack-Regel falsch herum zu setzen ist der klassische Fehler hier: Setzen Sie DROP zuerst, und jede ausgehende Verbindung jedes Containers stirbt am Antwortpaket — was sich präsentiert als "Docker hat DNS und Paketinstallationen kaputtgemacht" und Leute an völlig der falschen Stelle suchen lässt.

Zwei operative Details entscheiden, ob das den Kontakt mit der Realität übersteht. Es muss namentlich auf das öffentliche Interface beschränkt sein — -i eth0, oder was auch immer ip route get 1.1.1.1 auf Ihrer Box meldet —, sonst verwerfen Sie auch Traffic zwischen Ihren eigenen Bridges. Und es muss nach jedem Neustart neu angewendet werden, nachdem der Docker-Daemon die Chain erstellt hat. Eine oneshot-systemd-Unit, geordnet mit After=docker.service, ist die verlässliche Form; das Sichern mit netfilter-persistent funktioniert ebenfalls, solange Sie akzeptieren, dass das Wiederherstellen eines ganzen Regelwerks, das Docker gleichzeitig neu aufbaut, ein Wettlauf ist, den Sie prüfen und nicht einfach annehmen sollten.

Dann die IPv6-Hälfte. Wenn ip6tables -S DOCKER-USER eine Chain ausgibt, spiegeln Sie jede Regel hinein. Wenn es einen Fehler wirft, verwaltet Ihr Daemon gar keine v6-Regeln — was Ihnen nichts darüber sagt, ob v6-Traffic Ihre Container erreicht, nur dass Docker ihn nicht filtert. Denken Sie sich dort keine Antwort zusammen; die Pfade unterscheiden sich nach Version, nach Daemon-Einstellungen und nach Distribution, und eine selbstbewusst falsche Schlussfolgerung ist schlimmer als keine. Scannen Sie sich selbst mit nmap -6 und glauben Sie dem Ergebnis.

Eine Sache, die Sie nicht tun sollten: zu "iptables": false in /etc/docker/daemon.json greifen. Das hält Docker davon ab, die Firewall anzufassen, und stoppt zugleich, dass Container-NAT, ausgehendes Masquerading und Netzwerk-Isolation von irgendetwas konfiguriert werden. Sie haben das Problem nicht beseitigt, Sie haben den Job geerbt — von Hand, für jeden Container, für immer. Auf einem einzelnen VPS ist die ehrliche Wahl, Docker seine Chains verwalten zu lassen und DOCKER-USER zu übernehmen.

SP·06

Die andere Tür: der Socket, und was als root läuft

All das oben handelt von ankommenden Paketen. Dieser Abschnitt handelt davon, was sie vorfinden, und es gibt einen Punkt, der so viel schlimmer ist als der Rest, dass er es verdient, für sich allein genannt zu werden: /var/run/docker.sock in einen Container zu mounten ist gleichbedeutend damit, diesem Container Root auf dem Host zu geben. Nicht "nahe dran". Gleichbedeutend. Alles, was mit diesem Socket sprechen kann, kann einen neuen privilegierten Container starten, in den das Dateisystem des Hosts gemountet ist, und von dort aus jeden Schlüssel lesen, jede Datei schreiben und installieren, was es will. Etliche bequeme Images verlangen danach — Dashboards, Auto-Updater, Reverse Proxys mit Service Discovery. Behandeln Sie diese Anfrage als Entscheidung, dem Image ebenso zu vertrauen wie Ihrer eigenen Root-Shell, und lassen Sie bei der Docker-API über TCP dieselbe Vorsicht walten: Ein unauthentifizierter Daemon auf Port 2375 ist dieselbe Tür, geöffnet für das gesamte Internet.

Darüber hinaus gibt Ihnen die Container-Runtime vier billige Reduktionen, und keine davon erfordert, irgendetwas umzubauen. Laufen Sie als Non-Root-Benutzer mit user: "1000:1000", denn der Standard ist root innerhalb des Namespace, und das ist der Ausgangspunkt für jeden Escape. Setzen Sie security_opt: ["no-new-privileges:true"], was ein setuid-Binary im Image daran hindert, jemals mehr Rechte zu erlangen, als der Prozess gestartet ist. Entfernen Sie alle Capabilities mit cap_drop: [ALL] und fügen Sie nur das wieder hinzu, was der Dienst nachweislich braucht — die meisten Webanwendungen brauchen nichts davon. Mounten Sie das Root-Dateisystem read_only: true und geben Sie ihm ein kleines tmpfs für Scratch-Daten, was "eine Webshell im App-Verzeichnis platzieren" von einem Schritt zu einer Sackgasse macht.

Dieselbe Logik gilt für das, was dem Container mitgegeben wird. Secrets, die als Umgebungsvariablen übergeben werden, sind für alles sichtbar, was die Prozessumgebung lesen kann, und werden brav in die docker inspect-Ausgabe kopiert sowie in jeden Log- oder Crash-Report, der die Konfiguration dumpt; eine schreibgeschützt gemountete Datei an einem bekannten Pfad ist unbequemer und deutlich weniger löchrig. Und ein Container braucht nicht mehr Netzwerk, als sein Job verlangt: Ein Worker, der nur mit der Datenbank spricht, hat nichts verloren dabei, überhaupt Verbindungen ins Internet öffnen zu können.

Wenn sich das alles anfühlt wie ein Kampf gegen den Standard, ist das eine faire Lesart, und es ist das Argument für rootless Docker oder Podman — wo Daemon und Container als unprivilegierter Benutzer laufen, ein Container-Escape Sie als diesen Benutzer landen lässt statt als root, und veröffentlichte Ports von einem normalen Userland-Prozess auf dem Host gehalten werden, was bedeutet, dass Ihre ufw-Regeln auf sie in gewohnter Weise zutreffen. Der Kompromiss ist real: Manche Capabilities, manche Storage-Treiber und manche Netzwerk-Tricks verhalten sich anders oder gar nicht. Es lohnt sich zu wissen, dass diese Wahl existiert und was sie bringt, statt nach einem Vorfall zu entdecken, dass auch der Standard eine Wahl war.

SP·07

Fremde Images, auf einer Box, die zu verlieren Sie sich nicht leisten können

Ein Container-Image ist das Dateisystem eines anderen, das auf Ihrer Maschine läuft, zusammengesetzt aus Layern, die Sie nie gelesen haben. Das ist kein Argument gegen ihre Nutzung — es ist ein Argument dafür, zu wissen, welche Sie betreiben und wie alt sie sind. Die zwei Fehler, die auf kleiner Infrastruktur tatsächlich vorkommen, sind beide banal: Das Image war nie vertrauenswürdig, oder es war im März vertrauenswürdig, und niemand hat es seither neu gebaut.

Der erste wird meist durch Disziplin darüber gelöst, woher Images stammen. Bevorzugen Sie offizielle oder von Herstellern veröffentlichte Repositories gegenüber einem bequemen Fork mit drei Sternen, und seien Sie besonders misstrauisch bei Images, deren Reiz darin besteht, sechs Dienste in eine Zeile YAML zu packen. Wo es zählt, pinnen Sie per Digest statt per Tag: postgres:17 ist ein bewegliches Ziel, das sich zwischen zwei docker compose pull-Läufen unter Ihnen ändern kann, während postgres@sha256:… genau das Dateisystem ist, das Sie getestet haben. Pinnen tauscht automatische Fixes gegen Reproduzierbarkeit — der richtige Tausch, wenn Sie eine Rebuild-Gewohnheit haben, und der falsche, wenn nicht.

Der zweite ist der, der leise zubeißt. Unattended Upgrades rühren Ihre Container nicht an. Die automatischen Sicherheitsupdates, die Sie in der ersten Stunde konfiguriert haben, patchen die Pakete des Hosts und haben keine Sicht auf das Userland innerhalb eines Images — eine Box, die sich selbst als vollständig gepatcht meldet, kann also einen Webserver aus einem Basis-Image mit einem Jahr ungefixter Schwachstellen betreiben. Container werden nicht aktualisiert, sie werden ersetzt: pullen, neu erstellen und entfernen, was nicht mehr referenziert wird, in einem Rhythmus, den Sie tatsächlich einhalten. Einmal im Monat, aufgeschrieben, schlägt eine perfekte Absicht.

Das macht die Compose-Datei zum wertvollsten Objekt auf dem Server. Sie ist die einzige vollständige Beschreibung dessen, was diese Maschine ist, und der Wiederaufbau daraus sollte Routine sein statt ein Ausgrabungsprojekt — halten Sie sie in Versionskontrolle, halten Sie die Environment-Dateien direkt daneben, und bewahren Sie beides im verschlüsselten Backup außerhalb der Maschine auf, das Ihre Daten hält. Der Test für einen Container-Host ist nicht, ob er läuft. Er ist, ob Sie ihn identisch auf einem frischen VPS in etwa 15 min wieder aufstellen könnten.

SP·08

Logs, Volumes, und der Zustand, der den Container überlebt

Container werden als Wegwerfware beworben, was für den Prozess stimmt und für alles falsch ist, was er hinterlässt. Zwei Arten von Zustand sammeln sich auf einem Docker-Host an, und beide überraschen Leute im ungünstigsten Moment.

Das erste sind Logs. Der Standard-Treiber json-file erfasst jede Zeile, die Ihre Container auf stdout und stderr schreiben, und — sofern Sie ihm nichts anderes sagen — rotiert er sie nie. Eine geschwätzige Anwendung kann auf diese Weise in Wochen eine Platte füllen, und eine volle Platte auf einem Datenbank-Host ist ein eigener Vorfall. Setzen Sie einmalig eine Obergrenze, in /etc/docker/daemon.json, sodass sie für alles gilt, was Sie danach starten.

Dieselbe Einstellung hat auch eine Privacy-Lesart, und auf einem Server, den Sie wegen der Privatsphäre gewählt haben, ist sie womöglich die wichtigere. Ein nicht rotiertes Access-Log ist ein dauerhaftes, unindiziertes Protokoll der IP-Adresse, des User-Agents und des Anfragepfads jedes Besuchers, das auf einer Platte liegt, die Sie physisch nicht kontrollieren. Sie haben nicht entschieden, das zu behalten. Der Standard hat für Sie entschieden. Bewusst zu entscheiden heißt, die Aufbewahrung zu begrenzen und — besser noch — von vornherein weniger mitzuschreiben: Ein Log-Format am Proxy, das Client-Adressen weglässt oder kürzt, behält den operativen Nutzen und wirft die Haftung ab. Daten, die Sie nie geschrieben haben, können nicht beschlagnahmt, vorgeladen oder geleakt werden — dieselbe Überlegung, die Leute von vornherein dazu bringt, ihren Namen vom Server fernzuhalten.

Das zweite sind Volumes, und die scharfe Kante ist ein Flag. docker compose down stoppt und entfernt Container und lässt benannte Volumes unangetastet; docker compose down -v löscht sie, dauerhaft, mit derselben Selbstverständlichkeit. Datenbanken leben in diesen Volumes. Genauso alles andere, das Ihnen fehlen würde. Halten Sie Ihre Daten in benannten statt in anonymen Volumes, wissen Sie, welchen Befehl Sie um zwei Uhr morgens tippen, und denken Sie daran, dass docker system prune dafür existiert, Platz zurückzugewinnen — und Ihren genauso gern zurückgewinnt.

Bleibt das Backup selbst, wo die Container-Abstraktion ein letztes Mal in die Irre führt. Das Volume-Verzeichnis einer laufenden Datenbank zu kopieren ist kein Backup; es ist eine Kopie von Dateien, die gerade geschrieben wurden, während Sie sie lasen, und es stellt genau dann eine korrupte Datenbank wieder her, wenn Sie sie am dringendsten brauchen. Sichern Sie über die Engine — pg_dump, mysqldump, die eigene Snapshot-API der Engine —, verschlüsseln Sie dann das Ergebnis und schicken Sie es irgendwohin, wo Ihr Server nicht hinreicht. Dieser letzte Halbsatz ist der, auf den es ankommt, wenn der Eindringling bereits im Container sitzt: Ein Backup, das Ihr kompromittierter Host löschen kann, ist auch kein Backup.

SP·09

Schritt für Schritt

  1. 01

    Inventarisieren Sie, was die Box gerade veröffentlicht

    Bevor Sie irgendetwas ändern, schreiben Sie den aktuellen Zustand auf — Sie werden ihn später zum Vergleich brauchen. Lesen Sie die PORTS-Spalte für jeden Container, dann lesen Sie die NAT-Regeln, die Docker tatsächlich installiert hat, auf beiden IP-Familien. Alles, was ohne ein 127.0.0.1-Ziel ausgegeben wird, ist aus dem Internet erreichbar, was auch immer ufw status behauptet.

    docker ps --format 'table {{.Names}}\t{{.Ports}}'
    ss -tulpen | grep -E 'docker|LISTEN'
    sudo iptables  -t nat -S DOCKER      # -d 127.0.0.1/32 = loopback only
    sudo ip6tables -t nat -S DOCKER      # may not exist; that is an answer too
    sudo ufw status verbose              # what you believed was true

    Notieren Sie sich dabei gleich den Namen des öffentlichen Interfaces — die Firewall-Regeln später brauchen ihn, und er lautet nicht auf jedem Image eth0.

    ip route get 1.1.1.1 | awk '{print $5; exit}'
  2. 02

    Scannen Sie sich selbst von woanders

    Der Host kann sich nicht selbst auditieren: Loopback-Traffic kreuzt nie die Chains, die Sie eigentlich testen wollen. Führen Sie das von einer anderen Maschine in einem anderen Netzwerk aus — ein zweiter VPS ab $8.00/Monat in einer anderen Region ist die ehrliche Testbank, und er bleibt danach weiterhin nützlich. Scannen Sie beide Familien, denn sie werden getrennt konfiguriert.

    # from ANOTHER machine, against your server
    nmap -Pn -sS -p- --min-rate 1000 203.0.113.10
    nmap -Pn -6 -p 22,80,443,3306,5432,6379,9200,27017 2001:db8::1
    # no nmap? one port at a time is enough to settle an argument
    nc -zv 203.0.113.10 5432

    Jeder offene Port, der nicht 80, 443 oder Ihr SSH-Port ist, ist ein Fund. Bewahren Sie die Ausgabe auf; sie ist die "Vorher"-Hälfte des Nachweises, den Sie am Ende brauchen werden.

  3. 03

    Hören Sie auf zu veröffentlichen, was nicht öffentlich sein muss

    Jetzt beheben Sie es an der Quelle, in der Compose-Datei. Backend-Dienste ziehen auf ein privates Netzwerk um und verlieren ihre ports:-Zeile komplett — die Anwendung erreicht sie über den Servicenamen. Alles, was vom Host selbst aus erreichbar sein muss, bekommt eine explizite Loopback-Bindung statt eines nackten Ports. Markieren Sie das Backend-Netzwerk als internal, damit nichts darauf ungefragt mit dem Internet sprechen kann.

    services:
      db:
        image: postgres:17
        # ports: ["5432:5432"]   # deleted: the app reaches it as db:5432
        networks: [back]
        volumes: [dbdata:/var/lib/postgresql/data]
    
      app:
        image: myapp:1.4
        environment:
          DATABASE_URL: postgres://app@db:5432/app
        ports: ["127.0.0.1:8080:8080"]   # loopback only; proxy sits in front
        networks: [back, front]
    
    networks:
      front: {}
      back:
        internal: true
    
    volumes:
      dbdata: {}

    Erstellen Sie den Stack neu und bestätigen Sie, dass die NAT-Regeln ihre Form geändert haben — die DNAT-Zeile für die Anwendung sollte jetzt ein Loopback-Ziel tragen, und die Datenbank sollte überhaupt keine Zeile mehr haben.

    docker compose up -d
    docker ps --format 'table {{.Names}}\t{{.Ports}}'
    sudo iptables -t nat -S DOCKER
  4. 04

    Stellen Sie genau eine Sache vor die Öffentlichkeit

    Mit allem auf Loopback wird ein einziger Reverse Proxy zur alleinigen öffentlichen Oberfläche — der einzige Ort, an dem TLS terminiert wird, der einzige Ort, an dem je eine Client-Adresse gesehen wird, und der einzige Ort, an dem ein Log-Format entschieden wird. Betreiben Sie ihn auf dem Host oder in einem Container, der nur 80 und 443 veröffentlicht und sonst nichts; läuft er in einem Container, tritt er dem Netzwerk front bei und proxyt zu Servicenamen statt zu Loopback.

    # in the http{} block of /etc/nginx/nginx.conf — no client address recorded
    log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent';
    
    # /etc/nginx/conf.d/app.conf  (proxy on the host)
    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        server_name example.com;
    
        access_log /var/log/nginx/app.log privacy;
    
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    Wenn der Sinn der Übung ist, dass niemand erfährt, wo die Maschine steht, gehört der Proxy auf eine komplett andere Box — das ist das Setup in Die eigene Origin-IP hinter einem Reverse Proxy verbergen, und es lässt sich mit allem hier kombinieren.

  5. 05

    Schließen Sie die Lücke mit DOCKER-USER, auf beiden Familien

    Loopback-Bindungen reparieren die Container von heute; das hier repariert die, die Sie noch nicht geschrieben haben. Drei Regeln auf dem öffentlichen Interface, zuerst Conntrack, damit der ausgehende Traffic der Container weiterläuft, dann Ihre erlaubten Quellen, dann ein Drop. Testen Sie die ausgehende Verbindung eines Containers sofort nach dem Anwenden — das ist die Regel, die Leute verkehrt herum aufstellen.

    PUB=$(ip route get 1.1.1.1 | awk '{print $5; exit}')
    
    sudo iptables -I DOCKER-USER 1 -i "$PUB" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
    sudo iptables -I DOCKER-USER 2 -i "$PUB" -s 203.0.113.7/32 -j RETURN   # your admin address
    sudo iptables -I DOCKER-USER 3 -i "$PUB" -j DROP
    
    sudo iptables -S DOCKER-USER
    docker run --rm alpine sh -c 'apk update' >/dev/null && echo 'egress OK'

    Spiegeln Sie das für IPv6, falls der Daemon v6-Regeln verwaltet, und sorgen Sie dann mit einer Unit dafür, dass es einen Neustart übersteht, die läuft, nachdem Docker seine Chains neu aufgebaut hat.

    sudo ip6tables -S DOCKER-USER || echo 'no v6 chain: verify v6 reachability by scanning'
    
    # /etc/systemd/system/docker-user-rules.service
    [Unit]
    After=docker.service
    Requires=docker.service
    
    [Service]
    Type=oneshot
    ExecStart=/usr/local/sbin/docker-user-rules.sh
    RemainAfterExit=yes
    
    [Install]
    WantedBy=multi-user.target
  6. 06

    Reduzieren Sie Rechte in den Containern, und deckeln Sie die Logs

    Reduzieren Sie, was ein kompromittiertes Image anrichten kann, und verhindern Sie, dass der Host sich mit einem Protokoll füllt, das niemand bewusst behalten wollte. Die vier Container-Einstellungen kosten bei einem normalen Webdienst nichts; die Log-Obergrenze gilt für jeden Container, der nach dem Neuladen des Daemons startet.

    services:
      app:
        image: myapp:1.4
        user: "1000:1000"
        read_only: true
        tmpfs: [/tmp]
        cap_drop: [ALL]
        security_opt: ["no-new-privileges:true"]
        # never: volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
    # /etc/docker/daemon.json
    {
      "log-driver": "json-file",
      "log-opts": { "max-size": "10m", "max-file": "3" }
    }
    sudo systemctl reload docker
    docker inspect --format '{{.HostConfig.LogConfig}}' app
  7. 07

    Verifizieren Sie von außen, dann schreiben Sie das Runbook

    Starten Sie das System absichtlich neu, während noch nichts von der Antwort abhängt, und beweisen Sie, dass der Zustand, den Sie gebaut haben, der Zustand ist, der zurückkommt. Wiederholen Sie danach den externen Scan aus Schritt zwei und vergleichen Sie ihn mit der Ausgabe, die Sie aufbewahrt haben — dieser Vergleich ist das Ergebnis, nicht die Befehle.

    sudo reboot
    
    # after it returns:
    sudo iptables -S DOCKER-USER          # rules reapplied?
    docker ps --format 'table {{.Names}}\t{{.Ports}}'
    
    # from ANOTHER machine again:
    nmap -Pn -p- 203.0.113.10
    nmap -Pn -6 -p- 2001:db8::1

    Schreiben Sie sich fünf Zeilen irgendwohin, wo Sie sie wiederfinden: an welches Interface die Regeln gebunden sind, wo die Unit liegt, welche Dienste bewusst öffentlich sind, wann Sie die Images zuletzt neu gebaut haben, und wie Sie die Volumes wiederherstellen. Ein Container-Host ist nur so gut wie die Beschreibung, die Sie ihn nach dem schlimmsten Tag wieder aufbauen lässt.

SP·10 — FAQ

Schnelle Antworten

Macht ufw auf einem Docker-Host überhaupt noch etwas?

Ja, und Sie sollten es behalten. Es regelt weiterhin alles, was an den Host selbst adressiert ist — SSH, ein Proxy, der außerhalb von Docker läuft, jeder Daemon, den Sie über den Paketmanager installiert haben —, und das ist ein großer Teil dessen, was einen kleinen Server angreift. Was es nicht regelt, ist Traffic, den Docker per DNAT zu einem Container umleitet, denn dieser Traffic wird weitergeleitet statt lokal zugestellt und erreicht nie die Chain, die ufw filtert. Zwei Werkzeuge, zwei Pfade: ufw für den Host, DOCKER-USER für die Container.

Reicht die Bindung an 127.0.0.1 allein schon aus?

Sie reicht für diesen Port, auf dieser Familie, auf diesem Container — was genau ihre Schwäche als Strategie ist. Sie hängt davon ab, dass jedes zukünftige -p von jeder Person und jedem kopierten Snippet korrekt geschrieben wird, und sie schränkt nur IPv4 ein. Sie tut außerdem nichts zwischen Containern: Alles im selben Bridge-Netzwerk erreicht einen loopback-gebundenen Nachbarn weiterhin. Nutzen Sie sie als Standardgewohnheit, stellen Sie DOCKER-USER dahinter als die Richtlinie, die nicht davon abhängt, dass sich jemand erinnert, und teilen Sie Netzwerke auf, wenn der Schadensradius zählt.

Kann ich einfach "iptables": false setzen und die Firewall selbst verwalten?

Das können Sie, und auf einem einzelnen VPS sollten Sie es mit ziemlicher Sicherheit nicht. Dieser Schalter schenkt Ihnen kein unbeschriebenes Blatt — er stoppt, dass Docker die NAT-, Masquerading- und Isolations-Regeln konfiguriert, die Container-Networking überhaupt erst funktionieren lassen, sodass Sie diesen Job von Hand erben, für jeden Container und jedes Netzwerk, das Sie je anlegen. Das Ergebnis ist meist entweder kaputter ausgehender Traffic oder ein Regelwerk, das sich still und leise von dem entfernt, was tatsächlich läuft. Lassen Sie den Daemon seine Chains besitzen, und übernehmen Sie DOCKER-USER — genau die Stelle, die er Ihnen bewusst offengelassen hat.

Vermeiden rootless Docker oder Podman das Problem?

Weitgehend, und aus zwei getrennten Gründen. Veröffentlichte Ports werden von einem gewöhnlichen Userland-Prozess gehalten, der einem unprivilegierten Benutzer gehört, sodass sie als Traffic zum Host ankommen und Ihre ufw-Regeln so greifen, wie Sie es immer erwartet haben. Und ein Container-Escape landet den Angreifer als dieser unprivilegierte Benutzer statt als root. Der Preis ist, dass sich manche Capabilities, Storage-Treiber und Netzwerk-Arrangements anders verhalten oder gar nicht verfügbar sind, und dass Ports unterhalb von 1024 zusätzliche Konfiguration brauchen. Wenn Sie eine neue Box aufsetzen und Ihre Workloads gewöhnliche Webdienste sind, ist das eine ernsthafte Option. Wenn Sie ein laufendes System auditieren, beheben Sie zuerst die Chain und erwägen Sie eine Migration getrennt davon.

Mein VPS liegt hinter Ihrem Netzwerk-Scrubbing. Deckt das nicht schon alles ab?

Nein, und es lohnt sich, diese Unterscheidung präzise zu treffen. Jede Maschine in der Flotte liegt hinter 1.5 Tbps permanentem Scrubbing, das volumetrische Angriffe abfängt — es liest die Form des Traffics, nicht die Absicht, und es kann nicht wissen, dass der von Ihnen veröffentlichte Datenbank-Port nicht öffentlich gemeint war. Eine einzelne wohlgeformte Verbindung zu einem exponierten PostgreSQL ist auf dieser Schicht nicht von legitimem Traffic zu unterscheiden, denn aus Sicht des Netzwerks ist es legitimer Traffic: Sie haben den Port veröffentlicht. Vorgelagertes Scrubbing hält Ihren Server erreichbar. Was darauf erreichbar ist, bleibt Ihre Konfiguration.

Woher weiß ich, dass es jetzt wirklich geschlossen ist?

Nur ein Test zählt: die Verbindung von einer Maschine, die nicht Ihnen gehört, in einem Netzwerk, das nicht Ihres ist, auf beiden IP-Familien, nach einem Neustart. Lokale Prüfungen sind systematisch zu optimistisch — Loopback-Traffic überspringt die fraglichen Chains, und eine stille ss-Ausgabe bedeutet nichts, wenn der Userland-Proxy deaktiviert ist und der Kernel das Forwarding ohne lauschenden Socket erledigt. Scannen Sie den vollen Portbereich statt der Ports, die Sie erwarten, bewahren Sie die Ausgabe neben dem Runbook auf, und wiederholen Sie es nach jeder Änderung, die einen Container hinzufügt.

In die Praxis umsetzen

VPS in 15 min online, dedizierter Server übergeben in 2–12 h. Laden Sie ab $30.00 in Krypto auf — ohne angehängte Identität.

Einen VPS bereitstellen