SSD Nodes Learn 🎉 VPS ab $5.50/Monat
Anleitungen Matt ConnorVon Matt Connor

Headless Chromium auf einem VPS für KI-Agenten betreiben

Headless Chromium auf einem VPS scheitert oft an kleinem /dev/shm, Sandbox-Flags, fehlenden Fonts und übrig gebliebenen Prozessen. Setzen Sie Limits vorher.

Was Sie ausführen

Ein Headless-Browser auf einem VPS ist Chromium ohne Fenster. Ihr Code steuert ihn anstelle einer Person. Auf einem Server handelt es sich um einen langlebigen Prozessbaum, mit dem Ihr Agent über einen lokalen Socket kommuniziert. Die Installation erfordert einen Befehl. Die eigentliche Arbeit beginnt danach. Sie begrenzen, wie viele Ressourcen der Browser vom System verwenden darf. Außerdem halten Sie seinen Steuerungsendpunkt vom öffentlichen Internet fern.

Diese Anleitung setzt voraus, dass die Auswahl des Tools bereits getroffen wurde und Sie es nun betreiben müssen. Wenn Sie noch Crawler und Extractors vergleichen, beginnen Sie mit den selbst gehosteten Firecrawl-Alternativen und kehren Sie anschließend hierher zurück. Im Folgenden wird Playwrights Chromium verwendet. Playwright liefert einen eigenen Browser-Build und ein eigenes Installationsprogramm für Abhängigkeiten. Daher funktionieren dieselben Befehle auf einem unveränderten Ubuntu-VPS und in einem Container. Die Versionen entsprechen dem Stand von August 2026.

Chromium installieren, ohne Abhängigkeiten zu erraten

npm i -D playwright@1.62.0
npx playwright install --with-deps chromium

--with-deps prüft mit apt, welche Shared Libraries und Schriftarten Chromium benötigt, und fordert an dieser Stelle Root-Rechte an. Der Browser-Build selbst wird für den Benutzer, der den Befehl ausgeführt hat, nach ~/.cache/ms-playwright heruntergeladen. Das ist auf einem Server relevant, weil der Dienstbenutzer normalerweise nicht dem Benutzer entspricht, mit dem Sie sich anmelden. Installieren Sie die Systempakete einmal als Administrator mit sudo npx playwright install-deps chromium. Setzen Sie anschließend PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers sowohl im Installationsbefehl als auch in der Service-Unit, damit eine Kopie gemeinsam verwendet wird. Ein Dienst, der seinen Browser nicht sehen kann, schlägt beim Start mit einer Meldung fehl, die den gesuchten Pfad nennt.

Legen Sie die Playwright-Version fest. Jede Version ist an einen Browser-Build gebunden. Ein nicht festgelegtes npm update kann daher den Browser unter einem laufenden Dienst austauschen. Playwright 1.62 ist im August 2026 aktuell.

Es gibt zwei Chromium-Builds. Sie sind nicht dasselbe Programm. Der standardmäßige Download ist die Headless-Shell. Dabei handelt es sich um eine kleinere Binärdatei, die nur im Headless-Modus ausgeführt wird. npx playwright install --with-deps --only-shell installiert ausschließlich diese Shell. Den vollständigen Browser erhalten Sie mit dem Kanal chromium. In der Browser-Dokumentation von Playwright wird er als „der echte Chrome-Browser und damit authentischer, zuverlässiger und funktionsreicher“ bezeichnet. Verwenden Sie die Shell für umfangreiche Abrufe. Verwenden Sie den vollständigen Browser, wenn sich eine Website anders verhält und Sie die Ursache ermitteln müssen.

Warum ein Headless-Browser in einem Container abstürzt

Docker weist jedem Container einen 64 MB großen /dev/shm zu. Die Docker-Dokumentation ist eindeutig: „Wenn Sie die Größe vollständig weglassen, verwendet das System 64m.“ Chromium überträgt gerenderte Inhalte über diesen gemeinsamen Speicher zwischen seinen Prozessen. Eine einzelne umfangreiche Seite kann ihn daher vollständig belegen. Der Renderer beendet sich anschließend, und Ihr Client meldet ein abgestürztes Ziel, obwohl die Seite auf Ihrem Laptop problemlos funktioniert. Prüfen Sie die Größe aus dem Container heraus, bevor Sie Änderungen vornehmen.

df -h /dev/shm

Es gibt zwei tatsächliche Lösungen. Sie sind Alternativen und werden nicht gemeinsam verwendet. --ipc=host nimmt den Container in den IPC-Namensraum des Hosts auf. Dadurch verwendet er den /dev/shm des Hosts, der normalerweise der Hälfte des Arbeitsspeichers entspricht. Der Docker-Leitfaden von Playwright empfiehlt diese Option, weil „Chromium andernfalls nicht genügend Arbeitsspeicher haben und abstürzen kann“. Der Nachteil ist, dass die IPC-Isolierung zwischen Container und Host entfällt. --shm-size=1g behält den privaten Namensraum bei und vergrößert lediglich den Mount.

docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bash

Das Flag --disable-dev-shm-usage ist die Antwort, die Sie in den meisten Suchergebnissen finden werden. Es bewirkt jedoch etwas anderes: Die Dateien werden aus /dev/shm in ein temporäres Verzeichnis verschoben. Befindet sich /tmp auf einer Festplatte, ersetzen Sie den Absturz durch eine langsamere Darstellung und zusätzliche Schreibzugriffe. Ist /tmp ein tmpfs, liegen die Daten wieder im RAM, nun jedoch vollständig ohne Größenlimit. So kann ein Browser einen kleinen VPS überlasten. Legen Sie die Größe von /dev/shm stattdessen korrekt fest.

Was tatsächlich kostet

Chromium isoliert jeden Renderer in einer Sandbox, die auf Linux User Namespaces basiert. Diese Sandbox bildet die Grenze zwischen einer nicht vertrauenswürdigen Seite und Ihrem Server. Wenn sie nicht gestartet werden kann, verweigert Chromium den Betrieb. Im Log steht dann eine Zeile wie diese:

Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted

Der übliche Ratschlag lautet --no-sandbox. Die Sicherheitshinweise von Chromium benennen die Folgen ausdrücklich: Das Flag „deaktiviert kritische Sicherheitsfunktionen von Chromium und sollte beim Browsen im offenen Web niemals verwendet werden“. Ein Agent, der Links folgt, browsed definitionsgemäß im offenen Web. Ermitteln Sie die tatsächliche Ursache.

Zwei Ursachen decken fast alle Fälle ab. Wenn der Browser als root ausgeführt wird, ist die Sandbox deaktiviert, weil der Prozess keine bereits vorhandenen Berechtigungen abgeben kann. Deshalb enthält das Playwright-Image einen normalen Benutzer namens pwuser. Unter Ubuntu 24.04 und höher beschränkt AppArmor nicht privilegierte User Namespaces. Eine Chromium-Binärdatei an einem Pfad, den kein mitgeliefertes Profil abdeckt, wird dann abgewiesen. Der Download von Playwright unter ~/.cache/ms-playwright liegt genau an einem solchen Pfad. Prüfen Sie beides:

id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_create

Ein 1 aus dem sysctl und eine Kernelzeile mit apparmor="DENIED" operation="userns_create" bestätigen die zweite Ursache. Erlauben Sie diese eine Binärdatei in /etc/apparmor.d/pw-chromium. Die Einschränkung bleibt dadurch für alle anderen Dateien auf dem System bestehen:

abi <abi/4.0>,
include <tunables/global>

profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
  userns,
}

Laden Sie die Regel mit sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. Der Pfad enthält die Browser-Revision und ändert sich daher bei jedem Playwright-Upgrade. Die obigen Globs funktionieren auch dann weiter. Ein Profil, das auf einen exakt festgelegten Pfad ausgelegt ist, passt nach einer Änderung nicht mehr. Nach einem scheinbar unabhängigen Update schlägt der Browser dann wieder fehl.

Warum Screenshots leer oder voller Kästchen zurückkommen

Ein leerer Screenshot oder ein Screenshot voller leerer Rechtecke weist normalerweise auf ein Schriftproblem und nicht auf einen Rendering-Fehler hin. install-deps verwendet eine funktionierende Basis: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic für Japanisch, fonts-wqy-zenhei für Chinesisch und fonts-tlwg-loma-otf für Thai. Noto CJK ist in diesem Satz nicht enthalten. Koreanisch und mehrere andere Schriftsysteme greifen daher auf die Schrift zurück, die fontconfig findet. Fragen Sie fontconfig ab, statt zu raten:

fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -l

Wenn die gewünschte Sprache auf unifont oder auf eine Fallback-Schrift ohne echte Glyphen aufgelöst wird, installieren Sie fonts-noto-core und fonts-noto-cjk und führen Sie die Prüfung erneut aus. Fontconfig speichert die Ergebnisse im Cache. Starten Sie den Browser nach der Installation der Schriften neu. Ein reduziertes Image ohne Schriften protokolliert beim Start Fontconfig error: Cannot load default config file und rendert jede Seite leer.

Locale und Zeitzone sind von den Schriften unabhängig. Sie ändern, was auf der Seite steht, und nicht nur das Erscheinungsbild. Ein Container hat LANG normalerweise nicht gesetzt und verwendet TZ als UTC. Websites liefern dann englische Inhalte und geben Zeitstempel in UTC aus. Außerdem meldet Ihr Agent Zeiten, die nicht mit der Darstellung für eine Person in diesem Land übereinstimmen. Setzen Sie diese Werte pro Browser-Kontext und nicht pro Rechner. So kann ein Browser Aufgaben für verschiedene Regionen ausführen.

const context = await browser.newContext({
  locale: 'en-GB',
  timezoneId: 'Europe/Paris',
});

Warum verwaiste Browser-Prozesse den Server in den Swap zwingen

Zwei unterschiedliche Probleme werden als „Zombie“ bezeichnet. Ein echter Zombie ist ein beendeter Prozess, dessen übergeordneter Prozess wait() nie aufgerufen hat. Er belegt einen PID-Eintrag und sonst nichts. Daher verbraucht er keinen Arbeitsspeicher. Solche Prozesse sammeln sich an, wenn der Browser in einem Container als PID 1 läuft, weil PID 1 standardmäßig keine Prozesse bereinigt. Das Docker-Flag --init behebt genau dieses Problem. Es startet einen kleinen Init-Prozess, der „Signale weiterleitet und Prozesse bereinigt“. In Compose heißt die entsprechende Einstellung init: true.

Das Leck, das den Server tatsächlich in den Swap zwingt, ist ein anderes Problem: laufende Chromium-Prozesse, die niemand beendet. Es tritt auf, wenn eine Aufgabe zwischen newContext() und close() eine Ausnahme auslöst oder wenn das steuernde Skript beendet wird und seinen Browser-Prozessbaum verwaist zurücklässt. Besonders problematisch ist Code, der für jede Anfrage einen neuen Browser startet. Zählen Sie die Prozesse:

pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20

Dieser Wert sollte zwischen den Aufgaben auf den Leerlaufwert zurückgehen. Wenn er im Laufe eines Tages steigt, liegt die Ursache in Ihrem Code und nicht in den Startoptionen: Schließen Sie den Kontext in einem finally-Block, schließen Sie den Browser bei SIGTERM und starten Sie den Browser nach einer festen Anzahl von Aufgaben neu, statt ihn einen Monat lang laufen zu lassen. Unter systemd beendet ein Stop oder Restart alle Prozesse in der cgroup der Unit. Daher ist sudo systemctl restart browser.service ein zuverlässiger Reset. Ein manuell in einem Terminal-Multiplexer gestarteter Browser bietet diese Garantie nicht. Seine verwaisten Prozesse laufen über die Sitzung hinaus weiter.

Wie viel RAM benötigt ein Browserkontext?

Stellen Sie die Frage präzise, denn „ein Browser“ ist kein einzelner Prozess. Chromium führt einen Browserprozess, einen GPU-Prozess, Utility-Prozesse und pro Website einen Renderer-Prozess aus. Durch die Site-Isolation erhalten Cross-Site-Iframes ebenfalls einen eigenen Renderer. Ein BrowserContext ist innerhalb dieses Prozessbaums ein separater Cookie-Speicher und Speicherbereich. Ein zweiter Kontext benötigt daher wenig zusätzlichen Speicher. Eine zweite Seite ist etwas anderes, weil sie Renderer-Prozesse startet. Eine Seite mit vielen Anzeigen startet mehrere davon.

Messen müssen Sie daher den maximalen Speicherbedarf des gesamten Prozessbaums unter Ihrer eigenen Arbeitslast. Ein Wert aus dem Blog einer anderen Person ist hier nicht aussagekräftig, weil die vom Agent geöffneten Seiten den Bedarf bestimmen. Messen Sie auf der Maschine, die Sie verwenden werden, und mit den Websites, die Sie besuchen:

sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probe

Unter Ubuntu 24.04 gibt die Zeile Memory: in dieser Ausgabe sowohl die aktuelle als auch die maximale Nutzung der Unit an. Führen Sie den Worker zunächst mit jeweils einer Seite aus und notieren Sie den Spitzenwert. Wiederholen Sie die Messung anschließend mit zwei geöffneten Seiten, um den tatsächlichen zusätzlichen Bedarf einer zweiten Seite zu ermitteln. Die mögliche Parallelität lässt sich dann berechnen: Ziehen Sie vom gesamten RAM den Bedarf des restlichen Systems ab, lassen Sie einige hundert MB Reserve und teilen Sie den verbleibenden Wert durch den gemessenen Spitzenwert pro Worker. Informationen zur Dimensionierung der zugrunde liegenden Maschine finden Sie unter wie viel RAM und CPU ein Agent-VPS benötigt.

Erzwingen Sie diese Anzahl an zwei Stellen. Verwenden Sie in Ihrem Code einen festen Worker-Pool oder ein Semaphore, damit eine Anfragespitze in die Warteschlange gestellt wird, anstatt weitere Browser zu starten. Begrenzen Sie den Prozess außerdem im Betriebssystem mit einem cgroup-Limit. Dadurch kann ein Fehler in der Warteschlange nicht die gesamte Maschine beeinträchtigen:

[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=always

MemorySwapMax=0 ist wichtiger, als es zunächst scheint. Ohne diese Einstellung verschiebt die cgroup Seiten in den Swap, sobald das Limit erreicht ist. Dadurch bleibt das System verfügbar, während jede Anfrage langsamer wird. Das ist schwieriger zu diagnostizieren als ein sauberer Fehler. Mit dieser Einstellung beendet der Kernel den Browserprozessbaum innerhalb dieser cgroup. systemd startet die Unit neu, und sshd bleibt erhalten. Die entsprechenden Einstellungen in Compose sind mem_limit, shm_size und init. Sie werden unter Speicherlimits in Docker Compose festlegen erläutert.

Browser-Endpunkt nicht im öffentlichen Internet bereitstellen

Playwright kann den Browser als Server ausführen und Ihrem Agent eine WebSocket-URL übergeben:

const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());

Dieser Endpunkt verfügt über keine Anmeldung. Die API-Dokumentation von Playwright sagt es direkt: „Jeder Prozess und jede Webseite (einschließlich solcher, die in Playwright ausgeführt werden), die wsPath kennt, kann die Kontrolle über den Betriebssystembenutzer übernehmen.“ Der Standard-Host ist localhost, „der Verbindungen nur über das Loopback-Interface akzeptiert“. Die Dokumentation warnt außerdem, dass die Angabe einer expliziten Adresse wie 0.0.0.0 „den Browser-RPC für alles offenlegt, was den Listening-Port erreichen kann“. Chromes eigenes --remote-debugging-port ist noch problematischer. Das DevTools-Protokoll bietet keinerlei Authentifizierung und setzt vollständig darauf, an das Loopback-Interface gebunden zu sein.

Prüfen Sie, welche Dienste tatsächlich veröffentlicht sind. Führen Sie die Prüfung zusätzlich von einem zweiten Rechner und vom VPS aus:

ss -ltnp

Alles, was an einem Browser-Port auf 0.0.0.0 gebunden ist, stellt einen Befund dar. Denken Sie daran, dass die meisten Provider eine separate Netzwerk-Firewall in ihrem Control Panel betreiben. Ihre ufw-Regeln kennen diese Firewall nicht. Erreichen Sie den Endpunkt stattdessen von einem anderen Rechner über einen SSH-Tunnel oder ein privates VPN:

ssh -N -L 3000:127.0.0.1:3000 you@your-vps

Das Risiko besteht nicht nur darin, dass jemand Browserzeit stiehlt. Ein steuerbarer Browser ist eine Maschine zur Anforderungsfälschung innerhalb Ihres Netzwerks. Jeder, der diesen Socket erreicht, kann ihn http://127.0.0.1:8080, Ihre Datenbankverwaltungsseite oder die Cloud-Metadatenadresse unter 169.254.169.254 abrufen lassen und anschließend die Antwort aus der Seite auslesen. Ihre Firewall sieht eine Anfrage, die vom VPS selbst kommt, und lässt sie zu. Behandeln Sie den Steuerungsendpunkt so, als entspräche er Shell-Zugriff auf diesem Host.

Für MCP-Server gilt dasselbe Muster. npx @playwright/mcp@latest --headless --port 8931 stellt HTTP über localhost bereit. --host 0.0.0.0 ist das Flag, das aus einem lokalen Tool einen öffentlichen Dienst macht. Die README des Projekts sagt ausdrücklich, dass Playwright MCP „keine Sicherheitsgrenze“ darstellt. Belassen Sie den Port auf dem Loopback-Interface und lassen Sie den Agent über denselben Tunnel darauf zugreifen.

Die von Ihrem Agenten gelesenen Seiten sind nicht vertrauenswürdige Eingaben

Ein Agent, der das offene Web durchsucht, führt Text von unbekannten Dritten einem Modell zu, das zugleich Ihre Anweisungen verarbeitet. Eine Seite kann Text enthalten, der an dieses Modell gerichtet ist und es auffordert, die Aufgabe abzubrechen, ein Tool aufzurufen oder Daten an eine URL zu senden. Das Modell erhält beides als Text und kann daher nicht zuverlässig zwischen den Worten einer Seite und Ihren Anweisungen unterscheiden. Richten Sie die Umgebung so ein, dass eine schädliche Seite möglichst wenige Zugriffsmöglichkeiten hat.

  • Führen Sie den Browser unter einem eigenen OS-Benutzer aus. In dessen Umgebung dürfen sich weder SSH-Schlüssel noch Cloud-Anmeldedaten befinden.
  • Verwenden Sie pro Aufgabe einen neuen Kontext und --isolated mit Playwright MCP, damit eine Sitzung auf einer Website der nächsten Seite nicht zur Verfügung steht.
  • Führen Sie eine Allowlist für Origins, sofern der Auftrag dies zulässt. Playwright MCP übernimmt dafür --allowed-origins und --blocked-origins als durch Semikolons getrennte Listen.
  • Verlangen Sie vor jeder statusändernden Aktion einen manuellen Bestätigungsschritt, etwa vor dem Senden von E-Mails oder dem Ausgeben von Geld.

Noch besser ist es, den gesamten Browser auf einem Rechner auszuführen, den Sie verwerfen und neu aufbauen können. Das ist dasselbe Argument wie bei Coding-Agenten in einer verworfenen VM ausführen. Wenn die eigentliche Aufgabe des Agenten in der Suche und nicht im uneingeschränkten Browsing besteht, ist ein enger gefasstes Tool sicherer als ein vollständiger Browser: Ein Search Skill mit Ihrem eigenen SearXNG im Hintergrund liefert Ergebnisse, ohne die schädliche Seite jemals zu laden.

FAQ

Warum stürzt Chromium in Docker ab, funktioniert aber direkt auf demselben VPS problemlos?

Der Container erhält standardmäßig eine 64 MB große /dev/shm, während der Host über eine wesentlich größere verfügt. Chromium verarbeitet gerenderte Inhalte über diesen gemeinsam genutzten Speicherbereich. Eine umfangreiche Seite füllt ihn daher, und der Renderer-Prozess beendet sich. Führen Sie im Container df -h /dev/shm aus, um dies zu bestätigen. Starten Sie den Container anschließend entweder mit --ipc=host, das den gemeinsamen Speicher des Hosts verwendet, oder mit --shm-size=1g, das den eigenen Speicher des Containers vergrößert. --disable-dev-shm-usage verlagert das Problem lediglich nach /tmp.

Ist --no-sandbox sicher, wenn auf dem VPS keine anderen Dienste laufen?

Nein. Die Sandbox verhindert, dass eine bösartige Seite auf den restlichen Rechner zugreift. In der Chromium-Dokumentation heißt es, dass das Flag „kritische Sicherheitsfunktionen von Chromium deaktiviert und beim Browsen im offenen Web niemals verwendet werden sollte“. Ein Agent, der Links folgt, browsed im offenen Web. Beheben Sie stattdessen die Ursache: Führen Sie den Browser nicht als root aus. Fügen Sie unter Ubuntu 24.04 außerdem ein AppArmor-Profil mit userns, für den Pfad zur Browser-Binärdatei hinzu. Dadurch werden nicht privilegierte User Namespaces nur für dieses Programm erlaubt.

Wie viele Browser kann ich auf einem kleinen VPS ausführen?

Messen Sie den Bedarf, statt eine Zahl zu übernehmen. Chromium startet pro Website einen Renderer-Prozess. Die benötigte Anzahl hängt daher von den geöffneten Seiten ab. Führen Sie einen Worker unter systemd-run aus, wobei MemoryMax gesetzt ist. Lesen Sie den Spitzenwert aus der Zeile Memory: in systemctl status ab. Teilen Sie anschließend den freien RAM durch diesen Spitzenwert und lassen Sie eine Reserve. Begrenzen Sie das Ergebnis auf zwei Ebenen: mit einer Warteschlange im Code und mit einem MemoryMax in der Unit-Datei. Dadurch warten Anfragen bei einer Lastspitze, statt den Rechner zum Swapping zu zwingen.

Kann mein Agent von einem anderen Rechner aus eine Verbindung zum Browser herstellen?

Ja, aber binden Sie den Port niemals an 0.0.0.0. Sowohl der Playwright-Server-Endpunkt als auch der Chrome-DevTools-Port akzeptieren jeden Client, der sie erreichen kann, und verwenden kein Passwort. Lassen Sie den Listener an 127.0.0.1 gebunden und übertragen Sie die Verbindung über einen SSH-Tunnel oder ein privates VPN. Prüfen Sie den Server mit ss -ltnp und führen Sie von außerhalb eine Portprüfung durch. Überprüfen Sie außerdem die separate Netzwerk-Firewall Ihres Providers.

Warum sind meine Screenshots leer, obwohl die Seite eindeutig geladen wurde?

Es fehlen Schriftarten. Wenn keine Schriftart das Skript der Seite abdeckt, wird Text als leere Kästchen oder überhaupt nicht gerendert. Eine Seite mit wenigen Bildern wirkt im Screenshot dann leer. Führen Sie fc-match "sans-serif:lang=ko" für jede Sprache aus, die Sie auslesen. Installieren Sie fonts-noto-core und fonts-noto-cjk, wenn das Ergebnis auf eine generische Fallback-Schriftart verweist. Starten Sie den Browser anschließend neu, damit fontconfig seinen Cache neu lädt. Ein Container ohne Schriftarten protokolliert beim Start Fontconfig error: Cannot load default config file.

#headless-browser#playwright#chromium#ai-agents#automation