小Cの已经记不起来的博客

用 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,一切如常。到这里熔断的完整生命周期就都过了一遍。

踩过的坑

反正整套弄下来,最费时间的不是熔断本身,反而是 Java 打包和拉镜像这些杂事。熔断逻辑倒是意外地省心,配好之后基本不用管。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放