Armeria의 Circuit breaker 사용해보기

Circuit Breaker란?

만약 예상치 못한 장애(ex. 네크워크 이슈, 서버가 내려감)가 발생하여, 어떤 한 원격 서버가 요청에 대한 응답을 내리지 못하는 상태라고 가정해보자. 그렇다면 원격 서버로 요청을 한 클라이언트는 timeout이 발생할 때까지 응답을 기다리거나, 자원을 소모하며 결국 무의미한 요청을 계속 보낼 것이다. 그리고 MSA(Microservice Architecture)에서 이 클라이언트는 또 누군가에게는 서버일 수도 있다. 결국 이 서버에 대한 클라이언트 역시 똑같은 문제를 겪게될 것이다.
이렇게 계속 장애가 전파되며, 결과적으로 한 원격 서버의 장애가 모든 시스템에 큰 영향을 주게 된다. 이러한 문제를 해결하기 위해 등장한 개념이 바로 Circuit Breaker 이다.
Circuit Breaker 란 쉽게 말해, 클라이언트에서 한 원격 서버로의 요청에 대한 실패율이 특정 threshold를 넘게 되면, 이 서버에 문제가 있다고 스스로 판단하여 더이상 무의미한 요청을 날리지 않고, 빠르게 에러를 발생시키는(fail fast) 방법이다. 이러한 방법으로 앞서 언급한 문제들을 방지하며 장애의 규모를 최소화할 수 있다.
이미 많은 블로그들에 Circuit Breaker의 개념에 대한 내용이 잘 나와 있으므로, 더이상 긴 말은 생략하도록 하겠다.


Circuit Breaker의 상태

하나의 Circuit Breaker는 총 3가지 상태를 가진다.

  • CLOSED: 요청의 실패율이 정해놓은 threshold보다 낮은 상태. 정상적인 상태.
  • OPEN: 요청의 실패율이 정해놓은 threshold를 넘어선 상태. 실제 요청을 날리지 않고 바로 에러를 발생시킴(fail fast)
  • HALF_OPEN: OPEN 상태 중간에 한번씩 요청을 날려 응답이 성공인지를 확인하는 상태. 성공인 경우, 다시 CLOSED 상태로 전환. 실패인 경우는 그대로 OPEN 상태로 유지.

Armeria의 Circuit breaker

LINE 에서 오픈소스로 운영하고 있는 Netty 기반의 비동기 마이크로서비스 프레임워크, Armeria 에서는 이러한 Circuit breaker 기능을 직접 구현하여 잘 제공해주고 있다. 이를 이용하여 Circuit breaker의 동작을 한번 눈으로 확인해보자.


준비하기

먼저, 테스트를 위해 간단하게 요청과 응답을 주고 받을 서버 2대(서버1, 서버2)를 띄어보자.
서버1은 클라이언트로부터 /hello 요청이 들어오면, 서버2로 /world 요청을 보낸다. 그리고 서버2로부터 응답을 받으면, 그 응답을 다시 클라이언트에게 반환한다. 서버1은 서버이기도 하지만, 서버2의 클라이언트이기도 하다.

서버2

[Server2Application.java]

@SpringBootApplication
public class Server2Application {

    private static final AtomicInteger REQ_CNT = new AtomicInteger();

    public static void main(String[] args) {
        ServerBuilder sb = Server.builder();
        sb.http(5008);
        sb.decorator(LoggingService.newDecorator());

        sb.service("/world", (ctx, res) -> {
            if (REQ_CNT.addAndGet(1) % 2 == 0) {
                return HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR);
            }
            return HttpResponse.of(HttpStatus.OK);
        });

        Server server = sb.build();
        CompletableFuture<Void> future = server.start();
        future.join();
    }
}

먼저, 서버1의 응답을 받을 서버2의 구현이다. /world 로 들어오는 요청에 대한 응답으로 성공(200)과 실패(500)를 번갈아가면서 반환하도록 간단하게 구현하였다.

서버1

[Server1Application.java]

@SpringBootApplication
@Import(Server1Context.class)
public class Server1Application {

    public static void main(String[] args) {
        SpringApplication.run(Server1Application.class, args);
    }
}



[Server1Context.java]

@Configuration
public class Server1Context {

    @Bean
    public HttpServiceRegistrationBean httpService(WebClient webClient) {
        return new HttpServiceRegistrationBean()
                .setService((ctx, req) -> webClient.get("/world"))
                .setServiceName("httpService")
                .setRoute(Route.builder()
                               .path("/hello")
                               .methods(HttpMethod.GET)
                               .build());
    }

    @Bean
    public WebClient webClient() {
        CircuitBreakerStrategy strategy = CircuitBreakerStrategy.onServerErrorStatus();

        // CircuitBreaker 설정!!!
        CircuitBreaker circuitBreaker = CircuitBreaker
                .builder("test-circuit-breaker")
                .counterSlidingWindow(Duration.ofSeconds(10))
                .circuitOpenWindow(Duration.ofSeconds(5))
                .failureRateThreshold(0.3)
                .minimumRequestThreshold(5)
                .trialRequestInterval(Duration.ofSeconds(3))
                .build();

        return WebClient
                .builder("http://localhost:5008")
                .decorator(LoggingClient.newDecorator())
                .decorator(CircuitBreakerHttpClient.newDecorator(circuitBreaker, strategy))
                .build();
    }
}

이제 서버1의 구현이다. 서버2에서 ServerBuilder를 이용하여 간단하게 구현한 것과 다르게, 클라이언트로부터 요청을 받을 Service와 서버2로 요청을 보낼 Client를 모두 Bean으로 만들어 사용하고 있는 점이 눈에 띈다.
Armeria에서 CircuitBreaker를 구현할 때 주의해야할 점은, 하나의 Client 객체가 자신만의 CircuitBreaker를 가진다는 것을 꼭 인지하고 있어야한다. 즉, 만약 서버2로의 매번 요청에 대해 Client 객체가 새로 생성되고, 또 요청이 끝난 후에 소멸이 되버리는 방식으로 구현했다면 결국 하나의 Client에 연결된 CircuitBreaker가 사실상 무의미하다.
따라서 서버2로 요청을 보낼 WebClient 를 Bean으로 만들어, 모든 서버2로의 요청에 대해 동일한 WebClient 객체를 재사용하도록 구현하였다. 그리고 이 WebClient 객체에 Decorator 패턴 으로 Circuit breaker를 적용하였다.

위의 CircuitBreaker에 설정한 각 필드를 하나씩 살펴보자.

  • counterSlidingWindow(10s): Circuit breaker에서 요청의 성공/실패의 수를 측정하는 시간 간격이다. 즉 10초 동안의 집계를 바탕으로 어떤 상태로 전환 또는 유지할지 판단한다.
  • circuitOpenWindow(5s): Circuit breaker가 OPEN 상태로 유지되는 시간이다. 즉 한번 OPEN 상태가 되면, 5초 동안은 외부 서버로 요청하지 않고 바로 에러(FailFastException)를 던진다.
  • failureRateThreshold(0.3): OPEN 상태로 전환되는데 필요한 요청의 실패율이다. circuitOpenWindow 시간 동안 0.3(30%) 이상의 요청이 실패하여야만 OPEN 상태가 된다.
  • minimumRequestThreshold(5): 측정에 필요한 요청의 최소 갯수이다. 실패율이 failureRateThreshold보다 높다하더라도, 최소 5개 이상의 요청에 대한 결과값이여야만 OPEN 상태가 된다.
  • trialRequestInterval(3s): HALF_OPEN 상태로 유지되는 시간이다. OPEN 상태가 끝난 후 HALF_OPEN 상태로 전환되며 3초 동안 OPEN 상태일 때와 마찬가지로 FailFastException 에러를 던지기는 하지만, 그동안 외부 서버에 요청을 날리며 서버가 정상으로 돌아왔는지를 검사한다. 만약 이 3초 동안에 날린 요청의 응답이 성공이라면 즉시 CLOSED 상태로 전환되고, 모두 실패라면 다시 OPEN 상태로 전환된다.

테스트

loadtest 라이브러리를 이용하여 터미널에서 한번 서버1로 계속 요청을 날려보자.

$ loadtest http://127.0.0.1:5007/hello --rps 1

서버1의 로그를 통해, Circuit breaker의 동작을 눈으로 확인할 수 있다.

처음에 서버가 뜰 때, Circuit breaker의 초기 상태는 CLOSED이다.

03:03:41.830 초에 측정된 전체 5개의 요청 중 실패의 수는 2이므로, 실패율은 2/5 = 0.4이다. 따라서 실패율이 앞서 설정한 0.3(failureRateThreshold)보다 크고, 측정에 사용한 요청 수도 5(minimumRequestThreshold) 이상이므로 Circuit breaker는 OPEN 상태로 전환된다.

이후 03:03:41.830 ~ 03:03:47.824, 약 5초(circuitOpenWindow)동안 서버2로 요청을 날리진 않고 FailFastException 에러가 계속 발생한다. (서버2의 로그를 확인해보면, 해당 시간에 요청이 들어오지 않았음을 확인해볼 수 있다.)

03:03:47.824 초에 HALF_OPEN 상태로 전환되고, 이 때 클라이언트로부터 들어온 요청은 서버2로 실제 전송된다. 그리고 이 때 전송된 요청이 운좋게 200 응답을 받으면서 CLOSED 상태로 전환된다.

다시 CLOSED가 되었으므로 이 과정을 또 반복한다.
03:03:47.827 ~ 03:03:56.824, 약 10초(counterSlidingWindow)동안의 실패율을 측정하게 되고, 이번에 측정한 실패율은 4/8 = 0.5이므로 Circuit breaker는 다시 OPEN 상태로 전환된다.


참고