用 Docker 搭建熔断的完整流程
起因
前阵子家里服务器上的一个小组件莫名卡死,结果把上游服务的线程全占满了,连带整个站都刷不出来,排查半天才定位到是下游接口拖累的。这种一颗老鼠屎坏一锅粥的事情,理论上早就该有熔断来兜底,只是一直懒得折腾。这次趁着重搭环境,干脆用 Docker 把整套东西完整跑了一遍,顺便记录下来。
熔断的原理一句话就能说清:上游调下游的时候,如果一段时间内失败的比例太高,就直接“跳闸”,后续请求压根不去碰下游,而是立刻返回一个兜底结果,免得线程全堵在那儿干等。跳闸之后每隔一段时间放几个请求出去试探,下游缓过来了就恢复,没缓过来就继续跳着。
方案选择
Hystrix 是老古董了,官方早就不维护;Sentinel 功能是全,但还得额外跑一个 dashboard,对我这种就一个小服务的小场景来说太重了。最后选了 Resilience4j,加几个依赖、写几行配置就能用,状态还能通过 actuator 直接看,配合 Docker 正合适。
先造一个会挂的下游服务
要验证熔断,得先有个会挂的下游。我直接用 Flask 写了十来行,加了个开关接口,随时切换“正常”和“装死”两种状态:
from flask import Flask, jsonify
import time
app = Flask(__name__)
state = {"fail": False}
@app.route("/api")
def api():
if state["fail"]:
time.sleep(3)
return "boom", 500
return jsonify(msg="ok")
@app.route("/toggle")
def toggle():
state["fail"] = not state["fail"]
return jsonify(fail=state["fail"])
app.run(host="0.0.0.0", port=5000)
Dockerfile 也简单:
FROM python:3.11-slim
WORKDIR /app
RUN pip install flask -i https://pypi.tuna.tsinghua.edu.cn/simple
COPY app.py .
CMD ["python", "app.py"]
pip 那行加了清华源,不加的话在容器里拉依赖是真的慢。
上游接入 Resilience4j
上游用 Spring Boot 3,pom 里除了 web 之外,加 resilience4j-spring-boot3、spring-boot-starter-aop 和 spring-boot-starter-actuator 三个依赖就行。
配置文件是关键,统计窗口、阈值、跳闸时长都在这儿定:
resilience4j:
circuitbreaker:
instances:
backend:
sliding-window-size: 10 # 统计最近10次调用
minimum-number-of-calls: 5 # 至少攒够5次才开始算账
failure-rate-threshold: 50 # 失败率过半就跳闸
slow-call-duration-threshold: 2s # 超过2秒算慢调用
slow-call-rate-threshold: 50 # 慢调用占比过半也跳闸
wait-duration-in-open-state: 10s # 跳闸后10秒进入半开
permitted-number-of-calls-in-half-open-state: 3 # 半开时放3个请求试探
management:
endpoints:
web:
exposure:
include: health,circuitbreakers
health:
circuitbreakers:
enabled: true
特别注意 slow-call 这两项。我一开始只配了失败率,结果下游“卡住不返回”的时候熔断完全没反应,因为卡住的调用既不成功也不算失败,除非等到超时。配上慢调用比例之后才正常,而且默认的慢调用阈值是 60 秒,一定要自己改小,不然等它判慢黄花菜都凉了。
控制器就一个转发接口,加个注解和兜底方法:
@RestController
public class ProxyController {
private final RestTemplate restTemplate = new RestTemplate();
@GetMapping("/proxy")
@CircuitBreaker(name = "backend", fallbackMethod = "fallback")
public String proxy() {
return restTemplate.getForObject("http://provider:5000/api", String.class);
}
public String fallback(Throwable t) {
return "degraded: " + t.getClass().getSimpleName();
}
}
这里的 provider 是容器服务名,容器之间互相访问就是这么找的,写 localhost 是不行的,我第一次就栽在这儿。
打包镜像
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN mvn -q package -DskipTests
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /build/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
国内拉 maven 这类镜像慢的话,可以在名字前面加个加速前缀,比如 docker.1ms.run/maven:3.9-eclipse-temurin-17,不过这种加速域名说不好什么时候就失效了,不行就换一个或者挂代理。Maven 拉依赖慢的话再塞一个阿里云的 settings.xml 进去,能省不少时间。
用 compose 编排
目录结构大概就是 provider/ 和 consumer/ 各占一个文件夹,根目录放 compose 文件:
services:
provider:
build: ./provider
ports:
- "5000:5000"
consumer:
build: ./consumer
ports:
- "8080:8080"
depends_on:
- provider
然后直接 docker compose up -d --build,不出问题的话两个容器就都起来了。
完整验证一遍
先确认一切正常:
curl http://localhost:8080/proxy # 返回 {"msg":"ok"}
curl -s http://localhost:8080/actuator/circuitbreakers/backend | grep state # CLOSED
开个循环一直打,另开个终端观察:
while :; do curl -s -m 5 http://localhost:8080/proxy; echo; sleep 0.3; done
然后让下游装死:curl http://localhost:5000/toggle。
能观察到三个阶段。一开始返回的是 degraded: HttpServerErrorException,慢悠悠的,因为每个请求都得真去调下游、卡 3 秒才失败;循环打个十几二十秒,攒够次数之后突然变成 degraded: CallNotPermittedException,秒回,这时候再看 actuator,state 已经是 OPEN,说明闸跳了。再等 10 秒,state 变成 HALF_OPEN,放出去的几个试探请求依然失败,于是又回到 OPEN 继续跳着——这就是半开状态干的事。
最后让下游恢复:再 curl 一下 /toggle,等下一个半开窗口,试探请求全成功了,state 变回 CLOSED,一切如常。到这里熔断的完整生命周期就都过了一遍。
踩过的坑
- 手动一两次 curl 是永远触发不了熔断的,
minimum-number-of-calls不够,必须用循环把调用次数怼上去。 - 下游“卡住”和“报错”是两回事,只配失败率拦不住卡死,慢调用阈值必须配,还得改小。
- RestTemplate 默认没有超时,虽然慢调用机制能兜住,但最好把连接和读取超时自己也设上,别把宝全押在熔断上。
depends_on只管启动顺序,不管下游是不是真的 ready,要严谨还得加健康检查。
反正整套弄下来,最费时间的不是熔断本身,反而是 Java 打包和拉镜像这些杂事。熔断逻辑倒是意外地省心,配好之后基本不用管。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。