一个新上线的搜索服务,单元测试、集成测试、端到端测试全部绿灯通过,但这并不意味着高枕无忧。在真实的生产环境中,其下游依赖(例如,一个提供相关推荐的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
技术选型决策:
- Tekton: 作为CI/CD的核心编排引擎。它的优势在于原生运行在Kubernetes之上,以CRD(Custom Resource Definitions)的形式定义任务和流水线,可以像管理应用一样管理CI/CD流程。每个步骤都是一个容器,隔离性好,易于复用。
- Envoy Proxy: 用于故障注入。我们选择直接操作Envoy的配置,而不是使用更上层的服务网格工具(如Istio),是为了将方案解耦,使其不强依赖于特定的服务网格实现。我们只需要目标Pod中存在一个Envoy sidecar即可。通过
EnvoyFilter
资源,我们可以精确地向HTTP路由中添加故障注入过滤器。 - k6: 作为负载生成工具。它使用JavaScript编写测试脚本,性能出色,并且可以方便地打包成容器镜像,在Tekton Task中运行。
- 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_FILTER
与CLUSTER
组合匹配: 这是一个关键技巧。单独的HTTP_FILTER
会影响所有出站请求,但通过添加一个匹配特定CLUSTER
的patch
,即使该patch
内容为空,也能将HTTP_FILTER
的作用域限定在访问该cluster
的流量上。abort
和delay
: 同时配置了两种故障类型,模拟一个既慢又不稳定的下游服务。
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)
。
遇到的问题与权衡
在实施过程中,有几个细节值得注意:
- EnvoyFilter生效延迟:
EnvoyFilter
被kubectl apply
后,配置不会立即同步到所有的Envoy sidecar。如果在配置生效前就启动负载测试,结果将是不准确的。我们在run-k6-loadtest
任务的启动脚本中增加了一个3-5秒的sleep
。更稳健的做法是轮询Envoy的管理端口(15000)的/config_dump
端点,直到确认故障注入的过滤器已经加载。 - PromQL查询的精确性:编写正确的PromQL是整个方案的关键。
envoy_cluster_name
的格式可能因服务网格实现而异。在实践中,需要先手动查询Prometheus,找到准确的集群名称和指标标签。例如,我们选择envoy_cluster_upstream_rq_time_bucket
而不是应用自身的延迟指标,因为我们想测量的是包含Envoy处理在内的端到端延迟。 - 测试环境的隔离:整个流水线必须在一个完全隔离的命名空间中运行,以避免对其他环境造成干扰。
finally
任务中的资源清理至关重要,否则残留的EnvoyFilter
可能会成为一个巨大的隐患。
局限性与未来方向
这套方案有效地解决了在CI/CD流程中自动化应用层韧性测试的问题,但它并非万能。它主要验证的是应用对可预测的网络故障(延迟、错误)的响应。对于更复杂的混沌场景,如Pod被随机杀死、节点宕机、网络分区等,则需要更专业的混沌工程工具(如Chaos Mesh)。
未来的一个优化方向是让流水线更“智能”。例如,可以不使用固定的故障百分比,而是通过一个循环或Tekton的Matrix
功能,从0%到100%逐步增加故障注入的强度,找到服务的“韧性拐点”——即服务SLO开始无法满足的最大故障容忍度。这个数据对于容量规划和SLA的制定将具有极高的价值。