要对一个大规模WebRTC应用进行压力测试,模拟成百上千的并发用户,很快就会发现这不是一件简单的事。市面上的SaaS服务成本高昂,且灵活性不足以覆盖我们复杂的业务场景。手动在多台机器上运行浏览器自动化脚本,则是一场运维噩梦。我们需要的是一个可程序化控制、环境一致、并且能弹性伸缩的浏览器集群。这个需求,最终将Packer、Puppeteer、gRPC、WebRTC和Vite这几个看似不相关的技术栈串联成了一个完整的解决方案。
问题的核心在于如何管理和调度一个无头浏览器(Headless Browser)的机队。最初的构想是在每个测试节点上部署一个简单的HTTP服务,接收指令来启动Puppeteer任务。但这很快就暴露了问题:HTTP的请求-响应模式对于需要实时状态反馈和双向控制的场景显得笨拙。我们需要知道每个浏览器实例的实时状态(空闲、运行中、崩溃),并能随时下发新的指令,甚至在测试中途进行干预。这正是gRPC的用武之地。
定义控制契约:gRPC与Protobuf
gRPC的性能和基于HTTP/2的双向流能力,使其成为我们控制平面的不二之选。首先,我们需要用Protobuf定义服务契约,这是整个系统的“API文档”。
bot_controller.proto
文件定义了两个核心服务:一个用于控制浏览器机器人(BotController
),一个用于机器人向控制器上报心跳和状态(BotHeartbeat
)。
syntax = "proto3";
package bot.v1;
// BotController 服务定义了从控制中心到浏览器机器人的指令
service BotController {
// 启动一个新的浏览器会话,执行特定任务
rpc StartSession(StartSessionRequest) returns (StartSessionResponse);
// 强制终止一个正在运行的会话
rpc TerminateSession(TerminateSessionRequest) returns (TerminateSessionResponse);
}
// BotHeartbeat 服务定义了机器人向控制中心发送心跳和状态
service BotHeartbeat {
// 机器人定期发送心跳,汇报自身状态
rpc SendHeartbeat(stream HeartbeatRequest) returns (HeartbeatResponse);
}
// 机器人状态枚举
enum BotStatus {
BOT_STATUS_UNSPECIFIED = 0;
BOT_STATUS_IDLE = 1; // 空闲,可接收新任务
BOT_STATUS_BUSY = 2; // 正在执行任务
BOT_STATUS_ERROR = 3; // 发生错误,需要干预
}
message StartSessionRequest {
string session_id = 1; // 唯一的会话ID
string target_url = 2; // WebRTC应用的目标URL
string user_id = 3; // 模拟用户的ID
// 可以包含更多特定于场景的参数,例如要加入的房间名等
map<string, string> custom_params = 4;
}
message StartSessionResponse {
string session_id = 1;
bool success = 2;
string message = 3; // 成功或失败的信息
}
message TerminateSessionRequest {
string session_id = 1;
}
message TerminateSessionResponse {
string session_id = 1;
bool success = 2;
}
message HeartbeatRequest {
string bot_id = 1; // 运行机器人的节点ID
BotStatus status = 2;
string current_session_id = 3; // 如果BUSY,当前会话的ID
int64 timestamp = 4; // 心跳时间戳
string error_message = 5; // 如果是ERROR状态,附带错误信息
}
message HeartbeatResponse {
bool acknowledged = 1;
}
这个 .proto
文件是整个系统的基石。它清晰地定义了数据结构和RPC方法,为服务端和客户端的实现提供了强类型保证。特别是 SendHeartbeat
使用了客户端流,允许每个机器人Agent持续不断地向控制中心推送自己的状态,这比轮询模式高效得多。
实现gRPC Agent:Puppeteer的执行者
每个测试节点上都需要运行一个gRPC服务(我们称之为Agent),它负责接收指令并操控Puppeteer。这个Agent是用Node.js实现的。
agent.js
的核心逻辑是实现 BotController
服务,并在内部维护一个Puppeteer浏览器的实例池(尽管在我们的简化版中,一个Agent只管理一个浏览器实例)。
// agent.js
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const puppeteer = require('puppeteer');
const { v4: uuidv4 } = require('uuid');
const PROTO_PATH = './bot_controller.proto';
const BOT_ID = `bot-${uuidv4()}`;
const SERVER_ADDRESS = '0.0.0.0:50051';
let browser = null;
let botStatus = 'IDLE'; // 'IDLE', 'BUSY', 'ERROR'
let currentSessionId = null;
let errorMessage = '';
// 加载 protobuf
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const botProto = grpc.loadPackageDefinition(packageDefinition).bot.v1;
// 核心:Puppeteer 控制逻辑
async function runWebRTCSession(sessionId, url, userId) {
let page = null;
try {
if (!browser) {
throw new Error('Browser is not initialized.');
}
page = await browser.newPage();
// 授予摄像头和麦克风权限
const context = browser.defaultBrowserContext();
await context.overridePermissions(url, ['camera', 'microphone']);
await page.goto(url, { waitUntil: 'networkidle0' });
// 注入用户ID,并触发WebRTC逻辑
// 这里的 'joinRoom' 是目标WebRTC应用中预定义的全局函数
await page.evaluate((id) => {
// @ts-ignore
window.joinRoom(id);
}, userId);
console.log(`[${sessionId}] Session started for user ${userId} at ${url}`);
// 实际项目中,这里会有一个循环来监控页面状态或等待测试结束信号
// 为了演示,我们只是简单地等待
await new Promise(resolve => setTimeout(resolve, 60000)); // 模拟会话持续1分钟
} catch (error) {
console.error(`[${sessionId}] Error during session:`, error);
throw error; // 向上抛出,由 gRPC 服务捕获
} finally {
if (page) {
await page.close();
}
}
}
// 实现 BotController 服务
const botControllerService = {
startSession: async (call, callback) => {
const { session_id, target_url, user_id } = call.request;
if (botStatus === 'BUSY') {
return callback({
code: grpc.status.FAILED_PRECONDITION,
message: 'Bot is already busy with another session.',
});
}
botStatus = 'BUSY';
currentSessionId = session_id;
errorMessage = '';
console.log(`[${session_id}] Received startSession command.`);
try {
await runWebRTCSession(session_id, target_url, user_id);
console.log(`[${session_id}] Session completed successfully.`);
callback(null, { session_id, success: true, message: 'Session completed.' });
} catch (error) {
console.error(`[${session_id}] Session failed.`);
botStatus = 'ERROR';
errorMessage = error.message;
callback({
code: grpc.status.INTERNAL,
message: `Session failed: ${error.message}`,
}, null);
} finally {
// 任务结束后,重置状态
if (botStatus !== 'ERROR') {
botStatus = 'IDLE';
}
currentSessionId = null;
}
},
terminateSession: (call, callback) => {
// 实际项目中需要更复杂的逻辑来安全地终止Puppeteer进程
console.log(`Received terminate for session ${call.request.session_id}`);
botStatus = 'IDLE';
currentSessionId = null;
callback(null, { session_id: call.request.session_id, success: true });
}
};
// 启动 gRPC 服务器
async function main() {
// 初始化 Puppeteer
try {
console.log('Initializing Puppeteer browser...');
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-fake-ui-for-media-stream', // 伪造媒体流UI,自动授权
'--use-fake-device-for-media-stream', // 使用伪造的媒体设备
],
});
console.log('Browser initialized.');
} catch (error) {
console.error('Failed to launch browser:', error);
process.exit(1);
}
const server = new grpc.Server();
server.addService(botProto.BotController.service, botControllerService);
server.bindAsync(SERVER_ADDRESS, grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) {
console.error('Failed to bind server:', err);
return;
}
console.log(`gRPC agent server running at http://${SERVER_ADDRESS}`);
server.start();
});
// 心跳逻辑可以后续添加,连接到中央控制器
}
main();
这里的关键点在于Puppeteer的启动参数。--use-fake-ui-for-media-stream
和 --use-fake-device-for-media-stream
至关重要,它们让无头浏览器能够模拟出一个虚拟的摄像头和麦克风设备,并自动批准媒体权限请求,这是成功运行WebRTC应用的前提。
环境标准化:Packer与不可变基础设施
在多台机器上手动部署上述Node.js Agent和其依赖(Node.js运行时、Chrome浏览器、各种系统库)是极其痛苦且容易出错的。不同机器的操作系统、库版本稍有差异,就可能导致Puppeteer无法启动或运行异常。
这里的最佳实践是采用不可变基础设施的理念。我们使用Packer来构建一个“黄金镜像”(Golden Image),例如AWS的AMI。这个镜像预装了所有必需的软件和配置,每次启动新实例时,它们都是一模一样的。
ubuntu-focal-webrtc-bot.pkr.hcl
文件定义了镜像的构建过程:
// ubuntu-focal-webrtc-bot.pkr.hcl
packer {
required_plugins {
amazon = {
version = ">= 1.0.0"
source = "github.com/hashicorp/amazon"
}
}
}
variable "aws_access_key" {
type = string
default = env("AWS_ACCESS_KEY_ID")
}
variable "aws_secret_key" {
type = string
default = env("AWS_SECRET_ACCESS_KEY")
sensitive = true
}
source "amazon-ebs" "ubuntu" {
access_key = var.aws_access_key
secret_key = var.aws_secret_key
region = "us-east-1"
instance_type = "t3.medium"
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"
root-device-type = "ebs"
virtualization-type = "hvm"
}
most_recent = true
owners = ["099720109477"] # Canonical's owner ID
}
ssh_username = "ubuntu"
ami_name = "webrtc-bot-agent-{{timestamp}}"
tags = {
Name = "WebRTC-Bot-Agent"
OS = "Ubuntu 20.04"
}
}
build {
name = "webrtc-bot-agent"
sources = ["source.amazon-ebs.ubuntu"]
provisioner "shell" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y curl gnupg",
// 安装 Node.js 18.x
"curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -",
"sudo apt-get install -y nodejs",
// 安装 Google Chrome Stable
"curl -sS https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome-keyring.gpg",
"echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome-keyring.gpg] http://dl.google.com/dl/chrome/linux/stable/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list",
"sudo apt-get update",
"sudo apt-get install -y google-chrome-stable",
// 安装 Puppeteer 依赖的库
"sudo apt-get install -y libnss3 libxss1 libasound2 libatk-bridge2.0-0 libgtk-3-0",
]
}
provisioner "file" {
source = "./agent/" // 将包含 agent.js, package.json, bot_controller.proto 的文件夹上传
destination = "/tmp/agent"
}
provisioner "shell" {
inline = [
"cd /tmp/agent",
"npm install",
"sudo mv /tmp/agent /opt/webrtc-agent", // 将应用移动到最终位置
// 创建 systemd 服务
"sudo bash -c 'cat > /etc/systemd/system/webrtc-agent.service <<EOF\n[Unit]\nDescription=WebRTC Bot Agent Service\nAfter=network.target\n\n[Service]\nType=simple\nUser=ubuntu\nWorkingDirectory=/opt/webrtc-agent\nExecStart=/usr/bin/node agent.js\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\nEOF'",
"sudo systemctl enable webrtc-agent.service",
]
}
}
这个Packer配置做了几件关键事情:
- 选择基础AMI: 从最新的Ubuntu 20.04官方镜像开始。
- 安装依赖: 使用
shell
provisioner安装Node.js、Google Chrome稳定版以及Puppeteer运行所需的各种共享库。这是一个常见的坑,缺少这些库会导致Chrome无法启动。 - 部署应用代码: 使用
file
provisioner将我们的gRPC Agent代码上传到镜像中。 - 配置自启动: 创建一个
systemd
服务,确保实例启动后,我们的agent.js
能自动运行。
运行 packer build .
之后,我们就得到了一个包含了所有依赖和代码的AMI。基于这个AMI启动的任何EC2实例,都将是一个开箱即用的、功能完备的WebRTC测试机器人。
被测对象:一个Vite构建的WebRTC应用
我们的测试机器人总需要一个目标应用来连接。为了完整性,我们用Vite快速构建一个简单的WebRTC应用。这个应用不需要复杂的功能,只需能建立Peer-to-Peer连接即可。
main.js
部分核心逻辑:
// main.js - A minimal WebRTC client
import './style.css';
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
let localStream;
let peerConnection;
const config = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
// 暴露给 Puppeteer 调用的全局函数
window.joinRoom = async (userId) => {
console.log(`User ${userId} joining...`);
try {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
// 实际应用中,这里需要一个信令服务器来交换SDP和ICE Candidate
// 为了简化,我们假设这个过程已经通过某种方式完成
// 在真实测试中,信令交互是 Puppeteer 脚本需要处理的重要部分
startPeerConnection();
} catch (e) {
console.error('getUserMedia error:', e);
}
};
function startPeerConnection() {
peerConnection = new RTCPeerConnection(config);
peerConnection.onicecandidate = event => {
if (event.candidate) {
// Send candidate to the other peer via signaling server
}
};
peerConnection.ontrack = event => {
remoteVideo.srcObject = event.streams[0];
};
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
}
Vite的价值在于其极速的开发服务器和优化的构建输出,让我们能快速迭代这个被测应用。在测试场景中,这个应用会被部署到一个URL上,作为我们StartSessionRequest
中的target_url
。
架构整合与工作流
至此,所有技术组件都已就位。整个系统的架构和工作流程可以用一个图来表示:
graph TD subgraph "控制平面" A[Controller/Test Scheduler] end subgraph "AWS云环境" B[Packer] -- builds --> C[Custom AMI] D[Auto Scaling Group] -- uses --> C D -- launches --> E1[EC2 Instance 1] D -- launches --> E2[EC2 Instance 2] D -- launches --> E3[EC2 Instance N] end subgraph "测试目标" J[WebRTC App on Server] end A -- gRPC Call (StartSession) --> E1 A -- gRPC Call (StartSession) --> E2 subgraph "EC2 Instance (from AMI)" F[systemd] -- starts --> G[Node.js gRPC Agent] G -- controls --> H[Puppeteer/Headless Chrome] end E1 --- F H -- navigates to --> J style B fill:#f9f,stroke:#333,stroke-width:2px style C fill:#ccf,stroke:#333,stroke-width:2px
工作流程如下:
- 构建阶段 (CI/CD): 开发者更新Agent代码或依赖,触发CI流水线,运行
packer build
命令生成一个新的AMI。 - 部署阶段 (IaC): 使用Terraform或CloudFormation更新Auto Scaling Group(ASG)的启动配置,使其指向新的AMI ID。ASG负责根据负载(或预设数量)自动启动或终止EC2实例。
- 运行阶段: 每个由ASG启动的实例,都会自动运行我们预置的gRPC Agent。
- 测试执行: 测试调度器(Controller)向一个或多个Agent的gRPC服务地址发送
StartSession
请求。 - 任务执行: Agent接收到请求,启动Puppeteer,导航到Vite构建的WebRTC应用,执行加入房间、推流等操作。
- 状态监控: Agent通过gRPC流持续上报心跳和状态,调度器可以实时了解整个集群的健康状况和利用率。
局限性与未来迭代方向
这个方案虽然解决了核心问题,但在生产环境中还有几个方面需要深化:
- 弹性伸缩的闭环: 目前的架构是“准备好”了弹性伸缩,但还没实现闭环。下一步是在Agent中集成Prometheus客户端库,将
BotStatus
(IDLE/BUSY/ERROR的数量)作为自定义指标暴露出来。然后,在Kubernetes中(将Agent容器化后),可以使用KEDA(Kubernetes Event-driven Autoscaling)的gRPC scaler,或者在EC2环境中,使用自定义CloudWatch指标和ASG策略,根据空闲Agent的数量来自动增减实例。 - 资源利用率: 每个Puppeteer实例都相当消耗CPU和内存。在一个EC2实例上运行多个隔离的浏览器实例,而不是一个,可以提高资源利用率。将Agent容器化并部署在Kubernetes上是更现代的做法,可以利用cgroups进行更精细的资源隔离和管理。
- 信令服务器的模拟: 当前WebRTC示例简化了信令过程。一个完整的测试方案必须让Puppeteer脚本能够与信令服务器(通常是WebSocket服务)进行交互,以完成SDP和ICE Candidate的交换。
- 媒体流验证: 当前的方案只验证了用户能否“加入”会话,但没有验证媒体流的质量。更高级的测试需要使用WebRTC的
getStats()
API,从浏览器中抓取码率、丢包率、延迟等指标,并通过gRPC回传到控制中心进行分析。
通过这套组合拳,我们构建了一个健壮、可扩展的自动化测试基础设施。它将基础设施即代码(Packer)、远程过程调用(gRPC)、浏览器自动化(Puppeteer)和现代前端构建(Vite)的优势结合起来,解决了一个具体的、有挑战性的工程问题。这套系统在后续演进中,可以平滑地迁移到容器化和Kubernetes生态,实现更高级的调度和自动化能力。