fault tolerance library (장애 허용 시스템)
fault tolerance library는 무엇인가? 간단하게 이야기해보자. MSA 환경에서 한 개의 서비스에서 다른 api를 호출 할 때 일시적으로 에러가 발생하고 있다고 가정해보자. 만약 이 시기에 요청이 계속 들어오면 계속 500에러를 내보내게 된다. 그럼 사용자들은 이 서비스에 대해서 신뢰를 잃어 버리게 되고 안좋은 인식을 만들 수 있다.
그래서 특정 api 호출과 같은 작업에 에러가 발생했을 때, 그 횟수를 정해놓고 그 횟수 이상 에러를 초과하면 기존에 설정해 놓은 fallback에 맞게 동작하게 하고 일정 시간 후에 다시 시도하여 진행하는 등에 작업이 필요하다. 이게 바로 fault tolerance library (장애 허용 시스템) 이다.
요새 같이 msa로 동작하는 환경이 많아지면서 이에 대한 작업이 많이 필요해졌다.
circuit breaker
circuit breaker는 fault tolerance library 시스템에서 사용되는 대표적인 패턴으로써 서비스에서 타 서비스 호출 시 에러가 계속 발생하게 되면 circuit를 열어서 메시지가 다른 서비슬 전파되지 못하도록 막고 미리 정의해 놓은 fallback response를 보내어 서비스 장애가 전파되지 않도록 하는 패턴이다.
resilience4j
그럼 스프링 부트에서 어떻게 사용하면 될까? 그래서 라이브러리를 알아보다가 Hystrix가 유명하다는 걸 알게 되었다. 이 라이브러리는 netflix에서 만들어서 spring에 기본 라이브러리로 사용되었으나, 넷플릭스에서 더 이상 추가 개발 하지 않고 유지보수만 하겠다고 발표하였으며 resilience4j를 사용하기를 권고했다.
기본적으로 Resilience4j는 Ring Bit Buffer라는 곳에 결과를 저장하게 되는데 성공 여부에 따라 0(실패) 또는 1(성공)로 저장한다. 해당 buffer에 크기는 조정이 가능하다.
그래서 resilience4j를 사용해 보기로 했다.
spring boot2, webflux에서 필요한 라이브러리는 다음과 같다.
dependencies {
compile group: 'org.springframework.boot', name: 'spring-boot-configuration-processor', version: '2.2.4.RELEASE'
annotationProcessor group: 'org.springframework.boot', name: 'spring-boot-configuration-processor', version: '2.2.4.RELEASE'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
//Resilience4J
compile("io.github.resilience4j:resilience4j-spring-boot2:1.3.0")
compile("io.github.resilience4j:resilience4j-reactor:1.3.0")
compile("io.github.resilience4j:resilience4j-timelimiter:1.3.0")
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'io.projectreactor:reactor-test'
}
resilience4j에서 사용할 수 있는 여러 옵션이 있는데 대표적인 옵션은 다음과 같다.
resilience4j:
circuitbreaker:
backends:
wedul:
ringBufferSizeInClosedState: 30
ringBufferSizeInHalfOpenState: 30
waitDurationInOpenState: 5000ms
failureRateThreshold: 20
registerHealthIndicator: false
옵션명 | 설명 |
ringBufferSizeInClosedState | Returns the ring buffer size for the circuit breaker while in closed state. Circuit이 닫혀있을 때(정상) Ring Buffer 사이즈, 기본값은 100 |
ringBufferSizedHalfOpenState | Returns the ring buffer size for the circuit breaker while in half open state. half-open 상태일 때 RingBuffer 사이즈 기본값은 10 |
waitDurationInOpenState | Returns the wait duration the CircuitBreaker will stay open, before it switches to half closed half closed전에 circuitBreaker가 open 되기 전에 기다리는 기간 |
failureRateThreshold | Returns the failure rate threshold for the circuit breaker as percentage. Circuit 열지 말지 결정하는 실패 threshold 퍼센테이지 |
Circuit Open Test
그럼 실제로 실패가 발생하였을 때 circuit이 열리고 fallback이 정상적으로 전달되고 정해진 시간내에 다시 시도하여 정상을 돌아오는지 테스트 해보자.
우선 간단하게 테스트하기 위해서 Ring Buffer 사이즈와 failureRateThreshold 수를 줄여보자.
resilience4j:
circuitbreaker:
backends:
wedul:
ringBufferSizeInClosedState: 10
ringBufferSizeInHalfOpenState: 30
waitDurationInOpenState: 10000ms
failureRateThreshold: 20
registerHealthIndicator: false
위에 설정대로라면 실패가 10개의 ringBuffer 20Percent 이상 발생하였을 때 10초동안 fallback 메시지를 보내고 api가 정상적을 돌아오면 정상적으로 돌아오는지 테스트 해보자.
우선 CircuitBreaker를 생성한다. circuitName은 yml에서 설정했던 이름과 동일하게 하면 기본 설정이 Override되어서 지정된다.
@Configuration
public class WedulConfig {
private static final String CIRCUIT_NAME = "wedul";
@Bean
public io.github.resilience4j.circuitbreaker.CircuitBreaker circuitBreaker(CircuitBreakerRegistry registry) {
return registry.circuitBreaker(CIRCUIT_NAME);
}
}
그다음 webClient에 해당 Circuit Breaker를 사용하도록 지정하고 특정 api를 찌르도록 한다.
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(HttpClient.create()
.tcpConfiguration(tcpClient ->
tcpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, properties.getConnetTimeout())
)
))
.uriBuilderFactory(new DefaultUriBuilderFactory(
UriComponentsBuilder
.newInstance()
.scheme("http")
.host(properties.getUrl())
.port(properties.getPort())))
.build()
.get()
.uri(uriBuilder -> uriBuilder.path("/result")
.queryParam("name", request.getName())
.queryParam("price", request.getPrice())
.build()
)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(WedulResponse.class)
.doOnError(error -> log.error("에러 발생!!!"))
.transform(CircuitBreakerOperator.of(circuitBreaker))
.timeout(Duration.ofMillis(properties.getTimeout()));
특정 api는 name을 파라미터로 받는데 이름이 wedul이 아니면 RuntimeException을 발생시키도록 하고 wedul이면 isExist를 true로 반환하도록 한다.
// 컨트롤러
@RestController
@RequestMapping("/circuit")
@RequiredArgsConstructor
public class CircuitController {
private final WedulService wedulService;
@GetMapping("/test")
public Mono<ResponseEntity> circuitTest(@Valid WedulRequest wedulRequest) {
return wedulService.circuitTest(wedulRequest)
.map(ResponseEntity::ok);
}
}
// 서비스
@Slf4j
@Service
public class ResultService {
public Mono<WedulResponse> result(WedulRequest request) {
if (!request.getName().equals("wedul")) {
throw new RuntimeException("error");
}
return Mono.just(WedulResponse.builder().isExist(true).build());
}
}
그리고 해당 기능을 호출할 간단한 api를 만든다,
// 컨트롤러
@RestController
@RequestMapping("/circuit")
@RequiredArgsConstructor
public class CircuitController {
private final WedulService wedulService;
@GetMapping("/test")
public Mono<ResponseEntity> circuitTest(@Valid WedulRequest wedulRequest) {
return wedulService.circuitTest(wedulRequest)
.map(ResponseEntity::ok);
}
}
// 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class WedulService {
private final WedulClient wedulClient;
public Mono<WedulResponse> circuitTest(WedulRequest request) {
return wedulClient.isWedulExist(request);
}
}
도식을 간단하게 그리면 다음과 같이 circuit/test 엔드포인트로 들어온 요청을 Circuit Breaker가 설정되어있는 webClient를 사용해서 /result api를 찔러 결과를 리턴 받는다. 이때 에러가 지정한 percent이상 ring buffer에 발생하였을 때 circuit 스위치가 열리는지 확인해보면 된다.
먼저 10번 실행 중 3개 에러 발생 시켜서 에러 퍼센트를 만들어주면 다음 요청에 switch가 열리는지 확인해보자. 우선 10개 요청을 실패 7개 성공 3개를 나눠서 실행시켜보자.
curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:20.235+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"fdc3768a"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:22.995+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"284c6869"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:24.105+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"b890c0c6"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:24.720+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"5bae49c7"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:25.408+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"e7121964"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:26.125+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"c500e446"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:27.052+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"500 Internal Server Error from GET http://localhost:8081/result?name=weduls&price=22","requestId":"a5f1f79c"}%
아직 까지는 circuit이 열리지 않았다. 그럼 11번째 요청부터는 circuit이 열리는지 확인해보자.
➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=weduls
{"timestamp":"2020-02-24T02:02:27.987+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"0a553bba"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:29.524+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"6fb4e23a"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:30.451+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"36235d1f"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:31.392+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"41a434df"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:32.371+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"51661969"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:33.443+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"55be1c74"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:34.416+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"34150e67"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:35.451+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"51a9b6a6"}% ➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"timestamp":"2020-02-24T02:02:36.523+0000","path":"/circuit/test","status":500,"error":"Internal Server Error","message":"CircuitBreaker 'wedul' is OPEN and does not permit further calls","requestId":"8ac98f8d"}%
정상적으로 지정해놓은 10초동안 circuit이 열려있는 걸 확인 할 수있다.
그리고 10초 뒤에 다시 실행시켜보면 정상적으로 circuit이 다시 닫혀서 요청을 실행하는걸 볼 수 있다.
➜ ~ curl http://localhost:8080/circuit/test\?price\=22\&name\=wedul
{"exist":false}%
다른 옵션으로 retry, bulkhead등에 동작이 있는데 상황에 따라 지정해서 사용해보면 될 것 같다. 테스트에 사용했던 소스는 git에 있다.
https://github.com/weduls/circuit_breaker_test
참고
https://resilience4j.readme.io/docs/circuitbreaker
https://dlsrb6342.github.io/2019/06/03/Resilience4j란/