从零把 AI 浏览器跑上生产:Playwright 部署、安全加固与源码实战
AI浏览器 生产环境部署指南|附源码
本文将从架构设计、环境准备、后端服务、浏览器自动化、安全加固、Docker 部署、Nginx 反向代理、日志监控、性能优化等方面,完整讲解如何将一个“AI 浏览器”项目部署到生产环境。文末附带可直接运行的核心源码示例,适合用于二次开发、企业内部工具、AI Agent 浏览器执行器、网页自动化测试平台等场景。
一、什么是 AI 浏览器?
所谓 AI 浏览器,并不是传统意义上的 Chrome、Edge 或 Safari 浏览器,而是一个能够被 AI 调用、控制和理解网页内容的浏览器执行环境。
它通常具备以下能力:
- 打开指定网页
- 读取网页 DOM 内容
- 截图或生成页面快照
- 点击按钮、输入文本、滚动页面
- 提取网页中的结构化信息
- 结合大模型完成任务规划
- 将浏览器操作结果返回给用户或业务系统
在实际业务中,AI 浏览器可以应用于:
- 自动化网页数据采集
- AI Agent 网页操作执行器
- 表单自动填写
- 订单状态查询
- 网页内容摘要
- 自动化测试
- 内部管理后台辅助操作
- RPA 智能化升级
举个例子,用户输入:
帮我打开某个商品页面,提取商品标题、价格、库存状态,并截图保存。
AI 浏览器就可以自动打开网页,等待页面加载,解析页面内容,截取页面图片,并返回结构化数据。
二、生产环境部署的核心问题
在本地开发环境中,我们通常直接运行:
npm run dev
或者:
node server.js
但生产环境部署远比本地运行复杂,需要考虑以下问题:
| 问题 | 说明 |
|---|---|
| 稳定性 | 服务不能频繁崩溃,需要进程守护 |
| 并发控制 | 浏览器实例非常消耗资源,需要限制任务数量 |
| 安全性 | 不能让外部用户随意访问内网、执行危险操作 |
| 资源隔离 | 每个浏览器任务应尽量隔离上下文 |
| 日志追踪 | 每个任务的执行过程要可观测 |
| 超时机制 | 防止页面长时间卡死 |
| 容器化 | 方便迁移、扩容和回滚 |
| 反向代理 | 统一 HTTPS、域名和限流 |
| 监控告警 | CPU、内存、任务失败率需要监控 |
因此,生产环境的 AI 浏览器部署不能只是“能跑”,而是要做到:
安全、稳定、可观测、可扩展、可维护。
三、整体架构设计
本文示例采用如下技术栈:
- Node.js:提供 HTTP API 服务
- Express:构建后端接口
- Playwright:控制 Chromium 浏览器
- Docker:容器化部署
- Docker Compose:编排服务
- Nginx:反向代理、HTTPS、限流
- PM2 / Docker restart policy:进程守护
- Redis:可选,用于任务队列和并发控制
- Prometheus / Grafana:可选,用于监控
基础架构如下:
用户 / 业务系统
|
v
Nginx
|
v
AI Browser API Service
|
v
Playwright Chromium
|
v
目标网页
如果是更高并发的生产环境,可以进一步拆分为:
Client
|
Nginx / API Gateway
|
AI Browser API
|
Task Queue Redis
|
Browser Worker Cluster
|
Chromium / Browser Context
四、服务器环境要求
AI 浏览器对服务器资源要求相对较高,尤其是内存和 CPU。
1. 推荐配置
| 场景 | CPU | 内存 | 磁盘 |
|---|---|---|---|
| 测试环境 | 2 核 | 4 GB | 30 GB |
| 小型生产 | 4 核 | 8 GB | 50 GB |
| 中型生产 | 8 核 | 16 GB | 100 GB |
| 高并发生产 | 16 核以上 | 32 GB 以上 | 200 GB 以上 |
2. 系统推荐
建议使用:
Ubuntu 22.04 LTS
因为 Ubuntu 对 Docker、Playwright、Chromium 依赖支持较好。
3. 安装基础软件
sudo apt update
sudo apt install -y curl wget git vim ufw ca-certificates gnupg
安装 Docker:
curl -fsSL https://get.docker.com | bash
sudo systemctl enable docker
sudo systemctl start docker
安装 Docker Compose:
sudo apt install -y docker-compose-plugin
验证:
docker --version
docker compose version
五、项目目录结构
下面是一个适合生产部署的 AI 浏览器项目结构:
ai-browser/
├── src/
│ ├── app.js
│ ├── browser.js
│ ├── config.js
│ ├── middleware/
│ │ ├── auth.js
│ │ └── error.js
│ └── routes/
│ └── browser.route.js
├── logs/
├── screenshots/
├── Dockerfile
├── docker-compose.yml
├── nginx.conf
├── package.json
├── .env.example
└── README.md
目录说明:
| 文件/目录 | 说明 |
|---|---|
src/app.js |
Express 服务入口 |
src/browser.js |
Playwright 浏览器控制逻辑 |
src/config.js |
配置文件 |
middleware/auth.js |
API Key 鉴权 |
middleware/error.js |
错误处理中间件 |
routes/browser.route.js |
浏览器相关接口 |
screenshots/ |
截图保存目录 |
logs/ |
日志目录 |
Dockerfile |
容器构建文件 |
docker-compose.yml |
容器编排文件 |
nginx.conf |
Nginx 反向代理配置 |
六、后端接口设计
生产环境中,不建议让前端直接调用底层 Playwright 操作,而是封装成明确的 API。
示例接口如下:
| 方法 | 路径 | 功能 |
|---|---|---|
GET |
/health |
健康检查 |
POST |
/api/browser/open |
打开网页并返回标题 |
POST |
/api/browser/screenshot |
打开网页并截图 |
POST |
/api/browser/extract |
提取网页文本内容 |
POST |
/api/browser/action |
执行点击、输入等操作 |
请求示例:
curl -X POST https://browser.example.com/api/browser/screenshot \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{
"url": "https://example.com",
"fullPage": true
}'
返回示例:
{
"success": true,
"data": {
"title": "Example Domain",
"screenshot": "/screenshots/1700000000000.png"
}
}
七、核心源码
下面给出一个可运行的基础版本源码。
1. package.json
{
"name": "ai-browser",
"version": "1.0.0",
"description": "AI Browser production deployment demo with Playwright",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"playwright": "^1.42.1",
"uuid": "^9.0.1"
}
}
2. .env.example
PORT=3000
API_KEY=change-this-secret-key
NODE_ENV=production
BROWSER_HEADLESS=true
REQUEST_TIMEOUT=30000
MAX_TEXT_LENGTH=8000
SCREENSHOT_DIR=./screenshots
生产环境中请复制一份:
cp .env.example .env
然后修改:
API_KEY=一个足够复杂的密钥
3. src/config.js
require("dotenv").config();
const config = {
port: Number(process.env.PORT || 3000),
apiKey: process.env.API_KEY || "change-this-secret-key",
nodeEnv: process.env.NODE_ENV || "development",
browserHeadless: process.env.BROWSER_HEADLESS !== "false",
requestTimeout: Number(process.env.REQUEST_TIMEOUT || 30000),
maxTextLength: Number(process.env.MAX_TEXT_LENGTH || 8000),
screenshotDir: process.env.SCREENSHOT_DIR || "./screenshots"
};
module.exports = config;
4. src/middleware/auth.js
const config = require("../config");
function auth(req, res, next) {
const apiKey = req.headers["x-api-key"];
if (!apiKey || apiKey !== config.apiKey) {
return res.status(401).json({
success: false,
message: "Unauthorized"
});
}
next();
}
module.exports = auth;
5. src/middleware/error.js
function errorHandler(err, req, res, next) {
console.error("[ERROR]", err);
res.status(err.status || 500).json({
success: false,
message: err.message || "Internal Server Error"
});
}
module.exports = errorHandler;
6. src/browser.js
const { chromium } = require("playwright");
const path = require("path");
const fs = require("fs");
const { v4: uuidv4 } = require("uuid");
const config = require("./config");
let browserPromise = null;
async function getBrowser() {
if (!browserPromise) {
browserPromise = chromium.launch({
headless: config.browserHeadless,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-setuid-sandbox",
"--disable-web-security"
]
});
}
return browserPromise;
}
function validateUrl(url) {
if (!url) {
throw new Error("URL is required");
}
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("Only http and https protocols are allowed");
}
/**
* 生产环境建议增加 SSRF 防护:
* 1. 禁止访问 127.0.0.1
* 2. 禁止访问 localhost
* 3. 禁止访问内网 IP
* 4. 使用 DNS 解析后校验真实 IP
*/
const hostname = parsed.hostname;
const blockedHosts = [
"localhost",
"127.0.0.1",
"0.0.0.0"
];
if (blockedHosts.includes(hostname)) {
throw new Error("Access to local addresses is not allowed");
}
return parsed.toString();
}
async function createPage() {
const browser = await getBrowser();
const context = await browser.newContext({
viewport: {
width: 1365,
height: 768
},
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
});
const page = await context.newPage();
page.setDefaultTimeout(config.requestTimeout);
page.setDefaultNavigationTimeout(config.requestTimeout);
return {
context,
page
};
}
async function openUrl(url) {
const safeUrl = validateUrl(url);
const { context, page } = await createPage();
try {
await page.goto(safeUrl, {
waitUntil: "domcontentloaded",
timeout: config.requestTimeout
});
const title = await page.title();
return {
title,
url: page.url()
};
} finally {
await context.close();
}
}
async function screenshot(url, fullPage = true) {
const safeUrl = validateUrl(url);
const { context, page } = await createPage();
try {
await page.goto(safeUrl, {
waitUntil: "networkidle",
timeout: config.requestTimeout
});
if (!fs.existsSync(config.screenshotDir)) {
fs.mkdirSync(config.screenshotDir, {
recursive: true
});
}
const fileName = `${Date.now()}-${uuidv4()}.png`;
const filePath = path.join(config.screenshotDir, fileName);
await page.screenshot({
path: filePath,
fullPage
});
const title = await page.title();
return {
title,
fileName,
path: filePath
};
} finally {
await context.close();
}
}
async function extractText(url) {
const safeUrl = validateUrl(url);
const { context, page } = await createPage();
try {
await page.goto(safeUrl, {
waitUntil: "domcontentloaded",
timeout: config.requestTimeout
});
const title = await page.title();
let text = await page.locator("body").innerText({
timeout: config.requestTimeout
});
if (text.length > config.maxTextLength) {
text = text.slice(0, config.maxTextLength);
}
return {
title,
url: page.url(),
text
};
} finally {
await context.close();
}
}
async function performActions(url, actions = []) {
const safeUrl = validateUrl(url);
const { context, page } = await createPage();
try {
await page.goto(safeUrl, {
waitUntil: "domcontentloaded",
timeout: config.requestTimeout
});
for (const action of actions) {
if (action.type === "click") {
await page.click(action.selector);
}
if (action.type === "fill") {
await page.fill(action.selector, action.value || "");
}
if (action.type === "wait") {
await page.waitForTimeout(Number(action.ms || 1000));
}
if (action.type === "press") {
await page.press(action.selector, action.key);
}
}
const title = await page.title();
const text = await page.locator("body").innerText();
return {
title,
url: page.url(),
text: text.slice(0, config.maxTextLength)
};
} finally {
await context.close();
}
}
async function closeBrowser() {
if (browserPromise) {
const browser = await browserPromise;
await browser.close();
browserPromise = null;
}
}
module.exports = {
openUrl,
screenshot,
extractText,
performActions,
closeBrowser
};
7. src/routes/browser.route.js
const express = require("express");
const {
openUrl,
screenshot,
extractText,
performActions
} = require("../browser");
const router = express.Router();
router.post("/open", async (req, res, next) => {
try {
const { url } = req.body;
const data = await openUrl(url);
res.json({
success: true,
data
});
} catch (err) {
next(err);
}
});
router.post("/screenshot", async (req, res, next) => {
try {
const { url, fullPage } = req.body;
const data = await screenshot(url, fullPage !== false);
res.json({
success: true,
data
});
} catch (err) {
next(err);
}
});
router.post("/extract", async (req, res, next) => {
try {
const { url } = req.body;
const data = await extractText(url);
res.json({
success: true,
data
});
} catch (err) {
next(err);
}
});
router.post("/action", async (req, res, next) => {
try {
const { url, actions } = req.body;
const data = await performActions(url, actions || []);
res.json({
success: true,
data
});
} catch (err) {
next(err);
}
});
module.exports = router;
8. src/app.js
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const path = require("path");
const config = require("./config");
const auth = require("./middleware/auth");
const errorHandler = require("./middleware/error");
const browserRoute = require("./routes/browser.route");
const { closeBrowser } = require("./browser");
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json({
limit: "1mb"
}));
app.use(morgan("combined"));
app.get("/health", (req, res) => {
res.json({
success: true,
status: "ok",
uptime: process.uptime()
});
});
app.use("/screenshots", express.static(path.resolve(config.screenshotDir)));
app.use("/api/browser", auth, browserRoute);
app.use(errorHandler);
const server = app.listen(config.port, () => {
console.log(`AI Browser service running on port ${config.port}`);
});
async function shutdown() {
console.log("Shutting down AI Browser service...");
server.close(async () => {
await closeBrowser();
process.exit(0);
});
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
八、本地运行测试
安装依赖:
npm install
安装 Playwright 浏览器:
npx playwright install chromium
启动服务:
npm run dev
测试健康检查:
curl http://localhost:3000/health
测试截图接口:
curl -X POST http://localhost:3000/api/browser/screenshot \
-H "Content-Type: application/json" \
-H "X-API-Key: change-this-secret-key" \
-d '{
"url": "https://example.com",
"fullPage": true
}'
九、Dockerfile 编写
生产环境推荐使用 Docker 部署。Playwright 官方提供了带浏览器依赖的镜像,可以避免手动安装大量 Chromium 依赖。
Dockerfile
FROM mcr.microsoft.com/playwright:v1.42.1-jammy
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
RUN mkdir -p /app/screenshots /app/logs
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]
构建镜像:
docker build -t ai-browser:1.0.0 .
运行容器:
docker run -d \
--name ai-browser \
-p 3000:3000 \
--env-file .env \
-v $(pwd)/screenshots:/app/screenshots \
--restart always \
ai-browser:1.0.0
查看日志:
docker logs -f ai-browser
十、Docker Compose 部署
docker-compose.yml
services:
ai-browser:
build:
context: .
dockerfile: Dockerfile
container_name: ai-browser
restart: always
env_file:
- .env
ports:
- "3000:3000"
volumes:
- ./screenshots:/app/screenshots
- ./logs:/app/logs
shm_size: "1gb"
security_opt:
- seccomp=unconfined
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
启动:
docker compose up -d --build
查看状态:
docker compose ps
查看日志:
docker compose logs -f
停止:
docker compose down
升级:
git pull
docker compose up -d --build
十一、Nginx 反向代理配置
生产环境通常不会直接暴露 3000 端口,而是通过 Nginx 统一代理。
nginx.conf 示例
server {
listen 80;
server_name browser.example.com;
client_max_body_size 2m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
启用配置:
sudo ln -s /etc/nginx/sites-available/ai-browser.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
如果需要 HTTPS,推荐使用 Certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d browser.example.com
十二、生产环境安全加固
AI 浏览器的安全非常重要,因为它具备访问网页、执行点击、输入内容等能力。如果没有限制,可能被恶意利用。
1. API Key 鉴权
本文示例已经实现:
X-API-Key: your-secret-key
生产环境建议:
- 使用足够长的随机密钥
- 定期轮换密钥
- 不要把密钥写死在前端代码中
- 通过后端服务间调用
2. SSRF 防护
AI 浏览器可以访问 URL,因此必须防止访问内网地址。例如:
http://127.0.0.1http://localhosthttp://169.254.169.254http://10.0.0.1http://192.168.1.1http://172.16.0.1
生产级实现应该对域名解析后的 IP 做检查,防止通过 DNS 绕过。
3. 限制请求体大小
示例中配置了:
app.use(express.json({
limit: "1mb"
}));
防止用户提交过大的请求体导致内存占用过高。
4. 限制并发
浏览器任务非常消耗资源。即使服务器配置较高,也不能无限创建页面。
可以使用简单信号量控制并发,例如限制同时最多执行 5 个任务。
class Semaphore {
constructor(max) {
this.max = max;
this.current = 0;
this.queue = [];
}
async acquire() {
if (this.current < this.max) {
this.current++;
return;
}
await new Promise(resolve => this.queue.push(resolve));
this.current++;
}
release() {
this.current--;
const next = this.queue.shift();
if (next) {
next();
}
}
}
module.exports = new Semaphore(5);
然后在接口执行前后调用:
await semaphore.acquire();
try {
// 执行浏览器任务
} finally {
semaphore.release();
}
5. 禁止危险操作
如果开放 /action 接口,建议不要允许用户任意执行 JavaScript,例如:
page.evaluate(userInput)
这是非常危险的,因为用户可以构造恶意脚本。
建议只开放有限动作:
- click
- fill
- press
- wait
- screenshot
- extractText
十三、性能优化建议
1. 复用 Browser,隔离 Context
不要每次请求都启动一个新的 Chromium 进程。启动浏览器成本很高。
推荐方式:
- 全局复用
browser - 每个请求创建新的
context - 请求结束关闭
context
本文源码就是这种方式:
const browser = await getBrowser();
const context = await browser.newContext();
这样既能提升性能,也能保证 cookie、localStorage 等上下文隔离。
2. 设置超时时间
所有页面导航和元素操作都应该设置超时时间:
page.setDefaultTimeout(30000);
page.setDefaultNavigationTimeout(30000);
否则页面异常时可能长期占用资源。
3. 合理使用 waitUntil
常见选项:
| 选项 | 说明 |
|---|---|
load |
等待 load 事件 |
domcontentloaded |
DOM 加载完成即可 |
networkidle |
网络空闲,适合截图 |
commit |
导航提交即可 |
如果只是提取标题或正文,推荐:
waitUntil: "domcontentloaded"
如果要截图,推荐:
waitUntil: "networkidle"
但 networkidle 在部分网站可能等待较久,因此要结合超时时间使用。
4. 定期清理截图文件
截图文件长期保存会占满磁盘。可以使用定时任务清理 7 天前的图片:
find /path/to/ai-browser/screenshots -type f -mtime +7 -delete
加入 crontab:
crontab -e
添加:
0 3 * * * find /path/to/ai-browser/screenshots -type f -mtime +7 -delete
十四、日志与监控
生产环境不能只依靠 console.log。至少需要关注:
- 请求耗时
- 错误堆栈
- 任务成功率
- 浏览器崩溃次数
- CPU 使用率
- 内存使用率
- 磁盘剩余空间
- 截图目录大小
如果使用 Docker,可以先通过以下命令排查:
docker stats ai-browser
docker logs -f ai-browser
如果需要更完整的监控体系,可以使用:
- Prometheus
- Grafana
- Loki
- ELK
- OpenTelemetry
十五、常见问题排查
1. Chromium 启动失败
常见原因是系统依赖缺失。推荐直接使用 Playwright 官方 Docker 镜像:
FROM mcr.microsoft.com/playwright:v1.42.1-jammy
2. 页面截图为空白
可能原因:
- 页面还没加载完成
- 目标网站有反爬机制
- 页面依赖登录态
- CSS 或字体加载失败
可以尝试:
await page.goto(url, {
waitUntil: "networkidle"
});
await page.waitForTimeout(1000);
3. Docker 中浏览器崩溃
可增加共享内存:
shm_size: "1gb"
或者启动参数增加:
"--disable-dev-shm-usage"
4. 请求偶发超时
可能是目标网站响应慢,也可能是服务器并发过高。
建议:
- 增加超时时间
- 降低并发
- 增加任务队列
- 横向扩容 Worker
5. 访问部分网站被拦截
部分网站会识别自动化浏览器。可优化:
- 设置合理 User-Agent
- 使用真实浏览器上下文
- 降低访问频率
- 避免异常操作模式
- 必要时接入代理池
注意:实际业务中应遵守目标网站的服务条款与法律法规。
十六、上线检查清单
上线前建议逐项检查:
- [ ]
.env中 API Key 已修改 - [ ] Nginx 已开启 HTTPS
- [ ] 防火墙未直接暴露不必要端口
- [ ] Docker 设置了
restart: always - [ ] 截图目录已挂载到宿主机
- [ ] 日志可查看
- [ ] 设置了请求超时
- [ ] 设置了接口鉴权
- [ ] 设置了 SSRF 防护
- [ ] 设置了并发限制
- [ ] 设置了磁盘清理任务
- [ ] 健康检查接口正常
- [ ] 压测通过
- [ ] 备份和回滚方案明确
十七、与大模型集成的思路
AI 浏览器通常不是单独存在,而是作为大模型 Agent 的工具之一。
例如,可以把接口封装成工具:
{
"name": "browser_extract",
"description": "打开网页并提取正文内容",
"parameters": {
"url": "需要访问的网页地址"
}
}
当用户说:
总结这个网页的主要内容:https://example.com
大模型可以先调用 AI 浏览器提取网页正文,再对正文进行总结。
更复杂的 Agent 可以支持:
- 规划任务步骤
- 调用浏览器打开页面
- 观察页面内容
- 判断下一步点击或输入
- 多轮执行
- 生成最终结果
这类系统通常需要额外设计:
- 工具调用协议
- 页面观察格式
- 操作白名单
- 任务状态机
- 多轮上下文管理
- 错误恢复机制
十八、总结
本文完整介绍了一个 AI 浏览器从开发到生产部署的基本方案,并提供了可运行的 Node.js + Playwright 源码示例。
在生产环境中,AI 浏览器的重点不是简单地“打开网页”,而是要解决:
- 如何稳定运行
- 如何隔离浏览器上下文
- 如何防止恶意访问
- 如何控制资源消耗
- 如何记录日志和排查问题
- 如何通过 Docker 和 Nginx 实现标准化部署
如果只是内部低频使用,本文提供的单服务版本已经足够;如果要面向高并发业务,则建议进一步演进为“API 服务 + Redis 队列 + Worker 集群”的架构。
最终,一个可靠的 AI 浏览器服务应该具备以下特征:
API 清晰、权限严格、并发可控、任务可观测、部署可重复、故障可恢复。
有了这套基础设施,后续无论是接入大模型 Agent、构建自动化采集系统,还是开发企业内部智能助手,都可以在此基础上快速扩展。