Toxiproxy 网络故障注入与依赖韧性验证工具手册
超时配置看起来正确,测试为什么从不失败
应用把 Redis 读取超时写成了 300 ms,重试次数也写进配置,集成测试却总在毫秒内通过。发布后依赖抖动,线程池被等待中的请求占满,重试又把流量放大。问题不在于“没有超时参数”,而在于测试从未让真实 TCP 连接经历延迟、停流、限速和重置。
Toxiproxy 把测试连接放进一段可动态操纵的 TCP 管道。应用连接 proxy 的 listen 地址,proxy 再连接真实 upstream;测试通过 Admin API 或 CLI 添加 toxic,改变 client 到 server 的 upstream 流,或 server 到 client 的 downstream 流。它不理解 Redis、MySQL、HTTP 或 MQ 协议,因此适合验证连接、读写、超时、重试、连接池与恢复,不会替应用判断业务响应是否正确。
稳定版本 v2.12.0 提供 Linux、macOS、Windows 二进制与 GHCR 镜像。Docker Hub 只保留旧版本入口,容器应锁定 ghcr.io/shopify/toxiproxy:2.12.0。Admin API 默认监听 8474,没有内建业务认证;只能暴露在本机或隔离测试网络。发布与安装入口见 Shopify/Toxiproxy Releases 和 GHCR package。
启动一个只代理测试 Redis 的环境
下面的 Compose 让宿主机通过 26379 访问 proxy,Toxiproxy 容器通过 Compose DNS 访问 redis:6379。应用若仍连 6379,任何 toxic 都不会生效。
services:
redis:
image: redis:8.0
command: ["redis-server", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 1s
retries: 20
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.12.0
command: ["-host=0.0.0.0", "-proxy-metrics"]
ports:
- "127.0.0.1:8474:8474"
- "127.0.0.1:26379:26379"
depends_on:
redis:
condition: service_healthydocker compose up -d
curl -fsS http://127.0.0.1:8474/version/version 应返回服务端版本。覆盖镜像默认命令时必须保留 -host=0.0.0.0,否则服务端会退回只监听容器内部 localhost,宿主机端口映射也无法访问;-proxy-metrics 则启用后文用于证明流量经过代理的字节指标。容器镜像同时包含 /toxiproxy-cli;宿主机没有安装 CLI 时,可这样调用:
docker compose exec toxiproxy /toxiproxy-cli list二进制安装适合需要直接调试进程、日志和端口的开发机;Docker 单容器适合一次性试验;Compose 适合项目模板;Testcontainers 适合每个测试任务独享实例。把一个长期共享 daemon 放在所有项目入口前,节省的资源很少,却会引入命名冲突、全局 reset 和跨项目故障污染。
proxy 与 toxic 是两层状态
先用幂等的 /populate 创建 proxy:
curl -fsS -X POST http://127.0.0.1:8474/populate \
-H "content-type: application/json" \
-d '[
{
"name": "your-project_test_redis_1",
"listen": "0.0.0.0:26379",
"upstream": "redis:6379",
"enabled": true
}
]'重复提交相同 name、listen 和 upstream 时,proxy 保持不变;地址不一致时会替换配置。修改 listen 或 upstream 会重启 proxy 并丢弃活动连接,所以 /populate 虽然适合启动时收敛配置,也不能在并发测试中随意改地址。它只新增或替换请求体中列出的 proxy,不会删除请求体中没出现的旧 proxy;daemon 重启后这些内存状态又会全部消失。启动脚本既要调用 /populate 重建期望配置,也要查询 /proxies,识别并清理当前项目已经废弃的名称。
proxy 字段回答“哪段 TCP 链路被代理”:
| 字段 | 含义 | 改错时的证据 |
|---|---|---|
name | API、CLI 和测试代码引用的稳定标识 | 404 或命中别人的 proxy |
listen | 应用连接 Toxiproxy 的地址 | connection refused、端口冲突 |
upstream | Toxiproxy 实际连接的依赖 | proxy 存在但业务连接失败 |
enabled | 是否接受并转发连接 | 关闭后新旧连接被切断 |
toxic 字段回答“怎样破坏这段链路”:
| 字段 | 含义 | 设计影响 |
|---|---|---|
name | 同一 proxy 内的 toxic 标识 | 清理脚本必须按它精确删除 |
type | latency、timeout、bandwidth 等行为 | 决定故障机制,不只是错误名称 |
stream | upstream 或 downstream | 分别影响请求方向或响应方向 |
toxicity | 对连接应用 toxic 的概率,默认 1.0 | 小于 1 会引入随机性,不适合作为首个确定性测试 |
attributes | toxic 特有参数 | 单位错误会让实验强度完全失真 |
Toxiproxy 为每个连接建立双向管道,toxic 只插入指定方向的数据流。TCP 没有“请求/响应”语义;对大多数客户端而言 upstream 近似请求写入,downstream 近似响应读取,但协议握手也会经过这些方向。这个模型解释了为何 downstream timeout 可能让客户端卡在握手或读响应,而不是返回一个业务 500。
正向实验:延迟出现,删除后恢复
先建立正常基线:
docker compose exec redis \
redis-cli -h toxiproxy -p 26379 SET probe ok
time docker compose exec redis \
redis-cli -h toxiproxy -p 26379 GET probe预期 SET 返回 OK,GET 返回 ok。然后添加固定名称的 downstream latency:
curl -fsS -X POST \
http://127.0.0.1:8474/proxies/your-project_test_redis_1/toxics \
-H "content-type: application/json" \
-d '{
"name": "case_latency_downstream",
"type": "latency",
"stream": "downstream",
"toxicity": 1.0,
"attributes": {"latency": 800, "jitter": 0}
}'
time docker compose exec redis \
redis-cli -h toxiproxy -p 26379 GET probe第二次 GET 仍应返回 ok,墙钟耗时相对基线增加约 800 ms。它证明的是下行数据被延迟,不证明应用的 300 ms 超时有效;redis-cli 自己可能愿意等待更久。
删除 toxic 后再次测量恢复:
curl -fsS -X DELETE \
http://127.0.0.1:8474/proxies/your-project_test_redis_1/toxics/case_latency_downstream
time docker compose exec redis \
redis-cli -h toxiproxy -p 26379 GET probe预期数据仍在,额外延迟消失。若删除后仍慢,检查是否还有其他 toxic、客户端是否保留坏连接,以及慢点是否本来就在 upstream。
四类故障不能用一个 timeout 代替
latency:数据最终到达,只是更晚
{
"name": "case_latency_downstream",
"type": "latency",
"stream": "downstream",
"attributes": {"latency": 800, "jitter": 100}
}latency 与 jitter 单位是毫秒。它适合验证调用 deadline、慢请求指标、熔断窗口和延迟预算。带 jitter 的实验不应断言某个精确毫秒值,而应断言超时上限、错误类型和恢复趋势。
timeout:停止传输,达到期限后关闭连接
{
"name": "case_timeout_downstream",
"type": "timeout",
"stream": "downstream",
"attributes": {"timeout": 1500}
}timeout=0 会持续丢弃数据,直到 toxic 被删除,不会自动关闭连接。这是最容易让测试任务永久挂住的配置;测试进程自身必须有更外层 deadline 和 finally 清理。
bandwidth:连接可用,但吞吐受限
{
"name": "case_bandwidth_downstream",
"type": "bandwidth",
"stream": "downstream",
"attributes": {"rate": 32}
}rate 单位是 KB/s。小 Redis 响应几乎看不出差异,应使用已知大小的大 payload,比较传输时间与客户端内存,而不是拿一次 PING 判断限速是否生效。
reset_peer:模拟 TCP RST
{
"name": "case_reset_downstream",
"type": "reset_peer",
"stream": "downstream",
"attributes": {"timeout": 0}
}预期客户端看到 connection reset、socket closed 或库封装后的连接异常,而不是业务超时。不同客户端会包装成不同异常,断言应落在项目公开的错误语义和重试策略上。
slow_close 用于延迟 socket 关闭,slicer 把 TCP 数据切成小块并可加入微秒级间隔,limit_data 在传输超过指定字节数后断开。它们分别暴露关闭阶段等待、错误的报文边界假设和半截响应处理。Toxic 参数与 API 字段见 Toxiproxy README。
反实验:重试把一次慢依赖放大成多次等待
把应用测试 profile 指向 127.0.0.1:26379,设置单次 socket timeout 为 300 ms、最多重试 2 次,再注入 800 ms downstream latency。测试应记录三类证据:最终异常类型、总墙钟耗时、依赖尝试次数。
单次 deadline = 300 ms
最多尝试次数 = 3(首次 + 2 次重试)
注入延迟 = 800 ms错误实现常见结果不是“300 ms 后失败”,而是连续等待约三个 deadline,连接池在每次失败后还可能新建连接。并发请求一起重试时,upstream 尝试数与连接创建数同步上升。这项反实验的通过条件应写成业务不变量:
总耗时受调用级 deadline 约束;
重试次数不超过预算;
不可幂等写不自动重试;
重试有退避和抖动;
依赖恢复后连接池回到稳定活跃连接数。仅断言“最终失败”会放过重试风暴。项目测试应从客户端 hook、mock server 计数、应用指标或 Toxiproxy metrics 中取得尝试次数,不能靠日志行数猜测。服务端只有启用 -proxy-metrics 后,GET /metrics 才会包含 toxiproxy_proxy_received_bytes_total 与 toxiproxy_proxy_sent_bytes_total;未启用该参数时,HTTP endpoint 可达也没有代理字节证据。可在实验前后抓取快照:
curl -fsS http://127.0.0.1:8474/metrics > before.prom
# 运行项目测试
curl -fsS http://127.0.0.1:8474/metrics > after.prom指标名称要以锁定版本实际输出为准。基线与实验使用同一镜像、同一 proxy 和同一负载,比较趋势而不是硬编码从别处抄来的数值。
反实验:连接池会隐藏注入,也会拖延恢复
先让应用完成一次健康调用以预热连接池,再添加 reset_peer 或关闭 proxy:
curl -fsS -X PATCH \
http://127.0.0.1:8474/proxies/your-project_test_redis_1 \
-H "content-type: application/json" \
-d '{"enabled": false}'预期活动连接被切断,新连接被拒绝。重新启用:
curl -fsS -X PATCH \
http://127.0.0.1:8474/proxies/your-project_test_redis_1 \
-H "content-type: application/json" \
-d '{"enabled": true}'若应用仍持续失败,Toxiproxy 已恢复而客户端池中可能还保留失效连接,或熔断器尚未进入半开。修复不是在测试中 sleep 一个任意时长,而是让客户端具备借出前校验、失效连接驱逐、有限连接建立 deadline,并断言恢复最终发生且池大小不随循环轮次单调增长。
另一个反例是应用绕过 proxy:MySQL 客户端在 host 为 localhost 时可能优先使用 Unix socket,即使填写了端口。实验中应使用 127.0.0.1 或明确 TCP 配置,并从连接地址、Toxiproxy metrics 与禁用 proxy 后的失败三方面证明流量确实经过代理。
把故障场景接进测试生命周期
项目配置把真实依赖地址和代理地址分开:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:26379}
socket-timeout-ms: ${REDIS_SOCKET_TIMEOUT_MS:300}
retry-max-attempts: ${REDIS_RETRY_MAX_ATTEMPTS:1}测试生命周期应显式拥有状态:
beforeAll 启动独享 Toxiproxy,并用 /populate 收敛 proxy
beforeEach 查询 proxy,确认没有残留 toxic
test 添加命名 toxic,执行调用,断言错误、耗时与尝试次数
finally 精确删除本测试 toxic,重新启用 proxy
afterEach 执行一次健康调用,证明连接池和熔断器恢复
afterAll 删除 proxy 或销毁独享容器推荐把 API 调用封装成测试 fixture,而不是在每个用例复制 curl。fixture 的 finally 必须即使断言失败也执行;测试任务还应设置总 deadline,防止 timeout=0 把 CI worker 永久占住。并行测试使用独立 Toxiproxy 容器或独立 proxy/端口,不能共享同名 toxic。
CLI 与 API 可以互相验证:
docker compose exec toxiproxy \
/toxiproxy-cli inspect your-project_test_redis_1
curl -fsS \
http://127.0.0.1:8474/proxies/your-project_test_redis_1/toxics若 CLI 参数顺序报错,先执行 /toxiproxy-cli help;2.x 的 CLI 要求 flag 位于位置参数之前。服务端与语言客户端也要兼容同一 2.x API,升级后先检查 /version 和客户端库说明。
清理、回滚与共享环境的爆炸半径
独享实例可在兜底清理时使用:
curl -fsS -X POST http://127.0.0.1:8474/reset
docker compose down -v --remove-orphansPOST /reset 会启用所有 proxy 并删除所有 active toxic。它不是“清理当前测试”,而是全局状态修改;共享 daemon 禁止把它放进普通 afterEach。共享实例只能删除本测试持有的 toxic,并在资源登记中记录 project、environment、dependency、owner、proxy name 和 listen port。
更稳妥的失败兜底是:测试开始前生成唯一 toxic 名称,创建成功后立即注册清理回调,结束时 DELETE /proxies/{proxy}/toxics/{name},随后 GET 确认 404 或列表中已不存在。若测试进程崩溃,由 CI job 的最终阶段销毁独享容器;共享 daemon 则需要按 owner 与创建记录执行过期清理,不能看到陌生 toxic 就全局 reset。
Admin API 不处理账号、TLS 或授权,任何能访问 8474 的主体都能禁用 proxy、改 upstream 和清除 toxic。宿主机端口绑定到 127.0.0.1 只限制宿主机网络入口,同一 Docker 网络中的其他容器仍可能直接访问 toxiproxy:8474;共享测试平台要把控制客户端和数据平面放进明确的网络策略,或在 API 前增加受控代理,不能把 loopback 映射误当成容器间授权。它还会接触通过代理的真实 TCP 字节流。不要让生产凭证、真实用户数据或公网依赖经过开发 Toxiproxy;日志、命令历史和测试报告也不能打印带密码的连接串。
部署形态、容量与长期成本
| 形态 | 适用场景 | 选型判断 |
|---|---|---|
| 本机二进制 | 调 CLI、进程和端口 | 启停快,机器差异需要团队脚本收敛 |
| 项目 Compose | 开发联调和手工实验 | 便于复现,端口与命名要隔离 |
| Testcontainers 独享实例 | CI、并行集成测试 | 隔离最好,增加镜像拉取与启动时间 |
| 共享 daemon | 少量受控手工测试 | 需要 API 网络隔离、owner、租约和精确清理 |
| 完整混沌平台 | 生产前受控演练 | 能覆盖编排、权限和审计,建设与运行成本更高 |
Toxiproxy 本身是额外的数据转发跳点。proxy 数、并发连接、带宽 toxic、日志级别和 metrics 抓取都会消耗 CPU、内存与网络;限速实验尤其会延长连接持有时间,间接放大应用连接池与测试时长。容量验收应看基线代理开销、活动连接、字节吞吐、测试队列等待和 worker 占用趋势。不要把项目测试用 daemon 当成无限共享资源,也不要把官方硬件示例吞吐当成自己的容量承诺。
长期治理应固定镜像 digest 或明确 tag,登记客户端库版本,审查故障 profile,限制并行度与最大持续时间,清理过期 proxy,并统计因环境污染导致的重跑。若维护 Toxiproxy 的成本高于它提供的确定性,单元层可以改用客户端 stub;若要模拟 DNS、TLS、负载均衡、节点调度、网络策略或数据一致性故障,则应升级到对应层级的测试工具,而不是继续堆 toxic。
合入团队测试前逐项验收
镜像锁定 2.12.0 或团队批准版本,Admin API 只在隔离网络可达。应用测试 profile 指向 proxy,禁用 proxy 时测试能稳定失败。latency、timeout、bandwidth、reset_peer 的单位、方向和预期错误已区分。
正向恢复与连接池、重试反实验都断言耗时、尝试次数和恢复状态。每个 toxic 有唯一名称、owner、finally 清理和外层测试 deadline。共享 daemon 不调用全局 /reset,proxy 与端口不会跨项目冲突。
metrics、日志和报告不包含真实连接串、凭证或敏感业务数据。服务端已启用 -proxy-metrics,基线与故障实验都能看到目标 proxy 的字节计数变化。连续多轮后 active toxic 归零,连接池与测试时长回到稳定基线。
