클라이언트 측 회복성
회복성(resilience)을 갖춘 시스템을 구축할 때 소프트웨어 엔지니어 대부분은 인프라스트럭처나 중요 서비스의 한 부분이 완전히 실패한 경우만 고려
4가지 클라이언트 회복성 패턴
client side load balancing
- 클라이언트가 서비스 디스커버리 에이전트(eg.Eureka)에서 서비스의 모든 인스턴스를 검색한 후 해당 서비스 인스턴스의 물리적 위치를 캐싱하는 작업을 포함
- 사용자가 서비스 인스턴스를 호출해야 할 때 클라이언트 측 로드 밸런싱은 관리 중인 서비스 위치 풀에서 위치를 반환
- 클라이언트 측 로드 밸런서는 서비스 클라이언트와 사용자 사이에 위치하기 때문에 서비스 인스턴스가 에러를 발생하거나 정상적으로 동작하지 않는지 탐지 가능
- 클라이언트 측 로드 밸런서가 문제를 탐지하면 가용 서비스 풀에서 문제된 서비스 인스턴스를 제거하여 해당 서비스 인스턴스로 더 이상 호출되지 않게 함
circuit breaker
- 원격 서비스가 호출될 때 호출을 모니터링 하다가 너무 오래 걸리면 차단기가 개입해서 호출을 종료
fullback pattern
- 원격 서비스 호출이 실패할 때 예외 처리를 진행하지 않고 대체 코드를 경로를 실행하여 다른 수단을 통해 작업을 수행할 수 있음
- 향후 처리를 위해 사용자 요청을 Queue에 입력하는 작업이 포함
- 사용자 호출에 문제가 있다고 예외를 표시하지는 않지만 나중에 요청을 시도해야 한다고 알려줄 수 있음
bulkhead
- 선박을 건조하는 개념에서 유래
- Thread Pool은 서비스의 bulkhead 역할을 함
- 각 원격 자원을 분리하여 Thread Pool에 각각 할당하여 한 서비스가 느리게 응답한다면 해당 서비스의 호출 그룹에 대한 Thread Pool만 포화되어 요청 처리를 중단하게 될 수 있음
- 스레드 풀 별로 서비스를 할당하면 다른 서비스는 포화되지 않기 때문에 이러한 병목 현상을 우회하는데 유용
Thread Pool
- 작업 처리에 사용되는 스레드를 제한된 개수만큼 지정해놓고 Queue에 들어오는 작업들을 하나씩 스레드가 맡아 처리
- 작업 처리 요청이 급증하더라도 스레드의 전체 개수는 늘어나지 않기 때문에 시스템 성능이 급격히 저하되지 않음
클라이언트 회복성의 중요성
시나리오
- Organization service는 완전히 다른 데이터베이스 플랫폼에서 데이터를 검색하고 외부 클라우드 공급자에게 Inventory service라는 다른 서비스 호출
- 이 서비스는 공유 파일 시스템에 데이터를 기록하려고 내부 NAS 장치에 크게 의존하며 애플리케이션 C는 직접 재고 서비스를 호출
- 주말 동안 네트워크 관리자는 NAS의 구성을 변경하고 테스트 결과 특별히 이상이 없다고 판단
- 월요일 아침에 특정 디스크 하위 시스템에 읽기 작업이 이례적으로 느리게 수행되기 시작
- Organization Service를 작성한 개발자는 Inventory Service를 호출할 때 발생하는 성능 저하를 전혀 예상하지 못함 -> Organization Service의 자체 데이터베이스에 대한 쓰기와 서비스에서 읽기가 동일한 트랜잭션 안에서 수행되는 코드를 작성
- Inventory Service가 느려지면서 서비스 요청에 대한 스레드 풀이 쌓이기 시작하여 결국 서비스 컨테이너의 커넥션 풀에 있는 데이터베이스 커넥션 수도 고갈
- 이것은 Inventory Service에 대한 호출이 완료되지 않아 커넥션이 사용 중이기 때문에 발생하는 현상
- 결국 Licensing Service는 Inventory Service로 느려진 Organization Service를 호출하기 때문에 자원이 부족해지기 시작
- 애플리케이션이 모두 요청이 완료될 때까지 기다리는 동안 자원이 고갈되어 응답을 못하게 됨
해결책
- 해당 서비스가 제대로 수행되지 못하기 시작했을 때 해당 호출에 대한 Circuit Breaker가 작동해서 스레드를 소모하지 않고 빠르게 실패하게 해야 함
- Organization Service에 여러 엔드 포인트가 있다면 Inventory Service에 대한 특정 호출과 연관된 엔드포인트만 영향을 받을 것
3가지 시나리오
1. 정상 시나리오(the happy path)
Circuit breaker는 타이머를 설정하고, 타이머가 만료되기 전에 원격 호출이 완료되면 Licensing-service는 정상적으로 모든 작업을 계속 수행할 수 있음
2. 부분적 서비스 저하 시나리오
- Licensing-service는 circuit breaker를 통해 organization-service를 호출
- organization-service가 느리게 실행되어 circuit breaker가 관리하는 스레드 타이머가 만료되기 전에 호출이 완료되지 않으면, circuit breaker는 원격 서비스에 대한 연결을 종료하고 licensing-service는 호출 오류를 반환
- Licensing-service는 organization-service 호출이 완료되길 기다리기 위해 자원(자체 스레드 및 커넥션 풀)을 점유하지 않음
- Organization-service에 대한 호출 시간이 만료되면 Circuit Breaker는 발생한 실패 횟수를 추적하기 시작하는데 특정 시간 동안 서비스에서 오류가 필요 이상으로 발생하면 Circuit Breaker는 회로를 차닪고 organization-service에 대한 모든 호출은 organization-service 호출 없이 실패
3. 즉시 문제 인식
빠른실패(fail fast)
원격 서비스가 성능 저하를 겪으면 애플리케이션은 빠르게 실패하고 전체 애플리케이션을 완전히 다운시킬 수 있는 자원 고갈 이슈를 방지
원만한 실패(fail gracefully)
타임아웃과 빠른 실패를 사용하는 Circuit Breaker pattern은 원만하게 실해하거나 사용자 의도를 충족하는 대체 메커니즘을 제공할 수 있게 해줌
예를 들어 사용자는 한 가지 데이터 소스에서 데이터를 검색하려고 하고, 해당 데이터 소스가 서비스 저하를 겪고 있다면 다른 위치에서 해당 데이터를 검색 가능
원만한 회복(recover seamlessly)
Circuit Breaker pattern이 중개자 역할을 하므로 Circuit Breaker는 요청 중인 자원이 다시 온라인 상태가 되었는지 확인하고, 사람의 개입 없이 자원에 대한 재접근을 허용하도록 주기적으로 확인
Resilence4j 구현
패턴
1. Circuit Breaker
요청받은 서비스가 실패할 때 요청을 중단
2. retry(재시도)
서비스가 일시적으로 실해할 때 재시도
3. bulkhead
과부하를 피하고자 동시 호출하는 서비스 요청 수를 제한
4. rate limit(속도 제한)
서비스가 한 번에 수신하는 호출 수를 제한
5. fallback(폴백)
실패하는 요청에 대해 대체 경로를 설정
설정
Licensing-Service의 pom.xml 파일에 의존성 포함
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-circuitbreaker</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-timelimiter</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
spring-boot-starter-app는 스프링 AOP 관점을 실행하는데 필요
Circuit Breaker 구현
- 처음에는 Resilience4j Circuit Breaker는 닫힌 상태에서 시작한 후 클라이언트 요청을 기다림
- 닫힌 상태는 링 비트 버퍼를 사용하여 요청의 성과 및 실패 상태를 저장
- 요청이 성공하면 차단기는 링 비트 버퍼에 0비트를 저장하지만, 호출된 서비스에서 응답받지 못하면 1비트를 저장
- 실패율을 계산하려면 링을 모두 채워야 함
- 예를 들어 위의 링비트 버퍼에서는 적어도 12호출은 평가해야 실패율을 계산할 수 있음
- Circuit Breaker는 고장율이 임계값을 초과할 때만 열림
- 열린 상태라면 설정된 시간 동안 호출은 모두 거부되고 회로 차단기는 CallNotPermittedException 예외를 발생
- 설정된 시간이 만료되면 Circuit Breaker는 반열린 상태로 변경되고, 서비스가 여전히 사용 불가능한지 확인하고자 일부 요청을 허용
- 반열린 상태에서 차단기는 설정 가능한 다른 링 비트 버퍼를 사용하여 실패율을 평가하여 이 실패율이 설정된 임계치보다 높으면 차단기는 다시 열린 상태로 변경
- 만약 임계치보다 작거나 같다면 닫힌 상태로 돌아감
Circuit Breaker 작동 조건
- failureRateThreshold = 실패율이 설정값보다 클 때
- slowCallRateThreshold = 느린 호출 비율이 설정값보다 크거나 같을 때
Circuit Breaker 코드에 적용하기
@CircuitBreaker(name = "licenseService") // 1
public List<License> getLicensesByOrganization(String organizationId) throws
TimeoutException {
logger.debug("getLicensesByOrganization Correlation id: {}",
UserContextHolder.getContext().getCorrelationId());
randomlyRunLong();
return licenseRepository.findByOrganizationId(organizationId);
}
의도적으로 느리게 만들기
public List<License> getLicensesByOrganization(String organizationId) throws
TimeoutException {
logger.debug("getLicensesByOrganization Correlation id: {}",
UserContextHolder.getContext().getCorrelationId());
randomlyRunLong();
return licenseRepository.findByOrganizationId(organizationId);
}
private void randomlyRunLong() throws TimeoutException{
Random rand = new Random();
int randomNum = rand.nextInt((3 - 1) + 1) + 1;
if (randomNum==3) sleep();
}
private void sleep() throws TimeoutException{
try {
System.out.println("Sleep");
Thread.sleep(5000);
throw new TimeoutException();
} catch (InterruptedException e) {
logger.error(e.getMessage());
}
}
실패 중인 서비스를 계속 호출하면 결국 링 비트 버퍼가 가득 차 Circuit Breaker가 동작
Circuit Breaker 사용자 정의
- 스프링 컨피그 서버 저장소에 있는 application.yml, bootstrap.yml 또는 서비스 구성 파일에 몇 가지 파라미터를 추가하면 쉽게 해결
resilience4j.circuitbreaker:
instances:
licenseService:
registerHealthIndicator: true
ringBufferSizeInClosedState: 5
ringBufferSizeInHalfOpenState: 3
waitDurationInOpenState: 10s
failureRateThreshold: 50
recordExceptions:
- org.springframework.web.client.HttpServerErrorException
- java.io.IOException
- java.util.concurrent.TimeoutException
- org.springframework.web.client.ResourceAccessException
organizationService:
registerHealthIndicator: true
ringBufferSizeInClosedState: 6
ringBufferSizeInHalfOpenState: 4
waitDurationInOpenState: 20s
failureRateThreshold: 60
* licensing-service의 인스턴스 구성(circuit breaker 어노테이션에 전달되는 이름과 동일)
* registerHealthIndicator: true
-> 상태 정보 엔드포인트에 대한 구성 정보 노출 여부를 설정
* ringBufferSizeInClosedState: 5
-> 링 버퍼의 닫힌 상태 크기를 설정
-> 기본값은 100
* ringBufferSizeInHalfOpenState: 3
-> 링 버퍼의 반열린 상태 크기를 설정
-> 기본값은 10
* waitDurationInOpenState: 10s
-> 열린 상태의 대기 시간을 설정
-> 기본값은 60,000ms
* failureRateThreshold: 50
-> 실패율 임계치를 백분율로 설정
-> 기본값은 50
* recordExceptions:
-> 실패로 기록될 예외를 설정
-> 기본적으로 모든 예외는 실패로 기록
* organizationService
-> organization-service 인스턴스 구성
fallback 처리
- Circuit breaker pattern의 장점 중 하나는 이 패턴이 중재자로 원격 자원과 그 소비자 사이에 위치하기 때문에 서비스 실패를 가로채서 다른 대안을 취할 수 있다는 점
- 이 대안을 fallback strategy이라고 하며 쉽게 구현할 수 있음
@CircuitBreaker(name = "licenseService", fallbackMethod="buildFallbackLicenseList")
public List<License> getLicensesByOrganization(String organizationId) throws TimeoutException {
logger.debug("getLicensesByOrganization Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
randomlyRunLong();
return licenseRepository.findByOrganizationId(organizationId);
}
@SuppressWarnings("unused")
private List<License> buildFallbackLicenseList(String organizationId, Throwable t){
List<License> fallbackList = new ArrayList<>();
License license = new License();
license.setLicenseId("0000000-00-00000");
license.setOrganizationId(organizationId);
license.setProductName("Sorry no licensing information currently available");
fallbackList.add(license);
return fallbackList;
}
- fallbackMethod="buildFallbackLicenseList -> 서비스 호출이 실패할 때 호출되는 함수(메서드) 하나를 정의
- private List<License> buildFallbackLicenseList(String organizationId, Throwable t) -> 이 풀백 메서드에서는 하드코딩된 값을 반환
- 원래 메서드와 동일한 클래스에 위치해야 함
- 폴백 메서드를 생성하려면 원래 메서드(getLicenseByOrganization())처럼 하나의 매개변수를 받도록 하는 것과 동일한 서식
벌크헤드 패턴 구현
- MSA 기반 어플리케이션에서 특정 작업을 완료하기 위해 여러 마이크로서비스를 호출해야 할 경우가 많다
- 벌크헤드 패턴을 사용하지 않는다면 이러한 호출의 기본 동작은 전체 자바 컨테이너에 대한 요청을 처리하려고 예약된 동일한 스레드를 사용해서 실행
- 대규모 요청이라면 하나의 서비스에 대한 성능 문제로 자바 컨테이너의 모든 스레드가 최대치에 도달하고, 작업이 처리되길 기다리는 동안 새로운 작업 요청들은 후순위로 대기
- 결국 자바 컨테이너는 멈춤
- 벌크헤드 패턴은 원격 자원 호출을 자체 스레드 풀에 격리해서 한 서비스의 오작동을 억제하고 컨테이너를 멈추지 않게 함
- Resilience4j 벌크헤드 패턴을 위해 두 가지 다른 구현을 제공
세마포어 벌크헤드(semphore bulkhead)
세마포어 격리 방식으로 서비스에 대한 동시 요청 수를 제한
한계에 도달하면 요청을 거부
스레드 풀 벌크헤드(thread pool bulkhead)
제한된 큐와 스레드 풀을 사용
이 방식은 풀과 큐가 다 찬 경우만 요청을 거부
resilience4j.bulkhead:
instances:
bulkheadLicenseService:
maxWaitDuration: 2ms
maxConcurrentCalls: 20
resilience4j.thread-pool-bulkhead:
instances:
bulkheadLicenseService:
maxThreadPoolSize: 1
coreThreadPoolSize: 1
queueCapacity: 1
* maxWaitDuration: 2ms
스레드를 차단할 최대 시간
기본 값은 0
* maxConcurrentCalls: 20
최대 동시 호출 수
기본값은 25
* maxThreadPoolSize: 1
스레드 풀에서 최대 스레드 수
* coreThreadPoolSize: 1
코어 스레드 풀 크기
* queueCapacity: 1
큐 용량
기본값은 100
@CircuitBreaker(name = "licenseService")
@Bulkhead(name="bulkheadLicenseService", fallbackMethod="buildFallbackLicenseList")
public List<License> getLicensesByOrganization(String organizationId) throws
TimeoutException {
logger.debug("getLicensesByOrganization Correlation id: {}",
UserContextHolder.getContext().getCorrelationId());
randomlyRunLong();
return licenseRepository.findByOrganizationId(organizationId);
}
재시도 패턴 구현
- 처음 실패했을 때 서비스와 통신을 재시도하는 역할을 함
- 핵심 개념은 고장이 나도 동일한 서비스를 한 번 이상 호출해서 기대한 응답을 얻을 수 있는 방법을 제공하는 것
- 이 패턴의 경우 해당 서비스 인스턴스에 대한 재시도 횟수와 재시도 사이에 전달하려는 간격을 지정해야 함
resilience4j.retry:
instances:
retryLicenseService:
maxRetryAttempts: 5
waitDuration: 10000
retry-exceptions:
- java.util.concurrent.TimeoutException
* maxRetryAttempts: 5
재시도 최대 횟수
* waitDuration: 10000
재시도 간 대기 시간
* retry-exceptions
재시도 대상이 되는 예외 목록
@CircuitBreaker(name = "licenseService")
@Bulkhead(name="bulkheadLicenseService", fallbackMethod="buildFallbackLicenseList")
@Retry(name="retryLicenseService", fallbackMethod="buildFallbackLicenseList")
public List<License> getLicensesByOrganization(String organizationId) throws TimeoutException {
logger.debug("getLicensesByOrganization Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
randomlyRunLong();
return licenseRepository.findByOrganizationId(organizationId);
}
속도 제한기 패턴 구현
- 재시도 패턴은 주어진 시간 내 소비할 수 있는 양보다 더 많은 호출로 발생하는 서비스 과부하를 막음
- 이 패턴은 고가용성과 안정성을 위한 API를 준비하는데 필수 기술
resilience4j.ratelimiter:
instances:
licenseService:
limitForPeriod: 5
limitRefreshPeriod: 5000
timeoutDuration: 1000ms
* limitRefreshPeriod: 5000
갱신 제한 기간을 정의
* timeoutDuration: 1000ms
스레드가 허용을 기다리는 시간을 정의
* limitForPeriod: 5
갱신 제한 기간 동안 가용한 허용 수를 정의
***참고
@CircuitBreaker
실패율 / 느린호출비율 계산 -> 비율이 설정값보다 크면 -> 폴백
@Bulkhead
특정 공간 내 일꾼이 더이상 버티지못할정도로 요청이 많이들어올때 -> 폴백
(스레드풀) (스레드)
@RateLimiter
특정 시간 내 요청횟수가 내 설정보다 높을때 -> 폴백
@Retry
재요청 / 재요청 날린후 대기시간 설정해놓고 그이상 재요청하게되면 -> 폴백