Virtualization

Headless Chrome ngốn RAM: cách đo và đặt giới hạn

Headless Chrome là cỗ máy vô hình ngốn RAM khủng khiếp nhất mà nhiều người mới dùng VPS không ngờ tới. Chạy một con bot Puppeteer lên, vài phút sau free -h báo đã dùng hết 4 GB. Mình từng thấy cảnh VPS 4 GB chết ngất chỉ vì 3 tab headless render một trang web React. Nếu bạn đang chạy VPS OpenClaw hoặc bất kỳ hệ thống automation nào dùng Chromium, bài này là dành cho bạn: làm sao đo đúng headless Chrome RAM, đặt rào cản, và dọn dẹp khi nó chạy quá đà.

Yêu cầu trước khi bắt đầu

  • VPS chạy Linux (Ubuntu 24.04 LTS, Debian 12 hoặc AlmaLinux 9) với quyền sudo hoặc root.
  • Đã cài headless Chrome hoặc Puppeteer/Playwright.
  • Cài sẵn Docker nếu bạn chạy container (dùng docker stats để đo).
  • Hiểu cơ bản về ps, kill và biên tập file với nano/vim.

Vì sao headless Chrome ngốn RAM không kiểm soát?

Không giống Chrome bản desktop có thanh task manager, headless mode chạy trình duyệt hoàn chỉnh (V8 engine, renderer, GPU compositor, network stack). Mỗi tab tương đương một process riêng. Trang web hiện đại (SPA, video, WebSocket, iframe) dễ kéo Chrome từ 100 MB lên 600 MB mỗi tab. Cộng dồn: 5 tab song song = 2-3 GB RAM. Không giới hạn gì, OOM killer sẽ tóm gọn process của bạn khi swap cũng không kịp trữ.

Phần lớn lập trình viên mới dùng Puppeteer thường bỏ qua flag --disable-dev-shm-usage và không đóng browser sau mỗi lần scrape. Hậu quả: Chrome giữ bộ nhớ đã cấp phát, không trả về OS dù không còn dùng.

Cách 1 - Đo headless Chrome RAM bằng ps và smem

ps cho cái nhìn nhanh. smem cho báo cáo chính xác hơn vì nó tính RSS scaled, không chỉ resident set.

Đo nhanh với ps

ps aux --sort=-%mem | grep -i chrome | head -10

Lệnh này liệt kê top 10 process Chrome đang ngốn nhiều RAM nhất theo %MEM. Cột RSS (resident set size) in KB, chia 1024 để ra MB. Nhưng ps đếm trùng shared pages giữa các process, nên số thường cao hơn mức thực tế.

Đo chính xác với smem

sudo apt install smem -y   # Debian/Ubuntu
sudo dnf install smem -y   # AlmaLinux/Rocky
smem -t -p -k | grep chrome | sort -k4 -n -r | head -10

smem trả về RSS, USS (Unique Set Size, bộ nhớ chỉ riêng process đó dùng, không share), và PSS (Proportional Set Size, chia đều shared memory). USS là con số đáng tin nhất để biết một process Chrome ngốn thực sự bao nhiêu. Cột PSS cho biết tổng gánh nặng lên hệ thống.

Ví dụ output mong đợi:

  PID  User     USS     PSS     RSS  Command
 1234  root   245.8M  278.4M  340.2M  /usr/lib/chromium/chrome --type=renderer

Nhìn cột USS: renderer này chiếm ~246 MB không share được. Nếu có 5 renderer như thế, hãy gấp 5.

Cách 2 - Đo RAM headless Chrome trong container với docker stats

Nếu bạn chạy headless Chrome trong Docker, docker stats là công cụ dễ nhất. Nó hiển thị real-time CPU, RAM, NET I/O mỗi container.

docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"

Output mẫu:

NAME                  CPU %     MEM USAGE / LIMIT   MEM %
chrome_scraper_1      12.5%     1.245GiB / 2GiB     62.25%

Lưu ý: docker stats tính total memory của container (bao gồm cache). Nếu thấy container dùng gần hết limit, process sắp bị OOM kill. Bạn có thể kiểm tra log: docker logs chrome_scraper_1 2>&1 | grep -i "killed\|oom".

Mình khuyên: luôn dùng Docker với flag --memory khi chạy headless Chrome để tránh container ăn hết RAM host:

docker run -d --memory="2g" --memory-swap="2g" --name chrome_scraper my-chrome-image

Giá trị --memory-swap="2g" tắt swap cho container, đảm bảo Chrome bị kill đúng lúc thay vì chậm dần rồi swap đầy.

Cách 3 - Giới hạn headless Chrome RAM bằng flag và ulimit

Flag --disable-dev-shm-usage

Flag này là quan trọng nhất. Khi không set, Chrome ghi dữ liệu render vào /dev/shm (RAM disk, thường chỉ 64-256 MB trên VPS). Nếu trang lớn, Chrome crash vì hết /dev/shm. Set flag này để Chrome dùng /tmp thay thế:

const browser = await puppeteer.launch({
  headless: "new",
  args: ['--disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox']
});

Với Playwright (C# hoặc Python), cũng thêm --disable-dev-shm-usage vào launch args.

Giới hạn process con bằng --max-old-space-size

Bạn có thể giới hạn heap V8 của Node.js process chạy Puppeteer. Thêm vào lệnh chạy:

NODE_OPTIONS="--max-old-space-size=512" node scraper.js

Con số 512 là MB. Nếu process vượt quá, nó tự crash thay vì treo cả hệ thống. Nhưng lưu ý: giới hạn quá thấp (dưới 256 MB) sẽ khiến Chrome hay bị crash khi render trang nặng.

Dùng ulimit shell

Bạn có thể đặt giới hạn toàn cục cho process headless Chrome trong script startup:

#!/bin/bash
ulimit -v 1048576   # virtual memory 1 GB
ulimit -m 524288    # resident set 512 MB
node scraper.js

Nhưng lưu ý: ulimit không phân biệt shared memory, dễ kill nhầm process khác nếu chạy chung script. Mình chỉ dùng trong tình huống quick-fix tạm thời.

Cách 4 - Giới hạn số tab đồng thời, dừng đúng và dọn zombie

Dùng pool song song

Đừng bao giờ mở 20 tab headless một lúc. Thiết kế queue hoặc pool: chỉ chạy tối đa 2-3 tab song song trên VPS 4 GB, 4-5 tab trên VPS 8 GB. Dùng thư viện p-limit (Node.js) hoặc asyncio.Semaphore (Python):

const pLimit = require('p-limit');
const limit = pLimit(3);  // 3 tab tối đa
const tasks = urls.map(url => limit(() => scrapeOne(url)));
await Promise.all(tasks);

Đóng browser sau mỗi lần dùng

Đây là lỗi phổ biến nhất: để browser mở sau mỗi lần render. Luôn gói trong try/finally:

let browser;
try {
  browser = await puppeteer.launch({ headless: "new", args: ['--disable-dev-shm-usage'] });
  const page = await browser.newPage();
  await page.goto('https://example.com');
  // ... xử lý
} finally {
  if (browser) await browser.close();
}

Nếu bạn dùng nhiều page, đóng từng page khi không còn dùng: await page.close(). Browser parent process vẫn tồn tại nhưng không ngốn thêm bộ nhớ.

Dọn zombie process

Zombie xuất hiện khi process con Chrome chết nhưng process cha chưa gọi waitpid(). Dùng ps aux | grep -w Z để kiểm tra. Nếu có, tìm PID cha và kill nó (hoặc restart service). Cách nhanh: kill toàn bộ Chrome còn sót:

pkill -f "chrome" --signal SIGTERM
sleep 2
pkill -f "chrome" --signal SIGKILL   # nếu vẫn còn

Để tránh zombie, đảm bảo script gọi browser.close() đúng cách (đã nói ở trên). Nếu chạy Chrome qua child_process, dùng child.on('exit') để thu dọn.

Mình cũng thêm cron job mỗi 30 phút dọn Chrome treo (nếu process tồn tại quá 30 phút không có activity):

* * * * * pgrep -f "chrome" -a | awk '{print $1}' | while read pid; do
  if [ $(ps -o etimes= -p $pid) -gt 1800 ]; then
    kill -9 $pid
  fi
done

Cách 5 - Dùng systemd resource control cho headless Chrome

Nếu bạn chạy headless Chrome như service systemd, có thể giới hạn RAM bằng MemoryMaxTasksMax. Sửa file service:

[Service]
ExecStart=/usr/bin/node /opt/scraper/index.js
MemoryMax=1.5G
TasksMax=20

Chạy systemctl daemon-reload && systemctl restart scraper. Systemd sẽ gửi SIGTERM khi process vượt MemoryMax, và dùng cgroups để đảm bảo kill đúng process.

Kiểm tra sau khi áp dụng: systemd-cgtop xem bộ nhớ thực tế của service.

Xử lý lỗi thường gặp

LỗiDấu hiệuKhắc phục
Chrome crash vì /dev/shmLog: "FATAL:dev_shm_handle.cc"Thêm --disable-dev-shm-usage
OOM killdmesg | tail -20 thấy "oom-killer" + tên processTăng RAM host, giới hạn parallel tab, đặt memory limit Docker/systemd
Zombie process đầyps aux | grep -w Z ra nhiều dòngKill cha của zombie, sửa code đóng browser đúng
Container chạy chậm dầndocker stats thấy memory leakĐặt --memory--memory-swap bằng nhau, hạn chế dùng tab mới liên tục

Câu hỏi thường gặp

Headless Chrome có dùng RAM nhiều hơn bản GUI không?

Có thể ít hơn vì không có GPU render thật, nhưng vẫn ngon RAM tương đương vì Chrome vẫn load đầy đủ renderer, V8, network stack. Bản headless thường thiếu giới hạn tab nên dễ bị "out of control".

Nên đặt memory limit bao nhiêu cho headless Chrome trên VPS 4 GB?

Để dành 1 GB cho OS và các service khác, còn 3 GB cho Chrome. Giới hạn 2 GB cho container/service, chạy max 2-3 tab song song. Nếu trang nặng, thử giảm còn 1 GB + 1 tab.

smem và ps cái nào đúng hơn?

smem cho USS/PSS chính xác hơn. ps chỉ cho RSS có share pages nên dễ cao ảo. Dùng smem để ước lượng thực tế, dùng ps để phát hiện process ngốn bất thường nhanh.

Dùng crontab dọn Chrome treo có an toàn không?

An toàn nếu bạn chỉ kill process quá thời gian cho phép (>30 phút). Nhưng nếu script của bạn đang crawl trang dài, 30 phút có thể chưa đủ, hãy điều chỉnh threshold cho phù hợp.

Có nên dùng --max-old-space-size thấp quá không?

Không nên. Dưới 256 MB Node.js sẽ không đủ heap chứa Chrome subprocess. hàng đầu 512 MB đến 1 GB tuỳ VPS.

Headless Chrome có leak RAM không?

Bản thân Chromium không leak nhiều, nhưng code Puppeteer/Playwright viết sai (không đóng page, không clear buffer) là nguyên nhân chính. Luôn gói lệnh trong try/finally.

Bài viết liên quan

Lưu ý: Bài viết mang tính tham khảo, tổng hợp kiến thức chung. Mỗi hệ thống, hạ tầng và nhu cầu có đặc thù riêng, nên kiểm thử trong môi trường an toàn và tham vấn kỹ sư trước khi triển khai thực tế.