leo.dev
infra

CORS 에러로 신고된 Fargate Spot 회수

2026.07.2811 min read awsecsfargatespotincident

프로덕션 API가 30분간 완전히 죽었다. 신고는 CORS 에러로 들어왔는데 CORS와는 무관했다.

ALB(Application Load Balancer)가 뒤에 붙일 타깃이 없을 때 반환하는 503은 애플리케이션을 안 거친 로드밸런서 응답이라 Access-Control-Allow-Origin 헤더가 없고, 브라우저는 헤더가 없는 이유까지 구분해주지 않는다. curl -i -X OPTIONS로 때려보면 HTTP/2 503, server: awselb/2.0이 바로 나온다. 5xx에 awselb면 CORS가 아니라 서버가 죽은 것이다.

원인은 ECS 서비스의 태스크가 0개가 된 것이었고, 그 밑에는 Fargate Spot의 계약을 잘못 읽은 구성이 있었다.

Spot의 명세

Fargate Spot은 남는 컴퓨트 용량을 할인가에 빌려주는 대신, AWS가 그 용량을 회수할 때 태스크를 중단한다. 중단 자체는 버그가 아니라 명세에 적힌 동작이다.

AWS 문서에 회수 절차가 이렇게 적혀 있다.

When tasks using Fargate Spot capacity are stopped due to a Spot interruption, a two-minute warning is sent before a task is stopped. The warning is sent as a task state change event to Amazon EventBridge and as a SIGTERM signal to the running task.

Fargate Spot termination notices, AWS Documentation

회수로 멈춘 태스크에는 왜 멈췄는지 알아볼 수 있는 코드가 남는다.

"stoppedReason": "Your Spot Task was interrupted.",
"stopCode": "SpotInterruption"

여기까지가 Spot 명세에 적힌 보장이다. 예고 없이 중단하지 않고, 2분을 주고, 그 2분을 SIGTERM으로 알린다.

명세에 없는 보장

같은 문서의 다음 문단이 그 다음에 일어난 일을 적고 있다.

During periods of extremely high demand, Fargate Spot capacity might be unavailable. This can cause Fargate Spot tasks to be delayed. When this happens, Amazon ECS services retry launching tasks until the required capacity becomes available. Fargate doesn’t replace Spot capacity with on-demand capacity.

Fargate Spot termination notices, AWS Documentation

회수당한 태스크를 ECS가 다시 띄우려 하지만 그 시점에 리전의 Spot 재고가 말라 있으면 재시도가 계속 실패하고, 실패한다고 온디맨드로 넘어가지 않는다. 우리가 겪은 순서가 정확히 이랬다.

중지된 태스크 stopCode = SpotInterruption
이후 재배치 "Capacity is unavailable at this time"
결과 running 0 / desired 2 → ALB 타깃 0개 → 전 API 503

나는 Spot을 “가끔 재시작되지만 알아서 복구되는 싼 옵션”으로 알고 있어서 이 문단을 안 읽었다. 복구는 재고가 돌아와야 일어나고, 재고가 언제 돌아오는지는 우리 통제 밖이다.

문서에 한 줄이 더 있는데 dev 환경에 그대로 해당했다. 태스크가 하나뿐인 서비스는 용량이 생길 때까지 중단된 상태로 있는다. 여러 태스크가 있으면 회수되지 않은 태스크가 트래픽을 받는 동안 나머지가 복구를 시도하는데, 하나면 받아줄 태스크가 없다. dev 서비스가 desired 1이었다.

같은 클러스터에서 살아 있던 워커

같은 클러스터의 워커 서비스는 1/1로 멀쩡했는데, 이 사실 하나가 후보를 대량으로 잘라낸다. 계정도, VPC나 보안그룹도, 이미지 빌드도, 배포 파이프라인도 아니다. 같은 계정·같은 클러스터·같은 이미지에서 하나는 죽고 하나는 살았으니 차이는 두 서비스의 설정에 있다.

api-prod-service [ FARGATE_SPOT weight=2 base=1 ] ← 온디맨드 항목 없음
worker-prod-service [ FARGATE ] ← 온디맨드

base=1이 붙어 있던 쪽

위 전략에서 눈에 걸리는 건 base=1이 이미 붙어 있다는 점이다. 없어서 문제가 아니라 잘못된 쪽에 붙어 있었다.

capacity provider strategy 정의를 보면 두 필드의 역할이 다르다.

필드
base이 용량 공급자에서 최소한 몇 개를 돌릴지. 전략 안에서 오직 하나만 가질 수 있다
weightbase를 채운 뒤 나머지를 어떤 비율로 나눌지

배치 순서도 문서가 정해두는데, base를 먼저 채운 다음 weight 비율로 나눈다.

base는 전략당 하나뿐인 자원이고 그 하나가 최소 보장을 담당하는데, 그걸 Spot에 썼다. 최소를 보장할 수 없다고 문서에 적힌 공급자에게 최소 보장을 맡긴 것이다.

고친 건 그 base 슬롯을 온디맨드로 옮긴 것이고, 명령 하나로 끝났다.

Terminal window
aws ecs update-service --cluster app-cluster --service api-prod-service \
--capacity-provider-strategy \
capacityProvider=FARGATE,weight=1,base=1 \
capacityProvider=FARGATE_SPOT,weight=1,base=0 \
--force-new-deployment

온디맨드 1태스크를 먼저 확보하고 초과분을 weight 비율로 Spot이 채우므로, Spot이 전부 회수돼도 서비스가 0이 되지 않는다. Spot을 걷어내지는 않았고 비용 이점은 그대로 두고 하한만 만든 것이다.

2분을 쓰려면 필요한 설정

용량 공급자를 고치고 나서 조사가 한 겹 더 들어갔다. Spot이 2분을 준다는 건 알겠는데 우리 태스크가 그 2분을 쓸 수 있게 돼 있느냐가 남는다.

문서에 걸린 요구가 두 가지다. 하나는 stopTimeout을 컨테이너 정의에 120초 이하로 지정하는 것이고, 다른 하나는 컨테이너 안에서 SIGTERM을 실제로 받아 처리하는 것이다.

The SIGTERM signal must be received from within the container to perform any cleanup actions. Failure to process this signal results in the task receiving a SIGKILL signal after the configured stopTimeout and may result in data loss or corruption.

Fargate Spot termination notices, AWS Documentation

우리 task definition에는 stopTimeout이 없어 기본 30초이므로, Spot이 2분을 주는데 그중 30초만 쓰도록 돼 있다.

SIGTERM 쪽은 더 걸린다. HTTP 앱에 SIGTERM 핸들러가 하나 있는데 OpenTelemetry 버퍼의 span을 flush하고 나가는 용도다. NestJS의 enableShutdownHooks()는 워커 앱에만 걸려 있고 HTTP 앱엔 없다. HTTP 앱이 SIGTERM을 받으면 트레이스를 비우고 끝나며, 진행 중인 요청을 마무리하는 절차는 앱 쪽에 없다.

ECS가 태스크를 멈추는 순서

그러면 진행 중인 요청은 어떻게 되는가. 답은 ECS가 태스크를 멈추는 순서에 있고, AWS 가이드에 이렇게 적혀 있다.

1. ALB 타깃 그룹에서 태스크를 등록 해제
2. deregistration delay 동안 기존 연결은 유지, 새 요청은 다른 태스크로
3. SIGTERM 전송
4. stopTimeout 대기
5. SIGKILL

새 요청은 1번에서 이미 끊기고 기존 연결은 2번의 드레이닝 시간 동안 유지되므로, ALB가 앞에서 막아주는 덕에 앱이 SIGTERM을 안 잡아도 정상 배포에서는 대체로 무해하다.

같은 가이드에는 Spot에 붙는 시간 제약이 하나 더 있다.

Spot only gives you two minutes before the task is shutdown, so the deregistration delay for any Target Groups associated with Spot should be set to a value less than two minutes.

Graceful shutdowns with ECS, AWS Containers Blog

2분 안에 등록 해제와 드레이닝과 SIGTERM 처리가 다 끝나야 하기 때문이다. ALB 기본값이 300초라 그대로 두면 넘친다.

우리는 60초로 돼 있었다. 배포 워크플로우가 매번 60초로 되돌리게 해뒀는데, 그건 Spot 때문이 아니라 ECS 안정화 대기를 줄여 배포를 빠르게 하려고 넣은 것이다. 다른 목적으로 한 설정이 Spot 요구사항과 우연히 맞았다. 60초 드레이닝에 30초 stopTimeout이면 90초라 2분 창 안에 들어온다.

어디까지 고쳤나

용량 공급자는 고쳤고, 세 서비스 모두 온디맨드 base=1이며 prod 2/2, dev 1/1로 안정 상태다.

stopTimeout과 SIGTERM 처리는 안 고쳤다. 지금 판단의 근거는 이렇다. 이 API는 요청-응답이 짧고 상태를 메모리에 들고 있지 않아서 30초 안에 못 끝나는 작업이 요청 경로에 없다. 긴 작업은 전부 워커 쪽이고 워커는 온디맨드에 enableShutdownHooks()도 걸려 있다.

다만 이건 “지금은 안 아프다”이지 “설정이 옳다”가 아니다. 요청 경로에 30초를 넘길 만한 게 들어오는 순간, 또는 배포 중이 아니라 Spot 회수로 죽는 순간 차이가 난다. 문서가 데이터 유실 가능성을 명시한 설정인데 안 준 채로 두고 있다. 요청 경로에 30초를 넘길 작업이 하나라도 들어오는 날 stopTimeout부터 준다.

3주 전의 배포 실패

이 장애를 파기 3주 전에 운영 배포가 한 번 실패한 적이 있다. Deploy Backend Service 잡이 18분 만에 실패했는데, 태스크가 ALB 헬스체크를 통과하지 못하고 계속 교체되다 배포 서킷 브레이커가 직전 릴리스로 롤백했다. 같은 이미지로 재배포하니 즉시 성공해서 “일시적 헬스체크 실패”로 적고 넘어갔다.

이번 복구 과정에서 신규 태스크가 헬스체크로 세 번 교체되는 걸 다시 봤고, 3주 전과 증상이 같다.

교체가 하필 세 번인 건 우연이 아니다. 서킷 브레이커 문서를 보면 기본 임계값이 원하는 태스크 수의 50%인데 최소 3으로 고정된다. 우리 prod는 태스크가 둘이라 계산값이 1이고, 최소값에 걸려 3이 된다. 헬스체크 실패가 세 번 쌓이면 배포가 FAILED가 되고 롤백이 뛴다는 뜻이라, 세 번이라는 수는 실패가 얼마나 심했는지와 상관이 없고 임계값이 3이라는 것만 뜻한다.

그래서 이 숫자로는 두 사건이 같은 원인인지 알 수 없다. 원인이 무엇이든 헬스체크가 계속 실패하면 세 번에서 멈추기 때문이다. 남는 질문은 애초에 왜 헬스체크가 실패했느냐인데, 여기에는 답을 못 찾았다.

가설을 세우려면 Spot 구성이 기동을 느리게 만든다는 연결이 있어야 하는데 그 메커니즘을 나는 모른다. Spot과 온디맨드는 같은 컴퓨트라 컨테이너가 늦게 뜰 이유가 딱히 없다. 3주 전 태스크가 어느 용량 공급자에서 떴는지도 기록해두지 않아 소급 확인이 안 된다.

그래서 두 가능성이 열려 있었다. 3주 전 실패가 Spot 구성에서 온 것이었거나, 무관한 별개의 일시적 실패였거나다. 온디맨드 base=1로 바꿨으니 앞으로 헬스체크 교체가 줄어드는지를 보면 알 수 있다고 적어뒀다.

엿새 뒤에 나온 답

Deploy Backend Service 잡의 소요를 GitHub Actions 실행 이력에서 뽑아 용량 공급자 변경 전후로 나눴다. 표본 44건이 전부 같은 파이프라인 버전이라 구조 개편이 섞이지 않았다.

표본medianmax10분 초과
변경 전23건5.8분21.4분8건 (35%)
변경 후21건5.0분7.4분0건

꼬리가 사라졌다. 10분을 넘긴 8건은 전부 7/21~7/27에 몰려 있고, 7/28 이후로는 한 건도 없다. 최댓값이 21.4분에서 7.4분으로 잘렸다.

3주 전 실패도 Spot 구성에서 온 것이었다. 헬스체크로 태스크가 계속 교체되던 건 Spot 구성과 같이 움직였다. 여전히 메커니즘은 모른다.

Spot과 온디맨드가 같은 컴퓨트인데 왜 기동이 다른지는 설명하지 못하고, 3주 전 태스크의 용량 공급자 기록이 없다는 것도 그대로다. 다만 원인을 몰라도 상관관계는 재서 확인할 수 있고, 그 상관이 이만큼 뚜렷하면 “무관한 별개의 실패”라고 부르기 어렵다.

이걸 재려고 새로 만든 건 없다. 배포 실패율을 계속 재고 있었으니, 같은 데이터를 용량 공급자 변경일로 한 번 더 나눴다.

온디맨드 base=1 이후

세 서비스 모두 온디맨드 base=1이고, 그 뒤 21번의 배포에서 10분을 넘긴 건 0건이다. deregistration_delay는 60초로 배포마다 자가 복구되고, stopTimeout은 여전히 기본 30초다. 장애 대응 순서에는 한 줄이 추가됐는데, CORS 에러 신고를 받으면 설정 파일을 열기 전에 curl -i -X OPTIONS로 상태 코드부터 본다는 것이다.

↑↓ 이동 열기esc 닫기