JVM Garbage Collection Tuning
Spring boot + Kotlin GC 튜닝
Spring Boot + Kotlin을 기반으로 GC 튜닝을 실습하고 모니터링을 통해서 아웃풋이 어떻게 변화되는지 모니터링 해보자.
우선 스프링부트 사용시 가장 일반적으로 많이 사용될 스택과 코드 구성을 구성했다.
- Backend: Spring Boot + Kotlin
- Database: MySQL, Redis
- Monitoring: Micrometer + Prometheus + Grafana
- Containerization: Docker
Service
일반적으로 많이 사용되고 간단하게 구성을 했다.
@Service
class OrderService(
private val productRepository: ProductRepository,
private val orderRepository: OrderRepository,
) {
@CacheEvict(value = ["orders"])
@Transactional
fun createOrder(): Order {
val product = productRepository.findAll().random()
val quantity = Random.nextInt(1, 10)
val order = OrderEntity(
orderNumber = Instant.now().toEpochMilli().toString(),
amount = product.price * quantity.toBigDecimal(),
quantity = quantity,
createdAt = Instant.now(),
product = product
)
return orderRepository.save(order).let {
Order.valueOf(it)
}
}
@Cacheable(value = ["orders"])
@Transactional(readOnly = true)
fun getOrders(pageRequest: PageRequest): List<Order> {
return orderRepository.findAll(pageRequest).let { paged ->
paged.content.map { Order.valueOf(it) }
}
}
@Transactional(readOnly = true)
fun getOrdersRealTime(pageRequest: PageRequest): List<Order> {
return orderRepository.findAll(pageRequest).let { paged ->
paged.content.map { Order.valueOf(it) }
}
}
@Cacheable(value = ["orders"], key = "#orderId")
@Transactional(readOnly = true)
fun getOrder(orderId: Int): Order {
val order = orderRepository.findByIdOrNull(orderId)
?: throw EntityNotFoundException("Order not found with id:$orderId")
return Order.valueOf(order)
}
@CachePut(value = ["orders"], key = "#orderId")
@Transactional
fun completeOrder(orderId: Int): Order {
val order = orderRepository.findByIdWithLock(orderId)
?: throw EntityNotFoundException("Order not found with id:$orderId")
order.complete()
return Order.valueOf(order)
}
}
Dockerfile, Docker compose
Dockerfile.serial, Dockerfile.g1gc, ... 으로 실행 환경 옵션별로 Dockerfile을 생성 하였고 이미지 빌드 및 푸시를 한 후 환경별 이미지를 docker-compose.yaml 파일에 선언을 하였다.
FROM amazoncorretto:21.0.7 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew clean build -x test
###################################
FROM amazoncorretto:21.0.7
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_TOOL_OPTIONS="-XX:+UseG1GC" # 환경별 옵션
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
version: "3.8"
name: about-gc-tuning
services:
agt-mysql8:
image: mysql:8.4
container_name: agt-mysql8
ports:
- "10010:3306"
environment:
MYSQL_ROOT_PASSWORD: rootroot
MYSQL_DATABASE: tuning
MYSQL_USER: user
MYSQL_PASSWORD: passw0rd
deploy:
resources:
limits:
cpus: '0.5'
memory: 1G
agt-redis:
image: redis:latest
container_name: agt-redis
ports:
- "10011:6379"
deploy:
resources:
limits:
cpus: '0.5'
memory: 1G
agt-app-serial:
image: girr311/agt-app:serial
container_name: agt-app-serial
ports:
- "10012:8080"
depends_on:
- agt-mysql8
- agt-redis
deploy:
resources:
limits:
cpus: '1'
memory: 2G
agt-app-g1gc:
image: girr311/agt-app:g1gc
container_name: agt-app-g1gc
ports:
- "10013:8080"
depends_on:
- agt-mysql8
- agt-redis
deploy:
resources:
limits:
cpus: '1'
memory: 2G
agt-app-zgc:
image: girr311/agt-app:zgc
container_name: agt-app-zgc
ports:
- "10014:8080"
depends_on:
- agt-mysql8
- agt-redis
deploy:
resources:
limits:
cpus: '1'
memory: 2G
agt-prometheus:
image: prom/prometheus:latest
container_name: agt-prometheus
ports:
- "10020:9090"
volumes:
- .docker/prometheus:/etc/prometheus/
command:
- '--config.file=/etc/prometheus/prometheus.yml'
deploy:
resources:
limits:
cpus: '0.5'
memory: 500M
agt-grafana:
image: grafana/grafana:latest
container_name: agt-grafana
ports:
- "10030:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
depends_on:
- agt-prometheus
deploy:
resources:
limits:
cpus: '0.5'
memory: 500M
Locust
부하 테스트 설정은 다음처럼 설정 하였다.
import collections
import random
from dataclasses import dataclass, field
from locust import FastHttpUser, between, task
@dataclass(frozen=True)
class Config:
@dataclass(frozen=True)
class TaskConfig:
endpoint: str
weight: int
create_order: TaskConfig = field(
default=TaskConfig(endpoint="/orders", weight=5),
)
get_orders: TaskConfig = field(
default=TaskConfig(endpoint="/orders", weight=80),
)
get_orders_realtime: TaskConfig = field(
default=TaskConfig(endpoint="/orders/realtime", weight=10),
)
get_order: TaskConfig = field(
default=TaskConfig(endpoint="/orders/{order_id}", weight=10),
)
complete_order: TaskConfig = field(
default=TaskConfig(endpoint="/orders/{order_id}/complete", weight=5),
)
config = Config()
class MyUser(FastHttpUser):
order_ids: collections.deque = None
wait_time = between(1, 3)
def on_start(self) -> None:
self.order_ids = collections.deque(maxlen=100)
@task(config.create_order.weight)
def create_order(self):
response = self.client.post(config.create_order.endpoint)
if response.ok:
self.order_ids.append(response.json()["id"])
@task(config.get_orders.weight)
def get_orders(self):
self.client.get(config.get_orders.endpoint)
@task(config.get_orders_realtime.weight)
def get_orders_realtime(self):
self.client.get(config.get_orders_realtime.endpoint)
@task(config.get_order.weight)
def get_order(self):
if self.order_ids:
order_id = random.choice(self.order_ids)
self.client.get(
config.get_order.endpoint.format(order_id=order_id),
name=config.get_order.endpoint,
)
@task(config.complete_order.weight)
def complete_order(self):
if self.order_ids:
self.order_ids.remove(order_id := random.choice(self.order_ids))
self.client.post(
config.complete_order.endpoint.format(order_id=order_id),
name=config.complete_order.endpoint,
)
Locust 실행 CLI
locust -f main.py \
--master \
--host http://127.0.0.1:10014 \
--users 5000 \
--spawn-rate 100 \
--run-time 10m \
--stop-timeout 2s
# in another terminal
locust -f main.py --worker
코드 구현 저장소
https://github.com/sanggi-wjg/kotlin-spring-boot-demos/tree/main/about-gc-tuning
kotlin-spring-boot-demos/about-gc-tuning at main · sanggi-wjg/kotlin-spring-boot-demos
Contribute to sanggi-wjg/kotlin-spring-boot-demos development by creating an account on GitHub.
github.com
1 Core + 2GB Memory 환경에서 GC 부하 테스트 결과
GC 옵션별 하나하나 하기 보다는 전체적인 GC의 맥락을 이해하기 위해서 JVM 옵션으로 GC만 설정하여 테스트 하였고 10분간 5000명 유저까지 요청 하도록 부하 테스트 설정을 하고 진행 하였다.
GC | RPS Request per Second |
ART Average Response TIme 50th Percentile 기준 |
Serial GC | 2000 peek 후 계속 우하향하여 900 까지 하락함 |
시간이 지남에 따라 계속해서 증가하여 4000ms 까지 도달 |
G1 GC | 600 ~ 700 유지 | 6000ms 유지 |
Z GC | 500 ~ 600 유지 | 6000~8000ms 유지 |
초기 부하 테스트 결과, Seiral GC가 가장 높은 응답률과 낮은 지연 시간을 기록, 반면 최신 GC 알고리즘들은 예상보다 낮은 성능을 보였습니다.
GC | GC 수행 | STW (pause time) |
Serial GC | Minor GC 약 1~1.5 ops/s | Major GC 약 500ms 지연 발생 Minor GC 약 50ms 지연 발생 |
G1 GC | Minor GC 0.5 ops/s | Major GC는 발생하지 않았고 minor GC만 발생 20~100ms 지연 발생 |
Z GC | GC 0.2~ 0.8 ops/s | GC 수행시 1ms 지연 발생 |
JVM 모니터링 결과에서는 특이점 없이 예측 했던 결과를 보여주었다.
이러한 결과를 바탕으로 몇 가지 가정을 설정하고 분석을 진행했습니다
- 단일 코어 환경에서는 멀티-코어 멀티-쓰레드 GC의 장점이 나타나지 않았으며 오히려 컨텍스트 스위칭 및 오버헤드만 증가하여 성능 저하를 유발했다. 그렇다면 멀티 코어 환경에서 기본 설정값으로도 충분한 성능을 보일 것인가?
- Serial GC 경우 수치들이 계속해서 우하향 했는데 10분이 아니라 1시간 동안 부하 테스트 했다면 어떻게 될까?
Serial GC
G1 GC
Z GC
Serial GC (부하 테스트 40분)
Serial GC는 부하테스트 시간이 점점 길어짐에 따라서 RPS, ART 지표가 계속해서 안좋은 결과를 보여주었다.
3 Core + 6GB Memory 환경에서 GC 부하 테스트 결과
JVM Application을 3 코어로 실행했지만 RPS가 크게 변화가 없었다. 원인은 주요 테스트 로직들이 MySQL, Redis를 사용하는 로직이고 아마 Redis가 싱글 스레드로 동작하여 눈에 띄는 변화가 없지 않을까 싶다.
Serial GC
Z GC
3 Core + 6GB Memory 환경에서 컴퓨팅 로직을 추가한 GC 부하 테스트 결과
Redis 병목으로 유의미한 테스트가 안되는 것 같아서 아래 임의 로직을 추가 후 테스트 해보았다.
@Service
class LoadService {
companion object {
const val EMAIL_REGEX = "^([^@]+)@"
}
fun compute(): ComputeResult {
val results = mutableListOf<Double>()
for (i in 0 until 10_000) {
val term = 4.0 * (1 - (i % 2) * 2) / (2 * i + 1)
results.add(term)
}
val pi = results.sum()
return ComputeResult(pi, results)
}
fun convert(people: People): PeopleAdapter {
return PeopleAdapter(
people.persons.map {
PersonAdapter(
it.id,
it.name,
it.email,
extractEmailId(it.email)
)
}
)
}
private fun extractEmailId(email: String): String {
val regex = Regex(EMAIL_REGEX)
val match = regex.find(email)
return match?.groups?.get(1)?.value ?: email
}
}
@dataclass(frozen=True)
class Config:
@dataclass(frozen=True)
class TaskConfig:
endpoint: str
weight: int
create_order: TaskConfig = field(
default=TaskConfig(endpoint="/orders", weight=5),
)
get_orders: TaskConfig = field(
default=TaskConfig(endpoint="/orders", weight=20),
)
get_orders_realtime: TaskConfig = field(
default=TaskConfig(endpoint="/orders/realtime", weight=10),
)
get_order: TaskConfig = field(
default=TaskConfig(endpoint="/orders/{order_id}", weight=10),
)
complete_order: TaskConfig = field(
default=TaskConfig(endpoint="/orders/{order_id}/complete", weight=5),
)
computing: TaskConfig = field(
default=TaskConfig(endpoint="/load/computing", weight=30),
)
json_computing: TaskConfig = field(
default=TaskConfig(endpoint="/load/json", weight=30),
)
Serial GC를 도입하기 전, 기존 1 코어 환경과의 성능 차이에 대한 우려가 있었습니다. 이번 3코어 환경에서 부하 테스트 결과, Serial GC의 멀티코어 환경의 한계로 보이는 처리량과 응답 시간에서 차이를 확인했습니다.
특히, Serial GC의 RPS(초당 처리 요청 수)는 738로, Z GC의 986에 비해 현저히 낮았습니다. 이는 Serial GC가 병렬 처리 능력이 부족하여 전체적인 처리 속도에 영향을 미치고 있음을 시사합니다.
주목할 점은 Serial GC의 평균 응답시간이 G1 GC나 Z GC에 비해 훨씬 더 길었다는 것입니다. 이는 사용자 경험에 직접적인 영향을 미칠 수 있는 요소이므로, Serial GC 도입 후 추가적인 성능 개선이 필요함을 알 수 있었습니다.