안녕하세요! 웹서비스셀에서 백엔드 개발자로 근무 중인 윤준성입니다🙇 지난 2020년 12월, 쿠키런:킹덤 사전예약의 시민권 생성 서비스를 개발하였는데요. 이 서비스의 개발 과정에서 서버의 성능을 테스트하고, linux perf를 통한 서버 프로파일링으로 서버의 병목 구간을 찾아 성능을 개선하였던 경험을 소개합니다.
#시민권 발급 이벤트
웹서비스셀에서는 지난 1월 런칭한 쿠키런:킹덤의 사전예약 서비스를 운영하였습니다.
사전예약 서비스의 다양한 이벤트 중, 시민권 발급 이벤트는 쿠키 이 름을 입력하면 랜덤하게 생성된 귀여운 쿠키 이미지와 함께 시민권을 발급해주었습니다. 그리고 이 시민권을 SNS에 공유하면, 쿠키런:킹덤에서 사용할 수 있는 보상을 얻을 스탬프를 획득할 수 있었습니다.
시민권에 사용되는 랜덤 쿠키 이미지는 얼굴, 몸, 눈, 입, 앞머리 모양 5개의 필수요소와 배경, 페이스페인트, 안경, 왕관, 스티커, 뒷머리 모양 6개의 선택요소, 그리고 머리 색깔로 결정됩니다. 각 요소의 가짓수를 고려하여 계산해보면... 무려 5,074,167,364,156,800가지의 쿠키 경우의 수가 존재합니다! 시민권에 들어가는 주소와 입국허가 사유도 랜덤으로 생성되는 것을 고려하면 사실은 더 많은 시민권 가짓수가 존재합니다. 이렇게나 많은 이미지를 미리 만들어 둘 수 없기에, 쿠키 이미지들은 요청이 들어오는 대로 생성하고 있었고, 이는 꽤 많은 리소스를 잡아먹는 서비스였습니다.
#시민권 발급 서비스 로직
시민권 발급 서비스의 입출력은 단순합니다. Input은 시민권에 들어갈 쿠키의 이름과 언어를 받습니다(킹 덤 사전예약은 한국어를 포함한 4개의 언어로 진행되었습니다.). Output으로는 생성된 시민권 이미지의 URL을 반환합니다.
input: 쿠키의 이름(준성맛 쿠키), 언어(한국어)
output: 생성된 시민권 이미지의 URL
서버는 node.js 환경에서 typescript로 작성되었으며, 서비스의 내부 로직은 아래와 같습니다.
- 랜덤 요소들(쿠키 증명사진 조합에 쓰일 컴포넌트들 11종, 주소, 입국허가 사유)의 값을 생성한다.
- 시민권 배경, 쿠키 이미지 요소들, 시민권 테두리 이미지를 node-canvas로 로드해온다.
- 시민권에 필요한 텍스트를 node-canvas로 이미지로 만들고, 이미지들을 합쳐서 시민권 이미지를 완성한다!
- 생성된 시민권 이미지를 PNGStream으로 바꿔서 S3 버킷에 업로드한다. 업로드된 이미지들은 CloudFront를 통해 프론트엔드로 서빙된다!
#Artillery로 부하테스트하기
Artillery라는 Node.js 부하 테스트 서드파티를 이용하여, 시민권 서버의 성능을 테스트하였습니다. Artillery는 쉽게 말하자면, 서버에 내가 원하는 요청을 마구마구 쏴볼 수 있는 도구인데요. Artillery를 선택한 이유는
- 서버와 같은 Node.js 환경이라 사용이 편리하고
- 다양한 시나리오의 테스트를 YAML로 쉽게 관리할 수 있으며
- javascript로 쉽게 로직을 커스텀 할 수 있기 때문이었습니다.
아래의 YAML 파일을 이용하였습니다.
config:
target: 'https://...' #시민권 서버 주소
processor: './artillery-functions.js'
phases:
- duration: 600 #n초간 테스트를 진행
arrivalRate: 10 #초당 n개의 API 요청을 보냄
scenarios:
- name: 'create citizen card'
flow:
- function: 'generateRandomData'
- post:
url: '/citizen-card'
afterResponse: 'logError'
json:
nameOnCard: '{{ nameOnCard }}'
language: 'en'대략 요약하자면, 600초간 초당 10개의 /citizen-card POST 요청을 보내는 테스트입니다. 요청에 필요한 nameOnCard 값은 artillery-functions.js의 generateRandomData 함수에서 넘겨주며, 응답으로 에러가 날아오면 artillery-functions.js의 logError 함수가 에러 로그를 남깁니다.
위의 숫자들을 바꿔가며 요리조리 테스트해본 결과... 2코어 CPU + 메모리 512MB짜리 노드 9개로 고작 10 RPS(Request Per Second)를 겨우 서빙할 수 있다는 절망적인 결과를 확인했습니다😭😭 하나의 요청을 처리하는 데에는 대략 1,400~1,600ms까지도 걸렸으니, 로직의 병목 구간을 찾는 것이 급선무였습니다.
#Node.js Profiler로 분석하기
우리의 목표는 docker 컨테이너 위에 떠 있는 서버를 프로파일링하여 병목 구간을 찾아내는 것입니다. node에는 이를 가능하게 해주는 --inspect 옵션이 있습니다. 먼저, 서버를 docker 컨테이너로 띄우고 곧바로 node를 실행해봅시다!
docker run -p 9229:9229 -p 80:80 -it random-cookie-test /bin/bash
# docker 컨테이너 안에서
node -r ts-node/register/transpile-only --inspect=0.0.0.0:9229 src/index.ts-p 9229:9229 -p 80:80: 80번 포트는 API 호출용, 9229번 포트는 inspector를 붙이는 용으로 열어주었습니다-r ts-node/register/transpile-only: 저는 typescript를 사용했기에 필요했던 옵션입니다. Javascript 코드라면 생략 가능합니다.
이제 크롬 inspector로 프로파일링을 진행해봅시다. 크롬을 키고 주소창에 chrome://inspect를 입력하시면 아래 이미지를 볼 수 있습니다.
왼쪽 Remote Target에 우리가 방금 띄운 node 서버가 잡히는 것을 확인할 수 있습니다. 또, 그 밑의 inspect를 누르면 오른쪽의 크롬 debugger 창과 컨테이너에 Debugger attached. 로그 메시지를 확인 할 수 있습니다.
- 이제 Start를 누르고
- API 요청을 쏘고
- Stop 버튼을 누르면
이렇게 첫 flamegraph를 확인할 수 있습니다! 왼쪽의 뾰족뾰족한 기둥들은 시민권에 필요한 이미지들을 하나씩 그리는 부분, 그리고 그 옆의 이미지들을 합치는 부분, S3 버킷에 이미지를 업로드하는 부분입니다. 여기서 확인할 수 있는 사실은, 대부분의 시간이 그림을 불러오고 조립해서 시민권을 그려내는 데 사용된다는 것이었습니다. 하지만 정확히 왜 그림을 그리는데 이토록 오래 걸리는지는 알 수 없었습니다. 자세하게 파고들고 싶어도 단순히 그리기 함수 호출에 시간이 많이 걸린다 외에는 알 수 있는 사실이 없었습니다. 사용하고 있는 라이브러리 node-canvas의 설명을 보니 Cairo 라는 백엔드를 사용하고 있었습니다. Cairo는 C로 구현된 라이브러리이므로 더 로우레벨로 내려가서.. C로 작성된 코드까지 파고들어 병목을 확인하고 싶었습니다.
#Linux Perf로 분석하기
다시 우리의 목표는 서버를 띄운 docker 컨테이너 안에서 linux perf(이하 perf)를 통해, 서버의 병목 구간을 찾아내는 것입니다.
가장 먼저 할 일은 서버를 docker 컨테이너로 띄우기입니다! docker 이미지는 본래 서버 배포해 사용하기 위해 만들어둔 이미지를 그대로 사용하였습니다. 그리고, 컨테이너 안에서 node와 perf를 동시에 실행하기 위해 터미널 두 개로 컨테이너에 접속했습니다.
# 터미널 1에서
docker run -p 80:80 -it --privileged random-cookie-test /bin/bash
# random-cookie-test는 이미지 이름
# 터미널 2에서
docker ps
docker exec 0579 -it /bin/bash
# 0579는 터미널 1에서 띄운 컨테이너의 id 일부--privileged: 이 옵션이 있어야 perf가 필요한 곳에 모두 접근할 수 있습니다
컨테이너를 띄웠으니 perf를 설치해야 합니다. 그런데 perf가 OS 버전마다 다른 버전을 사용해야 하여 설치가 어려울 수 있습니다(저는 여기서 많은 삽질을 했습니다😢). 저는 아래 방법으로 perf 설치에 성공했습니다.
apt update
apt install linux-tools-common linux-tools-generic
ln -fs /usr/lib/linux-tools/*/perf /usr/bin/perf
#perf가 잘 설치됐는지 확인!
perf -vperf는 리눅스 시스템의 커널 성능을 측정할 수 있는 도구입니다. perf는 커널의 다양한 요소를 모니터링할 수 있는데요. 자세한 내용은 링크를 통해 확인하실 수 있고, 우리가 설치한 perf가 어떤 요소들을 모니터링하는지는 perf list 명령어를 통해 확인할 수 있습니다.
이제 준비물들이 다 갖추어졌으니 컨테이너 안에서 성능을 테스트하는 일만 남았습니다! 앞서 열어놓은 두 개의 터미널에서 아래의 명령어를 실행하면 됩니다. (사실 저는 이 이전에 docker cp 명령어로 node 서버 실행에 필요한 환경변수를 관리하는 파일을 옮겨주었으니 참고해주세요.)
#터미널 1에서
node -r ts-node/register/transpile-only --perf-basic-prof src/index.ts
#터미널 2에서
perf record -i -g -e cycles:u -p `pgrep -n node`
# API 요청 쏘고 control+c로 perf record 종료-
node -r ts-node/register/transpile-only --perf-basic-prof src/index.ts
-r ts-node/register/transpile-only: 저는 typescript를 사용했기에 필요했던 옵션입니다. Javascript 코드라면 생략 가능합니다.--perf-basic-prof: perf가 javascript symbol을 매핑하는데 필요한 옵션입니다. 이 옵션을 사용하지 않으면 나중에 빈칸이 뻥뻥 뚫리거나 모든 칸이 **[unknown]**으로 칠해진 flamegraph를 만날 수 있습니다. 이 옵션이 필요한 이유는 v8 엔진이 JIT 방식의 컴파일을 하여 perf가 javascript symbol들을 읽을 수 없기 때문인데요.--perf-basic-prof옵션은 javascript symbol과 메모리 주소를 매핑해주는 /tmp/perf-{PID}.map 파일을 생성하게 함으로써 이를 해결합니다. 자세한 내용을 링크를 통해 확인하실 수 있습니다.
-
perf record -i -g -e cycles:u -p `pgrep -n node`
-p `pgrep -n node`: 원래 -p는 PID를 넘겨주는 옵션입니다. 저는 node 서버의 PID를 넘겨주기 위해 해당 방법을 사용했습니다.
이렇게 하고 나면 perf record는 perf.data라는 파일을 남깁니다. 이 파일을 vim으로 열어보면 사람이 읽을 수 없는 외계어로 되어있지만 perf report를 이용하면 이 내용을 확인할 수 있습니다. 사실 이 perf report를 확인하면 서버의 병목 구간을 이미 확인하는 데에는 성공했지만 이대로는 한눈에 결과가 들어오지 않으니, flamegraph로 이 결과를 확인해봅시다.
#Flamegraph 그리기
Flamegraph 그리기는 이 레포지토리를 이용했습니다.
apt install git
git clone https://github.com/brendangregg/FlameGraph.git
perf script > out.perf-folded
cd FlameGraph
./stackcollapse-perf.pl < ../out.perf-folded | ./flamegraph.pl > ../graph.svg
#컨테이너 밖에서
docker cp 0579:/app/graph.svg .
# 0579는 터미널 1에서 띄운 컨테이너의 id 일부이렇게 perf 프로파일링 결과를 graph.svg로 변환하고, 컨테이너 밖으로 빼내는데 성공했습니다! 그리고 시민권 발급 서비스의 flamegraph는...
짜잔! 이렇게 나왔습니다! (마우스를 올려보세요) 이제 이 flamegraph를 분석해야 했는데요. bits_image_fetch_convolution_affine_reflect_a8r8g8b8라는 함수가 약 30%를 차지하고 있는 것을 확인할 수 있습니다. 이 함수는 제가 직접 이름 짓거나 호출한 함수는 아니기에 node-canvas가 어디선 가 호출하고 있는 함수라는 것을 짐작할 수 있습니다. 아쉽게도 node-canvas가 언제 이 함수를 호출하는지는 찾지 못하였습니다. 다만, convolution은 흔히 이미지 프로세싱에 사용되는 연산의 이름이기에 언제 호출되었는지를 유추할 수 있었습니다.
const canvas = createCanvas(CARD_WIDTH, CARD_HEIGHT);
const ctx = canvas.getContext('2d');
const cardBackground = new Image();
cardBackground.src = 'data:image/png;base64,iVBORw0KGgoAAAANS...';
ctx.drawImage(cardBackground, 0, 0, CARD_WIDTH, CARD_HEIGHT);바로 이 부분이었습니다! ctx.drawImage 함수는 이름에서도 알 수 있듯이 canvas 객체 위에 이미지를 그리는 함수입니다. 공식 문서를 참고하면 parameter로는 3개 또는 5개를 받을 수 있는데요.
void ctx.drawImage(image, dx, dy);
void ctx.drawImage(image, dx, dy, dWidth, dHeight);dx, dy는 이미지를 canvas 위에 그리기 시작할 좌푯값, dWidth, dHeight는 이미지의 사이즈를 의미합니다. 만약 원래 이미지의 크기가 dWidth, dHeight와 다르다면, 이 안에서 이미지의 resizing이 일어나고 있었습니다. 실제 제 코드에서도 원본 이미지가 dWidth, dHeight보다 커서 이미지의 축소가 일어나고 있었습니다.
그래서 코드에 사용된 모든 drawImage 함수들의 dWidth, dHeight를 원본 이미지 값보다 더 크게 주어 보았더니...30%를 잡아먹던 bits_image_fetch_convolution_affine_reflect_a8r8g8b8가 사라지고, bits_image_fetch_bilinear_affine_reflect_a8r8g8b8 함수가 그 자리를 차지하고 있었습니다! (convolution이 bilinear로 바뀌었습니다)
pixman이라는 라이브러리의 아마도 관련 함수일 것으로 추정되는 비슷한 이름의 함수 코드를 보아하니, image의 크기에 상관없이 2중 for문으로 모든 픽셀을 순회하며 연산을 수행하는 듯 보였습니다. 시민권 발급 서비스는 나중에 시민권을 실물로 인쇄할 목적으로 꽤 고해상도의 이미지를 사용하고 있었고, 이 이미지들을 쿠키의 눈, 입, 머리 등에 대해 10차례가 넘게 resizing하고 있었으니 병목이 생기고 있었던 것입니다. 실제 소스 이미지들은 1000x1000정도의 큰 이미지였으나, 시민권에 들어가는 이미지의 크기는 300x400면 충분하였기에, 모든 소스 이미지들을 미리 resizing하고, drawImage 함수에 넘겨주던 dWidth, dHeight를 최대한 지우고 나니...
시간을 30% 잡아먹던 bits_image_... 함수가 사라진 것을 확인할 수 있었습니다. 위의 flamegraph와 직접 비교하기는 어렵지만, 왼쪽의 가장 굵은 기둥의 두께를 통해 얼마나 개선되었는지 가늠할 수 있습니다.
#마무리
이 작업 후, 비슷한 리소스 조건에서 artillery 테스트를 다시 해보았더니 70RPS까지 서버가 버텨주는 것을 확인하였습니다. 원래 10RPS를 겨우 버텨냈으니 무려 7배나 많은 부하를 견딜 수 있게 된 것이었죠. 실행 시간도 요청 하나에 1,400~1,600ms 정도 걸리던 것이 700~800ms까지 감소하는 것을 확인하였습니다. Node.js profiler로 다시 분석해보았을 때도 개선 이전의 flamegraph와의 차이를 한눈에 알아볼 수 있었 습니다.
이렇게
- Artillery로 서버의 성능을 테스트하고
- Linux perf를 통해 docker 컨테이너 환경에서 서버를 프로파일링하고
- Flamegraph로 서버의 병목 구간을 확인하여
시민권 이미지 서버의 성능을 획기적으로 개선할 수 있었습니다.
저희 웹서비스셀은 이외에도 어떤 환경에서나 통용되는 웹 기술을 통해 데브시스터즈 안팎으로 접근성을 향상시킨다는 목표하에 다양한 업무를 수행하고 있습니다.
저희 셀이 더 궁금하시다면 데브시스터즈 채용 페이지를 확인해주세요.
감사합니다🙏

