-
부하 테스트 ( + k6, grafana + influxdb, ngrinder)네트워크 & 인프라 2022. 4. 14. 11:40
이번엔 도구를 이용하여 부하테스트를 진행하는 방법을 배웠다.
부하테스트
부하테스트는 서버가 어느정도의 부하를 견딜 수 있는지 확인하기 위한 테스트 이다. 서버의 한계치를 확인함으로서 한계점을 넘어설 때 어떤 증상이 나타나는지 확인하고 장애 발생 시에 어떻게 대응하고 복구할 지 계획할 수 있다.
- Smoke Test
- VUser 1~2로 구성
- 최소한의 부하로 테스트 시나리오 오류 검증 및 시스템 오류 검증
- Load Test
- 평소트래픽과 최대 트래픽에서의 성능 확인(서비스 배포 전에도 가설을 세워서 테스트 진행)
- 기능이 정상적으로 동작하는지 검증
- 배포, 인프라변경 (scale out, DB failover)시 성능 변화 확인
- 외부 요인(결제)등에 따른 예외 상황 확인
- Stress Test
- 점진적으로 부하가 증가하도록 구성하여 최대 사용자, 최대 처리량 등 한계점을 확인
- 스트레스 테스트 이후 시스템이 수동 개입 없이도 복구가 되는지 확인
테스트 도구로는 ngrinder, k6, Apache JMeter, Gatling, Locust 등이 존재한다. 테스트 도구는 시나리오 기반의 테스트가 가능해야 하고 동시접속자 수 , 요청 간격, 최대 Throughput등 부하를 조정하고, 부하테스트 서버 스케일 아웃을 지원하는 등 충분한 부하를 줄 수 있어야 한다. 부하테스트에서는 클라이언트 내부 처리 시간이 배제되어 있다.
성능테스트 시에는 실제 사용자가 접속하는 환경에서 진행 되어야 하고, 테스트 DB에 들어있는 데이터 양이 실제 운영 DB와 동일해야 한다는 점 (통상 전체 성능의 70% 이상이 DB에 좌우)이다. 또한 서버에 일정하게 발생하는 부하가 있다면 성능 테스트 시나리오에도 포함해 주어야 한다. 추가로 외부요인(결제 등)의 경우 시스템과 분리된 별도의 서버로 구성하여 진행 한다. (객체를 Mocking 하는 경우에는 Http Connection Pool, Conncetion Thread 등을 미사용하고 IO 발생하지 않고, Dummy Controller를 구성하는 경우 테스트 시스템의 자원과 리소스를 같이 사용하므로 테스트의 신뢰성이 떨어짐)
부하테스트 전제조건(설정값) 구하기
이제 부하테스트를 진행할 설정값을 정해보자. 규칙은 다음과 같다.
- 대상 시스템 범위
- 예상 1일 사용자 수 (DAU)
- 피크시간대 집중률(최대트래픽/최소트래픽) (모르면 가정)
- 1일 요청수 가정(R)
- Throughput
- 1일 총 접속수 : DAU * R
- 1일 평균 rps : 1일 총 접속수 / 86400(하루)
- 1일 최대 rps : 1일 평균 rps * (최대트래픽/최소트래픽)
- Latency : 보통 50~100m/s (여기선 50이라고 가정)
- T =R * (http_req_duration) (+1s 내부망에서 테스트 할 경우 예상 latency 추가) = R * ((Latency/1000)*2)
- 평균 VUser = (1일 평균 rps * T) / R
- 최대 VUser = (1일 최대 rps * T) / R
- 테스트 기간 : load 보통 30~2시간
- 부하 테스트 시 저장될 데이터 건수 및 크기
나의 테스트 전제조건은 다음과 같다.- 대상 시스템 범위 : 경로찾기 (https://yunha-infra-subway.o-r.kr/path)
- (하루 지하철 사용자 수 4500000의 50%가 지하철 앱을 사용한다고 가정)
- 예상 1일 사용자 수 (DAU) : 4500000 * 0.5 = 2250000
- 피크시간대 집중률 (최대트래픽 / 최소트래픽) : 10으로 가정
- 1일 요청 수 : 3 (카카오 지하철 일일 실행 횟수 참고)
- Throughtput
- 1일 총 접속수 : 2250000 * 3 = 6750000
- 1일 평균 rps : 6750000 / 86400 = 78.125
- 1일 최대 rps : 781.25
- Latency = 50ms
- 요청 수 (R) = 3
- T = (3 * 0.1) = 0.3s
- 평균 VUser = (78.125 * 0.3)/3 = 7.8125 -> 8
- 최대 VUser = (781.25 * 0.3)/3 = 78.125 -> 79
- 부하 유지 기간 :
- K6 :
- smoke test : 10s
- load test : 3m
- stress test : 8m
- Ngrinder :
- smoke test : 10s
- load test : 3m
- stress test : 3m
- K6 :
k6를 이용한 부하테스트 스크립트
k6는 시나리오 기반의 테스트로 동시 접속자 수, 요청간격, 최대 처리량 등 부하조정이 가능하며 부하 테스트 서버 scale out을 지원하는 테스트 도구이다.
테스트는 접속빈도가 높은 페이지 (main), 데이터를 갱신하는 페이지 (사용자 정보 update), 데이터를 조회하는 여러 데이터를 참조하는 페이지 (길찾기 페이지)에 대해서 각각 진행되도록 설정하였다. 부하가 주어진 상황에서 DB Failover, 배포 등 여러상황을 부여하여 서비스 성능을 확인한다. 여기서 http_req_duration은 우리가 설정했던 latency * 2 값으로 설정해준다.
여기서 스트레스 테스트 부하 설정은 아래 사이트를 참고하였다.
https://k6.io/docs/test-types/stress-testing/
// main smoke.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { vus: 1, // 1 user looping for 1 minute duration: '10s', thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 0.1s }, }; const BASE_URL = 'https://[도메인]' export default function () { http.get(`${BASE_URL}`); sleep(1); } // main load.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { stages: [ { duration: '1m', target: 79 }, // simulate ramp-up of traffic from 1 to 15-16 users over 5 minutes. { duration: '2m', target: 79 }, // stay at 15-16 users for 10 minutes { duration: '10s', target: 0 }, // ramp-down to 0 users ], thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 0.1s 'logged in successfully': ['p(99)<100'], // 99% of requests must complete below 0.1s }, }; const BASE_URL = 'https://[도메인]' export default function () { http.get(`${BASE_URL}`); sleep(1); } // main stress.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { stages: [ { duration: '30s', target: 100 }, // below normal load { duration: '1m', target: 100 }, { duration: '30s', target: 200 }, // normal load { duration: '1m', target: 200 }, { duration: '30s', target: 300 }, // around the breaking point { duration: '1m', target: 300 }, { duration: '30s', target: 400 }, // beyond the breaking point { duration: '1m', target: 400 }, { duration: '2m', target: 0 }, // scale down. Recovery stage. ], thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 0.1s }, }; const BASE_URL = 'https://[도메인]' export default function () { http.get(`${BASE_URL}`); sleep(1); }
// update smoke.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { vus: 1, // 1 user looping for 1 minute duration: '10s', thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 1.5s }, }; const BASE_URL = 'https://[도메인]' const USERNAME = '[사용자 이메일]'; const PASSWORD = '[사용자 비밀번호]'; export default function () { var payload = JSON.stringify({ email: USERNAME, password: PASSWORD, }); var params = { headers: { 'Content-Type': 'application/json', }, }; let loginRes = http.post(`${BASE_URL}/login/token`, payload, params); check(loginRes, {'logged in successfully': (resp) => resp.json('accessToken') !== '',}); let authHeaders = { headers: { Authorization: `Bearer ${loginRes.json('accessToken')}`, 'Content-Type': 'application/json', }, }; var updatePayload = JSON.stringify({ email: USERNAME, password: PASSWORD, age: 30 }); let updateRes = http.put(`${BASE_URL}/members/me`, updatePayload, authHeaders); check(updateRes, { 'updated member successfully': (resp) => resp.status === 200 }); let myObjects = http.get(`${BASE_URL}/members/me`, authHeaders).json(); check(myObjects, { 'retrieved member': (obj) => obj.id != 0 }); sleep(1) }; // update load.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { stages: [ { duration: '1m', target: 79 }, // simulate ramp-up of traffic from 1 to 15-16 users over 5 minutes. { duration: '2m', target: 79 }, // stay at 15-16 users for 10 minutes { duration: '10s', target: 0 }, // ramp-down to 0 users ], thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 1.5s 'logged in successfully': ['p(99)<100'], // 99% of requests must complete below 1.5s }, }; const BASE_URL = 'https://[도메인]' const USERNAME = '[사용자 이메일]'; const PASSWORD = '[사용자 비밀번호]'; export default function () { var payload = JSON.stringify({ email: USERNAME, password: PASSWORD, }); var params = { headers: { 'Content-Type': 'application/json', }, }; let loginRes = http.post(`${BASE_URL}/login/token`, payload, params); check(loginRes, {'logged in successfully': (resp) => resp.json('accessToken') !== '',}); let authHeaders = { headers: { Authorization: `Bearer ${loginRes.json('accessToken')}`, 'Content-Type': 'application/json', }, }; var updatePayload = JSON.stringify({ email: USERNAME, password: PASSWORD, age: 30 }); let updateRes = http.put(`${BASE_URL}/members/me`, updatePayload, authHeaders); check(updateRes, { 'updated member successfully': (resp) => resp.status === 200 }); let myObjects = http.get(`${BASE_URL}/members/me`, authHeaders).json(); check(myObjects, { 'retrieved member': (obj) => obj.id != 0 }); sleep(1) }; // update stress.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { stages: [ { duration: '30s', target: 100 }, // below normal load { duration: '1m', target: 100 }, { duration: '30s', target: 200 }, // normal load { duration: '1m', target: 200 }, { duration: '30s', target: 300 }, // around the breaking point { duration: '1m', target: 300 }, { duration: '30s', target: 400 }, // beyond the breaking point { duration: '1m', target: 400 }, { duration: '2m', target: 0 }, // scale down. Recovery stage. ], thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 0.1s 'logged in successfully': ['p(99)<100'], // 99% of requests must complete below 0.1s }, }; const BASE_URL = 'https://[도메인]' const USERNAME = '[사용자 이메일]'; const PASSWORD = '[사용자 비밀번호]'; export default function () { var payload = JSON.stringify({ email: USERNAME, password: PASSWORD, }); var params = { headers: { 'Content-Type': 'application/json', }, }; let loginRes = http.post(`${BASE_URL}/login/token`, payload, params); check(loginRes, {'logged in successfully': (resp) => resp.json('accessToken') !== '',}); let authHeaders = { headers: { Authorization: `Bearer ${loginRes.json('accessToken')}`, 'Content-Type': 'application/json', }, }; var updatePayload = JSON.stringify({ email: USERNAME, password: PASSWORD, age: 30 }); let updateRes = http.put(`${BASE_URL}/members/me`, updatePayload, authHeaders); check(updateRes, { 'updated member successfully': (resp) => resp.status === 200 }); let myObjects = http.get(`${BASE_URL}/members/me`, authHeaders).json(); check(myObjects, { 'retrieved member': (obj) => obj.id != 0 }); sleep(3) };
// path smoke.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { vus: 1, // 1 user looping for 1 minute duration: '10s', thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 1.5s }, }; const BASE_URL = 'https://[도메인]' export default function () { http.get(`${BASE_URL}/path`); sleep(1); }; // path load.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { stages: [ { duration: '1m', target: 79 }, // simulate ramp-up of traffic from 1 to 15-16 users over 5 minutes. { duration: '2m', target: 79 }, // stay at 15-16 users for 10 minutes { duration: '10s', target: 0 }, // ramp-down to 0 users ], thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 0.1s 'logged in successfully': ['p(99)<100'], // 99% of requests must complete below 0.1s }, }; const BASE_URL = 'https://[도메인]' export default function () { http.get(`${BASE_URL}/path`); sleep(1); }; // path stress.js import http from 'k6/http'; import { check, group, sleep, fail } from 'k6'; export let options = { stages: [ { duration: '30s', target: 100 }, // below normal load { duration: '1m', target: 100 }, { duration: '30s', target: 200 }, // normal load { duration: '1m', target: 200 }, { duration: '30s', target: 300 }, // around the breaking point { duration: '1m', target: 300 }, { duration: '30s', target: 400 }, // beyond the breaking point { duration: '1m', target: 400 }, { duration: '2m', target: 0 }, // scale down. Recovery stage. ], thresholds: { http_req_duration: ['p(99)<100'], // 99% of requests must complete below 0.1s }, }; const BASE_URL = 'https://[도메인]' export default function () { http.get(`${BASE_URL}/path`); sleep(1); };
k6를 이용한 부하테스트 실행
먼저 성능을 진단하기 위해서 k6 프로그램 을 설치하자.
$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 $ echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list $ sudo apt-get update $ sudo apt-get install k6 # k6 테스트 실행 $ k6 run smoke.js
위와 같이 테스트를 실행하면 다음과 같이 결과값을 받을 수 있다.
k6 + grafana + influxdb 를 이용한 부하테스트 실행
나온 테스트 결과를 시간대 별로 자세하게 확인하기 위해서 grafana와 influxdb 를 사용하자.
먼저 다음의 사이트를 참고하여 설치를 진행하고 테스트를 실행해보자.
https://k6.io/docs/results-visualization/influxdb-+-grafana/
https://grafana.com/docs/grafana/latest/installation/debian/
https://archive.docs.influxdata.com/influxdb/v1.2/introduction/installation/
## influxdb 설치 $ curl -sL https://repos.influxdata.com/influxdb.key | sudo apt-key add - $ source /etc/lsb-release $ echo "deb https://repos.influxdata.com/${DISTRIB_ID,,} ${DISTRIB_CODENAME} stable" | sudo tee /etc/apt/sources.list.d/influxdb.list $ sudo apt-get update && sudo apt-get install influxdb ## grafana 설치 $ sudo apt-get install -y apt-transport-https $ sudo apt-get install -y software-properties-common wget $ wget -q -O - https://packages.grafana.com/gpg.key | sudo apt-key add - $ echo "deb https://packages.grafana.com/enterprise/deb stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list $ sudo apt-get update $ sudo apt install grafana ## influxdb 실행 (8086 포트에 실행됨) $ sudo service influxdb start ## grafana 실행 (3000 포트에 실행됨) $ sudo systemctl start grafana-server ## k6 결과 grafana+influxdb로 확인하기 k6 run --out influxdb=http://[서비스망 외부 ip]:8086/[db이름] [실행시킬파일 이름].js
데이터로 받아진 결과값을 임포트 하기 위해서는 임포트 다음글 참고하였다. 여기서 보안그룹에서 3000번 포트와 8086포트를 서버에서 열어주어야 함을 잊지 말자.
https://github.com/HomoEfficio/dev-tips/blob/master/LoadTest-K6-InfluxDB-Grafana.md
받은 데이터로 대시보드를 구성하면 다음과 같이 볼 수 있다.
Ngrinder 를 이용한 부하테스트 실행
k6 말고 ngrinder 로 성능을 측정하는 방법은 다음과 같다.
## Ngrinder Controller 설치 및 실행 $ wget https://github.com/naver/ngrinder/releases/download/ngrinder-3.5.3-20201127/ngrinder-controller-3.5.3.war $ java -jar ngrinder-controller-3.5.3.war —port 7070 ## Ngrinder Agent 설치 ## http://[서비스망 외부 ip}:7070로 (초기 id:admin, password:admin) 에이전트 관리에서 다운로드 링크 참고해서 다음과 같이 다운 $ wget --content-disposition http://[IP]:7070/agent/download링크 $ tar xvf ngrinder-agent-3.5.3-[IP].tar ## ngrinder agent 실행 $ cd ngrinder-agent $ sh run_agent.sh ## 다른 서버에 agent를 추가하고 싶을 경우 추가 agent 서버에서 다음과 같이 설정해줌 (기본은 같은 서버라고 인식) $ vi ~/.ngrinder_agent/agent.conf common.start_mode=agent agent.controller_host=[controller 서버 public ip] ## 도커로 실행하고 싶을 경우 $ docker pull ngrinder/controller $ docker pull ngrinder/agent ## controller 실행 (여기서 7070포트는 다른 포트로 실행 가능(기본은 80)) ## 여기서 agent 서버의 포트 7070, 16001, 12000~12009포트를 열어주어야 한다 $ run -d -v ~/ngrinder-controller:/opt/ngrinder-controller --name controller -p 7070:80 -p 16001:16001 -p 12000-12009:12000-12009 ngrinder/controller $ docker run -d -v ~/ngrinder-agent:/opt/ngrinder-agent --name agent ngrinder/agent [controller 서버 IP: controller 포트]
controller서버:지정포트 로 들어가서 admin/admin으로 들어가 Script를 생성한다. 테스트하고자하는 url입력 후 validate를 하면 자동으로 스크립트가 생성된다.
import static net.grinder.script.Grinder.grinder import static org.junit.Assert.* import static org.hamcrest.Matchers.* import net.grinder.plugin.http.HTTPRequest import net.grinder.plugin.http.HTTPPluginControl import net.grinder.script.GTest import net.grinder.script.Grinder import net.grinder.scriptengine.groovy.junit.GrinderRunner import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread // import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3 import org.junit.Before import org.junit.BeforeClass import org.junit.Test import org.junit.runner.RunWith import java.util.Date import java.util.List import java.util.ArrayList import HTTPClient.Cookie import HTTPClient.CookieModule import HTTPClient.HTTPResponse import HTTPClient.NVPair /** * A simple example using the HTTP plugin that shows the retrieval of a * single page via HTTP. * * This script is automatically generated by ngrinder. * * @author admin */ @RunWith(GrinderRunner) class TestRunner { public static GTest test public static HTTPRequest request public static NVPair[] headers = [] public static NVPair[] params = [] public static Cookie[] cookies = [] @BeforeProcess public static void beforeProcess() { HTTPPluginControl.getConnectionDefaults().timeout = 6000 test = new GTest(1, ["도메인주소"]) request = new HTTPRequest() grinder.logger.info("before process."); } @BeforeThread public void beforeThread() { test.record(this, "test") grinder.statistics.delayReports=true; grinder.logger.info("before thread."); } @Before public void before() { request.setHeaders(headers) cookies.each { CookieModule.addCookie(it, HTTPPluginControl.getThreadHTTPClientContext()) } grinder.logger.info("before. init headers and cookies"); } @Test public void test(){ HTTPResponse result = request.GET("[https://도메인주소]/path", params) if (result.statusCode == 301 || result.statusCode == 302) { grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", result.statusCode); } else { assertThat(result.statusCode, is(200)); } } }
Performance test로 가서 새로운 테스트 작성 후 실행하면 다음과 같이 테스트 결과를 볼 수 있다. (스크립트 내용 입력)
( 참고한 사이트 )
https://intrepidgeeks.com/tutorial/ngrinder-performance-test
https://heedipro.tistory.com/279
https://wellbell.tistory.com/12
https://velog.io/@hellonayeon/nGrinder-install-and-how-to-use-memo
https://developer-c.tistory.com/55
https://wecandev.tistory.com/26
https://beaniejoy.tistory.com/52
'네트워크 & 인프라' 카테고리의 다른 글
화면 응답 개선하기 (0) 2022.04.14 서버 진단하기 ( + 로깅, 모니터링) (0) 2022.04.14 웹 성능 진단하기 (0) 2022.04.14 AWS 망 구성하고 서비스 배포하기 (0) 2022.03.28 Mac에서 docker, docker machine, virtualbox설치하기 (0) 2022.03.28 - Smoke Test