최근에 사내 서버 자원량 감축을 위해 개발 서버의 컴퓨팅 자원을 낮추기로 결정했는데요. 낮은 자원에서 많은 컨테이너를 띄우다보니 Thread Starvation 현상이 나타났어요. 한정된 자원에서 최선의 효율을 뽑기위해 아래와 같은 작업을 진행했습니다.
Thread Starvation 쓰레드 기아 상태
위키피디아에 따르면 기아 상태는 프로세스가 끊임없이 필요한 컴퓨팅 자원을 가져오지 못하는 상황으로, 이러한 자원 없이는 처리를 끝낼 수 없는 병행 컴퓨터에서 마주치는 문제에요. 기아 상태는 스케줄링이나 상호 배제 알고리즘의 오류에 기인하지만 자원 누수에 의해 일어날 수도 있으며 포크 폭탄과 서비스 거부 공격을 통해 고의적으로 발생할 수도 있어요.
ec2.small 2코어 2G 렘을 사용하면서 여러 서버들과 맞물려있는 서비스를 11개의 컨테이너를 띄어 자원을 할당받은 상태였어요. 추가적인 모듈 개발을 위해 컨테이너를 한개 더 띄우게 됬는데 이 때 문제가 발생하기 시작했어요. 서버가 적은 요청에도 불구하고 주기적으로 죽고 재부팅이 되는 현상을 발견했는데, 서버 로그에서 하래와 같은 warn이 발생했어요.
HikariPool-1 - Thread starvation or clock leap detected (housekeeper delta=4m37s...)
해당 메시지는 HikariCP 내부의 housekeeper 스레드가 주기적으로 실행되어야 할 타이밍보다 훨씬 늦게 실행됐을 때 발생하는데요. 이 Warn 로그가 찍히고 서버가 죽었어요. 그래서 원인을 분석하기 시작했습니다.
해당 에러가 발생한 이유들을 생각해봤는데요.
- GC(Garbage Collector)가 Minor GC, Major GC가 동작할 때, STW(Stop The World)를 일으켜 Runtime 환경을 일시적으로 중지시키는데 이 STW가 길어질 때
- JVM에 worker thread가 I/O, blocking, lock 경쟁 등의 이유로 너무 많을 때
- 하나의 코어에 작업이 몰릴 때 (주로 컨테이너 환경에 발생할 수 있음 )
- Clock Leap(시계 점프) 시스템 시계가 갑자기 크게 변경된 경우
해당 원인들을 분석하면서 낮은 컴퓨팅 자원 위에 Docker 컨테이너를 여러 개를 올려 CPU/메모리를 과하게 공유하면서, JVM의 GC, 스레드 실행, 하트비트, DB Connection 같은 백그라운드 작업이 기회를 못 받고 밀리는 상황으로 생각했어요. 그래서 한정된 자원 속에서 어떻게 서버를 낮은 자원으로 효율적으로 돌릴 수 있을까 고민하면서 아래와 같은 방법대로 순차적으로 진행해봤어요.
Swap Memory
Swap 메모리란? 🤔
- RAM 이 부족할 때 디스크 공간의 일부를 메모리처럼 사용하는 가상 메모리 영역
- Docker 나 Kubernetes에서 기본적으로 swap이 비활성화되어 있는 경우가 많다
설정하면 뭐가 좋을까? 💡
JVM 메모리가 부족할 때 OutOfMemoryError로 터지기보다, 느려지더라도 죽지 않고 버티는 쪽이 개발 환경에서 유리할 수 있다고 생각했어요.
# 1GB swap 생성
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# /etc/fstab 에 자동 mount 등록
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# docker run 실행 시 --memory-swap 사용
docker run -d --memory=512m --memory-swap=1g my-app-name
swap 메모리로 가상 메모리를 만들어줬지만 역시나 상황은 동일했어요. 그래서 무겁다고 생각되는 자바 애플리케이션 GC와 JVM 튜닝을 진행했습니다.
GC Tuning
JVM의 GC가 자주 발생하고, Full GC가 발생할 경우 STW로 인해 전체 스레드가 멈추기 때문에 이로 인해 thread starvation으로 이어질 수 있다고 생각했어요.
- Minor GC만 자주 발생하게 유도 (Full GC 회피)
- STW 시간이 짧은 GC 알고리즘 사용
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:+UseStringDeduplication
JVM Thread/Memory Limit
도커 환경에서 JVM이 사용 가능한 메모리/스레드를 정확히 모르고 실행될 수 있으므로 도커 리소스 제한이 JVM에 정확히 전달되도록 -XX:MaxRAMPercentage 와 -XX:ActiveProcessorCount 설정을 명시해야 해요.
# 메모리의 최대 75%만 JVM이 사용하게
-XX:MaxRAMPercentage=75.0
# CPU 코어 수 명시 (예: 1개만 사용)
-XX:ActiveProcessorCount=1
필요 없는 스레드를 줄이기 위해 ExecutorService, @Async, Schedulers 등의 ThreadPool 설정을 명시적으로 제한해 보는 방법도 찾아봤습니다.
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("MyApp-");
executor.initialize();
return executor;
}
이렇게 설정하고 서버 상태는 안정적이 되었어요. 이렇게 낮은 자원에서 주기적으로 죽는 서버를 기술적으로 살렸기에 100% 개선했다고 볼 수 있지 않을까요?
추가로 이런 환경에서는 Native Memory Tracking (NMT)을 통해 JVM 내부 자원 사용량을 추적해보는 것도 추천할 수 있어요.
-XX:NativeMemoryTracking=summary 옵션을 넣고 jcmd로 확인할 수 있으니, 혹시 모를 native 영역의 메모리 사용도 추적해보는 데 도움이 돼요. 또한, G1GC 외에 실험적으로 ZGC, Shenandoah 같은 낮은 latency를 보장하는 GC들도 Java 11 이상 환경이라면 고려해볼 수 있어요.
'OS' 카테고리의 다른 글
| [운영체제] 컴퓨터와 메모리 (1) | 2024.01.06 |
|---|---|
| [Github/MacOS] Authentication Token 발급 및 적용 (0) | 2023.10.10 |