헬스체크는 실패하는데 CPU는 멀쩡했다
20분짜리 스크래핑이 헬스체크를 죽였다. SQS 큐 구조를 제안했다가 반박받고 범위를 좁힌 이야기.
올해 4월에 있었던 일인데 이제야 정리한다. 구현까지 내가 한 게 아니라 제안으로 끝난 건이라 쓸지 말지 애매했는데, 정작 배운 게 많았던 쪽은 설계보다 제안이 한 번 반려됐다가 통과된 과정이었다.
고객사 서비스에 이벤트가 있는 날이면 특정 서버의 헬스체크가 실패했다. 4월에 두 번, 같은 패턴으로 반복됐다. 스크래핑 기능을 제공하는 서버였다.
처음엔 단순히 부하 문제인 줄 알았다. 그런데 지표를 보니 이상했다. 헬스체크는 실패하는데 CPU는 멀쩡했다.
이벤트 날마다 어떤 하나가 unhealthy였다
구조는 평범했다.
1
2
3
클라이언트 → ALB → EC2 (ASG) → gunicorn → FastAPI
└ POST /scrape (스크래핑 1건 약 20분)
└ 그 외 여러 API
이벤트가 시작되면 스크래핑 요청이 몰리고, 얼마 지나지 않아 ALB 타겟이 unhealthy로 빠졌다. 인스턴스가 죽은 것도 아니고 애플리케이션이 뻗은 것도 아닌데 헬스체크만 실패했다.
헬스체크와 20분짜리 작업이 같은 워커를 쓴다
gunicorn의 sync 워커는 요청 하나가 워커 하나를 통째로 점유한다. 워커가 4개면 동시에 처리할 수 있는 요청도 4개다. 그 요청이 20분짜리면 20분간 그 워커는 다른 일을 못 한다. (이건 인스턴스 직접 들어가서 처리 로그를 통해서 확인함)
1
2
3
4
5
6
워커 1 [████ 스크래핑 20분 ████████████████]
워커 2 [████ 스크래핑 20분 ████████████████]
워커 3 [████ 스크래핑 20분 ████████████████]
워커 4 [████ 스크래핑 20분 ████████████████]
↑
GET /health ← 받아줄 워커가 없음. 큐에서 대기하다 ALB 타임아웃
ALB 입장에서는 응답이 안 오니 인스턴스가 죽은 걸로 판단한다. 그래서 타겟에서 빼버리고, 그러면 남은 인스턴스로 트래픽이 몰리고, 그 인스턴스도 같은 식으로 워커가 차서 또 빠진다. 연쇄로 번진다.
그렇다면 왜 FastAPI를 쓰는데 왜 동시성이 없었나?
FastAPI가 async 프레임워크인 건 맞지만, 그건 async 워커(uvicorn worker) 위에서 non-blocking I/O로 짰을 때 얘기다. sync 워커를 쓰거나, async 함수 안에서 블로킹 라이브러리를 호출하면 그 순간 이벤트 루프가 멈춘다. 프레임워크 이름만으로 동시성이 생기지는 않는다.
더 큰 문제는 CPU 기반 오토스케일링이 안 돈다는 것
진짜 문제는 그 다음이었다. 요청이 밀리는데 인스턴스가 안 늘어났다.
스크래핑은 전형적인 I/O bound 작업이다. 대부분의 시간을 외부 사이트 응답을 기다리며 보낸다. 기다리는 동안 CPU는 거의 안 쓴다.
1
2
3
워커 점유율 ████████████████████ 100% ← 실제로는 꽉 참
CPU 사용률 ███ 낮음 ← ASG가 보는 지표
→ 스케일 아웃 조건 미달
용량은 워커 슬롯으로 결정되는데, 지표는 CPU를 보고 있었다. 응답시간 기반 스케일링 정책도 붙어 있긴 했지만, 그건 이미 밀리기 시작한 뒤에 반응하는 거라 근본 해결이 아니었다.
그래서 실제 운영은 이랬다. 이벤트 전에 사람이 미리 Desired를 올려놓는다. 자동화된 오토스케일링 그룹이 있는데 수동 스케일링으로 돌아가고 있었다.
그래서 그냥 워커를 추가해서 처리하거나 헬스체크를 FastAPI가 아닌 다른걸로 처리한다의 길도 있었지만 이쪽방법은 정석적인 해결방법이 아니라고 생각했다.
그래서 이런 비동기 구조에서 효과적인 큐를 생각해서 제안했다.
첫 제안 — SQS 큐 + 워커 구조
요청-응답 경로와 장시간 백그라운드 작업이 같은 워커를 공유하는 게 원인이니, 그 둘을 떼어내자는 제안을 보냈다.
1
2
API 서버가 요청을 받으면 SQS에 작업을 넣고 jobId를 즉시 반환
별도의 워커가 큐에서 메시지를 가져와 실제 스크래핑 수행
장점으로 네 가지를 정리했다.
| 내용 | |
|---|---|
| 장애 격리 | 비정상 요청이 와도 워커 1대만 묶인다. 다른 워커는 큐의 다음 메시지를 계속 처리한다 |
| 헬스체크 분리 | 워커는 ALB 뒤에 있지 않으므로 작업 중에도 헬스체크가 영향받지 않는다 |
| 부하 기반 스케일링 | 큐 깊이 기반 지표로 I/O bound 부하도 정확히 반영된다 |
| 재시도 / 격리 | visibility timeout과 DLQ로 실패 메시지를 자연스럽게 재시도하고, 임계 초과 건은 격리한다 |
작업 시간 자체가 줄어드는 건 아니라는 것, 클라이언트가 동기 응답 대신 jobId 기반 폴링이나 웹훅으로 바꿔야 한다는 것도 같이 적었다.
돌아온 질문은 “그래도 똑같은 거 아닌가요?”
고객사 개발 담당자에게서 회신이 왔다. 요지는 이랬다.
이 서버에는 스크래핑 API 말고도 API 호출 등 다른 API가 꽤 많다. SQS에서 pull 해서 처리한다고 해도, 스크래핑하는 워커가 펜딩 상태에 걸리면 결국 헬스체크를 받을 FastAPI 워커가 없어서 unhealthy가 발생하지 않겠나?
읽고 나서 내 제안서를 다시 봤다. 첫 문장이 이랬다.
1
현재 ALB → gunicorn 동기 처리 구조를 SQS 큐 + 워커(Pull 방식) 구조로 전환
“전환”이라고 썼다. 서버를 통째로 갈아엎는 걸로 읽히는 게 당연했다. 그렇게 읽으면 담당자 말이 맞다. 같은 서버 안에서 pull만 하는 거라면 워커가 묶이는 건 똑같으니까.
내가 의도한 핵심은 워커가 ALB 뒤에 있지 않다는 것이었는데, 그걸 장점 목록 네 줄 중 두 번째에 한 줄로 써놨다.
그리고 담당자가 알려준 정보가 하나 더 있었다. 이 서버에는 스크래핑 말고도 API가 많다. 나는 로그와 지표만 보고 “스크래핑 서버”라고 단순하게 생각하고 있었다. 전체를 갈아엎자는 제안이 부담스러울 수밖에 없는 서버였다.
범위를 좁혀서 다시 보냈다
그래서 전체 구조 변경이 아니라 스크래핑 엔드포인트의 처리 방식만 비동기로 바꾸는 쪽으로 다시 보냈다.
1
2
3
4
5
6
7
8
현재
클라이언트 → POST /scrape → FastAPI → 스크래핑 실행 (20분) → 응답
변경 후
클라이언트 → POST /scrape → FastAPI → SQS에 넣기 → jobId 즉시 반환
↓
별도 워커가 처리 ← ALB 뒤에 없음
클라이언트 → GET /scrape/{jobId} → 결과 조회 (폴링)
바뀌는 건 두 가지뿐이다.
- 스크래핑 엔드포인트: 실행 대신 SQS에 넣고 jobId 반환
- 스크래핑 워커: 새로 만든다. SQS pull → 실행 → 결과 저장
기존 서버 구조는 그대로 둔다. 다른 API들은 손댈 게 없고, 20분짜리 작업이 사라졌으니 헬스체크도 정상이다. 워커는 ALB에 연결하지 않고 EC2나 ECS 자체 헬스체크를 쓴다 — 이번엔 이걸 장점이 아니라 구성 설명에 명시적으로 적었다.
답은 금방 왔다. 프론트가 이미 폴링 방식이라 jobId를 받아 처리하면 될 것 같다고, 고려해보겠다는 회신이었다.
같은 아이디어인데 첫 번째는 막히고 두 번째는 통과했다. 바뀐 건 범위와, 핵심 전제를 어디에 썼는지뿐이다.
큐로 바꾸면 챙겨야 하는 것들
제안 메일에는 안 썼지만, 실제로 구현한다면 밟게 될 것들을 정리해둔다. 20분짜리 작업은 큐에 올리는 순간 챙길 게 네 가지 늘어난다.
1. 작업이 끝나기 전에 다른 워커가 또 집어가지 못하게 해야함
워커가 큐에서 메시지를 가져가면, SQS는 그 메시지를 일정 시간 동안 다른 워커에게 안 보이게 숨긴다. 그 시간이 지나도 처리가 안 끝났으면 메시지가 다시 보이기 시작하고, 다른 워커가 같은 작업을 또 집어간다.
문제는 이 시간의 기본값이 30초라는 것이다. 20분짜리 작업을 그대로 올리면 30초마다 다른 워커가 같은 스크래핑을 시작한다.
1
2
3
4
0초 워커 A가 메시지 받음 → 스크래핑 시작 (20분 예정)
30초 숨김 시간 끝 → 메시지가 큐에 다시 보임
30초 워커 B가 같은 메시지 받음 → 같은 스크래핑 또 시작
60초 워커 C도...
그래서 작업에 걸리는 최대 시간보다 길게 잡아야 한다. 얼마나 걸릴지 들쭉날쭉하면, 처리하는 동안 주기적으로 시간을 연장하는 방식(heartbeat)을 쓴다.
2. 같은 작업이 두 번 돌지 않게 해야함
위 시간을 넉넉히 잡아도 중복이 완전히 사라지지는 않는다. SQS 표준 큐는 “최소 한 번은 전달한다”는 보장이지 “딱 한 번만”이 아니다.
그래서 두 번 실행돼도 결과가 망가지지 않게 워커를 짜야 한다. jobId를 키로 “이미 처리한 작업이면 그냥 건너뛴다”를 넣어두는 식이다. 스크래핑이면 결과가 두 번 저장되거나, 외부 사이트에 같은 요청을 두 번 날리는 걸 막는 게 목적이다.
3. 작업 도중에 서버가 꺼지지 않아야함
ASG 설정으로 작업중인 서버가 꺼지지 않게 작업 유무에 따라 스케일 인 안되게 설정이 필요하다.
4. 계속 실패하는 작업은 따로 빼놓기
실패한 메시지는 큐로 돌아가서 다시 처리된다. 그런데 애초에 처리가 불가능한 메시지(잘못된 요청, 사라진 대상 사이트 등)는 돌아가고 또 실패하고를 무한 반복하면서 워커를 계속 잡아먹는다.
그래서 “몇 번 실패하면 별도 큐로 치운다”는 설정을 걸어둔다. 이 별도 큐를 DLQ(Dead-Letter Queue)라고 한다. 정상 처리를 방해하지 않게 치워두는 게 1차 목적이고, 나중에 “왜 실패했나”를 들여다볼 거리가 한곳에 모이는 게 2차 효과다.
따라서 3번 시도해서 실패하면 데드 큐로 보낸다.
내가 모르는 것도 같이 적었다
제안 메일 마지막에 유의사항을 붙였다. 애플리케이션 로직을 모르니 실제 동작은 다를 수 있다.
위 제안은 어디까지나 인프라/구조적인 관점에서만 분석한 내용입니다. 헬스체크 장애 로그와 부하 패턴, 스케일링 동작만을 근거로 “구조적으로 이런 방향이 적합해 보인다”고 말씀드린 것이며, 실제 서비스의 내부 로직, 비즈니스 요구사항, 클라이언트 호출 패턴, 트랜잭션 처리 방식 등은 확인하지 못한 상태입니다.
따라서 로그와 지표와 AWS 리소스 구성만 보고 판단한다. 그래서 “이렇게 하면 됩니다”라고 쓰면 안 되는 자리가 생긴다. 내가 본 근거가 무엇이고 못 본 게 무엇인지를 같이 적어야, 받는 쪽이 자기가 아는 정보와 합쳐서 판단할 수 있다.
실제로 이번에 돌아온 반박이 정확히 그거였다. “이 서버엔 다른 API도 많다”는 건 내가 알 수 없는 정보였고, 그 정보가 들어오면서 제안이 더 나은 모양으로 바뀌었다.
그리고 솔직히 말하면 — 이 건은 제안까지가 내 몫이었다. 구현은 고객사 개발팀이 판단할 일이고, 내가 아는 건 “고려해보겠다”는 회신까지다. 결론이 안 난 글을 쓰는 게 좀 찜찜했는데, 결론보다 과정에 배울 게 있었으니 그냥 남긴다.
정리
- sync 워커 모델에서는 요청 하나가 워커 하나를 통째로 점유한다. 20분짜리 작업과 헬스체크가 같은 워커 풀을 쓰면 헬스체크가 밀려서 멀쩡한 인스턴스가 unhealthy로 빠진다
- FastAPI 같은 async 프레임워크를 써도 sync 워커나 블로킹 호출이면 동시성은 없다. 프레임워크 이름으로 생기는 게 아니다
- I/O bound 작업에 CPU 기반 오토스케일링은 안 맞는다. 용량은 워커 슬롯이 정하는데 지표는 CPU를 보고 있으면, 요청이 밀려도 인스턴스가 안 늘어난다. 결국 사람이 Desired를 올리는 수동 스케일링이 된다
- 큐로 옮기면 챙길 게 네 가지 생긴다 — ① 작업 시간보다 메시지 숨김 시간을 길게(기본 30초라 20분 작업은 그냥 두면 중복 실행), ② 두 번 돌아도 괜찮게 짜기(최소 한 번 전달 보장이라 중복이 0이 되진 않음), ③ 작업 중인 서버가 스케일 인으로 꺼지지 않게, ④ 계속 실패하는 메시지는 별도 큐로 치우기
- 그리고 설계 얘기 하나. 맞는 설계보다 전달되는 설계가 먼저다. “구조를 전환하자”는 통째로 갈아엎자는 말로 읽혔고, 핵심 전제인 “워커는 ALB 뒤에 없다”는 장점 목록에 묻혀 있었다. 범위를 좁히고 전제를 앞으로 꺼내니 같은 제안이 통과됐다
- 남의 시스템을 제안할 때는 내가 못 본 것도 같이 적는다. 그 빈칸을 상대가 채워주면서 제안이 더 나아진다
참고
- Scaling policy based on Amazon SQS - Amazon EC2 Auto Scaling
- Create a target tracking scaling policy using metric math
- Amazon SQS visibility timeout
- Amazon SQS dead-letter queues
- Design your applications to gracefully handle instance termination
- Target tracking scaling policies for Amazon EC2 Auto Scaling
