
오늘은 실무에서 최소한의 중단 배포를 위해 적용한 사례를 기억하기 위해 기록하는 포스팅이다.
실무에서 개발/운영 중인 서비스는 모놀리식 프로젝트로 구성된 4대의 서버가 있었다. 서비스의 규모가 점차 커지고, 일관되지 않은 레거시 코드들로 인해 신규 기능 개발의 속도가 더뎌지고 CS 기능 버그들이 생겨 이를 개선하고자 작은 기능들부터 목적별 서버로 분리 작업을 하고 있다.
대표적으로 유저가 사용 중인 앱 서비스의 API 신규 서버, 어드민 API 서버, 스케쥴러 서버 등이 있다. 물론 완전히 다 마이그레이션을 한 상태로 오픈한 것이 아니기에 운영에서는 (구) 버전의 서버들과 (신) 버전의 서버들이 동시에 띄워져 있다.
이렇게 되면서 각 서버들에 기능 혹은 버그 수정 배포가 진행될 때에 서비스 중단이 발생하지 않도록 하기 위해 앱에서 직접 호출하는 서버들은 이중화를 해두었다.
이중화 서버들은 롤링 배포로 최소한의 중단을 하고 있으며 ( 비용 절감을 목적으로 블루/그린을 채택하지 않았다 ), 이를 위해 Spring Cloud 진영의 Gateway, Eureka를 적극 활용하고 있다.
오늘은 그 활용 과정에 대한 이야기를 하려 한다.

위 그림은 운영중인 서비스의 서버 구성도 중 일부만을 표현한 것이다.
서비스 도메인 주소로 요청을 하면, Spring Cloud Gateway 서버를 거쳐 각 API 서버로 요청이 넘어간다. 게이트웨이 서버는 Eureka를 통해 배포된 서버의 목록을 기반으로 트래픽을 라우팅/로드 밸런싱을 담당한다.
이때, 배포 중인 서버로 트래픽이 향하면 해당 요청은 실패하게 되기에 유저에게 좋지 않은 경험을 선사하게 된다. 이를 방지하고자 배포 중인 서버로 트래픽이 차단되게 하는 작업을 진행하였다. 여기에는 서킷 브레이커를 이용했다.
응답 지연 혹은 에러 발생 서비스에게 더는 요청을 보내지 않고 빠르게 실패하게 하여 부하를 겪고 있는 서비스가 회복할 수 있게 돕고, 이러한 응답 지연/에러 발생이 전체 서비스로 확산되지 않게 하는 장치.
사용자의 요청이 게이트웨이를 거쳐 가던 중 “api-server” 서버의 배포/응답지연/에러발생 등의 이유로 처리가 원활하지 않다면, 해당 서버로 향하는 모든 요청들로 인해 다른 서비스에도 장애가 전파될 수 있다.
우리의 경우, 배포 시에 트래픽 차단이 가장 큰 도입 배경이다.
라이브러리를 이용해 어플리케이션 단에서 서킷 브레이커를 적용하려 한다.
대표적으로 Hystrix와 Resilience4j가 있다.
이 중 Resilience4j를 선택했다.
그 배경에는 Hystrix가 더이상 개발이 이루어지지 않고, 유지보수 모드로만 운영이 되며 Resilience4j 사용을 권장하기 때문이다.
Ref. https://github.com/Netflix/Hystrix
이를 알기 전에 서킷 브레이커 패턴에서의 상태 3가지에 대한 이해가 필요하다.
서킷 브레이커의 상태를 결정하는 기준인 ‘실패 임계치’가 존재한다. 이를 기준으로 각 상태를 결정한다.
실패 임계치는 아래를 기준으로 요청이 실패했다고 정의할 수 있다.
Resilience4j는 슬라이딩 윈도우를 이용해 요청의 결과를 저장하고 집계한다.
시간/개수 기준으로 다음과 같이 나뉜다.
테스트한 게이트웨이 서버에 적용한 값에 대입해서 생각해보기
resilience4j.circuitbreaker:
instances:
serviceCircuitBreaker:
registerHealthIndicator: true
slidingWindowSize: 10 # 최근 호출을 기준으로 서킷 브레이커를 평가하는 창문 크기
failureRateThreshold: 50 # 실패율 임계치 (%)
waitDurationInOpenState: 3000 # 오픈 상태에서 대기하는 시간 (3초)
permittedNumberOfCallsInHalfOpenState: 3
minimumNumberOfCalls: 10
automaticTransitionFromOpenToHalfOpenEnabled: true
resilience4j:
timelimiter:
configs:
default:
timeout-duration: 5s
cancel-running-future: false
슬라이딩 윈도우 사이즈 (slidingWindowSize: 10)
실패율 임계치 (failureRateThreshold: 50)
오픈 상태 대기 시간 (waitDurationInOpenState: 3000)
Half-Open 상태에서 허용된 호출 수 (permittedNumberOfCallsInHalfOpenState: 3)
최소 호출 수 (minimumNumberOfCalls: 10)
자동 전환 활성화 (automaticTransitionFromOpenToHalfOpenEnabled: true)
타임 리미터 (timeout-duration: 5s)
실행 중인 작업 취소 (cancel-running-future: false)
Fallback은 서비스를 차단하는 경우 예외를 발생시키는 대신 다른 준비된 동작을 실행하는 것을 의미.
서킷 브레이커가 Open인 상태에서 사용자 요청을 에러가 아닌 성공으로 응답하고자 할 때, 사용!
게이트웨이에 적용한 Fallback 처리
⇒ 502으로 응답하고, 클라이언트에서 ‘다시 시도해주세요’ 띄우기
다른 준비된 동작을 정의하지 않았기에 통일화된 응답 코드를 내려주도록 처리
@RestController
class FallbackController {
var logger: Logger = LoggerFactory.getLogger(this.javaClass)
@GetMapping("/fallback")
fun fallback(exchange: ServerWebExchange): Mono<Void> {
val exception = exchange.getAttribute<Throwable>(ServerWebExchangeUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR)
logger.error("##### ERROR: ", exception)
// 클라이언트에게 반환할 응답 생성
exchange.response.statusCode = HttpStatus.BAD_GATEWAY
return exchange.response.setComplete()
}
}
만약 위와 같이 Fallback 처리를 하지 않으면, NoFallbackAvailableException 에러 발생.
spring:
application:
name: application-gateway
gateway:
routes:
- id: api-server-v2
uri: lb://API-SERVER-V2
predicates:
- Path=/api-server/**
filters:
- name: CircuitBreaker
args:
name: serviceCircuitBreaker
fallbackUri: forward:/fallback
- name: Retry
args:
retries: 3
statuses: SERVICE_UNAVAILABLE
methods: GET
eureka:
client:
fetch-registry: true
register-with-eureka: true
service-url:
defaultZone: http://localhost:8761/eureka/
registry-fetch-interval-seconds: 3 # 3초마다 서비스 등록 정보를 가져옴
disable-delta: true
instance:
instance-id: ${spring.application.name}:${spring.application.instance_id:${server.port}}
lease-renewal-interval-in-seconds: 1
lease-expiration-duration-in-seconds: 2
resilience4j.circuitbreaker:
instances:
serviceCircuitBreaker:
registerHealthIndicator: true
slidingWindowSize: 10 # 최근 호출을 기준으로 서킷 브레이커를 평가하는 창문 크기
failureRateThreshold: 50 # 실패율 임계치 (%)
waitDurationInOpenState: 3000 # 오픈 상태에서 대기하는 시간 (3초)
permittedNumberOfCallsInHalfOpenState: 3
minimumNumberOfCalls: 10
automaticTransitionFromOpenToHalfOpenEnabled: true
resilience4j:
timelimiter:
configs:
default:
timeout-duration: 5s
cancel-running-future: false
게이트웨이 yaml 파일에서 유레카에 등록된 서비스들 중 라우팅할 서비스를 정의한 부분에 filter로 circuit breaker와 retry 설정을 추가해준다.
서킷 브레이커는 앞서 설명한 옵션을 그대로 적용한 것이고,
retry는 실패 시, 3번의 재시도를 하게 설정하였다. 이때 실패의 케이스가 503인 경우에만 하도록 했다. (배포 시에 발생하는 오류 응답코드)
배포 중에 유저의 요청이 실패되는 경험에 노출하지 않는 것이 좋은 사용자 경험을 제공하는 것이란 생각에 인프라적으로 추가 비용을 들이지 않고 애플리케이션 레벨에서 최소한의 중단 배포를 만들기 위해 고민하는 시간을 가졌다.
사수님의 조언으로 유레카와 스프링 클라우드 게이트웨이 부분을 집중적으로 살펴보면서 이해도를 다지는 시간이 되었다.
또한, 실무에서도 위의 설정을 기반으로 재구성하여 jMeter로 동시 요청 10,000건에 대해 이중화 서버 배포 중에 모든 요청들이 실패 없이 성공하는 것을 확인했다.
운영을 하면서 위의 설정값에서 일부 설정값들이 변경되었는데, 이 부분은 차차 후속편으로 업로드할 예정이다.
완전한 무중단이라기 보다 일부 실패하는 요청이 발생해서 이것을 바로잡기 위한 방법을 고민중에 있다.
(* 서버가 부팅 완료되기까지 시간이 더 오래걸리게 되어 ㅠㅠ)