构建基于Packer镜像与gRPC控制的WebRTC自动化测试集群


要对一个大规模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配置做了几件关键事情:

  1. 选择基础AMI: 从最新的Ubuntu 20.04官方镜像开始。
  2. 安装依赖: 使用shell provisioner安装Node.js、Google Chrome稳定版以及Puppeteer运行所需的各种共享库。这是一个常见的坑,缺少这些库会导致Chrome无法启动。
  3. 部署应用代码: 使用file provisioner将我们的gRPC Agent代码上传到镜像中。
  4. 配置自启动: 创建一个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

工作流程如下:

  1. 构建阶段 (CI/CD): 开发者更新Agent代码或依赖,触发CI流水线,运行packer build命令生成一个新的AMI。
  2. 部署阶段 (IaC): 使用Terraform或CloudFormation更新Auto Scaling Group(ASG)的启动配置,使其指向新的AMI ID。ASG负责根据负载(或预设数量)自动启动或终止EC2实例。
  3. 运行阶段: 每个由ASG启动的实例,都会自动运行我们预置的gRPC Agent。
  4. 测试执行: 测试调度器(Controller)向一个或多个Agent的gRPC服务地址发送StartSession请求。
  5. 任务执行: Agent接收到请求,启动Puppeteer,导航到Vite构建的WebRTC应用,执行加入房间、推流等操作。
  6. 状态监控: Agent通过gRPC流持续上报心跳和状态,调度器可以实时了解整个集群的健康状况和利用率。

局限性与未来迭代方向

这个方案虽然解决了核心问题,但在生产环境中还有几个方面需要深化:

  1. 弹性伸缩的闭环: 目前的架构是“准备好”了弹性伸缩,但还没实现闭环。下一步是在Agent中集成Prometheus客户端库,将BotStatus(IDLE/BUSY/ERROR的数量)作为自定义指标暴露出来。然后,在Kubernetes中(将Agent容器化后),可以使用KEDA(Kubernetes Event-driven Autoscaling)的gRPC scaler,或者在EC2环境中,使用自定义CloudWatch指标和ASG策略,根据空闲Agent的数量来自动增减实例。
  2. 资源利用率: 每个Puppeteer实例都相当消耗CPU和内存。在一个EC2实例上运行多个隔离的浏览器实例,而不是一个,可以提高资源利用率。将Agent容器化并部署在Kubernetes上是更现代的做法,可以利用cgroups进行更精细的资源隔离和管理。
  3. 信令服务器的模拟: 当前WebRTC示例简化了信令过程。一个完整的测试方案必须让Puppeteer脚本能够与信令服务器(通常是WebSocket服务)进行交互,以完成SDP和ICE Candidate的交换。
  4. 媒体流验证: 当前的方案只验证了用户能否“加入”会话,但没有验证媒体流的质量。更高级的测试需要使用WebRTC的getStats() API,从浏览器中抓取码率、丢包率、延迟等指标,并通过gRPC回传到控制中心进行分析。

通过这套组合拳,我们构建了一个健壮、可扩展的自动化测试基础设施。它将基础设施即代码(Packer)、远程过程调用(gRPC)、浏览器自动化(Puppeteer)和现代前端构建(Vite)的优势结合起来,解决了一个具体的、有挑战性的工程问题。这套系统在后续演进中,可以平滑地迁移到容器化和Kubernetes生态,实现更高级的调度和自动化能力。


  目录