Redis P99가 갑자기 400ms 튄 날 - 범인은 THP였다
지난주 화요일 오후 3시쯤이었다. 우리 팀 결제 서비스 앞단에 붙어있는 Redis 클러스터에서 P99 latency가 갑자기 400ms를 넘겼다는 알림이 왔다. 평소엔 2ms 아래로 놀고 있던 애가. 슬랙에 알림이 뜬 순간 등에 소름이 돋았다.
RDB 스냅샷을 저장하려고 fork()가 돈 시점과 정확히 겹쳤다. 처음엔 그냥 "아, 세이브 중이니까 잠깐 튄 거겠지" 했는데, 그 이후로도 4시간 간격으로 계속 같은 패턴이 반복됐다. 백엔드 팀에서 결제 타임아웃이 늘었다고 항의가 들어오기 시작했다.
처음엔 엉뚱한 데를 팠다
솔직히 처음엔 네트워크를 의심했다. 노드 간 latency, VPC 피어링, ENI 대역폭. 근데 아무리 CloudWatch를 뒤져봐도 뭔가 튀는 게 없었다. 그다음엔 slow log를 봤다. KEYS * 같은 이상한 쿼리가 있나 싶어서. 없었다. 클라이언트 커넥션 수도 평시랑 똑같았다.
한 시간쯤 헤매다가 redis-cli --latency-history 붙여놓고 지켜봤다. 4시간 주기로 튀는 게 확실했다. 근데 이 주기가 뭐랑 맞을까 하고 서버 프로세스를 훑다가 눈에 들어온 게 BGSAVE 로그였다.
fork()와 THP의 조합
BGSAVE는 fork()로 자식 프로세스를 만들어서 스냅샷을 뜬다. 그리고 부모/자식이 메모리를 공유하다가 어느 쪽이든 페이지에 write하면 커널이 그 페이지를 복사해준다. 그 유명한 copy-on-write.
문제는 이 페이지가 4KB짜리 일반 페이지가 아니라 2MB짜리 huge page라는 것. THP(Transparent HugePages)가 켜져 있으면 커널이 알아서 anonymous 메모리를 2MB huge page로 승격시킨다. 그 상태에서 fork 이후에 write가 발생하면? 2MB짜리 페이지를 통째로 복사해야 한다. 원래 몇 마이크로초에 끝날 일이 몇 밀리초씩 늘어난다. 그게 20GB짜리 인스턴스에서 수천 번 반복되면 latency spike가 된다.
Redis 공식 문서도 "Transparent Huge Pages support enabled" 경고를 시작할 때 뿌린다. 우리 인프라 팀이 AMI를 새로 굽는 과정에서 THP 비활성화 스크립트가 어디선가 빠졌던 거였다. 확인해보니 /sys/kernel/mm/transparent_hugepage/enabled 값이 always로 설정돼 있었다.
대응은 간단했지만
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
이렇게 두 줄이면 즉시 꺼진다. 근데 재부팅하면 되돌아가니까 systemd unit으로 박아놨다.
[Unit]
Description=Disable Transparent Huge Pages
DefaultDependencies=no
After=sysinit.target local-fs.target
Before=basic.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled && echo never > /sys/kernel/mm/transparent_hugepage/defrag"
[Install]
WantedBy=basic.target
바로 적용하고 나서 다음 BGSAVE 사이클을 지켜봤는데 P99가 3ms를 넘지 않았다. 4시간 후에도, 8시간 후에도 조용했다. 그 순간 팀 슬랙에 "해결"이라고 올리면서 안도의 한숨을 쉬었다.
그런데 2026년에 THP를 무조건 꺼야 하나
이 사건 정리하면서 최근 자료를 좀 뒤져봤는데, 요즘은 무조건 never가 정답은 아니라는 얘기가 많더라. Ubuntu, RHEL 최신 배포판의 기본값은 madvise인데, 이건 프로그램이 명시적으로 madvise(MADV_HUGEPAGE)를 호출한 영역에만 huge page를 붙여주는 모드다. 일반 워크로드는 THP 혜택을 그대로 받고, 알아서 요청 안 하는 애들(Redis 같은)은 영향 없다.
문제는 우리 AMI가 어느 시점부턴가 always로 세팅돼 있었다는 것. 만약 madvise였다면 애초에 이 사건이 안 났을 확률이 높다.
그래서 결론은:
- Redis, MongoDB, PostgreSQL처럼 fork() 자주 도는 DB 서버는 여전히
never가 안전빵 - 일반 애플리케이션 서버라면
madvise가 합리적 always는 아무튼 지뢰다. 이번에 확인해서 IaC 쪽 base AMI 빌더에 assertion 걸어놨다
남는 질문
한 가지 찜찜한 건, 왜 THP가 켜져 있던 상태로 몇 주가 지나서야 이런 문제가 터졌는지다. dataset이 커지면서 huge page 승격이 임계점을 넘은 건지, 아니면 클라이언트 트래픽 패턴이 바뀐 건지. 이건 좀 더 파봐야 할 것 같다.
혹시 비슷한 케이스 겪어보신 분 계시면 어떻게 진단하셨는지 댓글 달아주세요. 특히 madvise 모드로 옮기고 나서 관측한 지표가 있으면 궁금합니다.