构建基于 APISIX 流量切分的 Puppeteer E2E 测试驱动的 Spinnaker 金丝雀发布工作流


在微前端架构下,金丝雀发布变得异常棘手。一个看似无害的 product-card 前端组件更新,如果存在隐蔽的渲染 bug 或性能问题,即便只切分 1% 的线上流量,也可能在高峰期影响成千上万的用户。传统的 E2E 测试流程在此场景下几乎失效——当测试脚本运行时,它有 99% 的概率会命中稳定的生产版本,导致针对新版本的验证形同虚设。手动验证 canary 实例不仅效率低下、容易出错,更无法适应高频部署的节奏。我们需要的是一个全自动、流量感知、且与发布流程深度绑定的验证闭环。

我们的初步构想是:在 Spinnaker 的金丝雀发布工作流中,插入一个强制性的自动化 E2E 测试阶段。这个阶段必须保证所有的测试流量都精确地导向新部署的 canary 实例,而不是稳定版。测试的成功与否,将成为一个刚性的、二进制的质量门,直接决定 Spinnaker 是继续增加 canary 流量比重,还是立即执行回滚。

技术选型决策过程直截了当,因为我们团队已经有了一套成熟的技术栈:

  1. 持续交付平台: Spinnaker。这是我们进行所有云原生应用部署的核心工具。它的可扩展流水线和内置的金丝雀发布(Automated Canary Analysis - ACA)能力是实现这一构想的天然土壤。
  2. API 网关: Apache APISIX。作为集群的流量入口,APISIX 以其动态、高性能和丰富的插件生态系统而著称。我们将利用其强大的路由能力,基于特定请求头来精准控制测试流量的走向。这比修改 DNS 或 Kubernetes Service 这种“重”操作要灵活得多。
  3. E2E 测试框架: Puppeteer。我们需要一个能够精细控制浏览器行为的工具,特别是在网络请求层面。Puppeteer 允许我们在每个请求中注入自定义 HTTP 头,这正是与 APISIX 流量路由策略进行“握手”的关键。

整个工作流的核心逻辑在于创建一个“秘密通道”,让 Spinnaker 触发的 Puppeteer 测试能够通过这个通道,绕过常规的流量切分规则,直达 canary 实例。

graph TD
    subgraph Spinnaker Pipeline
        A[开始] --> B(部署 Canary 版本);
        B --> C{运行流量感知 E2E 测试};
        C -- 测试成功 --> D(执行 Canary 分析/逐步放量);
        C -- 测试失败 --> E(立即回滚);
        D --> F(全量发布);
        E --> G[结束];
        F --> G;
    end

    subgraph "Kubernetes & APISIX"
        H(用户流量) --> I[APISIX Gateway];
        J(Spinnaker 触发的 Puppeteer 测试) --> I;
        I -- 99% 流量 --> K[Stable 版本 Service];
        I -- 1% 流量 --> L[Canary 版本 Service];
        I -- "请求头 X-E2E-Target: canary" --> L;
    end

第一步:配置 APISIX 实现流量精准路由

我们的基础环境是一个运行在 Kubernetes 上的微前端应用,例如一个商品详情页服务 product-details。它有两个 Deployment 和对应的 Service:product-details-stableproduct-details-canary

首先,配置一个标准的金丝雀发布路由。我们使用 APISIX 的 traffic-split 插件,将 1% 的常规用户流量分配给 canary 版本。

这是基础的 APISIX Route 配置,通过 kubectl apply -f 应用。

# apisix-route-product-details-canary.yaml
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: product-details-route
spec:
  http:
    - name: product-details-rule
      match:
        hosts:
          - "www.example.com"
        paths:
          - "/products/*"
      plugins:
        - name: traffic-split
          enable: true
          config:
            rules:
              - weighted_upstreams:
                  - upstream_id: "product-details-stable-id" # 指向稳定版 Upstream
                    weight: 99
                  - upstream_id: "product-details-canary-id" # 指向 Canary 版 Upstream
                    weight: 1
      # 注意:这里的 upstream_id 需要与 ApisixUpstream 资源中的 id 匹配
      # 为简洁起见,此处省略了 ApisixUpstream 的定义,实际项目中必须定义
      # 假设 product-details-stable-id 指向 product-details-stable Service
      # 假设 product-details-canary-id 指向 product-details-canary Service

现在是关键所在。我们需要创建一条更高优先级的路由规则,它专门用来捕获我们的 E2E 测试流量。这条规则不使用 traffic-split,而是通过匹配一个特定的 HTTP 请求头(例如 X-E2E-Target: canary),将所有匹配的流量 100% 转发到 canary 实例。

# apisix-route-product-details-e2e.yaml
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: product-details-e2e-route
spec:
  http:
    - name: product-details-e2e-rule
      priority: 100 # 设定一个比默认路由更高的优先级
      match:
        hosts:
          - "www.example.com"
        paths:
          - "/products/*"
        exprs:
          # 这是实现精准路由的核心:检查 HTTP Header
          - subject:
              scope: Header
              name: "X-E2E-Target"
            operator: "equal"
            value: "canary"
      # 当匹配成功时,流量直接打到 canary 的 upstream
      upstream_id: "product-details-canary-id" 

应用这条规则后,APISIX 的行为将变为:

  1. 检查进入的请求是否包含 X-E2E-Target: canary 头。
  2. 如果包含,由于优先级更高,product-details-e2e-route 规则将首先匹配,请求被直接发送到 product-details-canary 服务。
  3. 如果不包含,则回退到 product-details-route 规则,按照 99:1 的权重进行流量分配。

这样,我们就为 E2E 测试流量开辟了一条 VIP 通道,完全不受金丝雀发布流量比例的影响。

第二步:构建可注入上下文的 Puppeteer 测试执行器

我们的 Puppeteer 测试不能是简单的硬编码脚本。它必须是一个能够接收外部参数(如目标 URL、要注入的 Header)的健壮执行器,并能以标准方式(退出码)向上游系统报告测试结果。

我们将创建一个 Node.js 项目,并将其打包成一个 Docker 镜像,以便在 Spinnaker 的流水线中作为 Kubernetes Job 运行。

项目结构:

/e2e-runner
  |- package.json
  |- run-tests.js       # 测试执行入口
  |- /tests
  |  |- product-journey.test.js # 具体的测试用例
  |- Dockerfile

run-tests.js - 测试执行入口

这个脚本是核心,它负责初始化 Puppeteer,注入 Header,执行所有测试用例,并处理结果。

// run-tests.js
const puppeteer = require('puppeteer');
const path = require('path');
const fs = require('fs');

// 从环境变量中读取配置,这是与 Spinnaker 集成的关键
const {
  TARGET_URL,
  CANARY_HEADER_KEY,
  CANARY_HEADER_VALUE,
  VIEWPORT_WIDTH = '1920',
  VIEWPORT_HEIGHT = '1080',
  IS_HEADLESS = 'true',
} = process.env;

if (!TARGET_URL || !CANARY_HEADER_KEY || !CANARY_HEADER_VALUE) {
  console.error('Missing required environment variables: TARGET_URL, CANARY_HEADER_KEY, CANARY_HEADER_VALUE');
  process.exit(1); // 以失败状态退出
}

const customHeaders = {
  [CANARY_HEADER_KEY]: CANARY_HEADER_VALUE,
};

async function main() {
  console.log(`[E2E Runner] Starting browser...`);
  const browser = await puppeteer.launch({
    headless: IS_HEADLESS.toLowerCase() === 'true',
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage', // 在 Docker 环境中常见的优化
      `--window-size=${VIEWPORT_WIDTH},${VIEWPORT_HEIGHT}`,
    ],
  });

  const page = await browser.newPage();
  await page.setViewport({ width: parseInt(VIEWPORT_WIDTH), height: parseInt(VIEWPORT_HEIGHT) });

  // 核心步骤:为所有后续请求注入自定义 Header
  console.log(`[E2E Runner] Injecting headers:`, customHeaders);
  await page.setExtraHTTPHeaders(customHeaders);
  
  // 监听并打印所有控制台输出,便于调试
  page.on('console', msg => console.log(`[Browser Console] ${msg.type().toUpperCase()}: ${msg.text()}`));

  const testFiles = fs.readdirSync(path.join(__dirname, 'tests')).filter(file => file.endsWith('.test.js'));
  let failedTests = 0;

  for (const file of testFiles) {
    try {
      console.log(`\n[E2E Runner] Running test suite: ${file}`);
      const testSuite = require(path.join(__dirname, 'tests', file));
      // 将 page 和 targetUrl 传递给测试用例
      await testSuite({ page, targetUrl: TARGET_URL }); 
      console.log(`[E2E Runner] SUCCESS: ${file}`);
    } catch (error) {
      console.error(`[E2E Runner] FAILED: ${file}`);
      console.error(error);
      failedTests++;
      // 失败时截图,用于排查问题
      const screenshotPath = `/screenshots/failure-${file.replace('.test.js', '')}-${Date.now()}.png`;
      fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
      await page.screenshot({ path: screenshotPath, fullPage: true });
      console.log(`[E2E Runner] Screenshot saved to ${screenshotPath}`);
    }
  }

  await browser.close();

  if (failedTests > 0) {
    console.error(`\n[E2E Runner] Finished with ${failedTests} failed suite(s).`);
    process.exit(1); // 任何一个测试失败,整个 Job 就标记为失败
  } else {
    console.log(`\n[E2E Runner] All test suites passed.`);
    process.exit(0); // 所有测试成功
  }
}

main().catch(err => {
    console.error('[E2E Runner] A critical error occurred:', err);
    process.exit(1);
});

tests/product-journey.test.js - 具体测试用例

这是一个模拟用户购买商品的真实场景测试。

// tests/product-journey.test.js

// 使用 Jest/Jasmine 的 expect 风格断言库,比如 'expect'
const expect = require('expect');

module.exports = async ({ page, targetUrl }) => {
  console.log(`  [Test Case] Navigating to product page...`);
  await page.goto(`${targetUrl}/products/123`, { waitUntil: 'networkidle2' });

  // 1. 验证页面标题是否正确
  const pageTitle = await page.title();
  expect(pageTitle).toContain('Product Details');
  console.log('    - Verified page title.');

  // 2. 验证关键元素是否存在
  const addToCartButton = await page.waitForSelector('#add-to-cart-btn', { timeout: 5000 });
  expect(addToCartButton).not.toBeNull();
  console.log('    - Found "Add to Cart" button.');

  // 3. 模拟用户交互:点击按钮
  await addToCartButton.click();

  // 4. 验证交互结果:购物车图标是否更新
  await page.waitForSelector('#cart-item-count[data-count="1"]');
  const cartCount = await page.$eval('#cart-item-count', el => el.getAttribute('data-count'));
  expect(cartCount).toBe('1');
  console.log('    - Verified item was added to cart.');
  
  // 可以在这里添加更多步骤,例如跳转到购物车页面,完成结账流程等
};

Dockerfile

最后,我们将整个测试执行器打包成一个独立的 Docker 镜像。

# 使用官方的 Puppeteer 镜像,它包含了所有必要的依赖
FROM ghcr.io/puppeteer/puppeteer:21.3.8

# 设置工作目录
WORKDIR /usr/src/app

# 复制 package.json 并安装依赖
COPY package*.json ./
RUN npm install

# 复制所有项目文件
COPY . .

# 创建用于存放失败截图的目录
RUN mkdir -p /screenshots

# 定义容器启动时执行的命令
CMD [ "node", "run-tests.js" ]

构建并推送镜像:
docker build -t your-registry/e2e-runner:1.0.0 .
docker push your-registry/e2e-runner:1.0.0

第三步:在 Spinnaker 流水线中集成测试 Job

现在,我们将这个 Docker 化的 E2E 测试器集成到 Spinnaker 的部署流水线中。在 Deploy Canary 阶段之后,我们添加一个 Run Job (Manifest) 阶段。

这个阶段的作用是在 Kubernetes 中动态创建一个 Job 资源,该 Job 会拉取我们刚刚构建的 e2e-runner 镜像并运行它。Spinnaker 会等待这个 Job 执行完成,并根据其最终状态(SucceededFailed)来决定流水线的下一步走向。

以下是配置在 Spinnaker Run Job (Manifest) 阶段中的 Kubernetes Job YAML 定义:

# spinnaker-canary-test-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  # 使用 Spinnaker 的表达式生成唯一的 Job 名称,防止冲突
  name: "e2e-test-job-${execution.id}"
  namespace: "your-app-namespace"
spec:
  template:
    spec:
      containers:
        - name: e2e-runner
          image: your-registry/e2e-runner:1.0.0
          # 这是将 Spinnaker 上下文传递给测试容器的关键
          env:
            - name: TARGET_URL
              value: "https://www.example.com" # 应用程序的入口 URL
            - name: CANARY_HEADER_KEY
              value: "X-E2E-Target"
            - name: CANARY_HEADER_VALUE
              value: "canary"
            - name: IS_HEADLESS
              value: "true" # 在 CI/CD 环境中必须以 headless 模式运行
      restartPolicy: Never # Job 失败后不应重启,而是让 Spinnaker 捕获失败状态
  backoffLimit: 0 # 失败后不重试

在 Spinnaker 的 UI 中配置这个阶段时,你将这个 YAML 粘贴进去。Spinnaker 会自动监视此 Job 的状态。

  • 如果 run-tests.js 脚本以 exit 0 退出,Job 状态为 Succeeded,Spinnaker 流水线继续向下执行(例如,进入 Kayenta 分析阶段或逐步扩大流量)。
  • 如果脚本以 exit 1 退出,Job 状态为 Failed,Spinnaker 会立即停止流水线并将该阶段标记为红色失败。通常,我们会配置一个失败处理流程,自动触发 RollbackDestroy Canary 阶段。

至此,一个完整的自动化、流量感知的金丝雀验证闭环就形成了。开发人员提交一个微前端组件的更新,Spinnaker 自动部署 canary,然后触发一个精准靶向 canary 实例的 E2E 测试。整个过程无需人工干预,既保证了验证的有效性,又极大地提升了发布速度和安全性。

局限性与未来迭代方向

这套方案虽然强大,但在真实项目中依然存在需要权衡和优化的点。

首先,E2E 测试的不稳定性(Flakiness)是最大的挑战。网络波动、后端服务瞬时抖动都可能导致测试偶然失败,从而触发不必要的回滚。在生产环境中,必须为测试 Job 配置重试机制(例如在 Spinnaker 层面或脚本内部),并建立完善的失败告警和快速诊断能力(如自动上传失败截图和浏览器日志到 S3)。

其次,执行成本不容忽视。一个完整的 E2E 测试套件可能需要几分钟甚至更长时间才能跑完,这会直接延长整体的发布周期。对于变更频繁或非核心的组件,可以考虑引入分层测试策略:运行一个快速的“冒烟测试”作为质量门,而完整的回归测试套件则按需或在夜间触发。

最后,该方案主要解决了无状态前端应用的验证问题。对于涉及数据库变更或有状态服务的发布,回滚操作会复杂得多。在这种情况下,E2E 测试仍然是有效的验证手段,但整个发布策略需要配合数据库迁移版本控制、数据向后兼容设计等更为复杂的机制。未来的一个迭代方向是,将 E2E 测试的结果作为更复杂的、包含数据回滚预案的自动化发布决策的输入之一。


  目录