k6与Python协同构建自动化性能测试流水线

k6与Python协同构建自动化性能测试流水线 1. 为什么性能测试不能只靠“点点点”和“看日志”我第一次接手一个电商大促前的压测任务时团队还在用 Postman 手动发请求配个定时器循环跑 20 次再盯着 Grafana 看 CPU 和响应时间曲线。结果大促当天凌晨三点订单服务突然超时率飙升到 37%而我们前一天的“压测报告”里写着“系统稳定P95 300ms”。后来翻日志才发现那个“稳定”的结论是基于单接口、无并发、无数据污染、无真实用户行为路径的“理想裸奔”测试——它根本没模拟出购物车加商品→提交订单→扣库存→发消息→更新状态这一整条链路的资源争抢。这就是纯手工压测最致命的问题它不自动化就无法复现它不代码化就无法沉淀它不参数化就无法演进。而 k6 Python 的组合恰恰切中了这个痛点——k6 负责高性能、可编程、可观测的负载生成与指标采集Python 则负责把“测试逻辑”真正变成“业务逻辑”读取真实订单流水、调用风控 SDK 做动态签名、解析 HTML 表单提取 CSRF Token、按用户画像分层构造差异化请求体、对接内部 CMDB 动态拉取目标服务 IP 列表……这些事光靠 k6 自带的 JS 引擎写起来要么冗长难维护要么能力受限比如处理复杂加密、调用企业内网 Python 微服务、做图像识别式验证码绕过等。所以“基于 k6 和 Python 进行自动化性能测试”不是简单地把两个工具拼在一起而是构建一条“业务语义可表达、执行过程可编排、数据来源可治理、结果分析可闭环”的性能工程流水线。它面向的不是测试工程师个人的“能跑通”而是整个研发团队的“敢上线”。关键词k6、Python、自动化性能测试背后对应的是三个不可妥协的刚性需求高并发下的确定性压测能力k6、复杂业务逻辑的灵活表达能力Python、以及从脚本到报告全链路无人值守的工程化能力二者协同。如果你还在用 JMeter 录制回放跑固定 CSV 数据或者用 Locust 写一堆回调函数硬凑业务流那这套方案会直接把你从“压测执行者”升级为“性能守门人”。2. k6 的核心设计哲学为什么它不是另一个“JS 版 JMeter”很多人第一次接触 k6下意识把它当成“用 JavaScript 写的 JMeter”这是个危险的误解。JMeter 是基于线程模型的 GUI 工具每个虚拟用户VU对应一个 Java 线程内存开销大、水平扩展难、脚本调试像在黑盒里摸鱼而 k6 是基于 Go 语言协程goroutine构建的 CLI 工具一个进程可轻松支撑 10 万 VU内存占用仅为同等负载下 JMeter 的 1/8 —— 这不是参数优化的结果而是底层运行时模型的根本差异。k6 的设计哲学有三个锚点声明式场景定义、不可变执行上下文、原生可观测性注入。先说声明式。在 k6 中你不是写“先登录再加购再下单”而是定义一个options对象明确描述“我要 500 个用户在 5 分钟内均匀启动每个用户每秒最多发起 2 次请求错误率超过 5% 就自动中止”。k6 会根据这个声明自动调度 VU 生命周期、控制请求节奏、执行熔断策略。这就像 Kubernetes 的 PodSpec你声明“我要什么”k6 负责“怎么做到”。再看不可变上下文。k6 的每个 VU 在执行时其http、check、sleep等 API 都运行在一个隔离的、不可跨 VU 共享的状态空间里。你不能像在 JMeter 里那样用一个全局变量存 token 然后所有线程去读——k6 强制你把认证逻辑写进每个 VU 的setup()函数里或者通过sharedArray显式共享只读数据。这种设计看似麻烦实则消除了绝大多数因状态竞争导致的压测结果抖动。我曾遇到一个案例某支付接口压测时 P99 波动极大排查三天才发现是 JMeter 的 BeanShell 后置处理器里用了静态 HashMap 缓存了过期的 session ID多个线程同时写入导致脏数据。换成 k6 后这个问题天然消失。最后是原生可观测性。k6 不需要你额外装插件或改配置只要运行k6 run script.js --out influxdbhttp://localhost:8086/k6所有默认指标http_req_duration、vus、checks、iterations就会实时推送到 InfluxDB加上--thresholds http_req_duration{expected_response:true}!300就能在指标超标时自动退出并返回非零状态码。这种“开箱即用的 SLO 驱动”能力让 k6 天然适配 CI/CD 流水线——你可以把压测脚本当单元测试一样放进 GitLab CI失败直接阻断发布。提示k6 的 VUVirtual User不是“用户数”而是“并发请求数”的近似单位。100 个 VU 并不等于 100 个真实用户而是 k6 启动 100 个轻量级执行单元每个单元按脚本逻辑循环发起请求。实际并发量取决于脚本中sleep()时长和请求耗时。计算公式为平均并发 ≈ VU 数 × (1 / 平均请求周期)。例如 100 VU每个请求耗时 200mssleep 800ms则平均请求周期为 1s平均并发就是 100。3. Python 如何成为 k6 的“外挂大脑”三种深度集成模式k6 本身用 Go 编写运行时嵌入了 goja一个纯 Go 实现的 JavaScript 引擎因此原生只支持 JS/TS。但现实中的性能测试往往卡在 JS 做不了的事上比如调用公司自研的风控 SDKPython 编写、解析 PDF 订单附件提取金额字段、用 OpenCV 识别图形验证码、连接内部 LDAP 查询测试账号列表、甚至调用大模型 API 生成符合业务规则的测试文本。这时候Python 就不是“辅助工具”而是 k6 的“外挂大脑”。我们实践中总结出三种可靠、可落地的集成模式按侵入性和适用场景递进3.1 模式一Python 预处理生成 k6 可读数据文件低侵入推荐用于静态数据这是最安全、最易调试的方式。Python 脚本独立运行完成所有复杂逻辑后输出标准 JSON 或 CSV 文件k6 脚本通过open()API 读取。例如为模拟真实用户分层我们需要按 6:3:1 的比例生成 VIP/普通/新用户请求体# generate_test_data.py import json import random def generate_user_payloads(count1000): payloads [] for i in range(count): user_type random.choices([vip, normal, new], weights[6,3,1])[0] base { user_id: ftest_{i:06d}, channel: app, timestamp: int(time.time() * 1000) } if user_type vip: base.update({level: VIP, discount_rate: 0.15, priority: True}) elif user_type normal: base.update({level: NORMAL, discount_rate: 0.05, priority: False}) else: base.update({level: NEW, discount_rate: 0.0, priority: False}) payloads.append(base) with open(test_payloads.json, w) as f: json.dump(payloads, f, indent2) if __name__ __main__: generate_user_payloads(5000)k6 脚本只需import http from k6/http; import { check, sleep } from k6; // 在 init context 中一次性读取 const payloads JSON.parse(open(./test_payloads.json)); export default function () { const idx Math.floor(Math.random() * payloads.length); const payload payloads[idx]; const res http.post(https://api.example.com/order, JSON.stringify(payload), { headers: { Content-Type: application/json } }); check(res, { is status 200: (r) r.status 200, P95 300ms: (r) r.timings.duration 300 }); sleep(1); }优势在于Python 和 k6 完全解耦Python 脚本可单独单元测试数据文件可版本化管理k6 运行时不依赖任何外部进程。我们线上所有环境的压测数据都走此模式CI 流水线中先python generate_test_data.py再k6 run script.js稳定度 100%。3.2 模式二Python 作为 HTTP 微服务k6 通过 API 调用中侵入适用于动态决策当需要实时决策时比如每次请求前都要调用风控服务获取本次请求的 token或根据当前库存水位动态调整下单数量预生成数据就不够了。此时我们用 Flask/FastAPI 快速起一个轻量 Python 服务# risk_service.py from flask import Flask, request, jsonify import time import hmac import hashlib app Flask(__name__) app.route(/api/v1/token, methods[POST]) def get_token(): data request.get_json() # 模拟风控逻辑对用户ID和时间戳做HMAC-SHA256签名 message f{data[user_id]}|{int(time.time())} secret byour_risk_secret_key token hmac.new(secret, message.encode(), hashlib.sha256).hexdigest() return jsonify({ token: token, expire_at: int(time.time()) 300 # 5分钟有效期 }) if __name__ __main__: app.run(host0.0.0.0:5000)k6 脚本中import http from k6/http; import { check, sleep } from k6; export default function () { // 1. 先向 Python 服务申请 token const riskRes http.post(http://localhost:5000/api/v1/token, JSON.stringify({ user_id: test_${__ENV.TEST_USER_ID || 000001} }), { headers: { Content-Type: application/json } }); const tokenData riskRes.json(); // 2. 拿到 token 后发起主业务请求 const orderRes http.post(https://api.example.com/order, JSON.stringify({ user_id: test_${__ENV.TEST_USER_ID || 000001}, risk_token: tokenData.token }), { headers: { Content-Type: application/json, X-Risk-Token: tokenData.token } }); check(orderRes, { risk service success: (r) riskRes.status 200, order success: (r) orderRes.status 200 }); sleep(1); }关键点Python 服务必须保证高可用我们用 systemd 管理失败自动重启且 k6 脚本中要对风险服务调用做超时和重试http.post(url, body, { timeout: 5s })避免因风控服务抖动拖垮整个压测。3.3 模式三Python 与 k6 进程间通信高侵入仅用于不可替代的计算密集型任务极少数场景如需要实时 OCR 识别验证码图片、或用 PyTorch 加载小模型做请求特征打分Python 计算耗时长100ms且无法异步化。这时我们放弃“HTTP 调用”的网络开销改用 Unix Domain Socket 或命名管道Windows进行进程间通信。k6 侧用exec模块需编译启用调用 Python 脚本Python 脚本处理完后将结果 stdout 返回# ocr_processor.py import sys import json from PIL import Image import pytesseract def ocr_image(image_path): try: img Image.open(image_path) text pytesseract.image_to_string(img, config--psm 8 -c tessedit_char_whitelist0123456789) return {code: text.strip(), success: True} except Exception as e: return {error: str(e), success: False} if __name__ __main__: # 从 stdin 读取 JSON 参数 input_data json.loads(sys.stdin.read()) result ocr_image(input_data[image_path]) print(json.dumps(result)) # stdout 输出结果k6 侧需使用 k6 的 experimental exec 模块import exec from k6/experimental/exec; import http from k6/http; export default function () { // 1. 先下载验证码图片 const captchaRes http.get(https://api.example.com/captcha); // 2. 保存到临时文件注意k6 运行目录需有写权限 const imagePath /tmp/captcha_${__ENV.TEST_RUN_ID || Date.now()}.png; open(imagePath, wb).write(captchaRes.body); // 3. 调用 Python OCR 脚本 const ocrResult exec.run(python3, [ocr_processor.py], { stdin: JSON.stringify({ image_path: imagePath }) }); const ocrData JSON.parse(ocrResult.stdout); // 4. 提交验证码 const submitRes http.post(https://api.example.com/verify, JSON.stringify({ captcha_code: ocrData.code })); check(submitRes, { captcha verify success: (r) r.status 200 }); }注意此模式对环境要求高k6 进程需有执行外部命令权限Python 环境需预装所有依赖tesseract-ocr、PIL、pytesseract且必须严格管控临时文件生命周期避免磁盘占满。我们只在金融级反欺诈压测中用过一次日常强烈推荐前两种模式。4. 从脚本到报告构建端到端自动化性能测试流水线写好一个能跑通的 k6 脚本只是万里长征第一步。真正的自动化性能测试必须覆盖“准备 → 执行 → 分析 → 决策 → 归档”全生命周期。我们团队落地的流水线已稳定运行两年日均触发 17 次压测覆盖全部核心服务。下面拆解每个环节的关键设计和踩过的坑。4.1 环境准备用 Docker Compose 统一基线拒绝“在我机器上是好的”最大的协作成本从来不是脚本语法而是环境差异。开发说“本地压测 P95 是 120ms”运维说“预发环境 P95 是 450ms”最后发现开发用的是 SQLite 内存库运维用的是 PostgreSQL 集群。为此我们强制所有压测环境基于 Docker Compose 定义# docker-compose.perf.yml version: 3.8 services: k6-runner: image: grafana/k6:latest volumes: - ./scripts:/scripts - ./data:/data # 共享测试数据目录 - ./reports:/reports # 报告输出目录 network_mode: host # 关键让 k6 直接使用宿主机网络避免 Docker 网络栈引入延迟 command: run /scripts/checkout.js --out influxdbhttp://host.docker.internal:8086/k6 --thresholds http_req_duration{expected_response:true}!500 influxdb: image: influxdb:1.8-alpine ports: - 8086:8086 environment: - INFLUXDB_DBk6 volumes: - ./influxdb-data:/var/lib/influxdb grafana: image: grafana/grafana:9.5.2 ports: - 3000:3000 environment: - GF_SECURITY_ADMIN_PASSWORDadmin volumes: - ./grafana-provisioning:/etc/grafana/provisioning关键设计点network_mode: host这是性能敏感型压测的铁律。Docker 的 bridge 网络会增加 0.3~1.2ms 的固定延迟且在高并发下丢包率上升。用 host 网络k6 发出的请求与物理机直连无异。volumes映射确保 k6 脚本、测试数据、报告输出都在容器内外一致路径避免路径错乱。command中固化常用参数--out influxdb和--thresholds作为默认项业务脚本只需关注核心逻辑。我们还封装了一个make perf命令一键拉起环境、执行压测、打开 Grafana 面板# Makefile perf: docker-compose -f docker-compose.perf.yml up -d influxdb grafana sleep 5 docker-compose -f docker-compose.perf.yml up --build k6-runner echo ✅ 压测完成查看报告: http://localhost:3000 (admin/admin)4.2 执行阶段用 k6 的 stages 和 thresholds 实现“智能熔断”k6 的stages选项允许你定义阶梯式负载曲线这比简单设置一个固定 VU 数更贴近真实流量。我们定义的标准压测曲线是[{duration: 30s, target: 50}, {duration: 2m, target: 200}, {duration: 30s, target: 500}, {duration: 1m, target: 500}]—— 先热身再爬升最后冲击峰值并维持。但光有曲线不够必须配套熔断机制否则可能把预发环境压垮。我们的熔断策略分三级基础阈值Thresholds在脚本options中硬编码如http_req_failed 1%、http_req_duration{expected_response:true} 500。任一不满足k6 自动退出并返回状态码 1。动态熔断Check Abort在请求后置检查中若发现连续 3 次响应时间 1000ms则主动调用abortIteration()终止当前 VU 的本次迭代并记录告警。外部干预Signal在 CI 流水线中用timeout命令限制 k6 进程总耗时如timeout 10m k6 run ...超时则强杀防止脚本死循环。一个典型的options配置export const options { stages: [ { duration: 30s, target: 50 }, { duration: 2m, target: 200 }, { duration: 30s, target: 500 }, { duration: 1m, target: 500 } ], thresholds: { // 核心 SLO 指标不满足则失败 http_req_failed: [rate0.01], // 错误率 1% http_req_duration{expected_response:true}: [p(95)500], // P95 500ms // 辅助观测指标仅告警不失败 http_req_duration{expected_response:false}: [p(95)2000] // 非预期响应如 500P95 2s }, // 当前环境标识用于 InfluxDB tag ext: { tags: { env: __ENV.ENV_NAME || staging, service: checkout-api, run_id: __ENV.TEST_RUN_ID || Date.now().toString() } } };提示ext.tags是 k6 传递自定义标签到 InfluxDB 的唯一方式。我们用run_id关联同一轮压测的所有指标用env和service实现多维度下钻分析。没有这个Grafana 面板就是一团乱麻。4.3 分析与归档用 Python 解析 InfluxDB 数据生成可交付的 Markdown 报告k6 本身不生成 HTML 报告InfluxDB 存的是原始时序数据。我们用 Python 脚本定时查询生成带图表和结论的 Markdown 报告自动上传到 Confluence# generate_report.py import pandas as pd from influxdb import InfluxDBClient import matplotlib.pyplot as plt import io import base64 def query_k6_metrics(client, run_id): query f SELECT mean(value) FROM ( SELECT percentile(http_req_duration, 95) AS value FROM k6.autogen.http_req_duration WHERE run_id {run_id} AND expected_response true GROUP BY time(10s) ) WHERE time now() - 10m result client.query(query) return list(result.get_points()) def plot_p95_trend(data_points): df pd.DataFrame(data_points) df[time] pd.to_datetime(df[time]) plt.figure(figsize(10,4)) plt.plot(df[time], df[value], markero) plt.title(P95 Response Time Trend) plt.ylabel(Duration (ms)) plt.grid(True) # 转 base64 嵌入 Markdown buf io.BytesIO() plt.savefig(buf, formatpng, dpi100, bbox_inchestight) buf.seek(0) img_base64 base64.b64encode(buf.read()).decode() return f![P95 Trend](data:image/png;base64,{img_base64}) if __name__ __main__: client InfluxDBClient(localhost, 8086, databasek6) points query_k6_metrics(client, 20240520_1430_staging) report_md f # 性能测试报告 - checkout-api (Staging) **执行时间**: 2024-05-20 14:30 **峰值并发**: 500 VU **核心结论**: ✅ P95 稳定在 420±30ms满足 SLO 500ms 要求 ## P95 响应时间趋势 {plot_p95_trend(points)} ## 关键指标摘要 | 指标 | 数值 | SLO | |------|------|-----| | P95 响应时间 | 420ms | 500ms | | 错误率 | 0.2% | 1% | | 吞吐量 | 185 req/s | 150 req/s | 报告生成时间: {pd.Timestamp.now()} with open(report.md, w) as f: f.write(report_md)这个脚本每天凌晨自动运行抓取昨日所有压测run_id生成独立报告页。更重要的是它把“数据”变成了“结论”——不是罗列数字而是用 ✅/❌ 符号直观表达是否达标用趋势图揭示波动规律用表格对比 SLO 和实测值。这才是研发团队真正需要的“性能语言”。4.4 决策闭环将压测结果接入发布门禁Gate最后一步也是最关键的一步让性能测试真正影响发布决策。我们在 GitLab CI 的deploy-to-staging阶段后插入一个performance-gate阶段# .gitlab-ci.yml stages: - test - performance-gate - deploy performance-gate: stage: performance-gate image: python:3.9 before_script: - pip install influxdb pandas matplotlib script: - python generate_report.py --run-id $CI_PIPELINE_ID --env staging - | # 解析报告中的关键指标决定是否放行 if [ $(grep -c ✅ report.md) -eq 3 ]; then echo 性能达标准予发布 exit 0 else echo ❌ 性能未达标阻断发布 exit 1 fi allow_failure: false这个门禁不是摆设。去年 Q3一个订单优惠券计算逻辑重构单元测试全绿但压测报告显示 P95 从 380ms 涨到 620msCI 流水线自动阻断发布。团队回滚后定位到是缓存穿透导致 DB 查询激增加了布隆过滤器后重新压测通过。这就是自动化性能测试的价值它不替人思考但它把思考的依据以不可辩驳的数据形式摆在每个人面前。5. 实战避坑指南那些文档里不会写的 7 个血泪教训写了三年 k6 Python 性能测试踩过的坑比跑过的 VU 还多。这里不讲原理只说真金白银的教训每一条都来自凌晨三点的生产事故现场。5.1 教训一永远不要在 k6 的 init context 里做耗时操作k6 的脚本分为两个执行阶段init context只执行一次用于导入模块、读取文件、初始化全局变量和 default function每个 VU 每次迭代都执行。新手常犯的错误是在 init context 里调用http.get()去拉取远程配置// ❌ 危险init context 中发起网络请求 const configRes http.get(https://config-center/internal/config.json); // 这行会阻塞所有 VU 启动 const config configRes.json(); export default function () { // ... }后果1000 个 VU 启动时k6 会等这个http.get()返回后才开始调度 VU如果配置中心响应慢5s整个压测启动延迟 5 秒以上且所有 VU 的“首次请求时间”高度集中完全失真。正确做法是把配置拉取放到setup()函数里由第一个 VU 执行并缓存// ✅ 正确用 setup() 做一次性的、带缓存的远程调用 let globalConfig; export function setup() { // 只有第一个 VU 会执行 if (__ENV.K6_VU_ID 0) { const res http.get(https://config-center/internal/config.json); globalConfig res.json(); } // 等待所有 VU 同步 return { config: globalConfig }; } export default function (data) { // data.config 就是 setup() 返回的配置 const res http.post(https://api.example.com/, JSON.stringify(data.config)); }5.2 教训二Python 预生成数据时时间戳必须用 UTC且精度对齐我们曾压测一个金融交易接口脚本里 Python 生成的时间戳用的是datetime.now()本地时区而 k6 脚本里用Date.now()UTC。结果在跨时区的 CI 环境中Python 生成的“未来时间戳”被服务端拒绝错误率飙升。根源在于datetime.now()返回的是系统本地时间而Date.now()返回的是毫秒级 UTC 时间戳。解决方案Python 中统一用datetime.utcnow()并显式转为毫秒# ✅ 正确生成毫秒级 UTC 时间戳 from datetime import datetime timestamp_ms int(datetime.utcnow().timestamp() * 1000) payload {event_time: timestamp_ms, ...}同时k6 中也要用Date.now()确保两端时间基准一致。我们还加了一行校验// 在 k6 脚本中检查时间戳是否在合理窗口内 const now Date.now(); if (payload.event_time now 60000 || payload.event_time now - 60000) { console.warn(⚠️ 时间戳异常: ${payload.event_time}, now${now}); }5.3 教训三k6 的http.batch()不是银弹慎用于高并发 POSThttp.batch()可以一次发送多个请求减少 TCP 连接建立开销。但它的底层是并发发出所有请求如果批量中某个请求失败如 500整个 batch 返回失败你无法知道是哪个子请求出问题。更严重的是在 1000 VU 下http.batch([req1, req2, req3])会让每个 VU 同时发起 3 倍请求瞬间冲垮服务。我们曾用batch()模拟“用户登录后获取首页、订单、消息”三个接口结果压测时首页接口 P95 暴涨 300%而订单和消息正常。排查发现首页接口有缓存穿透风险batch()导致大量缓存失效请求雪崩。最终改为串行调用并给首页加了sleep(100)降频。提示http.batch()仅推荐用于幂等的 GET 请求且批量大小 ≤ 3。对于 POST/PUT 等有副作用的请求一律禁止使用。5.4 教训四InfluxDB 的 retention policy 必须按压测频次配置默认 InfluxDB 的 retention policy 是autogen数据永久保留。但我们的压测每小时跑一次一天 24 轮每轮产生数百万数据点。不到一周InfluxDB 磁盘爆满Grafana 查看历史数据卡死。解决方案创建专用 retention policy按天滚动# 创建 7 天保留策略 influx -execute CREATE RETENTION POLICY perf_7d ON k6 DURATION 7d REPLICATION 1 DEFAULT # 将 k6 数据写入该策略 k6 run script.js --out influxdbhttp://localhost:8086?rpperf_7d同时用 cron 每天清理旧的run_idtag# 删除 7 天前的 run_id 标签数据避免 tag cardinality 爆炸 influx -execute DELETE FROM \k6\.\autogen\.\http_req_duration\ WHERE time now() - 7d5.5 教训五Grafana 面板的 time range 必须用$__interval而非固定值很多 Grafana 面板用last 30 minutes这样的固定时间范围导致在查看历史压测报告时图表一片空白——因为数据点已经超出时间窗口。正确做法是用变量$__interval它会根据面板宽度自动计算合适的时间粒度-- ✅ 正确用 $__interval 适配任意时间范围 SELECT mean(value) FROM k6.autogen.http_req_duration WHERE run_id ~ /^$run_id$/ AND time :dashboardTime: AND time :dashboardTimeEnd: GROUP BY time($__interval) fill(null)同时在面板设置中开启Min interval如10s避免高频采样拖慢查询。5.6 教训六Python 服务的健康检查必须独立于业务逻辑前面提到用 Python 微服务提供动态 token我们最初把健康检查/healthz和业务接口/api/v1/token写在同一个 Flask 应用里。结果某次风控 SDK 升级/api/v1/token因依赖冲突 500但/healthz仍返回 200k6 以为服务正常持续重试加剧了故障。解决方案健康检查必须是“最简路径”不加载任何业务模块# health_check.py - 独立的轻量脚本 from flask import Flask app Flask(__name__) app.route(/healthz) def healthz(): return OK, 200 if __name__ __main__: app.run(host0.0.0.0:5001) # 单独端口k6 脚本中压测前先http.get(http://localhost:5001/healthz)失败则直接报错退出。5.7 教训七CI 流水线中k6 的 exit code 是唯一可信信号GitLab CI 默认把任何非零 exit code 当作失败但 k6 的 exit code 有明确语义0: 所有阈值通过执行成功1: 阈值未通过SLO 不达标2: 脚本语法错误或运行时 panic3: 网络超时或连接拒绝我们曾用|| true忽略 k6 的 exit code结果 SLO 不达标也继续发布。现在CI 脚本中严格判断# ✅ 正确只接受 exit code 0其他一律失败 k6 run script.js --out influxdb... --thresholds