Chạy Chromium headless cho AI agent trên VPS
Chromium headless trên VPS thường lỗi vì /dev/shm quá nhỏ, thiếu font, sandbox hoặc process rò rỉ. Cấu hình giới hạn trước khi agent chạm ngưỡng.
Bạn đang chạy gì
Headless browser trên VPS là Chromium không có cửa sổ, được điều khiển bằng code thay vì người dùng. Trên server, đây là một cây tiến trình chạy lâu dài để agent giao tiếp qua một socket cục bộ. Cài đặt chỉ cần một lệnh. Phần việc chính nằm ở mọi thứ sau đó. Bạn phải giới hạn tài nguyên browser được phép sử dụng và giữ endpoint điều khiển không lộ ra Internet công cộng.
Hướng dẫn này giả định bạn đã chọn công cụ và bây giờ cần vận hành nó. Nếu vẫn đang so sánh các crawler và extractor, hãy bắt đầu với các lựa chọn thay thế Firecrawl tự host rồi quay lại đây. Các phần bên dưới đều dùng Chromium của Playwright, vì Playwright cung cấp bản build browser riêng và trình cài dependency riêng. Do đó, các lệnh giống nhau trên Ubuntu VPS trống và bên trong container. Các phiên bản được cập nhật đến August 2026.
Cài Chromium mà không phải đoán dependency
npm i -D playwright@1.62.0
npx playwright install --with-deps chromium--with-deps chạy apt để cài các shared library và font mà Chromium cần, rồi yêu cầu quyền root khi cần. Bản build của trình duyệt được tải vào ~/.cache/ms-playwright cho user đã chạy lệnh. Điều này quan trọng trên server vì service user thường không phải user bạn dùng để đăng nhập. Hãy cài các system package một lần với quyền admin bằng sudo npx playwright install-deps chromium, rồi đặt PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers trong cả lệnh cài đặt và service unit để dùng chung một bản. Service không nhìn thấy trình duyệt sẽ fail khi khởi động và hiển thị thông báo nêu rõ path mà nó đã tìm kiếm.
Hãy pin phiên bản Playwright. Mỗi release gắn với một browser build cụ thể, vì vậy npm update không được pin có thể thay trình duyệt trong khi service đang chạy. Playwright 1.62 là phiên bản hiện tại tính đến tháng 8 năm 2026.
Có 2 Chromium build và chúng không phải cùng một chương trình. Bản tải xuống mặc định là headless shell, một binary nhỏ hơn chỉ chạy ở chế độ headless; npx playwright install --with-deps --only-shell chỉ cài bản này. Full browser là bản bạn nhận được với channel chromium, được tài liệu trình duyệt của Playwright gọi là “trình duyệt Chrome thực, nên xác thực hơn, đáng tin cậy hơn và có nhiều tính năng hơn”. Dùng shell để fetch số lượng lớn. Dùng full browser khi một site hoạt động khác thường và bạn cần tìm nguyên nhân.
Vì sao headless browser crash trong container
Docker cấp cho mỗi container một /dev/shm có dung lượng 64 MB. Tài liệu Docker nêu rõ: "Nếu bạn bỏ qua hoàn toàn kích thước, hệ thống sẽ dùng 64m". Chromium truyền nội dung đã render giữa các process thông qua vùng shared memory này, nên một trang nặng có thể làm đầy nó. Sau đó renderer bị dừng và client của bạn báo target đã crash, dù trang đó vẫn hoạt động bình thường trên laptop. Hãy xác nhận kích thước từ bên trong container trước khi thay đổi bất cứ điều gì.
df -h /dev/shmCó 2 cách sửa thực sự và chúng là các lựa chọn thay thế, không phải một cặp thiết lập cần dùng cùng nhau. --ipc=host đưa container vào IPC namespace của host để dùng /dev/shm của host, thường có kích thước bằng một nửa RAM. Hướng dẫn Docker của Playwright khuyến nghị cách này vì nếu không, "Chromium có thể hết bộ nhớ và crash". Đổi lại, bạn mất IPC isolation giữa container và host. --shm-size=1g vẫn giữ namespace riêng và chỉ làm mount lớn hơn.
docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bashFlag --disable-dev-shm-usage là cách bạn sẽ thấy trong hầu hết kết quả tìm kiếm, nhưng nó làm một việc khác: chuyển các file đó từ /dev/shm sang một thư mục tạm thời. Nếu /tmp nằm trên disk, bạn đã đổi lỗi crash lấy tốc độ render chậm hơn và các thao tác ghi disk. Nếu /tmp là tmpfs, dữ liệu vẫn nằm trong RAM nhưng hoàn toàn không bị giới hạn kích thước; đây là một cách khiến browser ngốn hết một VPS nhỏ. Thay vào đó, hãy đặt kích thước /dev/shm đúng cách.
Cái giá thực sự của --no-sandbox
Chromium cô lập từng renderer trong một sandbox dựa trên Linux user namespace. Sandbox đó là ranh giới giữa một trang độc hại và server của bạn. Khi sandbox không thể khởi động, Chromium từ chối chạy và log sẽ có một dòng như sau:
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permittedCách xử lý thường được khuyên là --no-sandbox. Tài liệu bảo mật của chính Chromium nói rõ cái giá phải trả: flag này “vô hiệu hóa các tính năng bảo mật quan trọng của Chromium và tuyệt đối không nên dùng khi duyệt web công khai”. Một agent làm theo các link thì về bản chất đang duyệt web công khai. Hãy tìm nguyên nhân thực sự.
Hầu hết trường hợp chỉ do một trong hai nguyên nhân. Chạy browser dưới root sẽ vô hiệu hóa sandbox vì process không thể hạ đặc quyền mà nó đã có. Đây là lý do image của Playwright có sẵn user thường tên là pwuser. Trên Ubuntu 24.04 trở lên, AppArmor hạn chế user namespace không đặc quyền. Một binary Chromium nằm tại path không có profile nào được cung cấp bao phủ sẽ bị từ chối. Bản download của Playwright trong ~/.cache/ms-playwright chính là một path như vậy. Kiểm tra cả hai nguyên nhân:
id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_createGiá trị 1 từ sysctl cùng với một dòng kernel có chứa apparmor="DENIED" operation="userns_create" xác nhận nguyên nhân thứ hai. Cho phép binary đó trong /etc/apparmor.d/pw-chromium. Cách này vẫn giữ hạn chế cho mọi thứ khác trên máy:
abi <abi/4.0>,
include <tunables/global>
profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
userns,
}Nạp profile bằng sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. Path này chứa browser revision nên sẽ thay đổi sau mỗi lần nâng cấp Playwright. Các glob ở trên vẫn hoạt động sau những thay đổi đó. Profile ghi theo một path cụ thể sẽ âm thầm ngừng khớp. Browser lại bắt đầu fail sau một bản update tưởng như không liên quan.
Vì sao screenshot bị trắng hoặc đầy ô vuông
Screenshot trắng, hoặc đầy các hình chữ nhật rỗng, thường là do vấn đề font chứ không phải lỗi render. install-deps cung cấp bộ nền hoạt động được: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic cho tiếng Nhật, fonts-wqy-zenhei cho tiếng Trung, fonts-tlwg-loma-otf cho tiếng Thái. Bộ này không có Noto CJK, nên tiếng Hàn và một số script khác sẽ fallback sang font mà fontconfig tìm được. Hãy hỏi fontconfig thay vì đoán:
fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -lNếu ngôn ngữ bạn cần được ánh xạ đến unifont hoặc đến một font fallback không có glyph thực, hãy cài fonts-noto-core và fonts-noto-cjk, rồi chạy lại kiểm tra. Fontconfig cache các kết quả, nên hãy khởi động lại browser sau khi cài font. Image đã bị lược bỏ, không có font nào sẽ ghi Fontconfig error: Cannot load default config file khi khởi động và render mọi trang thành rỗng.
Locale và time zone tách biệt với font, đồng thời chúng thay đổi nội dung trang chứ không chỉ cách hiển thị. Container thường để LANG chưa được set và đặt TZ ở UTC, nên các site sẽ phân phối nội dung tiếng Anh và in timestamp UTC; agent của bạn cũng báo thời gian không khớp với những gì người dùng tại quốc gia đó thấy. Hãy đặt chúng theo từng browser context thay vì theo từng máy, để một browser có thể xử lý task cho nhiều khu vực khác nhau.
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/Paris',
});Vì các tiến trình browser bị rò rỉ khiến máy chủ phải dùng swap
Hai vấn đề khác nhau cùng được gọi là “zombie”. Zombie thực sự là một tiến trình đã kết thúc nhưng tiến trình cha chưa gọi wait(). Nó chỉ giữ một mục PID và không giữ thêm gì khác, nên không tiêu thụ bộ nhớ. Bạn phải thu gom các tiến trình này khi browser chạy với PID 1 trong container, vì PID 1 không có cơ chế reaper mặc định. Flag --init của Docker xử lý đúng vấn đề này bằng cách chạy một init nhỏ để “chuyển tiếp signal và thu gom tiến trình”. Trong Compose, tùy chọn tương ứng là init: true.
Loại rò rỉ thực sự khiến máy chủ phải dùng swap lại khác: đó là các tiến trình Chromium vẫn đang chạy nhưng không có tiến trình nào đóng chúng. Điều này xảy ra khi một task phát sinh lỗi giữa newContext() và close(), hoặc khi script điều khiển bị kill và để lại cả cây browser mồ côi. Trường hợp tệ nhất là code khởi chạy một browser mới cho mỗi request. Hãy đếm số tiến trình:
pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20Số lượng này phải trở về mức idle giữa các task. Nếu nó tăng dần trong một ngày, vấn đề nằm trong code chứ không phải các flag khởi chạy: đóng context trong block finally, đóng browser khi SIGTERM, và recycle browser sau một số lượng task cố định thay vì để một browser chạy suốt một tháng. Với systemd, thao tác stop hoặc restart sẽ kill mọi thứ trong cgroup của unit, nên sudo systemctl restart browser.service là cách reset đáng tin cậy. Browser được khởi chạy thủ công bên trong terminal multiplexer không có bảo đảm này, và các tiến trình mồ côi của nó vẫn tồn tại sau khi session kết thúc.
Một browser context cần bao nhiêu RAM
Hãy đặt câu hỏi chính xác, vì “một browser” không tương ứng với một process. Chromium chạy một browser process, một GPU process, các utility process và một renderer process cho mỗi site. Cơ chế site isolation cũng cấp renderer riêng cho các iframe khác site. Một BrowserContext là một cookie jar và vùng lưu trữ riêng bên trong cùng cây process đó, nên context thứ hai không tốn nhiều tài nguyên. Page thứ hai thì khác, vì nó khởi động các renderer process. Một page có nhiều quảng cáo có thể khởi động thêm vài process.
Vì vậy, số cần đo là mức sử dụng memory cao nhất của toàn bộ cây process với workload của bạn. Số liệu trên blog của người khác không có giá trị trong trường hợp này, vì các page mà agent mở mới quyết định kết quả. Hãy đo trên chính máy bạn sẽ sử dụng, với các site bạn sẽ truy cập:
sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probeTrên Ubuntu 24.04, dòng Memory: trong output đó báo cáo cả mức sử dụng hiện tại và mức cao nhất của unit. Chạy worker với mỗi lần chỉ mở một page, ghi lại mức cao nhất, rồi lặp lại với hai page đang mở để biết chính xác page thứ hai tốn bao nhiêu. Sau đó tính concurrency như sau: lấy tổng RAM, trừ phần mà các thành phần khác trên máy cần dùng, giữ lại vài trăm MB dự phòng, rồi chia cho mức cao nhất đã đo của mỗi worker. Để sizing máy chạy agent, xem agent VPS cần bao nhiêu RAM và CPU.
Hãy áp dụng giới hạn này ở hai nơi. Trong code, dùng fixed worker pool hoặc semaphore để một đợt request agent tăng đột biến sẽ xếp hàng thay vì khởi động thêm browser. Ở cấp OS, dùng cgroup limit để lỗi trong queue không làm máy bị sập theo:
[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=alwaysMemorySwapMax=0 quan trọng hơn nhiều so với vẻ ngoài. Nếu không có nó, cgroup sẽ đẩy các page sang swap khi chạm giới hạn. Máy vẫn hoạt động, nhưng mọi request đều chậm, và việc chẩn đoán khó hơn một lỗi fail rõ ràng. Khi có nó, kernel sẽ kill cây browser bên trong cgroup, systemd restart unit, còn sshd vẫn tồn tại. Các control tương đương trong Compose là mem_limit, shm_size và init, được trình bày trong thiết lập memory limit trong Docker Compose.
Giữ endpoint của trình duyệt ngoài Internet công cộng
Playwright có thể chạy trình duyệt dưới dạng server và cung cấp cho agent một WebSocket URL:
const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());Endpoint này không có chức năng đăng nhập. Tài liệu API của Playwright nêu rõ: “Bất kỳ process hoặc trang web nào (kể cả những process và trang web đang chạy trong Playwright) biết wsPath đều có thể điều khiển user OS.” Host mặc định là localhost, “chỉ chấp nhận kết nối từ loopback interface”, và tài liệu cảnh báo rằng việc truyền một địa chỉ cụ thể như 0.0.0.0 “sẽ expose browser RPC cho mọi đối tượng có thể truy cập listening port”. --remote-debugging-port của Chrome còn nguy hiểm hơn. DevTools protocol không có bất kỳ cơ chế authentication nào và hoàn toàn phụ thuộc vào việc bind vào loopback.
Kiểm tra những gì bạn thực sự đã publish. Đồng thời kiểm tra từ một máy thứ hai, không chỉ từ VPS:
ss -ltnpBất kỳ browser port nào bind vào 0.0.0.0 đều là một phát hiện cần xử lý. Hãy nhớ rằng hầu hết nhà cung cấp đều có network firewall riêng trong control panel, trong khi các rule ufw của bạn không biết gì về firewall đó. Hãy truy cập endpoint từ một máy khác thông qua SSH tunnel hoặc private VPN:
ssh -N -L 3000:127.0.0.1:3000 you@your-vpsRủi ro ở đây lớn hơn việc ai đó chiếm thời gian chạy trình duyệt. Một trình duyệt mà bạn có thể điều khiển là một máy request forgery nằm bên trong network của bạn. Bất kỳ ai truy cập được socket đó đều có thể yêu cầu trình duyệt fetch http://127.0.0.1:8080, trang quản trị database hoặc địa chỉ cloud metadata tại 169.254.169.254, rồi đọc response từ trang web. Firewall của bạn thấy request xuất phát từ chính VPS, nên request đó được cho phép. Hãy coi control endpoint tương đương với quyền shell trên máy đó.
MCP server cũng có mô hình tương tự. npx @playwright/mcp@latest --headless --port 8931 phục vụ qua HTTP trên localhost, còn --host 0.0.0.0 là flag biến một local tool thành public tool. README của dự án nêu rõ rằng Playwright MCP “không phải là security boundary”. Giữ port trên loopback và cho agent truy cập qua cùng tunnel đó.
Các trang mà agent đọc là input không đáng tin cậy
Agent duyệt web mở sẽ đưa văn bản do người lạ viết vào cùng model đang nhận instructions của bạn. Một trang có thể chứa văn bản nhắm vào model đó, yêu cầu model bỏ nhiệm vụ, gọi tool hoặc đăng dữ liệu lên một URL. Model nhận cả hai dưới dạng văn bản, nên không có cách đáng tin cậy để phân biệt nội dung của trang với instructions của bạn. Hãy thiết kế hệ thống để một trang độc hại có ít khả năng gây tác động.
- Chạy browser dưới một OS user riêng, không có SSH keys và cloud credentials trong environment của user đó.
- Dùng context mới cho mỗi task và
--isolatedvới Playwright MCP để session trên một site không được dùng cho trang tiếp theo. - Duy trì origin allowlist khi job cho phép. Playwright MCP nhận
--allowed-originsvà--blocked-originsdưới dạng các danh sách phân tách bằng dấu chấm phẩy. - Yêu cầu con người xác nhận trước mọi hành động làm thay đổi trạng thái, chẳng hạn gửi mail hoặc thanh toán.
Tốt hơn nữa, hãy chạy toàn bộ browser trên một máy có thể loại bỏ và rebuild. Đây cũng là lý do nên chạy coding agent trong một VM dùng một lần. Nếu nhiệm vụ thực tế của agent chỉ là tìm kiếm thay vì duyệt web không giới hạn, một tool hẹp hơn sẽ an toàn hơn full browser: một search skill dùng SearXNG của bạn làm backend trả về kết quả mà không bao giờ load trang độc hại.
FAQ
Vì sao Chromium bị crash trong Docker nhưng chạy bình thường trên chính VPS đó?
Vì container mặc định chỉ có 64 MB /dev/shm, trong khi máy host có vùng này lớn hơn nhiều. Chromium đưa nội dung đã render qua vùng shared memory đó, nên một trang nặng có thể làm đầy vùng này và khiến renderer bị dừng. Chạy df -h /dev/shm bên trong container để xác nhận. Sau đó khởi động bằng --ipc=host để dùng shared memory của host, hoặc bằng --shm-size=1g để tăng kích thước shared memory riêng của container. --disable-dev-shm-usage chỉ chuyển vấn đề sang /tmp.
Dùng --no-sandbox có an toàn nếu VPS không chạy gì khác không?
Không. Sandbox ngăn trang độc hại truy cập phần còn lại của máy. Tài liệu Chromium nêu rõ flag này “tắt các tính năng bảo mật quan trọng của Chromium và tuyệt đối không nên dùng khi duyệt web công khai”. Agent theo các liên kết cũng đang duyệt web công khai. Hãy sửa nguyên nhân thay vì tắt sandbox: không chạy browser bằng root. Trên Ubuntu 24.04, hãy thêm AppArmor profile có userns, cho đường dẫn binary của browser để cho phép user namespace không đặc quyền đối với riêng chương trình đó.
Có thể chạy bao nhiêu browser trên một VPS nhỏ?
Hãy đo thực tế, đừng sao chép một con số cố định. Chromium khởi động một renderer process cho mỗi site, nên con số này phụ thuộc vào các trang bạn mở. Chạy một worker dưới systemd-run với MemoryMax đã được đặt, đọc mức dùng cao nhất từ dòng Memory: trong systemctl status, sau đó lấy RAM trống chia cho mức cao nhất đó và chừa lại phần dự phòng. Thực thi giới hạn này ở hai lớp: dùng queue trong code và đặt MemoryMax trong unit file, để một đợt request tăng đột biến phải chờ thay vì làm máy swap.
Agent của tôi có thể kết nối đến browser từ máy khác không?
Có, nhưng tuyệt đối không bind port vào 0.0.0.0. Playwright server endpoint và Chrome DevTools port đều chấp nhận mọi client có thể kết nối đến chúng mà không cần password. Giữ listener trên 127.0.0.1 và truyền kết nối qua SSH tunnel hoặc private VPN. Xác minh bằng ss -ltnp trên server và kiểm tra port từ bên ngoài. Đồng thời kiểm tra network firewall riêng của provider.
Vì sao screenshot của tôi bị trống dù trang đã tải rõ ràng?
Thiếu font. Khi không có font hỗ trợ script của trang, text sẽ hiển thị thành các ô trống hoặc không hiển thị, nên một trang chủ yếu có hình ảnh có thể cho ảnh kết quả trông như bị trống. Chạy fc-match "sans-serif:lang=ko" cho từng ngôn ngữ bạn scrape. Cài fonts-noto-core và fonts-noto-cjk nếu kết quả là font fallback chung. Sau đó restart browser để fontconfig nạp lại cache. Container hoàn toàn không có font sẽ ghi log Fontconfig error: Cannot load default config file khi khởi động.