사전예약 서버 프로파일링으로 서버 병목찾아 개선하기

윤준성

안녕하세요! 웹서비스셀에서 백엔드 개발자로 근무 중인 윤준성입니다🙇 지난 2020년 12월, 쿠키런:킹덤 사전예약의 시민권 생성 서비스를 개발하였는데요. 이 서비스의 개발 과정에서 서버의 성능을 테스트하고, linux perf를 통한 서버 프로파일링으로 서버의 병목 구간을 찾아 성능을 개선하였던 경험을 소개합니다.

시민권 발급 이벤트

웹서비스셀에서는 지난 1월 런칭한 쿠키런:킹덤의 사전예약 서비스를 운영하였습니다.

사전예약 서비스의 다양한 이벤트 중, 시민권 발급 이벤트는 쿠키 이름을 입력하면 랜덤하게 생성된 귀여운 쿠키 이미지와 함께 시민권을 발급해주었습니다. 그리고 이 시민권을 SNS에 공유하면, 쿠키런:킹덤에서 사용할 수 있는 보상을 얻을 스탬프를 획득할 수 있었습니다.

쿠키런:킹덤 사전예약 페이지의 시민권 생성 이벤트
쿠키런:킹덤 사전예약 페이지의 시민권 생성 이벤트

시민권에 사용되는 랜덤 쿠키 이미지는 얼굴, 몸, 눈, 입, 앞머리 모양 5개의 필수요소와 배경, 페이스페인트, 안경, 왕관, 스티커, 뒷머리 모양 6개의 선택요소, 그리고 머리 색깔로 결정됩니다. 각 요소의 가짓수를 고려하여 계산해보면... 무려 5,074,167,364,156,800가지의 쿠키 경우의 수가 존재합니다! 시민권에 들어가는 주소와 입국허가 사유도 랜덤으로 생성되는 것을 고려하면 사실은 더 많은 시민권 가짓수가 존재합니다. 이렇게나 많은 이미지를 미리 만들어 둘 수 없기에, 쿠키 이미지들은 요청이 들어오는 대로 생성하고 있었고, 이는 꽤 많은 리소스를 잡아먹는 서비스였습니다.

대신귀 여운쿠 키들을 드리겠 습니다
대신귀 여운쿠 키들을 드리겠 습니다

시민권 발급 서비스 로직

시민권 발급 서비스의 입출력은 단순합니다. Input은 시민권에 들어갈 쿠키의 이름과 언어를 받습니다(킹덤 사전예약은 한국어를 포함한 4개의 언어로 진행되었습니다.). Output으로는 생성된 시민권 이미지의 URL을 반환합니다.

input: 쿠키의 이름(준성맛 쿠키), 언어(한국어)

output: 생성된 시민권 이미지의 URL

서버는 node.js 환경에서 typescript로 작성되었으며, 서비스의 내부 로직은 아래와 같습니다.

  1. 랜덤 요소들(쿠키 증명사진 조합에 쓰일 컴포넌트들 11종, 주소, 입국허가 사유)의 값을 생성한다.
  2. 시민권 배경, 쿠키 이미지 요소들, 시민권 테두리 이미지를 node-canvas로 로드해온다.
  3. 시민권에 필요한 텍스트를 node-canvas로 이미지로 만들고, 이미지들을 합쳐서 시민권 이미지를 완성한다!
  4. 생성된 시민권 이미지를 PNGStream으로 바꿔서 S3 버킷에 업로드한다. 업로드된 이미지들은 CloudFront를 통해 프론트엔드로 서빙된다!

필요한 이미지들을 node-canvas로 그려낸다!
필요한 이미지들을 node-canvas로 그려낸다!

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를 입력하시면 아래 이미지를 볼 수 있습니다.

chrome inspector

왼쪽 Remote Target에 우리가 방금 띄운 node 서버가 잡히는 것을 확인할 수 있습니다. 또, 그 밑의 inspect를 누르면 오른쪽의 크롬 debugger 창과 컨테이너에 Debugger attached. 로그 메시지를 확인 할 수 있습니다.

  1. 이제 Start를 누르고
  2. API 요청을 쏘고
  3. Stop 버튼을 누르면

chrome before

이렇게 첫 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 -v

perf는 리눅스 시스템의 커널 성능을 측정할 수 있는 도구입니다. 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로 서버의 병목 구간을 확인하여

시민권 이미지 서버의 성능을 획기적으로 개선할 수 있었습니다.

저희 웹서비스셀은 이외에도 어떤 환경에서나 통용되는 웹 기술을 통해 데브시스터즈 안팎으로 접근성을 향상시킨다는 목표하에 다양한 업무를 수행하고 있습니다. 저희 셀이 더 궁금하시다면 데브시스터즈 채용 페이지를 확인해주세요.

감사합니다🙏

데브시스터즈는 최고의 인재를 찾고 있습니다.

데브시스터즈에서는 능력있는 웹 백엔드 엔지니어웹 프론트엔드 엔지니어를 찾고 있습니다.
자세한 내용은 채용 사이트를 확인해주세요!
백엔드프론트엔드사내시스템채용

© 2021 Devsisters Corp. All Rights Reserved.