- Published on
무중단 배포로 개선
- Authors
- Name
- 이건창
상황
서비스 배포시 다운타임 8분간 지속되는 상황을 겪었다.
문제
서비스 교체시 다운타임으로 일정 시간 동안 사용자 요청을 정상 처리할 수 없는 문제가 발생했다.
배포시 다운타임이 발생한다.
게이트웨이 레지스트리에서 존재하는 서비스 상태와 실제 서비스 상태보다 늦게 업데이트 돼 발생한다. 유레카를 이용하면 로드 밸런싱할 경로가 캐시 TTL만큼 다운타임이 발생한다. 로드 밸런싱할 경로 캐시 TTL은 기본 값이 30초로 업데이트가 즉각적으로 반영되지 않는다.
다운타임이 예상보다 오래 지속된다.
배포는 서비스 시작과 종료를 순차적으로 수행되고 있다. 다운타임은 인스턴스 개수 * 30s
까지 발생해야 하는데 그 이상 유지되고 있다.
원인
배포시 다운타임이 발생하는 문제 원인
유레카 서버는 즉각적으로 정보를 갱신하지만 Gateway에서 30초 간격으로 갱신하기 때문에 최장 30초까지 다운타임이 발생한다.

해당 다운타임은 서비스 인스턴스에 비례하기도 한다. 간단하게 아래 상황의 영향도가 존재한다.
- 서비스 시작과 종료를 순차적으로 수행한다면 서비스 다운타임은
인스턴스 개수 * 30s
까지 발생 - 서비스 시작과 종료를 동시에 수행한다면 서비스 영향도는
인스턴스 개수 * N 회
만큼 증가
서비스 시작과 종료를 순차적으로 수행한다면 서비스 다운타임은 인스턴스 개수 * 30s
까지 발생할 수 있다.

서비스 시작과 종료를 동시에 수행한다면 서비스 다운타임 시간을 줄일 수 있지만 서비스 영향도는 인스턴스 개수 * N 회
만큼 증가하게 된다.

다운타임이 예상시간보다 오래 지속되는 원인
기능 추가로 애플리케이션 컨텍스트가 올라가는 시간 증가하면서 컨텍스트가 뜨기 전에 health check 날리는게 원인이다.

유레카 서버 로그에서는 인스턴스가 새로 생성되고 종료됨이 반복되며 다운타임 영향도와 간격이 길어졌다. 생성 종료가 반복되는 현상을 인프라팀과 이야기를 나눴고, ecs에서 일정 시간동안 health check가 되지 않으면 재시작함을 알게 됐다.
해결
배포시 다운타임이 발생하는 현상 해결
다운타임 현상 해결 방법은 두 가지로 정의할 수 있다.
- 유레카 서버와 유레카 클라이언트 간 레지스트리
sync
를 맞춘다. - 배포 과정에서 유레카 클라이언트 등록 해제(
unregister
)를 서비스 종료 시점보다 빠르게 잡는다.
해결 방안 1 : 유레카 서버와 유레카 클라이언트 간 레지스트리 sync
를 맞춘다.
첫 번째 해결방안은 레지스트리 TTL을 짧게 잡고 TTL동안 sync
가 맞지 않아 실패하는 요청을 재시도하는 방식이다.

그 과정에서 레지스트리 갱신하기 위한 데이터 비교가 이루어지는데, 갱신된 데이터 만 받아 응답 데이터 양을 줄일 수도 있다.
설정할 옵션은 다음과 같다.
- eureka.client.registry-fetch-interval-seconds=5s
- eureka.client.disable-delta=true
hello-gateway:
build: ./hello-gateway
deploy:
replicas: 2
ports:
- "8080-8081:8080"
environment:
# fetching 타임을 줄여 런타임에 실시간으로 변경된 정보를 가져온다.
- eureka.client.registry-fetch-interval-seconds=5
# 유레카에서 변경되는 정보만 가져온다. See EurekaClientConfigBean 페치 사이클 변경에 쉽게 대응할 수 있다.
- eureka.client.disable-delta=true
- spring.cloud.gateway.httpclient.connect-timeout=10000
- spring.cloud.gateway.httpclient.response-timeout=5s
이 과정에 큰 문제는 connection timeout
제어가 힘들어진다는 점이다. 커넥션 연결을 시도하다 서비스가 내려가면 사용자는 최대 30초까지 대기해야 한다. 이런 문제를 해결하기 위해 httpclient 요청에 대한 connection timeout
설정이 가능하다.
hello-gateway:
build: ./hello-gateway
deploy:
replicas: 2
ports:
- "8080-8081:8080"
environment:
# fetching 타임을 줄여 런타임에 실시간으로 변경된 정보를 가져온다.
- eureka.client.registry-fetch-interval-seconds=5
# 유레카에서 변경되는 정보만 가져온다. See EurekaClientConfigBean 페치 사이클 변경에 쉽게 대응할 수 있다.
- eureka.client.disable-delta=true
# http client로 커넥션 맺어질 때 connection timeout 설정
- spring.cloud.gateway.httpclient.connect-timeout=5000
이후 게이트웨이에서 INTERNAL_SERVER_ERROR 에러 대해서 retry를 수행한다. 게이트웨이 API RetryGatewayFilterFactory
에서 아래 정보를 모아 필터를 수행해준다.
@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList("retries", "statuses", "methods", "backoff.firstBackoff", "backoff.maxBackoff",
"backoff.factor", "backoff.basedOnPreviousValue");
}
yaml
파일에서는 다음처럼 설정하면 된다.
filters:
- name: Retry
args:
retries: 3
statuses: INTERNAL_SERVER_ERROR
methods: *
backoff:
firstBackoff: 10ms
maxBackoff: 50ms
factor: 2
basedOnPreviousValue: false
아쉽게도 해당 방식은 connection timeout
이 발생하면 해당 사용자는 5초 정도 대기해야 한다. 만약 1초 내외로 줄이고 싶다면 connection timeout
을 더욱 짧게 잡아야해서 connection timeout
에 대한 의도치 않은 제약이 걸리게 된다.
그래서 connection timeout
설정을 변경하지 않는 방법을 활용했다.
해결방안 2 : actuator 활용해 배포 프로세스를 개선한다.
두 번째 해결 방법은 actuator를 활용해 유레카 서버 등록-해제를 제어하는 방법이다. 즉, 서비스 종료 전에 서비스 해제 API를 사용해 미리 유레카 서버에서 등록 해제한다.

spring actuator는 service registry 동작을 제어하는 인터페이스를 제공하고 spring cloud comons 은 해당 인터페이스의 구현체가 담겨있다. 내용은 다음과 같다.
Service Registry Actuator Endpoint Spring Cloud Commons provides a
/serviceregistry
actuator endpoint. This endpoint relies on aRegistration
bean in the Spring Application Context. Calling/serviceregistry
with GET returns the status of theRegistration
. Using POST to the same endpoint with a JSON body changes the status of the currentRegistration
to the new value. The JSON body has to include thestatus
field with the preferred value. Please see the documentation of theServiceRegistry
implementation you use for the allowed values when updating the status and the values returned for the status. For instance, Eureka’s supported statuses areUP
,DOWN
,OUT_OF_SERVICE
, andUNKNOWN
.
다음처럼 GET 요청을 보내면 현재 상태 확인이 가능하다.
GET /actuator/serviceregistry
{
"overriddenStatus": "UP",
"status": "UP"
}
다음처럼 POST 요청을 보내면 상태 변경이 가능하다.
POST /actuator/serviceregistry
{ "status": "DOWN" }
해당 방식은 유레카 서버에게 즉각적인 상태 반영은 가능하지만 서비스 상태와 달라 sync 맞추기가 어렵다. 만약 spring actuator
가 제공하는 health check
와 유레카 서버 상태 간 sync를 맞추려면 다음 방법이 좋다.
해결방안 3 : health check 와 eureka staus 간 sync 를 맞춘다.
유레카 서버는 eureka.client.healthcheck.enabled: true
로 설정되면 서비스 상태로 유레카 서버 등록 해제를 제어한다. 서비스 상태가 OUT-OF-SERVICE
나 DOWN
이면 유레카는 서비스 상태를 캐치해 등록 해제를 진핸한다.
eureka.client.healthcheck.enabled=true
설정과 함께 다음 헬스체크 핸들러 구현이 필요하다.
package com.netflix.appinfo;
/**
* This provides a more granular healthcheck contract than the existing {@link HealthCheckCallback}
*
* @author Nitesh Kant
*/
public interface HealthCheckHandler {
InstanceInfo.InstanceStatus getStatus(InstanceInfo.InstanceStatus currentStatus);
}
꼭 package com.netflix.appinfo 패키지에 있는 HealthCheckHandler를 구현해야 한다. 그렇지 않으면 spring actuator만 인식해 유레카 서버에 의도한 동작이 안먹힐 수 있다. spring actuator 동작으로 health 상태를
OUT_OF_SERVICE
로 변경한다면 유레카는 이를 감지해 강제로DOWN
으로 낮추게 된다. 유레카 설정으로 인해DOWN
으로 내려간 서버는 더 이상 UP으로 올라오지 않게 되니 주의해야 한다.
HealthCheckHandler
를 이용해 유레카 서버가 상태 확인시 우리가 지정한 상태로 인식할 수 있도록 구성할 수 있다.
@Component
class CustomHealCheckHandler : HealthCheckHandler {
var status = InstanceInfo.InstanceStatus.UP
private set(status) {
synchronized(this) {
field = status
}
}
override fun getStatus(currentStatus: InstanceInfo.InstanceStatus): InstanceInfo.InstanceStatus {
return status
}
fun setOutOfService() {
status = InstanceInfo.InstanceStatus.OUT_OF_SERVICE
}
fun setUp() {
status = InstanceInfo.InstanceStatus.UP
}
}
spring actuator
를 사용하는 경우 /actuator/pause
, /actuator/resume
메서드를 이용할 수 있는데, PauseHandler를 구현해 내부 기능을 채운다면 쉽게 제어가능하다.
@Component
class CustomPauseHandler(
private val healCheckHandler: CustomHealCheckHandler,
): PauseHandler {
override fun pause() {
healCheckHandler.setOutOfService()
}
override fun resume() {
healCheckHandler.setUp()
}
}
해결방안 4 : AWS에서 제공하는 서비스 디스커버리를 이용한다.
문제는 게이트웨이에 저장된 레지스트리 정보와 유레카 서버가 저장된 레지스트리 정보가 틀려서 문제가 됐다. 만약 게이트웨이에서 레지스트리를 저장하지 않고 라우팅만 한다면 어떨까 생각했고, AWS에서 제공하는 ECS 서비스 디스커버리을 이용해보기로 했다.
참고 자료 : https://aws.amazon.com/ko/blogs/aws/amazon-ecs-service-discovery/
ECS 서비스 디스커버리를 이용한다면 DNS로 서비스 간 통신을 진행한다. 게이트웨이에서 라우팅 정보를 http://localhost:8080/~
형식으로 수행한다면 디스커버리에 의존적이지 않다.
지금까지 다운타임이 발생할 수 있는 이슈를 정리하면 다음과 같다.
- 서비스 종료 전 요청을 받은 체 죽는 문제
- 서비스 종료 후 요청을 전달하는 문제
AWS ECS 디스커버리는 서비스 종료 전 요청을 받은 체 죽는 문제가 존재한다. 즉, 서비스 중지와 서비스 등록 해제가 동시에 진행되면서 이미 요청 보낸 데이터가 종료된다. 이런 경우는 server.shutdown=graceful
로 설정해 가지고 있는 요청이 종료할 때까지 서비스 종료를 잠시 멈춘다.
connection prematurely closed before response
AWS ECS 서비스 디스커버리는 서비스 종료 후 요청을 전달하는 문제가 존재하지 않는다. 서비스를 먼저 디스커버리에 해제하고 서비스 종료하는 절차로 진행하는 모습을 확인했다.
다운타임 예상시간보다 오래 지속되는현상 해결
다운타임이 예상시간보다 오래 지속되는 현상은 ECS가 서비스가 정상 동작하기 전에 health check하는게 원인이다. 문제를 해결하는 방법은 다음과 같다.
- health check 시간을 늘린다.
- 컨텍스트 로드 시간을 단축한다.
지금 당장 컨텍스트 로드 시간을 단축하기에는 너무 많은 리소스가 조모된다. 그래서 health check 시간을 늘려 다운타임 지속시간을 줄였다.
결말
AWS ECS 서비스 디스커버리를 이용하면서 무중단 배포를 실현했다. 데브옵스 팀과 협의 과정에서 데브옵스팀이 배포 관련 동작을 직접 제어하고 싶어하셨고 나 또한 애플리케이션에서는 비즈니스 로직만 남기고 싶었다. 책임을 적절히 분산해 함께 협업 할 수 있는 환경에 집중했고 그 결과로 AWS ECS 서비스 디스커버리를 활용했다.
AWS에 의존적인 인프라는 쿠버네티스로 전환하면서 플랫폼에 종속적인 문제를 해결해보기로 했고 다음 진행작업이 될 예정이다.
회고
다운타임을 문제로 삼았고 무중단 배포까지 달성했다.
다운타임 문제는 새벽 배포로 서비스 영향도를 대폭 낮출 수 있지만 개발팀이 정상적인 수면 패턴을 벗어나 작업해야 해서 피로가 쌓이고, 문제가 발생하면 즉시 대응 인력이 부족하다. 배포시 발생하는 다운타임은 수익성은 적지만 서비스 가치를 낮출 수 있는 이슈다. 문제를 삼지 않으면 문제가 되지 않을만한 이슈지만 팀의 웰빙을 지키기 위해서는 꼭 필요한 작업이었다.
기능 추가로 빌드 시간이 늘어나는 상황
빌드 시간이 늘어나면 서비스 관리를 위한 자기 수복 형태를 구성하기 어려워질 수 있음을 경험했다. 왜냐하면 인프라팀이 의도한 실행 실패 핸들링이 어려워질 수 있다고 생각했기 때문이다. 특히나 빌드 시간이 지속적으로 늘어나는 상황을 대응하는게 부담이 될 수 있다.
어디서 제어하냐에 따라 관리 책임이 달라진다.
애플리케이션에서도 충분히 제어할 수 있었지만 데브옵스 팀에서 관리가 어려워질 수 있음을 경험했다. 데브옵스 팀과 조율해서 어떻게 하면 잘 일 할 수 있을지 고민하며 솔루션을 도출하는게 재밌었다. 나 혼자였으면 애플리케이션에서 모두 해결했을텐데 서비스 관리 책임을 외부로 두면서 함께 관리할 수 있는 환경을 만들며 유관 부서와의 협업을 경험했다.