문제 상황
서버가 idle 상태를 유지하다가 요청을 받으면 응답까지 시간이 평소보다 훨씬 오래걸린다.
가설 세우기
쓰레드 할당 및 새 스택 생성에서 병목이 발생한다?
Spring Boot의 min-spare-threads 설정은 10이다.
요청은 단독으로 일어났으므로 쓰레드 할당의 문제는 아닌 것으로 보인다.
JIT 컴파일러의 특성인가?
Lazy Load
JVM은 클래스를 필요한 시점에 로드한다.
이걸 안쓰면 다시 내려서 문제가 생기는건가?
→ 아니었다. 언로드 되는 클래스가 없다.
동적 컴파일
JIT 컴파일러는 동적으로 컴파일을 진행한다.
보통 더 많이, 더 자주 쓰이는 코드를 네이티브 코드로 컴파일해둔다.
이것을 Hotspot이라고 하는데, 이걸 제거하기도 하나?
클래스 로드와 마찬가지로 컴파일한 코드를 굳이 제거하지는 않을거라고 추측된다.
→ 실제 프로파일링 해본 결과 줄어드는 경우는 확인하지 못했다.
GC가 문제인가?
Young Gen Max Size, Young Gen Min Size가 1.3 MiB다.
수치가 좀 이상하다. 더 살펴보자
JVM flags 살펴보기
디깅룸 JVM flags
스낵게임 JVM flags
VisualVM으로 G1 Eden Space, Survivor Space등을 확인했는데, max 가 -1(무제한) 이었다.
데이터독이 이상한 것 같고, 실제 설정은 문제없었다.
GC 끄기
EpsilonGC는 no-op GC다. 공갈GC
다음 옵션을 넣고 자바를 켜보자.
-XX:+UnlockExperimentalVMOptions
-XX:+UseEpsilonGC
GC가 확실히 동작하지 않는다.
문제는 사라졌을까?
문제 또한 사라지지 않았다.
따라서 GC의 문제도 아니었다.
웜업으로 해결해볼까?
- 주기적으로 ping을 보내 메서드를 호출한다
- 웜업 메서드를 통해 웜업 프로세스 자체를 만들고, 어플리케이션 시작 시 호출한다
해결은 되겠지만… 임시 방편 아닐까?
콜드 스타트는 개선될 듯. 하지만 이것도 마찬가지로 임시 방편이다.
DB 커넥션 풀 문제인가?
스낵게임에서는 현재 HikariCP를 사용하고 있다.
앞서 지연 시간이 길었던 요청들에서 ‘소켓 관련 메서드’가 보였고, 그 시점이 DB 조회 시점이었기 때문에 살펴보기로 했다.
커넥션 풀이 똑바로 안하고 중간에 커넥션을 해제하지는 않는지가 가장 의심이 됐다.
결과적으로 얘기하면 아니었다. 기본값에 따르면, 풀 사이즈는 10으로 고정되어 있다.
실제로 TCP 연결이 되어있는 것도 확인했다.
잘 열려있다.
MySQL이 일정 시간 후에 커넥션을 놓고 있는 것은 아닐까?
기본값은 28,800 seconds(= 8시간) 이며, 서버에도 이렇게 설정되어있다.
따라서 이 문제는 아니다.
MySQL 문제?
우리 디깅룸 팀 CTO, 파워가 DB 캐싱문제를 제시했다.
나는 이건 아니라고 생각했는데, DB 쿼리 시간은 총 요청시간의 극히 일부만 차지하기 때문이다.
한 시간 쯤 쉬면서 밥도 먹은 후 쿼리를 다시 날려봤더니, 지연이 느껴졌다.
이 지연은 오랜만에 요청을 날렸을 때의 그것 만큼 차이가 났다. (체감상)
여기서부터 나도 DB를 의심하기 시작했다.
쿼리 캐싱은 MySQL 8.0부터 아예 제거되었기 때문에 배제했고, 무언가 캐시가 있을 것이라고 판단했다.
그리고 InnoDB의 ‘버퍼 풀’이 라는 놈이 바로 이 캐시 역할을 하고 있다는 것을 알게 되었다.
InnoDB 스토리지 엔진에서 가장 핵심적인 부분으로, 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐시해 두는 공간이다.
쓰기 작업을 지연시켜 일괄 작업으로 처리할 수 있게 해주는 버퍼 역할도 같이 한다. 일반적인 어플리케이션에서는 INSERT, UPDATE, DELETE 처럼 데이터를 변경하는 쿼리는 데이터 파일의 이곳저곳에 위치한 레코드를 변경하기 때문에 랜덤한 디스크 작업을 발생시킨다.
하지만 버퍼 풀이 이러한 변경된 데이터를 모아서 처리하면 랜덤한 디스크 작업의 횟수를 줄일 수 있다.
— Real MySQL 8.0 - 4.2.7장
곧 서버 사양에 비해 이 버퍼 풀의 사이즈가 꽤 작다는 것도 발견할 수 있었다.
우선 버퍼 풀 크기를 늘려보자.
직접 늘릴 수도 있지만, MySQL 8.0에서 새로 도입된 innodb_dedicated_server 를 사용해 적절한 크기를 할당하는지 관찰해보고, 쓸만한지도 평가해보자.
내 판단이지만, 버퍼 풀은 InnoDB 효율성의 핵심이기 때문에 8GB는 할당해줘야 한다고 생각한다.
이제 실제로 테스트를 해봐야한다.
기존 설정
서버 재부팅 후 버퍼 풀 캐시 확인
1회 조회 후 캐시 확인
설정 변경
버퍼 풀 크기 변경, 시작 시 덤프되었던 버퍼 풀 로드
켜자 마자 버퍼 풀 확인
조회 후 확인
캐싱이 유효하다. 그럼 레이턴시도 해결되었을까?
인정하기 싫지만 레이턴시가 해결되었다고 판단하기는 어렵다.
환경 문제?
이쯤 되면 다 상관없는 것 같고, 그냥 환경의 문제인 것 같았다.
마이그레이션
기존에 사용하던 서버는 스낵게임 외에도 많은 서비스들을 돌리고 있어, 영향이 있을거라는 의심이 들었다.
아예 새로운 환경에서 인스턴스를 두 개 할당했다.
같은 성능의 서버를 각각 한쪽에는 어플리케이션, 한쪽에는 DB를 올렸다. (mysqldump로 옮겼다)
레이턴시가 엄청 튀는 증상은 좀 완화된 것으로 보인다.
20ms ↔300~400ms 이던게, 20ms ↔ 100~200ms 정도로 줄었다.
하지만 완화가 되었을 뿐, 증상이 해결되지는 않았다.
다만 요청이 들어왔을 때 CPU를 쪼개 사용하는데서 발생할 수 있는 지연은 해결됐다.
가상 환경의 특성
현재 가장 유력한 용의자다.
스낵게임은 현재 가상 환경에서 서비스 중인데, 온갖 최적화와 실험에도 원인을 찾기 어려웠던 것은 바로 이것 때문일 확률이 높다.
확인할 수는 없지만, 가설을 한 번 세워보자.
가상 환경은 한 물리 머신에 여러 인스턴스들을 운용한다.
이 머신의 물리 자원은 한정되어 있으므로 이를 잘 활용해야 한다.
기업 입장에서 한정된 자원을 최대한 활용해 이득을 취하려면 어떻게 해야 할까?
CPU 스케쥴링
자원을 거의 활용하지 않는 VM 인스턴스를 ‘쉬고 있다’(유휴)고 여기고, 자원 배분 대상에서 우선순위를 낮춰보자.
서버 머신의 스케쥴러는 쉬고 있는 인스턴스를 거의 신경쓰지 않고 자원을 배분할 수 있을 것이다.
그럼 쉬고 있는 인스턴스는 머신 성능에 거의 영향이 없게 되고, 한 머신에 더 많은 VM 인스턴스를 할당해도 일정 이상의 성능을 보장할 수 있다.
메모리
물리 메모리도 한정되어 있다.
사용자 당 24기가씩의 메모리를 주고 있는데, 4명만 사용해도 100GB에 육박한다.
1TB의 메모리를 가진 머신이라고 해도, 40명정도밖에 사용할 수 없다.
또, 개인들이 과연 24기가를 모두 사용할까?
이럴 때 사용하기 좋은 것이 바로 스왑이다.
오라클 클라우드에서는 스왑을 적극적으로 활용하고 있을 것이다.
단점
기업이 이득을 볼 수 있는 것은 이해하지만 이런 구조에는 치명적인 문제가 있다.
바로 유휴 상태의 인스턴스가 자원을 필요로 할 때 반응이 느리다는 것이다.
이건 스케쥴링 우선순위가 낮아 자원 할당까지 시간이 좀 걸리기 때문이다.
CPU도 스케쥴링 우선순위가 낮고, 메모리 적재에도 시간이 걸린다.
따라서 웹 서버의 경우에는 첫 요청이 느린 문제가 발생하는 것이다.
이러면 어떤 고객이 이 서비스를 사용하겠는가?
라는 의문이 든다면 아주 정상이다.
하지만 무료라면? 이 상황들이 바로 납득이 간다.
인당 4코어, 24기가를 평생 무료로 제공해버리면 오라클 클라우드는 무엇으로 먹고 살겠는가?
그래서 Oracle Cloud는 위의 방식으로 손해를 줄이고 있을 것이라고 추측해본다.
실제로 이 무료 인스턴스 할당은 인기가 아주 높다.스크립트까지 만들어서 하는 사람도 있다.
유료 고객에게는 이 VM 인스턴스를 즉시 할당해주는 것으로 미루어보아, ‘우선순위’를 정해뒀다고 추측해본다.
검증
검증을 해보자. 오라클 → AWS로 마이그레이션을 해보면 어떨까?
그래도 AWS는 Free-tier가 1년 뿐이고, 지불 후 사용하는 사람이 많아 오라클 클라우드처럼 운영하지는 않을 것이다.
오라클 vm.standard.a1.flex(2코어, 12GB) → AWS EC2 t4g.micro(2코어, 1GB)로 옮겨보았다.
사양은 오히려 다운그레이드인데, 문제가 해결되었을까?
References
결론
오라클 클라우드가 수많은 무료 인스턴스를 소화하기 위해 ‘어떤 정책’을 사용하고 있었다.
덕분에 진단하기 어려운 문제로 골머리를 썩었다.
공짜 너무 좋아하지 말고, 한번 의심을 해보기로 하자.
또한, 문제를 진단할 때는 상위부터 변인을 하나씩 통제해 나가는 방식을 취하자.
그리고 VM 환경을 사용할 때는 물리 환경의 영향이 있을 수 있다는 것을 꼭 기억하자.
이번 문제는 AWS 마이그레이션 먼저 했다면 빠르게 알게 되었을 내용인데, 시간 낭비가 많아서 아쉬웠다.
References
JVM 관련
- JVM Warm up - if(kakao)2022
- 이전 부하 테스트를 하던 중 발견했던 후디 블로그
- JVM Warm-Up으로 해결 본 사례
- VisualVM 사용 가이드 👍