클라우드 비용 최적화(FinOps) 프로젝트를 진행하면서 가장 매력적인 선택지는 단연 AWS EKS 상에서의 스팟 인스턴스 활용입니다. 이론적으로 온디맨드 대비 최대 90% 저렴한 비용으로 컴퓨팅 파워를 쓸 수 있으니까요. 하지만 최근 운영 중인 트래픽 50,000 RPM 규모의 마이크로서비스 환경에 스팟 인스턴스를 무턱대고 도입했다가, 간헐적인 502 Bad Gateway 오류 폭탄을 맞은 경험이 있습니다. 비용을 아끼려다 서비스 신뢰도를 잃을 뻔한 아찔한 순간이었죠.
핵심 문제는 AWS가 스팟 인스턴스를 회수할 때 주는 '2분의 경고(Two-minute Warning)'를 애플리케이션이 제대로 처리하지 못했다는 점이었습니다. 단순히 오토스케일링 그룹(ASG) 설정만 믿고 무중단 서비스를 기대하는 것은 프로덕션 환경에서 매우 위험한 도박입니다. 이 글에서는 Node Termination Handler와 파드 레벨의 Kubernetes Draining 로직을 결합하여, 인스턴스가 강제 종료되는 상황에서도 사용자 요청을 100% 처리해내는 엔지니어링 과정을 공유합니다.
실패 분석: 왜 502 에러가 발생했는가?
당시 환경은 AWS EKS 1.29 버전, 노드 그룹은 m5.large 인스턴스를 혼합하여 사용 중이었습니다. FinOps 달성을 위해 스팟 인스턴스 비중을 80%까지 높였는데, 특정 시간대(주로 주식 시장 개장/폐장 시간 등)에 스팟 회수가 빈번해지자 API 응답 실패율이 급증했습니다.
로그를 분석해보니 다음과 같은 패턴이 발견되었습니다.
[Warn] Connection reset by peer
[Error] Upstream prematurely closed connection while reading response header from upstream
원인은 명확했습니다. AWS가 인스턴스 회수 신호(Rebalance Recommendation 또는 Spot Instance Interruption Notice)를 보냈을 때, 쿠버네티스 컨트롤 플레인은 해당 노드를 즉시 제거 대상(Terminating)으로 간주하지 않거나, 타이밍 엇박자로 인해 파드(Pod)가 트래픽을 받고 있는 상태에서 강제 종료(SIGKILL) 당하고 있었던 것입니다. 특히 Load Balancer(ALB/NLB)가 해당 파드를 타겟 그룹에서 제외하기 전에 파드가 먼저 죽어버리는 'Race Condition'이 주범이었습니다.
단순 preStop hook의 한계
처음에는 애플리케이션 레벨에서 간단히 해결하려고 시도했습니다. 배포(Deployment) 매니페스트에 preStop 훅으로 sleep 30을 주어 파드가 종료되기 전 시간을 벌어보려 했습니다. 이는 롤링 업데이트 시에는 효과적이었으나, 스팟 인스턴스 회수 상황에서는 무용지물이었습니다.
이유는 단순했습니다. AWS가 보내는 회수 신호(Interruption Event)를 쿠버네티스 클러스터가 인지하지 못했기 때문입니다. 노드 자체가 OS 레벨에서 종료 절차를 밟기 시작하면, 그 위의 kubelet도 불안정해지며 preStop 훅이 실행될 시간조차 보장받지 못하는 경우가 발생했습니다. 즉, 하드웨어 레벨의 종료 신호를 쿠버네티스 이벤트로 변환해줄 중계자가 필요했습니다.
해결책: Node Termination Handler와 정교한 Draining
완벽한 무중단 서비스를 위해서는 두 가지 단계가 유기적으로 작동해야 합니다. 첫째, AWS 메타데이터 서비스(IMDS)를 감시하여 회수 신호를 즉각 포착하는 시스템. 둘째, 신호 감지 즉시 해당 노드를 'Cordone(스케줄링 금지)' 처리하고 실행 중인 파드를 안전하게 다른 노드로 옮기는 Kubernetes Draining 프로세스입니다.
이를 위해 AWS에서 공식 제공하는 AWS Node Termination Handler(NTH)를 Queue Mode가 아닌 IMDS Mode(DaemonSet)로 배치하여 가장 빠른 반응 속도를 확보했습니다. (Queue Mode는 다중 계정 관리에 유리하지만, 단일 클러스터 즉각 반응에는 IMDS 모드가 설정이 간편하고 직관적입니다.)
1. AWS Node Termination Handler 설치 (Helm)
가장 먼저 모든 스팟 인스턴스 노드에 NTH를 설치합니다. 이 데몬셋은 노드의 /latest/meta-data/spot/instance-action을 폴링하다가 종료 신호가 오면 즉시 노드에 Taint를 걸고 Drain을 시작합니다.
# helm repo add aws-eks https://aws.github.io/eks-charts
# 필수 설정: enableSpotInterruptionDraining를 true로 설정해야 합니다.
helm upgrade --install aws-node-termination-handler \
--namespace kube-system \
--set enableSpotInterruptionDraining=true \
--set enableRebalanceMonitoring=true \
--set deleteLocalData=true \
--set ignoreDaemonSets=true \
--set nodeSelector."lifecycle"=Ec2Spot \
aws-eks/aws-node-termination-handler
여기서 enableRebalanceMonitoring=true 설정이 중요합니다. AWS는 강제 종료 2분 전 경고 외에도, '조만간 회수될 가능성이 높음'을 알리는 리밸런싱 권고(Rebalance Recommendation)를 더 일찍 보냅니다. NTH가 이를 감지하면 2분이라는 촉박한 시간보다 더 여유롭게 파드를 이동시킬 수 있습니다.
2. 애플리케이션 레벨의 방어 로직 (Deployment YAML)
NTH가 노드를 Drain 하라는 명령을 내리면, 파드에는 SIGTERM 신호가 전달됩니다. 이때 애플리케이션이 수행해야 할 작업은 다음과 같습니다.
- 트래픽 차단 대기: 로드밸런서가 헬스 체크 실패를 인지하고 라우팅을 멈출 때까지 잠시 대기합니다.
- 기존 요청 처리: 이미 들어와서 처리 중인 트랜잭션은 끝까지 완료해야 합니다.
- 우아한 종료: DB 커넥션을 닫고 프로세스를 종료합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
replicas: 5
template:
spec:
# 중요: 종료 절차가 60초 이상 걸릴 경우를 대비해 넉넉히 설정
terminationGracePeriodSeconds: 60
containers:
- name: app
image: my-app:v2.1
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
# ... 기타 설정
위 코드에서 sleep 10은 "멍 때리는" 시간이 아닙니다. 이는 NTH가 Drain 명령을 내리고 파드가 Terminating 상태로 변했을 때, AWS ALB/NLB가 타겟 그룹에서 해당 파드의 IP를 제거하고 전파하는 데 걸리는 물리적인 시간을 벌어주는 핵심 완충제입니다. 이 설정이 없으면 로드밸런서는 파드가 죽은 줄 모르고 계속 요청을 보내 502 에러를 유발합니다.
적용 결과 및 성능 분석
이 구성을 프로덕션 환경에 배포한 후, Chaos Mesh를 이용해 인위적으로 스팟 인스턴스 중단을 유발하며 테스트를 진행했습니다. 결과는 극적이었습니다.
| 지표 (Metric) | 기존 (Naive Approach) | 개선 후 (With NTH + Graceful Shutdown) |
|---|---|---|
| 스팟 회수 시 5xx 에러율 | 1.5% ~ 3.0% (순간 스파이크) | 0.00% (Zero Downtime) |
| 월간 클라우드 비용 | $5,200 (On-Demand 위주) | $1,450 (Spot 80% 적용) |
| 운영 안정성 | 개발팀의 야간 호출 빈번 | 자동화된 처리로 호출 없음 |
비용 측면에서는 약 72%의 절감 효과를 보았습니다. 무엇보다 중요한 것은 무중단 서비스의 실현입니다. NTH가 노드 종료 이벤트를 감지하고 Drain을 시작하면, 파드는 sleep 시간 동안 새로운 연결은 받지 않으면서(Ready 상태 해제), 기존에 처리 중이던 로직은 완벽하게 수행하고 종료되었습니다. 이는 사용자 경험(UX)에 어떠한 부정적 영향도 주지 않으면서 인프라 비용을 획기적으로 낮출 수 있음을 증명합니다.
주의사항 및 엣지 케이스
이 솔루션이 만능은 아닙니다. 적용 시 몇 가지 주의해야 할 FinOps 엣지 케이스가 있습니다.
- 긴 처리 시간이 필요한 배치 작업: 만약 하나의 트랜잭션이 2분을 초과하는 배치(Batch) 성격의 파드라면 스팟 인스턴스는 적합하지 않습니다. 2분의 경고 시간 내에 처리를 완료하고 데이터를 저장(Checkpoint)할 수 없다면 데이터 유실로 이어질 수 있습니다. 이런 경우 온디맨드 노드 그룹을 별도로 운영하고
nodeSelector로 분리해야 합니다. - StatefulSet의 주의점: DB나 캐시 같은 StatefulSet은 데이터 정합성 문제로 인해 스팟 인스턴스 사용을 권장하지 않습니다. 다만, 복제본(Replica)이 많고 빠른 복구가 가능한 Stateless에 가까운 구조라면 고려해볼 만합니다.
- DaemonSet 무시 설정: NTH 설정 시
ignoreDaemonSets=true를 반드시 확인하세요. 로그 수집기나 모니터링 에이전트 같은 데몬셋 파드 때문에 노드 Drain이 완료되지 않고 멈춰있는(Stuck) 경우가 발생할 수 있습니다.
결론
AWS EKS 환경에서 스팟 인스턴스를 활용하는 것은 비용 효율성을 극대화하는 최고의 전략입니다. 하지만 "싸고 좋은 것"을 쓰기 위해서는 그에 맞는 기술적 투자가 필요합니다. Node Termination Handler를 통한 명시적인 이벤트 감지, 그리고 애플리케이션 레벨에서의 세밀한 종료(Graceful Shutdown) 처리만이 비용 절감과 서비스 안정성이라는 두 마리 토끼를 모두 잡을 수 있게 해줍니다.
지금 바로 여러분의 클러스터 비용 명세서를 확인해보세요. 그리고 스팟 인스턴스 도입이 두려워 망설이고 있었다면, 위에서 소개한 Kubernetes Draining 전략을 테스트 환경(Stage)부터 적용해보시길 권장합니다.
Post a Comment