Graceful Shutdown 문제 해결과 ECS 전환 이야기 (Spring Cloud Netflix + Docker Swarm)

CheolHo JungCheolHo Jung
4 min read

프롤로그: MSA 도입의 출발점

기존 시스템은 모놀리틱 아키텍처로 구성되어 있어 여러 대의 서버를 운영하는 데 따른 복잡성과 느린 배포 속도가 큰 고민거리였습니다. 특히, 코드 수정이 적음에도 전체 시스템을 빌드하고 배포해야 하는 비효율성이 존재했습니다. 이로 인해 기능을 모아서 배포할 수밖에 없었고, 이는 신속한 배포를 저해하는 주요 원인이었습니다.

2021년, 새로운 플랫폼을 구축하면서 MSA(Microservice Architecture) 도입을 결정했습니다.

  • 기술 스택: Spring Cloud Netflix OSS (Eureka, Zuul), Docker

  • 팀 경험: MSA, Docker, 클라우드 네이티브 환경 실무 경험 부족

  • 목표: 안정적인 서비스 디스커버리와 API 게이트웨이 구축

설레는 마음으로 시작했지만 곧 현실의 벽을 마주하게 되었습니다.

Chapter 1: Docker Swarm과의 첫 만남

왜 Docker Swarm을 선택했나?

당시 컨테이너 오케스트레이션 도구로 Docker Swarm을 선택한 이유는 다음과 같았습니다.

  • Kubernetes는 너무 복잡해 보였음

  • Docker Swarm은 상대적으로 진입 장벽이 낮아 보였음

  • Docker와 함께 제공되어 별도 설치가 불필요했음

초기 구성은 순조로웠다

# Docker Swarm 클러스터 초기화
docker swarm init

# 워커 노드 추가
docker swarm join --token <token> <manager-ip>:2377

# Eureka 서버 배포
docker service create \
  --name eureka-server \
  --network my-network \
  --replicas 2 \
  my-eureka:latest

Eureka, Zuul 같은 Netflix OSS 컴포넌트들을 Docker 서비스로 배포하는 것까지는 무난했습니다.

Chapter 2: Graceful Shutdown 악몽의 시작

문제 발견

서비스 업데이트를 위해 docker service update 명령어를 실행했을 때 문제가 발생했습니다.

docker service update --image my-app:v2 my-service

기대했던 시나리오:

  1. 새 컨테이너 시작

  2. 기존 컨테이너가 SIGTERM 수신

  3. Spring Boot가 Eureka 등록 해제 후 graceful shutdown

  4. 기존 컨테이너 종료

실제 일어난 일:

  1. 새 컨테이너 시작 ✅

  2. 기존 컨테이너 즉시 종료 ❌

  3. Eureka에 죽은 인스턴스 정보가 남아 있음 ❌

  4. API 게이트웨이에서 503 에러 발생 ❌

문제 분석: Eureka의 내부 동작 이해

종료된 서비스로 요청을 전달하는 문제가 발견되어 Eureka 서버의 코드를 분석했습니다. PeerAwareInstanceRegistry 구현체는 eureka-core 라이브러리에서 제공됩니다.

classDiagram
    LookupService <|-- InstanceRegistry
    LeaseManager <|-- InstanceRegistry
    InstanceRegistry <|-- PeerAwareInstanceRegistry
    InstanceRegistry <|-- AbstractInstanceRegistry
    PeerAwareInstanceRegistry <|-- PeerAwareInstanceRegistryImpl
    AbstractInstanceRegistry <|-- PeerAwareInstanceRegistryImpl

    class InstanceRegistry {
        <<Interface>>
    }
    class AbstractInstanceRegistry {
        <<Abstract>>
    }
    class PeerAwareInstanceRegistry {
        <<Interface>>
    }
    class LookupService {
        <<Interface>>
    }
    class LeaseManager {
        <<Interface>>
    }
public class EurekaServerAutoConfiguration implements WebMvcConfigurer {
    @Bean
    public PeerAwareInstanceRegistry peerAwareInstanceRegistry(
            ServerCodecs serverCodecs) {
        this.eurekaClient.getApplications(); // force initialization
        return new InstanceRegistry(this.eurekaServerConfig, this.eurekaClientConfig,
                serverCodecs, this.eurekaClient,
                this.instanceRegistryProperties.getExpectedNumberOfClientsSendingRenews(),
                this.instanceRegistryProperties.getDefaultOpenForTrafficCount());
    }
}
public class PeerAwareInstanceRegistryImpl extends AbstractInstanceRegistry {

    @Inject
    public PeerAwareInstanceRegistryImpl(/* ... */) {
        // 인스턴스 상태 결정 규칙 체인
        this.instanceStatusOverrideRule = new FirstMatchWinsCompositeRule(
            new DownOrStartingRule(),           // 1순위
            new OverrideExistsRule(overrides),  // 2순위  
            new LeaseExistsRule()               // 3순위
        );
    }
}

인스턴스의 상태 변경을 레지스트리에 갱신하기 위해서는 특정 Rule을 통과해야 합니다.

  • DownOrStartingRule: DOWN과 STARTING 상태만 통과

  • OverrideExistsRule: 인자로 전달된 Map에 존재하는 인스턴스만 통과

OUT_OF_SERVICE 상태가 통과할 수 있는 Rule은 OverrideExistsRule뿐입니다. 그러나 해당 Map이 비어 있어 OUT_OF_SERVICERule 체인을 통과하지 못하고 기존 상태인 UP으로 유지됩니다.

해결책: 커스텀 Graceful Shutdown 라이브러리 개발

라이프사이클에 개입하는 다소 위험한 방식으로 해결했습니다. 아래 코드는 OUT_OF_SERVICE 상태가 Rule 체인을 통과하도록 강제합니다.

@EventListener(ContextClosedEvent.class)
@Order(Ordered.HIGHEST_PRECEDENCE)
public void contextClosed(ContextClosedEvent event) throws InterruptedException {
    if (isEventFromLocalContext(event)) {
        updateHealthToOutOfService();
        waitForContainerStatusToSee();
    }
}

private void updateHealthToOutOfService() {
    health = new Health.Builder()
                    .status(props.getHealthDuringShutdown())
                    .build();
    LOG.info("Health status set to {}", health.toString());
}

여전히 남은 Docker Swarm의 한계

  • 제한적인 AWS 통합

  • 운영 부담: 클러스터 및 노드 관리

Chapter 3: AWS ECS로의 전환

현실적인 선택

팀의 상황을 재평가한 결과:

  • Kubernetes (EKS): 너무 복잡하고 학습 곡선이 가파름

  • Docker Swarm: 기능은 작동하지만 AWS 환경과의 통합에 한계

  • AWS ECS: AWS 생태계와의 통합이 용이하고 관리가 단순

ECS의 장점들

1. AWS 네이티브 통합

  • ALB 연동

  • CloudWatch 로그 자동 수집

  • IAM 역할 기반 권한 관리

  • Fargate로 인스턴스 관리 최소화

2. 운영 편의성

# 서비스 상태 확인
aws ecs describe-services --cluster my-cluster --services my-service

# 로그 확인
aws logs get-log-events --log-group-name /ecs/my-service

3. 안정적인 배포

  • 롤링 업데이트 자동 지원

  • 헬스체크 기반 트래픽 라우팅

  • 자동 롤백 기능

4. CI/CD 파이프라인 단순화

#!/bin/bash
# deploy.sh

docker build -t my-app:$BUILD_NUMBER .
aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_URI
docker tag my-app:$BUILD_NUMBER $ECR_URI/my-app:$BUILD_NUMBER
docker push $ECR_URI/my-app:$BUILD_NUMBER

aws ecs register-task-definition \
  --cli-input-json file://task-definition.json

aws ecs update-service \
  --cluster my-cluster \
  --service my-service \
  --task-definition my-app:$BUILD_NUMBER

Chapter 4: 교훈과 인사이트

기술 선택의 현실성

"최신 기술"과 "업계 표준"보다 더 중요한 것은:

  • 팀의 현재 역량

  • 실제 요구사항

  • 운영 가능한 복잡도 수준

MSA의 복잡성 체감

graph TD
    A[API Gateway] --> B[User Service]
    A --> C[Order Service]
    A --> D[Payment Service]
    B --> E[User DB]
    C --> F[Order DB]
    D --> G[Payment DB]
    B --> H[Redis Cache]
    C --> H
    D --> H

단순해 보이는 구조도 실제로는 다음과 같은 고려사항이 많았습니다.

  • 서비스 간 네트워크 통신

  • 분산 트랜잭션 관리

  • 서비스 디스커버리

  • 모니터링 및 로깅

  • 장애 전파 방지


다음 편 예고: "AWS ECS 기반 CI/CD 파이프라인 구축과 운영 노하우"에서는 실제 운영 환경에서의 배포 전략, 인증/인가, 트러블슈팅 경험을 공유하겠습니다.

참고 자료

0
Subscribe to my newsletter

Read articles from CheolHo Jung directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

CheolHo Jung
CheolHo Jung