Skip to main content Link Menu Expand (external link) Document Search Copy Copied

created at 2023-12-21

Table of contents

  1. 서론
  2. 서버 로드 테스트 환경
  3. 로드 테스트
    1. Original. 단일 채팅서버 파드로 핸들링 시 결과
    2. Phase 1. HPA 를 통한 파드 오토 스케일링 적용 시 결과
    3. Phase 2. Readiness 타임아웃 1초 -> 60초
    4. Phase 3. 내장 tomcat max-threads 크기 10개 -> 100개
    5. Phase 4. 내장 tomcat 최적화
  4. 성능개선 시작과 끝 비교!
  5. 결론
  6. Appendix - MTTFB 평균과 변동량/변동률, p50/p95/p99/p99.9 측정 코드

서론

저는 여러 페이즈를 진행하며 성능 개선을 진행하였고, 각 페이즈 별 성능수치들을 먼저 보여드리겠습니다. img

  • Phase 1 : HPA 를 통한 파드 오토 스케일링 아웃 적용
  • Phase 2 : Readiness 타임아웃 증가
  • Phase 3 : 내장 tomcat max-threads 크기 증가
  • Phase 4 : 내장 tomcat 최적화

표를 봐도 얼마나 개선되었는지 한 눈에 잘 안보이죠?ㅎㅎ 그래서 그래프로 표현해봤습니다! img img img

그럼 이제 각각의 성능 개선 페이즈를 하나씩 살펴보겠습니다!

서버 로드 테스트 환경

  • Thread(virtual user) : 296
  • URL : GET /rooms
  • Duration : 10min
  • Test-Server
    • CPU : 8 virtual core
    • Memory : 32GB
    • OS : MacOS Big Sur
  • Target-Server EC2 인스턴스
    • Instance Type: t3.medium, 3대 워커노드 운용
    • CPU : 2 virtual core, total 6 virtual core
    • Memory : 4GB, total 12GB
    • OS : Amazon Linux 2 x86_64 HVM gp2

로드 테스트

Original. 단일 채팅서버 파드로 핸들링 시 결과

MetricBefore
Total Tests40,228
Error Rate51.11% (20,560)
TPS 평균 (Average)109.27
TPS p5069.00
TPS p954.00
TPS p992.84
TPS p99.91.63
MTTFB 평균 (Average)1605.44 ms
MTTFB p501636.55 ms
MTTFB p9524013.28 ms
MTTFB p9927690.40 ms
MTTFB p99.928157.50 ms
MTTFB 차이 평균 (Average Difference)2838.38 ms
MTTFB 평균적인 변동률 (Average Variability)75.00%
  • 테스트 지표 확인

만약 HPA 파드 오토 스케일 아웃 없이 단일 파드로만 핸들링할 때 어떤 결과가 나올지 궁금하지 않나요? 그래서 실험해보았고 위의 결과가 나왔습니다. 실험 중 오류가 너무 많이 발생되서 자동으로 중지될 정도로 많은 오류를 반환합니다. 에러률이 50% 이상을 기록해버렸습니다. 전체 트랜잭션의 95% 는 4 TPS 내로 처리되고 99.9% 는 1.63 TPS 내로 처리됩니다. 하위 트랜잭션처리량이 매우 낮죠.

MTTFB 또한 높은 수치를 보여줍니다. p99 는 27초, p99.9 는 28초로 처리되고 있습니다… 이는 서버가 매우 느리게 응답하고 있다는 것을 의미합니다. 또한 평균 MTTFB 와 매우 큰 차이를 보인다는 뜻은 서버의 응답시간이 매우 불안정하다는 것을 보여주죠!

해당 로드 테스트는 10분동안 실행하도록 지시했지만 03:10 에 높은 에러율로 인해 종료되었습니다.

image

Phase 1. HPA 를 통한 파드 오토 스케일링 적용 시 결과

그러면 CPU 자원을 좀 더 많이 써볼까요? Kubernetes HPA(HorizontalPodAutoscaler) 를 통해 파드를 cpu 사용량에 따라 자동으로 스케일 아웃/인을 수행하도록 설정했습니다. 그리고 이를 통해 다시 로드 테스트를 진행해보았습니다.

MetricBeforeAfterChange
Total Tests40,228142,805254.91% 🟢
Error Rate51.11%(20,560)6.57%(9,389)-87.16% 🟢
TPS 평균 (Average)109.27228.45109.07% 🟢
TPS p5069.00238.25245.29% 🟢
TPS p954.0080.381909.50% 🟢
TPS p992.846.82139.44% 🟢
TPS p99.91.631.8714.72% 🟢
MTTFB 평균 (Average)1605.44 ms1265.08 ms-21.20% 🟢
MTTFB p501636.55 ms934.00 ms-42.96% 🟢
MTTFB p9524013.28 ms4105.42 ms-82.89% 🟢
MTTFB p9927690.40 ms19557.53 ms-29.33% 🟢
MTTFB p99.928157.50 ms21906.02 ms-22.19% 🟢
MTTFB 차이 평균 (Average Difference)2838.38 ms1020.87 ms-64.12% 🟢
MTTFB 평균적인 변동률 (Average Variability)75.00%86.83%15.77% 🔴
  • 테스트 지표 확인 및 개선점

image

중간중간 TPS 가 감소하는 모습을 확 위의 그래프에서 확인할 수 있습니다. TPS 감소 이유는 아래의 그래프에서 원인을 파악할 수 있습니다.

image

image

MTTFB 가 중간중간 5초가 넘습니다. 그리고 이런 피크를 보일 때 마다 오류또한 증가합니다. 마찬가지로 TPS 또한 같이 감소하는 것을 확인할 수 있었습니다. 저는 채팅서버의 로드가 중간중간 증가해서 헬스체크의 타임아웃이 발생했고, 서비스가 로드밸런싱을 다시 수행하면서 TPS 가 감소했다고 생각했어요. 한번 확인해보겠습니다.

채팅서버의 Deployment 의 readinessProbe 헬스체크 timeoutSeconds는 1초로 설정되어 있었습니다.

image

deploy 로그를 확인했더니 역시 health 타임아웃 오류가 발생했었습니다. 그렇다면 타임아웃을 60초로 설정하면 견딜 수 있겠죠?

Phase 2. Readiness 타임아웃 1초 -> 60초

MetricBeforeAfterChange
Total Tests142,805138,487-3.02% 🔴
Error Rate6.57%(9,389)0.02%(25)No Error 🟢
TPS 평균 (Average)228.45237.093.78% 🟢
TPS p50238.25243.502.21% 🟢
TPS p9580.38126.5357.38% 🟢
TPS p996.8264.56846.16% 🟢
TPS p99.91.8747.602430.48% 🟢
MTTFB 평균 (Average)1265.08 ms1224.22 ms-3.23% 🟢
MTTFB p50934.00 ms1150.59 ms23.19% 🔴
MTTFB p954105.42 ms2383.01 ms-41.98% 🟢
MTTFB p9919557.53 ms3636.65 ms-81.39% 🟢
MTTFB p99.921906.02 ms4132.45 ms-81.15% 🟢
MTTFB 차이 평균 (Average Difference)1020.87 ms437.17 ms-57.18% 🟢
MTTFB 평균적인 변동률 (Average Variability)86.83%35.20%-59.46% 🟢
  • 테스트 지표 확인 및 개선점

Error 는 0.0% 로 에러없이 정상적인 요청처리를 확인할 수 있었습니다. 또한 MTTFB와 TPS 의 p95, p99, p99.9 모두 큰 폭으로 상승한 것을 확인할 수 있었습니다! 원래는 서버가 어느정도 부하가 발생해서 K8S health check restAPI 를 1초내에 응답하지 못하면 서버가 꺼짐으로 인해 에러가 대폭 발생했습니다. 그 제한시간을 60초로 늘림에 따라 에러률이 거의 0% 로 줄었죠. Error 가 사라짐으로 인해 길고 긴 요청 대기시간 또한 사라졌습니다. 대기시간이 사라진 것은 MTTFB p99.9 의 감소량을 보연 알 수 있죠(21906.02 ms 에서 4132.45 ms 로 81.15% 감소)!

그래도 여전히 군데군데 MTTFB 피크가 4000ms 로 올라가면서 서버가 살작 불안정합니다. 그리고 이에 맞추어 TPS 또한 떨어지고, 오류도 중간에 조금씩 발생하는 것을 확인할 수 있습니다.

image

image

Phase 3. 내장 tomcat max-threads 크기 10개 -> 100개

그렇다면 이번엔 Spring boot 내장 톰켓 서버의 max-threads-size 를 늘려봅시다. 이유는 채팅서버의 cpu 사용량이 생각보다 작기 때문입니다. 많은 스레드를 돌리거나 요청을 많이 받을 수 있어야지 cpu 사용률도 올라가니 이 부분에 문제가 있다고 판단하고 스레드 풀 사이즈를 늘리는 방향을 설정하였습니다.

MetricBeforeAfterChange
Total Tests138,487134,988-2.52% 🔴
Error Rate0.02%(25)0.03%(35)No Error 🟢
TPS 평균 (Average)237.09234.29-1.18% 🔴
TPS p50243.50244.500.41% 🟢
TPS p95126.53110.55-12.63% 🔴
TPS p9964.5659.92-7.00% 🔴
TPS p99.947.6053.0711.51% 🟢
MTTFB 평균 (Average)1224.22 ms1249.87 ms2.09% 🔴
MTTFB p501150.59 ms1217.48 ms5.80% 🔴
MTTFB p952383.01 ms2280.26 ms-4.47% 🟢
MTTFB p993636.65 ms3473.83 ms-4.47% 🟢
MTTFB p99.94132.45 ms3887.59 ms-5.93% 🟢
MTTFB 차이 평균 (Average Difference)437.17 ms350.82 ms-19.76% 🟢
MTTFB 평균적인 변동률 (Average Variability)35.20%24.47%-30.49% 🟢

image

그래프로 볼 땐 몰랐지만, 수치를 확인해보니 확실히 TPS 와 MTTFB 의 최상위 지표(p99.9)가 높게 나오는 것을 확인할 수 있었습니다. 또한 MTTFB 의 변동폭(MTTFB 차이 평균)이 작은 것 또한 관찰되죠. 이 말은 트래픽을 균일한 시간 내 처리하게 되니까 서버의 안전성이 향상되었다고 볼 수 있겠죠?

반면, 아직 생각보다 cpu 리소스를 제대로 활용하지 못하는 모습을 볼 수 있습니다.

image

빨간선은 request 이며 노란선은 limit 입니다. y-grid 는 0.5 cpu 를 의미합니다.

Phase 4. 내장 tomcat 최적화

MetricBeforeAfterChange
Total Tests134,988181,05034.02% 🟢
Error Rate0.03%(35)0.00%(0)No Error 🟢
TPS 평균 (Average)234.29312.1633.26% 🟢
TPS p50244.50319.0030.53% 🟢
TPS p95110.55217.4596.82% 🟢
TPS p9959.92132.28120.51% 🟢
TPS p99.953.0796.5281.91% 🟢
MTTFB 평균 (Average)1249.87 ms950.89 ms-23.89% 🟢
MTTFB p501217.48 ms919.20 ms-24.55% 🟢
MTTFB p952280.26 ms1322.11 ms-42.04% 🟢
MTTFB p993473.83 ms1833.22 ms-47.26% 🟢
MTTFB p99.93887.59 ms2099.12 ms-46.05% 🟢
MTTFB 차이 평균 (Average Difference)350.82 ms112.52 ms-67.89% 🟢
MTTFB 평균적인 변동률 (Average Variability)24.47%10.67%-56.39% 🟢

PR https://github.com/ghkdqhrbals/spring-chatting-server/pull/334 에서 적용된 톰켓 최적화 설정을 아래에 풀어볼게요.

  • 대기 큐 크기(accept-count) 100 -> 100
    • 유지
  • 동시 연결 개수(max-connections) 100 -> 8192
    • 현재 vuser 300 의 동시 연결 요청을 전송하기때문에 이를 고려하여 default 개수까지 증량
  • 최대 스레드 풀 크기(max-threads) 100 -> 150
    • 채팅서버 vcpu limit 1500m 이기때문에 각 코어당 * 100 하여 처리 1.5 * 100 = 150
    • 현재 대부분의 API 요청에서 i/o 작업을 하기 때문에 왠만하면 스레드를 늘리는 것이 좋습니다. 만약 cpu 소모량이 많다고 한다면 줄이는 것이 좋다고 합니다. 괜한 context switch 를 줄이는 것이 효율적이기 때문입니다.
  • 연결 타임아웃(connection-timeout) 10s -> 60s
    • 현재 readinessProbe 타임아웃 설정이 60s 로 설정되어 있습니다. 이에 맞추어 오류를 줄이기 위해 60s 로 설정하는 것이 올바르다고 생각해서 변경하게 되었습니다.
  • 항상 유지되는 최소 스레드 크기(min-spare-threads) 30 -> 30
    • 유지

정말 신기하지 않나요? 모오든 지표에서 아주 큰 폭으로 상승하는 것이 확인되었습니다. 우리는 드디어 동시 요청 스레드 300 을 우리는 에러없이 안정적으로 견딜 수 있는 서버를 만들어낸거에요!

그럼 지표들을 살펴보겠습니다.

TPS 의 전체 지표들이 최소 30% 상승하였습니다. 심지어 TPS p99 는 120% 가 개선되면서 대부분의 요청들이 꾸준히 안정적으로 처리되고 있죠. MTTFB 도 볼까요? MTTFB 평균은 23%가 개선되었으며 90th, 95th, 99th, 99.9th 레이턴시 퍼센트가 모두 40% 이상 개선되었습니다. 즉, 마찬가지로 대부분의 요청이 매우 안정적으로 처리되고 있음을 보여주죠. MTTFB 그래프를 보시게 되면 눈으로 봐도 상당히 안정적으로 처리되고 있음을 확인할 수 있습니다(초기 Initial 과정 빼곤).

image image

저는 이 결과를 통해 병목이 가장 많이 걸리는 곳이 내장 톰켓이였다는 것을 확인할 수 있었습니다.

그럼 이제 현재까지의 모든 테스트들을 요약하여 시작과 끝의 성능지표를 비교해보겠습니다.

성능개선 시작과 끝 비교!

MetricBeforeAfterChange
Total Tests40,228181,050349.29% 🟢
Error Rate51.11%(20,560)0.00%(0)No Error 🟢
TPS 평균 (Average)109.27312.16185.94% 🟢
TPS p5069.00319.00362.32% 🟢
TPS p954.00217.455362.50% 🟢
TPS p992.84132.284556.34% 🟢
TPS p99.91.6396.525852.76% 🟢
MTTFB 평균 (Average)1605.44 ms950.89 ms-40.68% 🟢
MTTFB p501636.55 ms919.20 ms-43.90% 🟢
MTTFB p9524013.28 ms1322.11 ms-94.47% 🟢
MTTFB p9927690.40 ms1833.22 ms-93.40% 🟢
MTTFB p99.928157.50 ms2099.12 ms-92.52% 🟢
MTTFB 차이 평균 (Average Difference)2838.38 ms112.52 ms-96.04% 🟢
MTTFB 평균적인 변동률 (Average Variability)75.00%10.67%-85.77% 🟢

와우… 정말 놀라운 결과입니다. TPS 는 3배, MTTFB 평균은 40% 가량 개선되었습니다. 그리고 TPS, MTTFB 의 p50, p95, p99, p99.9 모두 매우 큰 폭으로 개선되었습니다!

TPS p99.9 는 5852.76% 개선되었습니다ㅋㅋ;; 무슨일이죠

이는 서버의 안정성이 매우 향상되었음을 의미합니다. 그리고 에러율은 0.00000% 로 에러가 전혀 발생하지 않았습니다.

결론

img

Appendix - MTTFB 평균과 변동량/변동률, p50/p95/p99/p99.9 측정 코드

import sys
import pandas as pd
import numpy as np
import locale


original_locale = locale.getlocale()
locale.setlocale(locale.LC_ALL, '')

def calculate_error_rate(data):
    total_tests = data['Tests'].sum()
    total_errors = data['Errors'].sum()
    error_rate = (total_errors / (total_tests+total_errors)) * 100
    return error_rate

def format_with_commas(number):
    return locale.format_string("%d", number, grouping=True)

def calculate_weighted_average_tps(data):
    tps = data['TPS']
    average_tps = tps.mean()
    return average_tps

def calculate_percentiles(data, column, percentiles, ascending=True):
    values = data[column]
    if not ascending:
        values = -values  # 내림차순 정렬을 위해 값에 마이너스(-)를 곱합니다.
    sorted_values = np.sort(values)
    return {f'p{p}': np.percentile(sorted_values, p) for p in percentiles}

def calculate_average_mttfb_difference(data):
    mttfb = data['Mean_time_to_first_byte']
    mttfb_differences = mttfb.diff().abs()[1:]  # 첫 번째 값은 NaN 이므로 제외
    average_difference = mttfb_differences.mean()
    return average_difference

def calculate_total_weighted_mttfb_average(data):
    mttfb = data['Mean_time_to_first_byte']
    tests = data['Tests']
    total_weighted_mttfb = (mttfb * tests).sum()
    total_tests = tests.sum()
    weighted_average = total_weighted_mttfb / total_tests
    return weighted_average

def calculate_mttfb_variation(data):
    mttfb = data['Mean_time_to_first_byte']
    tests = data['Tests']

    mttfb_changes = mttfb.diff()[1:]
    mttfb_abs_percent_changes = (mttfb_changes / mttfb[:-1]).abs() * 100
    average_tests_weights = (tests[:-1] + tests[1:]) / 2
    weighted_avg_abs_changes = mttfb_abs_percent_changes * average_tests_weights[1:]
    average_weighted_avg_abs_change = weighted_avg_abs_changes.sum() / average_tests_weights[1:].sum()

    return average_weighted_avg_abs_change

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python script.py <path_to_csv_file>")
        sys.exit(1)

    csv_file_path = sys.argv[1]
    data = pd.read_csv(csv_file_path)
    total_tests = data['Tests'].sum()
    total_errors = data['Errors'].sum()
    error_rate = calculate_error_rate(data)
    print(f"Total Tests: {format_with_commas(total_tests+total_errors)}")
    print(f"Error Rate: {error_rate:.2f}% ({format_with_commas(total_errors)})")

    average_tps = calculate_weighted_average_tps(data)
    tps_percentiles = calculate_percentiles(data, 'TPS', [50, 95, 99, 99.9], ascending=False)
    print(f"TPS 평균: {average_tps:.2f}")
    print(f"TPS p50: {-tps_percentiles['p50']:.2f}")
    print(f"TPS p95: {(-tps_percentiles['p95']):.2f}")
    print(f"TPS p99: {(-tps_percentiles['p99']):.2f}")
    print(f"TPS p99.9: {(-tps_percentiles['p99.9']):.2f}")

    weighted_average = calculate_total_weighted_mttfb_average(data)
    mttfb_percentiles = calculate_percentiles(data, 'Mean_time_to_first_byte', [50, 95, 99, 99.9])
    print(f"MTTFB 평균: {(weighted_average):.2f} ms")
    print(f"MTTFB p50: {(mttfb_percentiles['p50']):.2f} ms")
    print(f"MTTFB p95: {(mttfb_percentiles['p95']):.2f} ms")
    print(f"MTTFB p99: {(mttfb_percentiles['p99']):.2f} ms")
    print(f"MTTFB p99.9: {(mttfb_percentiles['p99.9']):.2f} ms")

    diff_average = calculate_average_mttfb_difference(data)
    print(f"MTTFB 차이 평균 : {(diff_average):.2f} ms")

    variation = calculate_mttfb_variation(data)
    print(f"MTTFB 평균적인 변동률: {(variation):.2f}%")

locale.setlocale(locale.LC_ALL, original_locale)