배포한 적도 없고, 에러 로그도 없는데 서비스는 N일마다 한 번씩 조용히 재시작되고 있었다.
처음엔 그냥 넘길 뻔했다. ECS 콘솔에 뜨는 재시작 정도는 발생할 수 있으니까. 그런데 이상한 점이 하나둘 늘어났다.
- 배포 이력 없음
- 에러 로그 없음
- OutOfMemoryError 없음
- HeapDumpOnOutOfMemoryError 옵션을 켜뒀는데도 OOM이었다면 남아있어야 할 힙 덤프가 없음
몇 개의 서비스가 죽을 이유가 보이지 않는데 N일에 한 번씩 재시작되고 있었다.

테스크가 재시작 직후엔 50% 근처에서 시작해, N일에 걸쳐 서서히 99.8%까지 올라간다. 이런 메모리 사용 수치를 톱니 모양이라고 하더라.
메모리 누수처럼 보였지만 힙 덤프가 하나도 없다는 점이 이상했다. JVM이 정말 OutOfMemoryError를 던졌다면 로그 한 줄, 덤프 파일 하나쯤은 남아 있어야 정상이다. 아무것도 없다는 건 JVM이 스스로 죽은 게 아니라, JVM 바깥의 무언가가 강제로 죽였다는 뜻이었다.
즉, Fargate 자신이 SIGKILL을 날리고 있었다는 얘기다.
참고로 당시 이 서비스가 쓰고 있던 JVM 옵션 중, 이번 문제와 직접 연관된 것들만 발췌 해오자면 다음과 같다.
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=60.0 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof \
-XX:+ExitOnOutOfMemoryError
근본 원인

결국 이 문제를 잡으려면 리눅스 시스템 레벨까지 내려가야 했다. 원인은 AWS가 컨테이너를 만드는 방식 자체에 있었다.
Fargate에서 태스크를 띄우면, 컨테이너는 우리가 지정한 크기(예: 2GB)로 딱 맞게 만들어질 거라 생각하기 쉽다. 하지만 실제로는 그 컨테이너를 담는 물리적인 microVM이 태스크 크기보다 더 크게 뜬다. 예를 들어 2GB짜리 태스크가 플랫폼 오버헤드까지 포함해서 실제로는 3.8GB짜리 호스트 위에서 돌아가는 식이다.
문제가 발생하는 순서를 정리하면 다음과 같다.
1. 메모리 한도는 컨테이너가 아니라 태스크 레벨에서 걸린다.
Fargate에서 실제로 OOM-kill이 발동하는 경계는 컨테이너의 cgroup이 아니라, 한 단계 위인 태스크 레벨이다. 정작 컨테이너 자신의 cgroup memory.limit_in_bytes를 열어보면 무제한(9223372036854771712)으로 찍혀 있었다. 컨테이너 입장에서는 "나는 한도가 없다"고 알고 있는 것이다.
2. JVM은 자기 cgroup만 보고, 무제한이라면 호스트 전체를 본다.
JVM의 UseContainerSupport 옵션은 자기 컨테이너의 cgroup 하드 리밋만 확인한다. 그런데 그 값이 무제한이니, JVM은 "컨테이너 한도를 못 찾았다"고 판단하고 /proc/meminfo의 MemTotal, 즉 호스트 전체 메모리(예시 기준 3.8GB)로 폴백해버린다.
3. 그 결과, 힙 상한이 실제 한도보다 커진다.
예를 들어 - MaxRAMPercentage=60%로 설정 해뒀다면, 호스트 메모리 3.8GB를 기준으로 계산한 MaxHeapSize는 2294MB가 된다. 문제는 태스크 자체의 실제 한도가 2048MB라는 점이다. JVM은 자신이 2294MB까지 써도 되는 줄 알지만, 실제로는 2048MB를 넘는 순간 이미 태스크의 메모리 상한에 걸리는 것이다.
4. 그래서 JVM이 스스로 멈추기 전에, Fargate가 먼저 죽인다.
서비스의 실제 메모리 사용량이 2GB를 넘어서면, JVM은 아직 "나는 힙 상한(2294MB)까지 여유가 있다"고 착각한 채로 계속 메모리를 쓴다. 그러다 Fargate가 태스크 메모리 한도 초과를 감지하고 그대로 SIGKILL을 날린다.
5. SIGKILL은 커널이 강제로 보낸 것이라, JVM은 죽는 순간조차 아무것도 남기지 못한다.
OutOfMemoryError도, HeapDumpOnOutOfMemoryError도, ExitOnOutOfMemoryError 옵션도 전부 JVM이 "내가 죽는다"는 걸 인지하고 처리할 시간이 있어야 발동한다. 하지만 커널 SIGKILL은 그 여유를 주지 않는다. 그래서 에러 로그도, 힙 덤프도 없이 조용히 재시작만 반복되는 상황이었다.
해결책: JVM에게 진짜 한도를 알려주기
핵심은 컨테이너의 하드 리밋을 태스크 메모리와 동일하게 맞추는 것이다.

AWS Task 정의를 할 때 containerDefinitions.memory 값, 즉 리소스 할당 제한(메모리 하드 제한)이라는 설정이 있다. 이 값을 태스크 메모리 크기 그대로 지정하면, 이 값이 컨테이너 cgroup에 그대로 반영된다. 그러면 UseContainerSupport가 더 이상 한도 없음으로 착각해 호스트로 폴백하는 일이 없어지고, JVM은 진짜 한도를 읽게 되는 것이다.

이렇게 수정하고 며칠 모니터링을 해보니 메모리를 안정적으로 사용하는 것을 확인할 수 있었다.
마치며
물론 이 설정 하나로 또 다른 메모리 문제가 해결되는 건 아닐 것이다. 다만 이 경우 컨테이너와 cgroup, JVM 옵션까지 보아야 비로소 원인이 보이는 문제였다. 그래서 해결하고 나니 무척 뿌듯했다.
현재 이 옵션을 적용하지 않은 서비스가 꽤 남아 있어서, 모니터링하면서 하나씩 서비스에 맞는 옵션을 찾아나갈 계획이다.
이 문제를 들여다보면서 JVM 자체에도 관심이 생겨 요즘은 《JVM 밑바닥까지 파헤치기》 책도 읽기 시작했다. 아직은 꽤 어렵지만, 그 나름대로 재미있다. 앞으로도 이런 깊이 있는 공부를 계속하면서 좋은 백엔드 개발자로 성장해나가고 싶다.
'알아두면 좋은 개발 지식 > Java & Spring' 카테고리의 다른 글
| Bucket4j를 이용해 API 요청 제한 정책 만들기 (1) | 2025.02.05 |
|---|---|
| 양방향 엔티티의 재귀 호출 문제와 해결 방법 (1) | 2025.01.05 |
| 선착순 쿠폰 발급 시스템 구현하기: Redis와 Kafka를 활용한 설계 (4) | 2024.09.26 |
| Spring에서 동시성 이슈 해결 방법(MySQL, Redis 이용하기) (2) | 2024.09.25 |
| [Spring] Spring Security '인증' 과정 (1) | 2024.09.13 |