اجرای مرورگر Headless روی VPS برای AI Agents
اجرای Chromium روی VPS با خطاهای رایج مانند کمبود حافظه dev/shm و نبود فونت همراه است. با تنظیم دقیق sandbox flags و مدیریت پردازشها، پایداری agent خود را در سال 2026 تضمین کنید.
آنچه در حال اجرا دارید
یک مرورگر headless روی یک VPS، در واقع همان Chromium بدون رابط گرافیکی است که بهجای کاربر، توسط کد شما هدایت میشود. روی سرور، این یک درخت پردازش (process tree) با طول عمر بالاست که agent شما از طریق یک socket محلی با آن ارتباط برقرار میکند. نصب آن تنها با یک دستور انجام میشود. بخش اصلی کار، مراحل پس از آن است. شما باید منابعی که مرورگر میتواند از ماشین مصرف کند را محدود کرده و endpoint کنترل آن را از دسترس اینترنت عمومی خارج نگه دارید.
این راهنما فرض میکند که ابزار مورد نظر خود را انتخاب کردهاید و اکنون باید آن را مدیریت کنید. اگر هنوز در حال مقایسه ابزارهای crawler و extractor هستید، با جایگزینهای self-hosted برای Firecrawl شروع کنید و سپس به اینجا بازگردید. تمام موارد زیر از Chromium در Playwright استفاده میکنند، زیرا Playwright نسخه مرورگر اختصاصی خود و نصبکننده وابستگیهای مخصوص به خود را ارائه میدهد؛ بنابراین دستورات یکسان روی یک VPS خام با Ubuntu و داخل یک container به درستی کار میکنند. نسخهها مربوط به آگوست 2026 هستند.
نصب Chromium بدون حدس زدن وابستگیها
npm i -D playwright@1.62.0
npx playwright install --with-deps chromium--with-deps دستور apt را برای کتابخانههای اشتراکی و فونتهای مورد نیاز Chromium اجرا میکند و در صورت لزوم، دسترسی root درخواست میکند. خودِ build مرورگر برای کاربری که دستور را اجرا کرده است، در ~/.cache/ms-playwright دانلود میشود. این موضوع در سرور اهمیت دارد، زیرا کاربرِ سرویس معمولاً همان کاربری نیست که شما با آن لاگین میکنید. بستههای سیستمی را یکبار به عنوان مدیر سیستم با sudo npx playwright install-deps chromium نصب کنید، سپس PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers را هم در دستور نصب و هم در unit سرویس تنظیم کنید تا یک نسخه به صورت اشتراکی استفاده شود. سرویسی که نتواند مرورگر خود را پیدا کند، هنگام اجرا با پیامی که مسیر جستجو شده را نشان میدهد، متوقف میشود.
نسخه Playwright را ثابت (Pin) کنید. هر release به یک build خاص از مرورگر وابسته است، بنابراین یک npm update بدون نسخه ثابت میتواند مرورگر را در حین اجرای سرویس تغییر دهد. نسخه Playwright 1.62 تا آگوست 2026 نسخه جاری است.
دو build از Chromium وجود دارد و آنها یک برنامه واحد نیستند. دانلود پیشفرض، headless shell است؛ یک binary کوچکتر که فقط به صورت headless اجرا میشود و npx playwright install --with-deps --only-shell تنها همان را نصب میکند. مرورگر کامل همان چیزی است که با کانال chromium دریافت میکنید که مستندات مرورگر Playwright آن را «مرورگر واقعی Chrome، و در نتیجه معتبرتر، قابلاطمینانتر و دارای ویژگیهای بیشتر» مینامد. برای دریافتهای انبوه از shell استفاده کنید. زمانی که یک وبسایت رفتار متفاوتی دارد و نیاز دارید دلیل آن را بیابید، از مرورگر کامل استفاده کنید.
چرا مرورگر headless در کانتینر کرش میکند
داکر به هر کانتینر یک /dev/shm با حجم 64 مگابایت اختصاص میدهد. مستندات داکر صراحتاً بیان میکند: «اگر اندازه را بهطور کامل حذف کنید، سیستم از 64m استفاده میکند». کرومیوم محتوای رندر شده را بین پردازشهای خود از طریق این ناحیه حافظه اشتراکی منتقل میکند، بنابراین یک صفحه سنگین میتواند آن را پر کند. در نتیجه، renderer از کار میافتد و کلاینت شما گزارش کرش کردن target را میدهد، در حالی که همان صفحه روی لپتاپ شما بهخوبی کار میکند. پیش از هر تغییری، اندازه را از داخل کانتینر تأیید کنید.
df -h /dev/shmدو راه حل واقعی وجود دارد که جایگزین یکدیگر هستند، نه مکمل هم. استفاده از --ipc=host کانتینر را در فضای نام IPC میزبان قرار میدهد تا از /dev/shm میزبان استفاده کند که معمولاً نیمی از RAM است. راهنمای داکر Playwright این روش را توصیه میکند، زیرا بدون آن «کرومیوم ممکن است با کمبود حافظه مواجه شده و کرش کند». هزینه این کار، از دست دادن ایزولاسیون IPC بین کانتینر و میزبان است. استفاده از --shm-size=1g فضای نام خصوصی را حفظ کرده و صرفاً mount را بزرگتر میکند.
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 هر رندر (renderer) را در یک sandbox ایزوله میکند که بر پایه user namespace در لینوکس ساخته شده است. این 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 یک کاربر معمولی به نام pwuser دارد. در Ubuntu 24.04 و نسخههای جدیدتر، AppArmor دسترسی unprivileged user namespaces را محدود میکند و یک فایل باینری Chromium در مسیری که هیچ پروفایل پیشفرضی آن را پوشش نمیدهد، مسدود میشود. دانلود Playwright در مسیر ~/.cache/ms-playwright دقیقاً چنین مسیری است. هر دو مورد را بررسی کنید:
id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_createیک 1 از دستور sysctl به همراه یک خط در کرنل که شامل apparmor="DENIED" operation="userns_create" باشد، علت دوم را تأیید میکند. آن فایل باینری خاص را در /etc/apparmor.d/pw-chromium مجاز کنید؛ این کار باعث میشود محدودیت برای سایر بخشهای سیستم همچنان برقرار بماند:
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 بارگذاری کنید. این مسیر شامل نسخه مرورگر است، بنابراین با هر بار ارتقای Playwright تغییر میکند. الگوهای (glob) بالا در برابر این تغییرات مقاوم هستند. پروفایلی که برای یک مسیر دقیق نوشته شده باشد، بدون هیچ هشداری از کار میافتد و مرورگر پس از یک بهروزرسانی که بیارتباط به نظر میرسد، دوباره با خطا مواجه میشود.
چرا اسکرینشاتها خالی یا پر از مربع نمایش داده میشوند
اسکرینشات خالی، یا اسکرینشاتی که پر از مستطیلهای توخالی است، معمولاً به دلیل مشکل فونت است و نه یک باگ رندرینگ. install-deps یک پایه کاری را فراخوانی میکند: fonts-liberation، fonts-freefont-ttf، fonts-noto-color-emoji، fonts-unifont، fonts-ipafont-gothic برای زبان ژاپنی، fonts-wqy-zenhei برای زبان چینی، و fonts-tlwg-loma-otf برای زبان تایلندی. در این مجموعه فونت Noto CJK وجود ندارد، بنابراین زبان کرهای و چندین خط دیگر به هر فونتی که fontconfig بتواند پیدا کند، بازگشت (fallback) میکنند. بهجای حدسزدن، از fontconfig بپرسید:
fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -lاگر زبانی که مد نظر شماست به unifont یا به یک فونت جایگزین بدون گلیفهای واقعی ارجاع داده میشود، fonts-noto-core و fonts-noto-cjk را نصب کنید و سپس بررسی را دوباره انجام دهید. Fontconfig نتایج را کش میکند، بنابراین پس از نصب فونتها، مرورگر را مجدداً راهاندازی کنید. یک ایمیج stripped که اصلاً فونتی ندارد، در هنگام شروع به کار خطای Fontconfig error: Cannot load default config file را در لاگ ثبت میکند و تمام صفحات را خالی رندر میکند.
تنظیمات Locale و منطقه زمانی (time zone) از فونتها جدا هستند و محتوای صفحه را تغییر میدهند، نه فقط ظاهر آن را. یک کانتینر معمولاً LANG تنظیمنشده و TZ را روی UTC دارد؛ بنابراین سایتها محتوا را به انگلیسی ارائه میدهند و زمانها را با فرمت UTC چاپ میکنند، و agent شما زمانهایی را گزارش میدهد که با آنچه یک فرد در آن کشور میبیند، مطابقت ندارد. این تنظیمات را بهجای سطح ماشین، برای هر context مرورگر تنظیم کنید تا یک مرورگر بتواند وظایف مربوط به مناطق مختلف را انجام دهد.
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/Paris',
});چرا نشت پردازشهای مرورگر باعث swap شدن سرور میشود
دو مشکل متفاوت با نام "zombie" شناخته میشوند. یک zombie واقعی، پردازشی پایانیافته است که والد آن هرگز wait() را فراخوانی نکرده است. این پردازش فقط یک ورودی در جدول PID اشغال میکند و چون حافظهای مصرف نمیکند، مشکلی ایجاد نمیکند. این موارد زمانی رخ میدهند که مرورگر به عنوان PID 1 در یک container اجرا شود، زیرا PID 1 بهطور پیشفرض قابلیت reaper ندارد. فلگ --init در Docker دقیقاً همین مشکل را با اجرای یک init کوچک که "سیگنالها را هدایت و پردازشها را جمعآوری میکند" حل میکند. در Compose، همین قابلیت با init: true در دسترس است.
نشتی که واقعاً باعث swap شدن سرور شما میشود متفاوت است: پردازشهای زنده Chromium که هیچکس آنها را نبسته است. این اتفاق زمانی میافتد که یک task بین newContext() و close() دچار خطا شود، یا زمانی که اسکریپت کنترلکننده کشته شود و درخت پردازشهای مرورگر را یتیم (orphaned) رها کند. بدترین حالت، کدی است که برای هر درخواست یک مرورگر جدید باز میکند. آنها را بشمارید:
pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20این شمارش باید بین هر task به مقدار اولیه (idle) بازگردد. اگر این تعداد در طول یک روز افزایش مییابد، راهحل در کد شماست و نه در فلگهای راهاندازی: context را در یک بلوک finally ببندید، مرورگر را در SIGTERM ببندید و به جای اجرای یک مرورگر برای یک ماه، آن را پس از تعداد مشخصی task بازیافت (recycle) کنید. تحت systemd، دستور stop یا restart تمام پردازشهای موجود در cgroup آن unit را میکشد، بنابراین sudo systemctl restart browser.service یک بازنشانی قابلاطمینان است. مرورگری که بهصورت دستی در یک terminal multiplexer اجرا شده باشد چنین تضمینی ندارد و پردازشهای یتیم آن پس از پایان session همچنان باقی میمانند.
یک کانتکست مرورگر به چه مقدار RAM نیاز دارد
این پرسش را دقیق مطرح کنید، زیرا «یک مرورگر» به معنای یک پردازش واحد نیست. Chromium یک پردازش مرورگر، یک پردازش GPU، پردازشهای جانبی (utility) و به ازای هر سایت یک پردازش رندر (renderer) اجرا میکند؛ همچنین قابلیت site isolation باعث میشود iframeهای بینسایتی نیز رندرکننده اختصاصی خود را داشته باشند. یک BrowserContext در واقع یک فضای ذخیرهسازی و کوکی مجزا در همان درخت پردازشی است، بنابراین ایجاد کانتکست دوم هزینه کمی دارد. اما صفحه دوم هزینه بیشتری دارد، زیرا پردازشهای رندر جدیدی را آغاز میکند و یک صفحه پر از تبلیغات، چندین پردازش را به راه میاندازد.
بنابراین عددی که باید اندازهگیری کنید، اوج مصرف حافظه (peak memory) برای کل درخت پردازشی تحت بار کاری خودتان است. ارقام موجود در وبلاگهای دیگران در اینجا بیارزش هستند، زیرا پاسخ به این سوال بستگی به صفحاتی دارد که عامل (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: در خروجی بالا، هم مصرف فعلی و هم اوج مصرف واحد را گزارش میدهد. worker را با یک صفحه در هر لحظه اجرا کنید، اوج مصرف را یادداشت کنید، سپس این کار را با دو صفحه باز تکرار کنید تا ببینید صفحه دوم واقعاً چه هزینهای دارد. محاسبه همزمانی (concurrency) یک عمل حسابی ساده است: کل RAM موجود را بردارید، مقدار مورد نیاز سایر بخشهای سیستم را از آن کم کنید، چند صد مگابایت فضای خالی (headroom) در نظر بگیرید و عدد باقیمانده را بر اوج مصرف اندازهگیریشده برای هر worker تقسیم کنید. برای تعیین ابعاد ماشین زیرساختی، به میزان RAM و CPU مورد نیاز برای یک VPS عامل مراجعه کنید.
این عدد را در دو نقطه اعمال کنید. در کد خود، از یک worker pool ثابت یا یک semaphore استفاده کنید تا در صورت هجوم درخواستهای عامل، به جای اجرای همزمان مرورگرها، درخواستها در صف قرار بگیرند. در سطح سیستمعامل، از محدودیت cgroup استفاده کنید تا یک باگ در صف نتواند کل ماشین را از کار بیندازد:
[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=alwaysMemorySwapMax=0 بیش از آنچه به نظر میرسد اهمیت دارد. بدون آن، cgroup هنگام رسیدن به حد مجاز، صفحات حافظه را به swap میفرستد؛ در نتیجه سیستم بالا میماند اما تمام درخواستها کند میشوند که عیبیابی آن بسیار دشوارتر از یک شکست (failure) واضح است. با فعال بودن آن، هسته سیستمعامل درخت مرورگر داخل آن cgroup را میکشد، systemd واحد را دوباره راهاندازی میکند و sshd سالم باقی میماند. کنترلهای مشابه در Compose عبارتند از mem_limit، shm_size و init که در تنظیم محدودیت حافظه در Docker Compose پوشش داده شدهاند.
محدود کردن دسترسی به endpoint مرورگر و جلوگیری از قرارگیری روی اینترنت عمومی
Playwright میتواند مرورگر را به عنوان یک سرور اجرا کرده و یک WebSocket URL در اختیار عامل (agent) شما قرار دهد:
const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());این endpoint فاقد هرگونه مکانیزم ورود است. مستندات API مربوط به Playwright مستقیماً بیان میکند: «هر پردازش یا صفحه وبی (از جمله مواردی که در Playwright اجرا میشوند) که از wsPath آگاه باشد، میتواند کنترل کاربر سیستمعامل را در دست بگیرد.» میزبان پیشفرض localhost است که «اتصالات را فقط از رابط loopback میپذیرد» و مستندات هشدار میدهند که تعیین یک آدرس صریح مانند 0.0.0.0، «پروتکل RPC مرورگر را در معرض هر چیزی که به پورت گوشدهنده دسترسی داشته باشد، قرار میدهد». پروتکل --remote-debugging-port خودِ Chrome وضعیت بدتری دارد. پروتکل DevTools فاقد هرگونه احراز هویت است و کاملاً به محدود بودن به رابط loopback وابسته است.
بررسی کنید که واقعاً چه چیزی را منتشر کردهاید؛ این بررسی را هم از یک ماشین دوم و هم از داخل خود VPS انجام دهید:
ss -ltnpهر چیزی که روی پورت مرورگر و متصل به 0.0.0.0 باشد، یک آسیبپذیری محسوب میشود. به یاد داشته باشید که اکثر ارائهدهندگان خدمات، یک فایروال شبکه مجزا در پنل کنترل خود دارند که قوانین ufw شما از آن بیاطلاع هستند. برای دسترسی به endpoint از ماشین دیگر، از یک تونل SSH یا یک VPN خصوصی استفاده کنید:
ssh -N -L 3000:127.0.0.1:3000 you@your-vpsخطر در اینجا بسیار فراتر از سرقت زمان پردازش مرورگر است. مرورگری که بتوانید آن را کنترل کنید، در واقع یک ماشین جعل درخواست (request-forgery) است که در داخل شبکه شما قرار دارد. هر کسی که به آن سوکت دسترسی پیدا کند، میتواند آن را وادار به فراخوانی http://127.0.0.1:8080، صفحه مدیریت دیتابیس شما یا آدرس متادیتای ابری در 169.254.169.254 کند و سپس پاسخ را از داخل صفحه بخواند. فایروال شما این درخواست را به عنوان درخواستی که از خودِ VPS میآید میبیند و آن را مجاز میشمارد. با این endpoint کنترلی، دقیقاً مانند دسترسی shell به آن سرور برخورد کنید.
سرورهای MCP نیز ساختار مشابهی دارند. npx @playwright/mcp@latest --headless --port 8931 از طریق HTTP روی localhost سرویسدهی میکند و --host 0.0.0.0 فلگی است که یک ابزار محلی را به یک ابزار عمومی تبدیل میکند. فایل README این پروژه به صراحت میگوید که Playwright MCP «یک مرز امنیتی نیست». پورت را روی loopback نگه دارید و اجازه دهید عامل (agent) از طریق همان تونل به آن دسترسی پیدا کند.
صفحاتی که عامل شما میخواند، ورودیهای غیرقابلاعتماد هستند
عاملی که وب عمومی را مرور میکند، متنی را که توسط افراد ناشناس نوشته شده است به مدلی تزریق میکند که دستورالعملهای شما را نیز در خود دارد. یک صفحه میتواند حاوی متنی باشد که خطاب به آن مدل نوشته شده و از آن بخواهد وظیفهاش را رها کند، ابزاری را فراخوانی کند یا دادهای را به یک URL خاص ارسال نماید. مدل هر دو مورد را به عنوان متن دریافت میکند، بنابراین راه مطمئنی برای تشخیص کلمات صفحه از دستورات شما ندارد. ساختار را بهگونهای طراحی کنید که یک صفحه مخرب، کمترین دسترسی و امکانات را داشته باشد.
- مرورگر را تحت یک کاربر سیستمعامل مجزا اجرا کنید، بهطوری که هیچ کلید SSH یا اعتبارنامه ابری در محیط آن وجود نداشته باشد.
- برای هر وظیفه از یک context تازه استفاده کنید و با استفاده از
--isolatedدر Playwright MCP، اطمینان حاصل کنید که نشست (session) یک سایت برای صفحه بعدی در دسترس نباشد. - در صورت امکان، یک لیست مجاز (allowlist) از مبدأها (origin) نگه دارید. Playwright MCP مقادیر
--allowed-originsو--blocked-originsرا به صورت لیستهای جدا شده با semicolon میپذیرد. - پیش از هر اقدامی که وضعیت را تغییر میدهد، مانند ارسال ایمیل یا تراکنش مالی، تأیید انسانی را الزامی کنید.
بهتر از آن، کل مرورگر را روی ماشینی نگه دارید که بتوانید آن را دور ریخته و دوباره بسازید؛ این همان استدلالی است که در اجرای عاملهای برنامهنویسی در یک VM یکبارمصرف مطرح شد. اگر وظیفه اصلی عامل، جستجو است و نه مرور آزاد وب، استفاده از ابزاری محدودتر از یک مرورگر کامل، امنتر است: یک مهارت جستجو که توسط SearXNG شخصی شما پشتیبانی میشود، نتایج را بدون بارگذاری صفحه مخرب بازمیگرداند.
FAQ
Why does Chromium crash in Docker but work fine on the same VPS directly?
Because the container gets a 64 MB /dev/shm by default while the host has a much larger one. Chromium passes rendered content through that shared memory area, so a heavy page fills it and the renderer dies. Run df -h /dev/shm inside the container to confirm, then start it either with --ipc=host, which uses the host's shared memory, or with --shm-size=1g, which enlarges the container's own. --disable-dev-shm-usage only relocates the problem to /tmp.
Is --no-sandbox safe if the VPS runs nothing else?
No. The sandbox is what stops a malicious page from reaching the rest of the machine, and Chromium's documentation says the flag "disables critical security features of Chromium and should never be used when browsing the open web". An agent following links is browsing the open web. Fix the cause instead: do not run the browser as root, and on Ubuntu 24.04 add an AppArmor profile carrying userns, for the browser binary path, so unprivileged user namespaces are allowed for that one program.
How many browsers can I run on a small VPS?
Measure it, do not copy a number. Chromium starts one renderer process per site, so the answer depends on the pages you open. Run one worker under systemd-run with MemoryMax set, read the peak from the Memory: line in systemctl status, then divide your free RAM by that peak and keep headroom. Enforce the result twice, with a queue in your code and a MemoryMax in the unit file, so a burst of requests waits instead of swapping the machine.
Can my agent connect to the browser from another machine?
Yes, but never by binding the port to 0.0.0.0. The Playwright server endpoint and the Chrome DevTools port both accept any client that can reach them, with no password. Keep the listener on 127.0.0.1 and carry the connection over an SSH tunnel or a private VPN. Verify with ss -ltnp on the server and a port check from outside, and look at your provider's separate network firewall too.
Why are my screenshots blank when the page clearly loaded?
Missing fonts. With no font covering the page's script, text renders as empty boxes or not at all, so an image-light page comes back looking blank. Run fc-match "sans-serif:lang=ko" for each language you scrape, install fonts-noto-core and fonts-noto-cjk when the answer is a generic fallback, and restart the browser so fontconfig reloads its cache. A container with no fonts at all logs Fontconfig error: Cannot load default config file at startup.