使用Tekton流水线自动化对搜索服务Envoy代理的故障注入测试


一个新上线的搜索服务,单元测试、集成测试、端到端测试全部绿灯通过,但这并不意味着高枕无忧。在真实的生产环境中,其下游依赖(例如,一个提供相关推荐的gRPC服务)可能出现网络抖动、响应延迟,甚至直接超时。这种局部故障是否会导致搜索服务本身雪崩,或者至少是服务质量(SLO)的急剧下降?这个问题,传统的测试金字塔无法给出答案。

我们面临的挑战是:如何将对服务韧性的验证,系统化、自动化地融入到CI/CD流程中。具体目标是,在每次部署前,自动模拟下游服务的故障,并量化评估搜索服务在压力下的核心SLO指标(P99延迟、错误率)是否达标。

最初的构想是在Tekton流水线中加入一个阶段,该阶段动态地为搜索服务的Envoy sidecar注入故障。Envoy Proxy作为我们服务网格的数据平面,其内置的故障注入能力是实现这一目标最理想的工具,因为它无需对应用代码做任何侵入式修改。

架构设计与技术选型

整个自动化测试流程被设计成一个独立的Tekton Pipeline。这个Pipeline的职责是清晰且专注的:接收一个待测试的搜索服务镜像版本,在一个隔离的命名空间中部署它,对其施加模拟故障和负载,最后根据SLO的结果裁定该版本是否通过韧性测试。

graph TD
    A[Start PipelineRun] --> B{1. Setup Namespace & Deploy};
    B --> C{2. Apply EnvoyFilter for Fault Injection};
    C --> D{3. Run k6 Load Test};
    C --> E[Prometheus];
    D --> E;
    E --> F{4. Execute SLO Validation};
    F --> G{5. Decision: Pass/Fail};
    G --> H{6. Cleanup};
    subgraph "Tekton Pipeline"
        direction LR
        B
        C
        D
        F
        G
        H
    end

技术选型决策:

  1. Tekton: 作为CI/CD的核心编排引擎。它的优势在于原生运行在Kubernetes之上,以CRD(Custom Resource Definitions)的形式定义任务和流水线,可以像管理应用一样管理CI/CD流程。每个步骤都是一个容器,隔离性好,易于复用。
  2. Envoy Proxy: 用于故障注入。我们选择直接操作Envoy的配置,而不是使用更上层的服务网格工具(如Istio),是为了将方案解耦,使其不强依赖于特定的服务网格实现。我们只需要目标Pod中存在一个Envoy sidecar即可。通过EnvoyFilter资源,我们可以精确地向HTTP路由中添加故障注入过滤器。
  3. k6: 作为负载生成工具。它使用JavaScript编写测试脚本,性能出色,并且可以方便地打包成容器镜像,在Tekton Task中运行。
  4. Prometheus: 作为可观测性后端。搜索服务和Envoy都已经暴露了Prometheus格式的指标。我们的SLO验证逻辑将直接查询Prometheus API来获取数据。

核心实现:Tekton任务与流水线

1. EnvoyFilter:故障注入的声明式定义

首先,我们需要一个EnvoyFilter的模板。这个YAML文件定义了我们要注入的故障类型。在真实项目中,这个文件会由Tekton任务动态生成或填充变量,但其核心结构是固定的。

这里的坑在于EnvoyFilter的配置层级非常复杂,需要精确理解Envoy的xDS配置结构。我们目标是在HTTP连接管理器(envoy.filters.network.http_connection_manager)的HTTP过滤器链中,针对到下游recommendation-service的出向流量(cluster)注入故障。

# envoy-fault-injection-filter.yaml
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: search-service-fault-injection
  namespace: resilience-testing # 将被应用到特定的测试命名空间
spec:
  workloadSelector:
    labels:
      app: search-service # 只对搜索服务的Pod生效
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_OUTBOUND # 只匹配出站流量
        listener:
          filterChain:
            filter:
              name: "envoy.filters.network.http_connection_manager"
      patch:
        operation: INSERT_BEFORE # 在路由过滤器之前插入
        value:
          name: envoy.filters.http.fault
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault
            abort:
              httpStatus: 503 # 模拟下游服务返回503错误
              percentage:
                numerator: 10 # 10%的请求会被中断
                denominator: HUNDRED
            delay:
              fixedDelay: 2s # 对70%的请求增加2秒的固定延迟
              percentage:
                numerator: 70
                denominator: HUNDRED
    - applyTo: CLUSTER
      match:
        context: SIDECAR_OUTBOUND
        cluster:
          service: "recommendation-service.prod.svc.cluster.local" # 精确匹配下游服务
      patch:
        operation: MERGE
        # 注意: 这里的value实际上是空的,但match本身已经将上述HTTP_FILTER的范围
        # 限制到了访问特定cluster的请求上。这是一个常见的技巧,用来提高匹配精度。
        value: {}

这份配置的注释解释了关键点:

  • workloadSelector: 确保只影响search-service
  • context: SIDECAR_OUTBOUND: 确保只影响出站请求,避免干扰入站流量。
  • HTTP_FILTERCLUSTER组合匹配: 这是一个关键技巧。单独的HTTP_FILTER会影响所有出站请求,但通过添加一个匹配特定CLUSTERpatch,即使该patch内容为空,也能将HTTP_FILTER的作用域限定在访问该cluster的流量上。
  • abortdelay: 同时配置了两种故障类型,模拟一个既慢又不稳定的下游服务。

2. Tekton Task:编排具体操作

我们需要三个核心的自定义Task

Task 1: apply-manifest

这是一个通用的任务,用于应用Kubernetes的YAML文件。

# task-apply-manifest.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: apply-manifest
spec:
  params:
    - name: manifest
      description: The Kubernetes manifest to apply
      type: string
  steps:
    - name: apply
      image: bitnami/kubectl:latest
      script: |
        #!/bin/bash
        set -e
        echo "Applying the following manifest:"
        echo "$(params.manifest)"
        echo "$(params.manifest)" | kubectl apply -f -

Task 2: run-k6-loadtest

这个任务负责运行k6负载测试脚本。测试脚本本身通过ConfigMap挂载进来。

# task-run-k6-loadtest.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: run-k6-loadtest
spec:
  params:
    - name: script-configmap
      description: The name of the ConfigMap containing the k6 script
      type: string
    - name: target-url
      description: The URL of the service to test
      type: string
    - name: duration
      description: Test duration (e.g., 60s)
      type: string
      default: "60s"
  steps:
    - name: run-test
      image: grafana/k6:latest
      # 挂载ConfigMap中的脚本到/scripts/test.js
      volumeMounts:
        - name: k6-script
          mountPath: /scripts
      script: |
        #!/bin/sh
        set -e
        echo "Starting k6 load test against $(params.target-url) for $(params.duration)..."
        k6 run --vus 10 --duration $(params.duration) --address=0.0.0.0:6565 /scripts/test.js
      env:
        - name: K6_PROMETHEUS_RW_SERVER_URL
          # 将k6的指标推送到Prometheus,便于事后分析,虽然本次SLO验证不直接用它
          value: "http://prometheus-kube-prometheus-stack-prometheus.monitoring.svc:9090/api/v1/write"
        - name: K6_PROMETHEUS_RW_STALE_MARKERS
          value: "true"
        - name: TARGET_URL
          value: "$(params.target-url)"
  volumes:
    - name: k6-script
      configMap:
        name: $(params.script-configmap)

k6脚本示例 (k6-script-cm.yaml):

apiVersion: v1
kind: ConfigMap
metadata:
  name: k6-search-script
data:
  test.js: |
    import http from 'k6/http';
    import { check, sleep } from 'k6';

    export const options = {
      // 阈值定义,如果测试本身失败,k6会返回非0退出码,Tekton任务也会失败
      thresholds: {
        'http_req_failed': ['rate<0.01'], // http errors should be less than 1%
        'http_req_duration': ['p(95)<500'], // 95% of requests should be below 500ms
      },
    };

    const TARGET_URL = __ENV.TARGET_URL;

    export default function () {
      const res = http.get(`${TARGET_URL}/search?q=tekton`);
      check(res, { 'status was 200': (r) => r.status == 200 });
      sleep(1);
    }

Task 3: validate-slo

这是最关键的任务,它决定了流水线的成败。它运行一个Python脚本,查询Prometheus API,并根据结果返回退出码。

# task-validate-slo.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: validate-slo
spec:
  params:
    - name: prometheus-url
      description: Prometheus server URL
      type: string
    - name: query
      description: PromQL query for the SLO metric
      type: string
    - name: threshold
      description: The SLO threshold value
      type: string
    - name: comparison-operator
      description: Comparison operator (e.g., '<', '>')
      type: string
      default: "<"
  steps:
    - name: run-validation
      image: python:3.9-slim
      script: |
        #!/usr/bin/env python
        import os
        import sys
        import requests
        import logging

        # 配置日志
        logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

        # 从环境变量获取参数
        PROMETHEUS_URL = "$(params.prometheus-url)"
        QUERY = "$(params.query)"
        THRESHOLD = float("$(params.threshold)")
        OPERATOR = "$(params.comparison-operator)"

        logging.info(f"Prometheus URL: {PROMETHEUS_URL}")
        logging.info(f"Query: {QUERY}")
        logging.info(f"Threshold: {THRESHOLD}")
        logging.info(f"Operator: {OPERATOR}")

        api_endpoint = f"{PROMETHEUS_URL}/api/v1/query"
        
        try:
            response = requests.get(api_endpoint, params={'query': QUERY}, timeout=30)
            response.raise_for_status() # 如果HTTP状态码不是2xx,则抛出异常
            data = response.json()

            if data['status'] != 'success':
                logging.error(f"Prometheus query failed with status: {data['status']}")
                if 'error' in data:
                    logging.error(f"Error details: {data['error']}")
                sys.exit(1)

            if not data['data']['result']:
                logging.error("Prometheus query returned no data.")
                # 在真实项目中,这里可能是个问题,可能意味着指标没采集到
                # 对于自动化测试,我们将其视为失败
                sys.exit(1)

            # 提取第一个结果的值
            metric_value = float(data['data']['result'][0]['value'][1])
            logging.info(f"Retrieved metric value: {metric_value}")

            # 进行比较
            passed = False
            if OPERATOR == '<':
                passed = metric_value < THRESHOLD
            elif OPERATOR == '>':
                passed = metric_value > THRESHOLD
            elif OPERATOR == '<=':
                passed = metric_value <= THRESHOLD
            elif OPERATOR == '>=':
                passed = metric_value >= THRESHOLD
            else:
                logging.error(f"Unsupported operator: {OPERATOR}")
                sys.exit(1)

            if passed:
                logging.info(f"SLO validation PASSED. {metric_value} {OPERATOR} {THRESHOLD}")
                sys.exit(0)
            else:
                logging.error(f"SLO validation FAILED. {metric_value} is not {OPERATOR} {THRESHOLD}")
                sys.exit(1)

        except requests.exceptions.RequestException as e:
            logging.error(f"Error connecting to Prometheus: {e}")
            sys.exit(1)
        except (ValueError, IndexError) as e:
            logging.error(f"Error parsing Prometheus response: {e}")
            sys.exit(1)

      # 安装依赖
      command: ["/bin/sh", "-c"]
      args:
        - "pip install requests && python -c \"$(script)\""

3. Pipeline:串联所有任务

最后,我们用Pipeline将所有任务组织起来。

# pipeline-resilience-test.yaml
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: resilience-test-pipeline
spec:
  params:
    - name: service-image
      description: The image of the search service to test
      type: string
    - name: test-namespace
      description: The isolated namespace for the test
      type: string
      default: "resilience-testing"
  workspaces:
    - name: manifests
      description: Workspace for Kubernetes manifests

  tasks:
    # 步骤1: 部署服务 (假设部署的YAML文件在Workspace中)
    - name: deploy-service
      taskRef:
        name: apply-manifest
      workspaces:
        - name: manifest
          workspace: manifests
      params:
        - name: manifest
          value: "$(workspaces.manifests.path)/search-service-deployment.yaml"

    # 步骤2: 注入故障
    - name: apply-fault-filter
      taskRef:
        name: apply-manifest
      runAfter: [ "deploy-service" ] # 确保服务部署后再注入故障
      workspaces:
        - name: manifest
          workspace: manifests
      params:
        - name: manifest
          value: "$(workspaces.manifests.path)/envoy-fault-injection-filter.yaml"

    # 步骤3: 执行负载测试
    - name: run-load-test
      taskRef:
        name: run-k6-loadtest
      runAfter: [ "apply-fault-filter" ]
      params:
        - name: script-configmap
          value: "k6-search-script"
        - name: target-url
          value: "http://search-service.$(params.test-namespace).svc.cluster.local"
        - name: duration
          value: "120s" # 运行2分钟以采集足够的数据

    # 步骤4: 验证P99延迟SLO
    - name: validate-p99-latency
      taskRef:
        name: validate-slo
      runAfter: [ "run-load-test" ]
      params:
        - name: prometheus-url
          value: "http://prometheus-kube-prometheus-stack-prometheus.monitoring.svc:9090"
        - name: query
          # 查询过去2分钟内,search-service的p99延迟
          value: 'histogram_quantile(0.99, sum(rate(envoy_cluster_upstream_rq_time_bucket{envoy_cluster_name="outbound|80||search-service.resilience-testing.svc.cluster.local"}[2m])) by (le))'
        - name: threshold
          value: "2500" # SLO: P99延迟必须小于2.5秒(注入了2秒延迟,这个值是合理的)
        - name: comparison-operator
          value: "<"

    # 步骤5: 验证错误率SLO
    - name: validate-error-rate
      taskRef:
        name: validate-slo
      runAfter: [ "run-load-test" ]
      params:
        - name: prometheus-url
          value: "http://prometheus-kube-prometheus-stack-prometheus.monitoring.svc:9090"
        - name: query
          # 查询5xx错误率
          value: 'sum(rate(envoy_cluster_upstream_rq_xx{envoy_cluster_name="outbound|80||search-service.resilience-testing.svc.cluster.local", envoy_response_code_class="5"}[2m])) / sum(rate(envoy_cluster_upstream_rq_total{envoy_cluster_name="outbound|80||search-service.resilience-testing.svc.cluster.local"}[2m]))'
        - name: threshold
          value: "0.15" # SLO: 错误率必须小于15% (注入了10%的abort,留出一些余量)
        - name: comparison-operator
          value: "<"

  finally:
    # 无论成功失败,都执行清理
    - name: cleanup
      taskRef:
        name: cleanup-resources
      params:
        - name: namespace
          value: "$(params.test-namespace)"

一个常见的错误是,finally任务的设计。它必须是幂等的,并且不能假设之前的任务成功了。一个简单的清理任务可以是执行kubectl delete namespace $(params.namespace)

遇到的问题与权衡

在实施过程中,有几个细节值得注意:

  1. EnvoyFilter生效延迟EnvoyFilterkubectl apply后,配置不会立即同步到所有的Envoy sidecar。如果在配置生效前就启动负载测试,结果将是不准确的。我们在run-k6-loadtest任务的启动脚本中增加了一个3-5秒的sleep。更稳健的做法是轮询Envoy的管理端口(15000)的/config_dump端点,直到确认故障注入的过滤器已经加载。
  2. PromQL查询的精确性:编写正确的PromQL是整个方案的关键。envoy_cluster_name的格式可能因服务网格实现而异。在实践中,需要先手动查询Prometheus,找到准确的集群名称和指标标签。例如,我们选择envoy_cluster_upstream_rq_time_bucket而不是应用自身的延迟指标,因为我们想测量的是包含Envoy处理在内的端到端延迟。
  3. 测试环境的隔离:整个流水线必须在一个完全隔离的命名空间中运行,以避免对其他环境造成干扰。finally任务中的资源清理至关重要,否则残留的EnvoyFilter可能会成为一个巨大的隐患。

局限性与未来方向

这套方案有效地解决了在CI/CD流程中自动化应用层韧性测试的问题,但它并非万能。它主要验证的是应用对可预测的网络故障(延迟、错误)的响应。对于更复杂的混沌场景,如Pod被随机杀死、节点宕机、网络分区等,则需要更专业的混沌工程工具(如Chaos Mesh)。

未来的一个优化方向是让流水线更“智能”。例如,可以不使用固定的故障百分比,而是通过一个循环或Tekton的Matrix功能,从0%到100%逐步增加故障注入的强度,找到服务的“韧性拐点”——即服务SLO开始无法满足的最大故障容忍度。这个数据对于容量规划和SLA的制定将具有极高的价值。


  目录