SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

VPS 上執行 AI 代理程式的無頭瀏覽器

VPS 上的 Headless Chromium 常因 /dev/shm 太小、sandbox 旗標、缺少字型或程序洩漏而中斷。本文以 Playwright 1.62 說明安裝、限制資源並保護控制端點。

執行內容

VPS 上的無頭瀏覽器是沒有視窗的 Chromium,由程式碼而非人員操作。在伺服器上,它是長時間執行的程序樹,代理程式透過本機 socket 與其通訊。安裝只需執行一個命令。真正的工作在這之後。您必須限制瀏覽器可使用的系統資源,並避免將其控制端點暴露在公用網際網路上。

本指南假設您已決定使用哪個工具,現在需要負責其運作。如果您仍在比較 crawler 與 extractor,請先從 自架 Firecrawl 替代方案開始,之後再回到此處。以下內容都使用 Playwright 的 Chromium,因為 Playwright 會提供自己的瀏覽器組建與相依套件安裝程式。因此,相同的命令可在全新安裝的 Ubuntu VPS 及容器內執行。版本截至 August 2026 為最新版本。

Install Chromium without guessing at dependencies

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

--with-deps runs apt for the shared libraries and fonts Chromium needs, and asks for root when it gets there. The browser build itself downloads into ~/.cache/ms-playwright for the user who ran the command. That matters on a server, because the service user is usually not the user you log in as. Install the system packages once as an admin with sudo npx playwright install-deps chromium, then set PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers in both the install command and the service unit so one copy is shared. A service that cannot see its browser fails at launch with a message naming the path it searched.

Pin the Playwright version. Each release is tied to one browser build, so an unpinned npm update can swap the browser under a running service. Playwright 1.62 is current as of August 2026.

Two Chromium builds exist and they are not the same program. The default download is the headless shell, a smaller binary that only runs headless, and npx playwright install --with-deps --only-shell installs that alone. The full browser is what you get with the chromium channel, which Playwright's browser docs call "the real Chrome browser, and is thus more authentic, reliable, and offers more features". Use the shell for bulk fetching. Use the full browser when a site behaves differently and you need to find out why.

容器中的無頭瀏覽器為何會當機

Docker 會為每個容器提供 64 MB 的 /dev/shm。Docker 文件明確指出:「如果完全省略大小,系統會使用 64m。」Chromium 會透過這個共享記憶體區域,在各個程序之間傳遞已渲染的內容,因此單一大型頁面就可能將其填滿。接著 renderer 會終止,而用戶端會回報 target 已當機,即使該頁面在筆記型電腦上運作正常。變更任何設定前,先從容器內確認其大小。

df -h /dev/shm

有 2 種真正的修正方式,兩者是替代方案,不應同時使用。--ipc=host 會讓容器加入主機的 IPC namespace,因此使用主機的 /dev/shm;該區域通常是 RAM 的一半。Playwright 的 Docker 指南 建議採用此方式,因為不使用它時,「Chromium 可能耗盡記憶體並當機」。代價是容器與主機之間不再具備 IPC 隔離。--shm-size=1g 則保留私有 namespace,只將掛載區域調大。

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

--disable-dev-shm-usage 是大多數搜尋結果會提供的答案,但它的作用不同:它會將這些檔案從 /dev/shm 移到暫存目錄。如果 /tmp 位於磁碟上,這只是將當機換成較慢的渲染速度與磁碟寫入。如果 /tmp 是 tmpfs,資料仍會回到 RAM,且完全沒有大小限制;這可能導致瀏覽器吃光小型 VPS 的記憶體。請改為正確設定 /dev/shm 的大小。

--no-sandbox 實際上會付出什麼代價

Chromium 會透過 Linux user namespaces,將每個 renderer 隔離在 sandbox 中。這個 sandbox 是惡意網頁與伺服器之間的邊界。sandbox 無法啟動時,Chromium 會拒絕執行,日誌中會出現類似以下內容:

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

常見建議是 --no-sandbox。Chromium 的安全性文件明確說明其代價:這個旗標「會停用 Chromium 的關鍵安全功能,瀏覽公開網路時絕不應使用」。會依循連結的 agent 本質上就是在瀏覽公開網路。請找出真正原因。

幾乎所有情況都可歸結為兩個原因。以 root 執行瀏覽器會停用 sandbox,因為程序無法放棄自己已經擁有的權限。這也是 Playwright 的 image 會提供名為 pwuser 的一般使用者帳號的原因。在 Ubuntu 24.04 及後續版本中,AppArmor 會限制非特權 user namespaces;若 Chromium binary 位於任何已提供 profile 都未涵蓋的路徑,也會遭到拒絕。Playwright 下載的檔案位於 ~/.cache/ms-playwright,正是這類路徑。請檢查兩者:

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

sysctl 的 1 加上包含 apparmor="DENIED" operation="userns_create" 的 kernel 訊息,即可確認第二個原因。在 /etc/apparmor.d/pw-chromium 中只允許該 binary,讓系統上的其他程式維持原有的限制:

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

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

使用 sudo apparmor_parser -r /etc/apparmor.d/pw-chromium 載入設定。該路徑包含 browser revision,因此每次 Playwright 升級都會變更。上述 glob 可持續涵蓋這些路徑。若 profile 僅針對某個精確路徑建立,路徑會在升級後停止匹配,瀏覽器也會在一次看似無關的更新後再次啟動失敗。

為什麼螢幕擷取結果是空白或充滿方框

空白的螢幕擷取結果,或充滿空矩形的結果,通常是字型問題,而不是渲染錯誤。install-deps 會載入可運作的基礎字型:fonts-liberationfonts-freefont-ttffonts-noto-color-emojifonts-unifont;日文使用 fonts-ipafont-gothic,中文使用 fonts-wqy-zenhei,泰文使用 fonts-tlwg-loma-otf。這組字型不包含 Noto CJK,因此韓文和其他數種文字系統會改用 fontconfig 找到的字型。請直接查詢 fontconfig,不要自行猜測:

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

如果需要的語言解析到 unifont,或解析到沒有實際字形的後備字型,請安裝 fonts-noto-corefonts-noto-cjk,然後再次執行檢查。fontconfig 會快取結果,因此安裝字型後請重新啟動瀏覽器。完全沒有字型的精簡映像檔會在啟動時記錄 Fontconfig error: Cannot load default config file,並將每個頁面渲染成空白。

Locale 和時區與字型無關,兩者會改變頁面顯示的內容,而不只是外觀。容器通常未設定 LANG,且 TZ 會設為 UTC,因此網站會提供英文內容並顯示 UTC 時間戳記,而你的代理程式回報的時間也會與當地使用者看到的時間不一致。請依瀏覽器 context 設定,而不要依機器設定,這樣同一個瀏覽器就能處理不同地區的工作。

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

瀏覽器程序洩漏如何導致伺服器使用 swap

兩個不同的問題共用「zombie」這個名稱。真正的 zombie 是已結束、但其父程序從未呼叫 wait() 的程序。它只保留 PID 項目,不會再消耗其他資源,因此不會佔用記憶體。當瀏覽器在容器中以 PID 1 執行時,這些程序會累積,因為 PID 1 預設不會回收子程序。Docker 的 --init 旗標可精確解決這個問題,方法是執行一個會「轉送訊號並回收程序」的小型 init。在 Compose 中,對應設定是 init: true

真正導致伺服器使用 swap 的洩漏則不同:沒有人關閉的 Chromium 活躍程序。當工作在 newContext()close() 之間拋出例外,或控制腳本遭終止而留下孤立的瀏覽器程序樹時,就會發生這種情況。最嚴重的情況,是每個請求都啟動新的瀏覽器。請計算數量:

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

這個數量在工作之間應回到閒置時的值。如果經過一天後持續上升,問題出在程式碼,而不是啟動旗標:在 finally 區塊中關閉 context,在 SIGTERM 時關閉瀏覽器,並在處理固定數量的工作後回收瀏覽器,不要讓同一個瀏覽器執行一個月。在 systemd 下,停止或重新啟動會終止該 unit cgroup 中的所有程序,因此 sudo systemctl restart browser.service 是可靠的重設方式。手動在 terminal multiplexer 中啟動的瀏覽器則沒有這項保證,其孤立程序會在工作階段結束後繼續執行。

單一瀏覽器內容需要多少 RAM

這個問題必須明確定義,因為「單一瀏覽器」不是單一程序。Chromium 會執行 browser process、GPU process、utility processes,以及每個網站各自的 renderer process;site isolation 也會讓跨網站 iframe 使用獨立的 renderer。BrowserContext 是同一個程序樹內獨立的 cookie jar 與儲存區,因此建立第二個 context 的成本很低。第二個 page 則不同,因為它會啟動 renderer processes;含有大量廣告的頁面還會啟動數個程序。

因此,應測量的是整個程序樹在你自己的工作負載下所需的尖峰記憶體。其他人部落格中的數值在這裡沒有參考價值,因為答案取決於 agent 開啟的頁面。請在實際使用的機器上,針對即將造訪的網站進行測量:

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

在 Ubuntu 24.04 中,該輸出的 Memory: 行會顯示此 unit 目前及尖峰的使用量。讓 worker 一次只開啟一個 page,記錄尖峰值,再以兩個 page 同時開啟的情況重複測量,以確認第二個 page 的實際成本。接著即可用算術方式計算並行數:以總 RAM 扣除系統其他部分所需的容量,保留數百 MB 的餘裕,再除以每個 worker 測得的尖峰用量。若要估算底層機器的規格,請參閱agent VPS 需要多少 RAM 與 CPU

請在兩個層級限制這個數字。在程式碼中使用固定的 worker pool 或 semaphore,讓 agent 請求突增時進入佇列,而不是直接啟動更多瀏覽器。在 OS 中使用 cgroup limit,避免佇列發生錯誤時連帶拖垮整台機器:

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

MemorySwapMax=0 的影響比表面上更大。未設定它時,cgroup 達到限制後會將 pages 推送至 swap,因此機器仍維持運作,但每個請求都會變慢;這比明確失敗更難診斷。設定後,kernel 會終止該 cgroup 內的瀏覽器程序樹,systemd 會重新啟動該 unit,而 sshd 仍能存活。Compose 中的相同控制項是 mem_limitshm_sizeinit,詳見在 Docker Compose 中設定記憶體限制

讓瀏覽器端點遠離公開網際網路

Playwright 可以將瀏覽器作為伺服器執行,並提供 WebSocket URL 給代理程式:

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

該端點沒有登入機制。Playwright API 文件直接說明:「任何知道 wsPath 的程序或網頁(包括在 Playwright 中執行的程序或網頁)都能控制作業系統使用者。」預設主機是 localhost,「只接受來自 loopback 介面的連線」;文件也警告,傳入 0.0.0.0 這類明確位址時,「會將瀏覽器 RPC 暴露給任何能連到監聽埠的對象」。Chrome 自己的 --remote-debugging-port 更不安全。DevTools 通訊協定完全沒有任何驗證機制,安全性完全取決於是否繫結至 loopback。

確認你實際發布了哪些端點,也要從第二台機器以及 VPS 進行檢查:

ss -ltnp

任何繫結至 0.0.0.0 的瀏覽器埠都是安全性問題。請記住,多數供應商會在控制面板中另設網路防火牆,而你的 ufw 規則無法看見這些設定。請改用 SSH tunnel 或私有 VPN,從另一台機器連到該端點:

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

這裡的風險不只是有人竊取瀏覽器運算時間。可由他人操控的瀏覽器,就是位於你網路內部的請求偽造工具。任何能連到該 socket 的人,都能讓它擷取 http://127.0.0.1:8080、資料庫管理頁面,或雲端 metadata 位址 169.254.169.254,接著從頁面中讀取回應。你的防火牆會將這些請求視為來自 VPS 本身,因此予以允許。請將控制端點視同該主機上的 shell 存取權限。

MCP 伺服器也有相同的結構。npx @playwright/mcp@latest --headless --port 8931 會在 localhost 上透過 HTTP 提供服務,而 --host 0.0.0.0 是將本機工具轉為公開服務的旗標。該專案的 README 明確指出,Playwright MCP「不是安全邊界」。將該埠維持在 loopback,並讓代理程式透過相同的 tunnel 存取。

代理程式讀取的頁面是不受信任的輸入

會瀏覽開放網路的代理程式,會將陌生人撰寫的文字饋送給同時持有您指令的模型。頁面可能包含直接寫給模型的文字,要求模型放棄目前工作、呼叫工具,或將資料張貼到某個 URL。模型收到的兩者都是文字,因此沒有可靠的方法區分頁面內容與您的指令。請將環境設計成讓惡意頁面能利用的資源越少越好。

  • 讓瀏覽器使用獨立的 OS 使用者執行,且該使用者沒有 SSH keys,環境中也不提供 cloud credentials。
  • 每項工作使用新的 context,並搭配 Playwright MCP 使用 --isolated,讓某個網站的工作階段無法提供給下一個頁面。
  • 工作允許時,維護 origin allowlist。Playwright MCP 會將 --allowed-origins--blocked-origins 視為以分號分隔的清單。
  • 對任何會變更狀態的操作要求人工確認,例如寄送郵件或消費。

更好的做法,是將整個瀏覽器放在可直接捨棄並重建的機器上。這與在可捨棄的 VM 中執行 coding agents採用相同的理由。如果代理程式的實際工作是搜尋,而不是開放式瀏覽,使用範圍較小的工具會比完整瀏覽器安全:使用自有 SearXNG 支援的 search skill可回傳結果,且完全不載入惡意頁面。

FAQ

為什麼 Chromium 在 Docker 中會當掉,但直接在相同的 VPS 上執行卻正常?

因為容器預設只有 64 MB 的 /dev/shm,而主機的容量大得多。Chromium 會透過這個共享記憶體區域傳遞渲染內容,因此複雜頁面會將其填滿,導致 renderer 終止。請在容器內執行 df -h /dev/shm 進行確認,然後使用 --ipc=host 啟動,讓容器使用主機的共享記憶體;或使用 --shm-size=1g,擴大容器本身的共享記憶體。--disable-dev-shm-usage 只會將問題移到 /tmp

如果 VPS 沒有執行其他服務,使用 --no-sandbox 是否安全?

不安全。sandbox 可防止惡意頁面存取機器的其他部分。Chromium 文件指出,此旗標「會停用 Chromium 的重大安全功能,瀏覽公開網路時絕不應使用」。遵循連結的 agent 就是在瀏覽公開網路。請改正根本原因:不要以 root 執行瀏覽器;在 Ubuntu 24.04 上,為瀏覽器 binary path 加入包含 userns, 的 AppArmor profile,讓該程式可以使用 unprivileged user namespaces。

我可以在小型 VPS 上執行多少個瀏覽器?

請實際測量,不要直接套用某個數字。Chromium 會為每個網站啟動一個 renderer process,因此數量取決於開啟的頁面。使用 MemoryMax 設定,在 systemd-run 下執行一個 worker,從 systemctl status 中的 Memory: 行讀取峰值,然後以可用 RAM 除以該峰值,並保留餘裕。在程式碼中加入 queue,並在 unit file 中設定 MemoryMax,以雙重限制結果,讓突增的請求排隊等待,而不是讓機器開始使用 swap。

我的 agent 可以從另一台機器連線到瀏覽器嗎?

可以,但絕對不要將連接埠繫結到 0.0.0.0。Playwright server endpoint 和 Chrome DevTools port 都會接受任何能連線的 client,且不需要密碼。請讓 listener 綁定在 127.0.0.1,再透過 SSH tunnel 或 private VPN 傳送連線。使用 ss -ltnp 在伺服器上驗證,並從外部檢查連接埠;也要檢查供應商另外提供的 network firewall。

為什麼頁面明明已載入,擷取的螢幕截圖卻是空白?

缺少字型。若沒有涵蓋頁面所用文字系統的字型,文字會顯示為空白方框,或完全不顯示,因此以圖片為主的頁面看起來會是空白。請針對擷取的每種語言執行 fc-match "sans-serif:lang=ko";若查詢結果是通用 fallback,請安裝 fonts-noto-corefonts-noto-cjk,然後重新啟動瀏覽器,讓 fontconfig 重新載入快取。完全沒有字型的容器會在啟動時記錄 Fontconfig error: Cannot load default config file

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