我们的监控仪表盘一片绿色。Cassandra 集群的每个节点都报告 UN (Up/Normal) 状态,CPU 和内存利用率稳定,延迟 P99 指标在可接受范围内。然而,用户支持渠道却周期性地收到投诉:在高并发时段,部分用户在执行关键业务操作时会遇到“操作超时”或“未知错误”。标准的健康检查和基础设施监控显然遗漏了某些东西。
在真实项目中,组件级别的健康并不等同于系统级别的韧性。一个 Cassandra 节点可能会因为短暂的网络抖动、GC 停顿或磁盘 IO 争用而响应变慢,即使它在nodetool status中看起来是健康的。这种瞬间的降级,足以导致上游应用出现雪崩效应。我们需要一种方法来验证整个系统,从用户浏览器到数据库,在面对底层基础设施故障时的真实表现。这正是混沌工程的用武之地。
我们的目标不是简单地验证 Cassandra 集群的故障转移能力,而是要回答一个更重要的问题:当 Cassandra 集群中的一个节点失效时,正在执行关键业务流程的真实用户会经历什么?他们会看到一个优雅的降级提示,还是一个丑陋的 500 错误页面?为了回答这个问题,我们决定构建一个轻量级的自动化混沌实验框架。这个框架将 Azure AKS 作为混沌注入平台,以一个真实的 Cassandra 节点宕机作为故障场景,并使用 Playwright 模拟用户端到端的行为作为系统状态的最终验证者。
架构设计与技术选型
整个实验由一个用 Go 编写的命令行编排器(Orchestrator)驱动,部署在 AKS 集群内部。
- Azure AKS (Azure Kubernetes Service): 作为我们的基础设施平台,AKS 提供了与 Kubernetes API 的原生交互能力。我们可以通过
client-go库,以编程方式操纵集群中的资源,例如识别并删除一个 Cassandra Pod,这是实现混沌注入的核心。 - Cassandra: 我们的状态存储。我们使用 Kube-Prometheus-Stack 监控其性能指标,但这次我们要超越指标,直接测试其在故障下的表现。
- Playwright: 这是一个关键的选择。我们没有使用简单的
curl或 gRPC 客户端来检查 API 健康状况,因为这无法反映前端渲染逻辑、资源加载和异步调用链的复杂性。Playwright 可以驱动一个真实的浏览器内核,执行一个完整的用户故事(例如:登录、浏览商品、添加到购物车、发起支付),从而提供最接近真实用户的验证。 - Go Orchestrator: Go 语言非常适合构建这类云原生命令行工具。它编译为静态二进制文件,依赖少,并且拥有强大的 Kubernetes 客户端库。
实验的执行流程如下:
sequenceDiagram
participant Orchestrator as Go Orchestrator (Pod)
participant K8s API as Kubernetes API Server
participant C_Pod as Cassandra Pod
participant Playwright as Playwright Script Runner (Job)
participant App as Web Application (Deployment)
Orchestrator->>Playwright: 1. 执行基线测试 (验证系统健康)
Playwright->>App: 模拟完整用户流程
App->>C_Pod: 读/写数据
Playwright-->>Orchestrator: 返回成功 (Exit Code 0)
Orchestrator->>K8s API: 2. 注入混沌:随机选择并删除一个Cassandra Pod
K8s API-->>C_Pod: 发送 SIGTERM
C_Pod->>K8s API: 开始终止流程
loop 持续 60s
Orchestrator->>Playwright: 3. 在混沌期间反复执行验证测试
Playwright->>App: 模拟完整用户流程
App->>C_Pod: 读/写请求 (可能路由到其他节点)
Playwright-->>Orchestrator: 返回测试结果 (Exit Code 0 or 1)
end
Orchestrator->>Orchestrator: 4. 收集并分析结果
编排器实现:Go 与 Kubernetes API 的交互
我们的 Go 编排器是整个实验的核心。它负责连接 AKS、执行混沌、触发验证并报告结果。首先,我们需要配置与 Kubernetes API 的 in-cluster 连接。
main.go - 核心逻辑
package main
import (
"context"
"fmt"
"log"
"math/rand"
"os"
"os/exec"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
const (
cassandraNamespace = "database"
cassandraLabelSelector = "app.kubernetes.io/name=cassandra"
playwrightTestScript = "node"
playwrightTestArgs = "tests/critical-path.js"
chaosDuration = 60 * time.Second
)
func main() {
log.Println("混沌实验开始:验证 Cassandra 节点故障下的用户体验")
// 1. 初始化 Kubernetes 客户端
clientset, err := initK8sClient()
if err != nil {
log.Fatalf("无法初始化 Kubernetes 客户端: %v", err)
}
// 2. 执行基线测试,确保系统在注入混沌前是健康的
log.Println("执行基线 Playwright 测试...")
if !runPlaywrightTest("baseline") {
log.Fatal("基线测试失败,系统初始状态不健康,终止实验。")
}
log.Println("基线测试成功。")
// 3. 注入混沌:随机删除一个 Cassandra Pod
targetPod, err := terminateRandomCassandraPod(clientset)
if err != nil {
log.Fatalf("注入混沌失败: %v", err)
}
log.Printf("混沌注入成功:已删除 Pod [%s]", targetPod)
// 4. 在混沌期间持续进行验证
log.Printf("进入混沌观测期,持续 %s...", chaosDuration)
startTime := time.Now()
var testRuns, failedRuns int
for time.Since(startTime) < chaosDuration {
testRuns++
log.Printf("执行第 %d 次混沌验证测试...", testRuns)
if !runPlaywrightTest(fmt.Sprintf("chaos-run-%d", testRuns)) {
failedRuns++
log.Printf("警告:第 %d 次验证测试失败。", testRuns)
} else {
log.Printf("第 %d 次验证测试成功。", testRuns)
}
time.Sleep(5 * time.Second) // 两次测试之间稍作停顿
}
// 5. 报告结果
log.Println("混沌观测期结束。")
log.Println("------ 实验结果 ------")
log.Printf("总测试次数: %d", testRuns)
log.Printf("失败次数: %d", failedRuns)
if failedRuns > 0 {
log.Fatalf("实验失败:在 Cassandra 节点故障期间,检测到 %d 次用户流程失败。", failedRuns)
// 在 CI/CD 环境中,这将导致 pipeline 失败
os.Exit(1)
} else {
log.Println("实验成功:系统在 Cassandra 节点故障期间保持了用户流程的韧性。")
}
}
// initK8sClient 使用 in-cluster 配置初始化 Kubernetes 客户端
func initK8sClient() (*kubernetes.Clientset, error) {
config, err := rest.InClusterConfig()
if err != nil {
// 这里的错误处理很重要,如果不在集群内运行,它会明确地失败
return nil, fmt.Errorf("获取 in-cluster 配置失败: %w", err)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("创建 clientset 失败: %w", err)
}
return clientset, nil
}
混沌注入的实现细节在于如何与 Kubernetes API 交互来选择并删除 Pod。这里的坑在于,必须确保我们选择的是一个正在运行且准备就绪的 Pod,而不是一个正在终止或启动的 Pod。
chaos.go - 混沌注入实现
package main
import (
"context"
"fmt"
"log"
"math/rand"
"time"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// terminateRandomCassandraPod 查找所有健康的 Cassandra Pod 并随机删除一个
func terminateRandomCassandraPod(clientset *kubernetes.Clientset) (string, error) {
pods, err := clientset.CoreV1().Pods(cassandraNamespace).List(context.TODO(), metav1.ListOptions{
LabelSelector: cassandraLabelSelector,
})
if err != nil {
return "", fmt.Errorf("无法列出 Cassandra Pods: %w", err)
}
// 过滤出真正处于 Running 状态的 Pod
var runningPods []v1.Pod
for _, pod := range pods.Items {
// 确保 Pod 正在运行且没有被标记为删除
if pod.Status.Phase == v1.PodRunning && pod.ObjectMeta.DeletionTimestamp == nil {
runningPods = append(runningPods, pod)
}
}
if len(runningPods) == 0 {
return "", fmt.Errorf("未找到正在运行的 Cassandra Pods")
}
// 随机选择一个目标
rand.Seed(time.Now().UnixNano())
targetPod := runningPods[rand.Intn(len(runningPods))]
podName := targetPod.Name
log.Printf("准备删除 Pod: %s in namespace: %s", podName, cassandraNamespace)
// 使用 DeleteOptions 来设置优雅删除周期(例如 0 秒立即删除)
deletePolicy := metav1.DeletePropagationForeground
gracePeriodSeconds := int64(0)
err = clientset.CoreV1().Pods(cassandraNamespace).Delete(context.TODO(), podName, metav1.DeleteOptions{
GracePeriodSeconds: &gracePeriodSeconds,
PropagationPolicy: &deletePolicy,
})
if err != nil {
return "", fmt.Errorf("删除 Pod %s 失败: %w", podName, err)
}
return podName, nil
}
验证器实现:Playwright 端到端用户流程
这是证明混沌实验价值的关键部分。我们的 Playwright 脚本不能只是访问首页,它必须模拟一个对数据库有强依赖的、有状态的关键业务流程。
tests/critical-path.js
// @ts-check
const { test, expect } = require('@playwright/test');
const fs = require('fs');
// 从环境变量获取目标URL,这使得脚本更灵活
const baseURL = process.env.APP_BASE_URL || 'http://webapp-service.default.svc.cluster.local';
const testIdentifier = process.env.TEST_IDENTIFIER || 'default';
test.describe('Critical User Path Validation', () => {
// 增加超时时间,因为在混沌期间系统响应可能变慢
test.setTimeout(45 * 1000);
test('should complete the purchase process', async ({ page }) => {
try {
// 步骤 1: 访问产品页面
// 这里的断言很重要,要确保页面不仅加载了,而且关键数据也从 Cassandra 渲染出来了
await page.goto(`${baseURL}/products/P12345`);
await expect(page.locator('h1.product-title')).toHaveText('高可用分布式系统设计');
await expect(page.locator('span.stock-level')).not.toHaveText('库存查询失败');
// 步骤 2: 将商品加入购物车
// 这个操作通常会写入 Cassandra 的用户购物车表 (carts_by_user)
await page.locator('button#add-to-cart').click();
await expect(page.locator('div.cart-notification')).toHaveText('商品已成功加入购物车');
// 步骤 3: 访问购物车页面并验证
// 这是一个读操作,验证上一步的写入是否成功且数据一致
await page.goto(`${baseURL}/cart`);
await expect(page.locator(`div.cart-item[data-product-id="P12345"]`)).toBeVisible();
await expect(page.locator('span.cart-total-price')).not.toBeEmpty();
// 步骤 4: 尝试发起结账
// 这可能是一个复杂的事务,涉及多个表的读写和更新
await page.locator('button#checkout').click();
// 验证是否跳转到支付确认页或订单成功页
await expect(page).toHaveURL(/\/order\/\w+/); // URL 匹配 /order/some-order-id
await expect(page.locator('h1.order-success-title')).toHaveText('订单创建成功');
console.log(`Playwright test [${testIdentifier}] PASSED.`);
} catch (error) {
// 在CI环境中,保存失败截图和trace至关重要
const screenshotPath = `/reports/failure-${testIdentifier}.png`;
const tracePath = `/reports/failure-${testIdentifier}.zip`;
console.error(`Playwright test [${testIdentifier}] FAILED: ${error.message}`);
if (!fs.existsSync('/reports')) {
fs.mkdirSync('/reports');
}
await page.screenshot({ path: screenshotPath, fullPage: true });
await page.context().tracing.stop({ path: tracePath });
console.error(`Failure screenshot saved to: ${screenshotPath}`);
console.error(`Playwright trace saved to: ${tracePath}`);
// 抛出错误以确保测试进程以非零代码退出
throw error;
}
});
});
为了让 Go 程序能够调用这个脚本并捕获其结果,我们需要一个简单的封装。
verifier.go - Playwright 脚本执行器
package main
import (
"log"
"os"
"os/exec"
)
// runPlaywrightTest 执行 Playwright 测试并返回是否成功
// identifier 用于命名失败时的截图和 trace 文件
func runPlaywrightTest(identifier string) bool {
cmd := exec.Command(playwrightTestScript, "test", playwrightTestArgs)
// 传递环境变量给 Playwright 进程
cmd.Env = append(os.Environ(),
"APP_BASE_URL=http://webapp-service.application.svc.cluster.local",
"PLAYWRIGHT_BROWSERS_PATH=/ms-playwright", // 在容器中指定浏览器缓存路径
"TEST_IDENTIFIER="+identifier,
)
// 将子进程的输出实时打印到主进程的日志中,便于调试
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
// exec.Run 在命令以非零代码退出时返回错误
log.Printf("Playwright test run '%s' failed: %v", identifier, err)
return false
}
return true
}
部署与执行
为了在 AKS 中运行,我们需要为这个 Go 编排器构建一个 Docker 镜像。这个镜像必须包含 Go 程序、Node.js 环境和 Playwright 及其浏览器依赖。
Dockerfile
# Stage 1: Build the Go orchestrator
FROM golang:1.19-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静态编译,不依赖 C 库,生成的二进制文件更小
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o chaos-orchestrator .
# Stage 2: Build the final image with Playwright environment
FROM mcr.microsoft.com/playwright:v1.30.0-focal
WORKDIR /app
# 安装 Node.js 依赖
COPY tests/package.json tests/package-lock.json ./tests/
RUN cd tests && npm install
# 复制 Playwright 测试脚本
COPY tests/critical-path.js ./tests/
# 从 builder stage 复制编译好的 Go 二进制文件
COPY /app/chaos-orchestrator .
# 设置工作目录并定义入口点
WORKDIR /app
ENTRYPOINT ["./chaos-orchestrator"]
最后,我们需要一个 Kubernetes Role 和 RoleBinding 来授权我们的编排器 Pod 能够 list 和 delete database 命名空间下的 Pod。
rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: database
name: chaos-pod-manager
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: chaos-runner-binding
namespace: database
roleRef:
kind: Role
name: chaos-pod-manager
apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
name: default # 或者一个专用的 service account
namespace: default # 编排器运行的 namespace
将这些资源部署到 AKS 后,我们只需创建一个 Pod 来运行我们的 chaos-orchestrator 镜像,实验就会自动开始。
遗留问题与未来迭代路径
这个框架虽然有效,但仍处于初级阶段。一个常见的错误是认为完成这样一个实验就万事大吉了。在真实项目中,这只是一个开始。
当前的实现有几个局限性:
- 故障模型的单一性: 目前只模拟了 Pod 崩溃(
SIGTERM)。更真实的场景包括网络延迟、丢包、磁盘满或 CPU 资源被完全占用。这些需要更复杂的混沌注入工具,比如利用 sidecar 注入iptables规则,或者使用 Chaos Mesh 这类成熟的混沌工程平台。 - 验证的二进制性: Playwright 测试目前只返回成功或失败。但用户体验的降级通常不是二进制的。一个更高级的验证器应该测量关键操作的耗时(如页面加载时间、API 响应时间),并判断这些指标是否超出了预设的 SLO(服务等级目标)。
- 手动触发: 实验是手动触发的。最终目标是将其集成到 CI/CD 流水线中,作为发布前或发布后的一个自动化阶段。每次有对核心服务或基础设施的变更时,都自动运行一套混沌实验,实现持续验证。
- 爆炸半径控制: 在生产环境中直接运行此类实验风险很高。需要实现更精细的“爆炸半径”控制,例如只针对一小部分用户流量(通过服务网格实现流量镜像或灰度),或者只在一个隔离的、但与生产环境配置一致的预发环境中进行。
尽管存在这些局限,但这个实践的核心价值在于它改变了我们验证系统韧性的思维模式:从孤立地检查基础设施组件的健康,转向端到端地、从用户视角出发衡量整个系统在压力和故障下的真实表现。这才是构建真正高可用系统的必经之路。