Skip to main content

애플리케이션 성능 측정과 튜닝

아래 내용은 DevOps와 SE를 위한 리눅스 커널 이야기 12장 애플리케이션 성능 측정과 튜닝을 읽고 정리한 내용입니다.

Flask 서버 설치 및 실행

# pip 및 python3 설치
yum install -y python3 python3-pip

# 가상 환경 설정
python3 -m vnev myenv

# Activate
source myenv/bin/activate && cd /myenv

# flask 및 redis 설치
pip install flask redis

vi.app.py

애플리케이션 코드 작성

import redis
import time
from flask import Flask
app = Flask(__name__)

@app.route("/test/<key>")
def testApp(key):
r = redis.StrictRedis(host='172.31.37.40', port=6379, db=2)
r.set(key, time.time())

return r.get(key)

if __name__ == "__main__":
app.run(host="0.0.0.0")

코드 실행 방법 3가지

# 1. 내장 애플리케이션(기본) 사용
python app.py

# 2. sync 타입의 워커 4개 실행
gunicorn -w 4 -b 0.0.0.0:5000 app:app

# 3. async 타입의 워커 4개 실행 및 keepalive 설정
gunicorn -w 4 -b 0.0.0.0:5000 app:app --keep-alive 10 -k eventlet

처음에는 기본 내장 애플리케이션을 사용하기 위해 첫번째 방법으로 코드를 실행합니다.

응답 확인

[ec2-user@ip-172-31-43-234 ~]$ curl 127.0.0.1:5000/test/2
1696914740.9461732

정상적으로 코드를 실행하면 위와 같은 응답을 확인할 수 있습니다.


벤치마킹 테스트 도구 설치 및 실행

벤치마킹 테스트를 위해 siege 도구를 사용합니다.

sudo amazon-linux-extras install epel
yum install siege
wget http://download.joedog.org/siege/siege-latest.tar.gz
tar -zxvf siege-latest.tar.gz

./configure --prefix=/usr/local/siege
make;make install

cd /usr/local/siege/bin

# 성능 테스트 시작
# 30초 동안 100개ㅑ의 동시 요청
./siege -c 100 -b -t30s http://127.0.0.1:5000/test/1

Lifting the server siege...
Transactions: 12252 hits
Availability: 100.00 %
Elapsed time: 29.61 secs
Data transferred: 0.21 MB
Response time: 0.24 secs
Transaction rate: 413.78 trans/sec
Throughput: 0.01 MB/sec
Concurrency: 99.58
Successful transactions: 12252
Failed transactions: 0
Longest transaction: 0.30
Shortest transaction: 0.02

Transaction rate는 초당 서버가 처리할 수 있는 트랜잭션(요청)의 수를 나타내며 위에서는 해당 서버가 초당 약 413.78개의 HTTP 요청을 처리할 수 있다는 것을 의미합니다. 지금 사용하고 있는 플라스크의 내장 애플리케이션 서버는 싱글 스레드로 동작하기 때문에 당연한 결과입니다. 그래서 다음으로는 플라스크의 내장 애플리케이션 서버를 사용하지 않고 gunicorn이라는 별도의 애플리케이션을 사용합니다.

pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app

위 명령을 실행하면 마스터 프로세스 1개와 4개의 워커 프로세스가 실행되어 총 5개의 워커 프로세스가 실행됩니다.

devops-se-ch-12-1

성능 테스트

./siege -c 100 -b -t30s http://127.0.0.1:5000/test/1

Lifting the server siege...
Transactions: 12488 hits
Availability: 100.00 %
Elapsed time: 29.74 secs
Data transferred: 0.21 MB
Response time: 0.24 secs
Transaction rate: 419.91 trans/sec
Throughput: 0.01 MB/sec
Concurrency: 99.38
Successful transactions: 12488
Failed transactions: 0
Longest transaction: 0.34
Shortest transaction: 0.02

Transaction rate가 큰 변화가 없습니다. CPU가 1개 1코어인 서버에서 gunicorn의 멀티 프로세스 방식을 사용하는 것과 싱글 스레드로 실행하는 것은 성능상 큰 차이를 보이지 않는 것 같습니다. 따라서 위와 같은 성능 테스트 변화를 관측하기 위해서는 테스트 서버를 단일 코어로 생성할 것이 아니라 멀티 코어로 생성하는 것을 권장드립니다. 교재에서는 gunicorn으로 실행했을 때 성능이 확연히 좋아졌었는데 이는 gunicorn은 멀티 프로세스 모드로 동작시키기 때문이기도 하지만 그만큼 플라스크 기본 애플리케이션 서버의 성능이 좋지 않음을 의미한다고 합니다!

단일 코어 CPU에서는 한 번에 하나의 작업만 실행하므로 여러 프로세스가 있다고 한들 이들 프로세스는 CPU 코어에서 번갈아 가며 실행되어 단일 코어에서 멀티 프로세스 방식은 병렬 처리의 이점을 제대로 활용할 수 없습니다.

또한 멀티 프로세스 방식이 무조건 좋은 건 아닙니다. 여러 프로세스를 실행하면 운영 체제는 프로세스들 사이에서 컨텍스트 스위칭을 수행해야 하며 이러한 스위칭은 오버헤드를 발생시키고 많은 양의 컨텍스트 스위치는 곧 성능 저하를 일으킬 수 있습니다. 웹 서버의 경우 대부분의 작업이 I/O 바운드인데 네트워크 I/O나 디스크 I/O와 같은 작업에서 대기하는 시간(대기큐에서 대기하는 시간)이 CPU에서 실제로 작업을 수행하는 시간(준비큐에서 실행상태로 변경되어 실행되는 시간)보다 길 수 있습니다. 이러한 경우, 멀티 프로세스나 멀티 스레드 방식이 I/O 바운드 작업의 처리 속도를 향상 시킬 수 있습니다.

네트워크 소켓 최적화

네트워크 소켓 최적화 전에 6379번 포트에서 다수의 TIME_WAIT 소켓이 발생하는 것을 확인합니다.

[ec2-user@ip-172-31-43-234 ~]$ ss -s
Total: 384
TCP: 4309 (estab 207, closed 4096, orphaned 0, timewait 4096)

Transport Total IP IPv6
RAW 0 0 0
UDP 8 4 4
TCP 213 211 2
INET 221 215 6
FRAG 0 0 0

[ec2-user@ip-172-31-43-234 ~]$ netstat -napo | grep -ic 6379
2076

커넥션 풀 사용하기

app.py 코드에서 애플리케이션이 먼저 redis 서버와의 연결을 끊기 때문에(flask 서버가 active closer가 되기 때문에) flask 서버에서 다수의 TIME_WAIT 소켓이 발생합니다. 이처럼 6379번 포트에 TIME_WAIT 소켓을 줄이기 위해 아래와 같이 코드를 수정합니다.

import redis
import time
from flask import Flask
app = Flask(__name__)

pool = redis.ConnectionPool(host='172.31.37.40', port=6379, db=0)

@app.route("/test/<key>")
def testApp(key):
r = redis.Redis(connection_pool=pool)
r.set(key, time.time())

return r.get(key)

if __name__ == "__main__":
app.run(host="0.0.0.0")

API 호출 시마다 연결했던 부분을 수정해서 미리 커넥션 풀(전역 변수)을 만들어 놓고 요청이 올 때는 그 커넥션 풀을 사용하는 방식으로 수정합니다.

[ec2-user@ip-172-31-43-234 ~]$ ss -s
Total: 384
TCP: 4309 (estab 207, closed 4096, orphaned 0, timewait 4096)

Transport Total IP IPv6
RAW 0 0 0
UDP 8 4 4
TCP 213 211 2
INET 221 215 6
FRAG 0 0 0


[ec2-user@ip-172-31-43-234 ~]$ netstat -napo | grep -i 6379
tcp 0 0 172.31.43.234:48586 172.31.37.40:6379 ESTABLISHED 13453/python3 off (0.00/0/0)
tcp 0 0 172.31.43.234:48592 172.31.37.40:6379 ESTABLISHED 13452/python3 off (0.00/0/0)
tcp 0 0 172.31.43.234:48614 172.31.37.40:6379 ESTABLISHED 13451/python3 off (0.00/0/0)
tcp 0 0 172.31.43.234:48598 172.31.37.40:6379 ESTABLISHED 13450/python3 off (0.00/0/0)

코드를 수정하고 실행한다면 6379번 포트로 TIME_WAIT 상태의 소켓이 아닌 EST 상태의 소켓이 생성됩니다. 여기서는 각 워커 프로세스에서 하나씩 소켓을 사용하게 됩니다.

siege -c 100 -b -t30s http://127.0.0.1:5000/test/1

Lifting the server siege...
Transactions: 20991 hits
Availability: 100.00 %
Elapsed time: 30.71 secs
Data transferred: 0.35 MB
Response time: 0.15 secs
Transaction rate: 683.52 trans/sec
Throughput: 0.01 MB/sec
Concurrency: 99.58
Successful transactions: 20991
Failed transactions: 0
Longest transaction: 0.22
Shortest transaction: 0.01

Transaction rate가 419에서 683으로 증가하여 성능이 향상된 것을 확인할 수 있습니다. 이는 한번 맺은 세션을 계속해서 사용하게 되므로 TCP handshake에 대한 오버헤드가 줄어 성능이 향상되었음을 보여줍니다.

Connection 응답헤더1

# 외부 요청
telnet 15.164.93.238 5000
Trying 15.164.93.238...
Connected to ec2-15-164-93-238.ap-northeast-2.compute.amazonaws.com.
Escape character is '^]'.
GET /test/1 HTTP/1.1
Host: 15.164.93.238

HTTP/1.1 200 OK
Server: gunicorn
Date: Tue, 10 Oct 2023 06:29:08 GMT
Connection: close # Connection close 확인 (서버가 Active Closer)
Content-Type: text/html; charset=utf-8
Content-Length: 18

1696919348.2717874Connection closed by foreign host.

# 내부 요청 (flask 서버)
[ec2-user@ip-172-31-43-234 ~]$ telnet 15.164.93.238 5000
Trying 15.164.93.238...
Connected to 15.164.93.238.
Escape character is '^]'.
GET /test/1 HTTP/1.1
Host: 15.164.93.238

HTTP/1.1 200 OK
Server: gunicorn
Date: Tue, 10 Oct 2023 06:29:51 GMT
Connection: close # Connection close 확인 (서버가 Active Closer)
Content-Type: text/html; charset=utf-8
Content-Length: 18

1696919391.5181108Connection closed by foreign host.

flask 서버는 클라이언트의 GET 요청에 대한 응답으로 Connection 필드의 값을 close로 내려주는데 이는 서버가 연결을 유지하지 않는다는 의미로 이를 통해 서버가 먼저 연결을 끊었음을 확인할 수 있습니다.

keepalive 적용

다수의 TIME_WAIT 소켓이 생기는 것은 TCP 연결의 맺고 끊음이 그만큼 빈번하게 일어난다는 의미이며 이때 어디서 TIME_WAIT 소켓이 발생하는지(누가 Active Closer인지) 찾아서 연결을 유지한 상태로 사용하면 성능을 더욱 향상시킬 수 있습니다.

keepalive 기능을 사용할 경우 응답 헤더의 Connection 필드의 값은 close가 아닌 keep-alive로 변경됩니다. 이를 통해 keepalive 적용을 확인할 수 있습니다. 먼저 기본으로 사용하는 sync 타입의 워커에서는 keepalive 기능을 사용할 수 없어 async 타입으로 동작하는 다른 워커를 사용해야만 keepalive 기능을 사용할 수 있기에 async 타입을 실행합니다.

# sync (not support keepalive)
# gunicorn --keep-alive 5 -w 4 -b 0.0.0.0:5000 app:app

# async (support keepalive)
gunicorn -w 4 -b 0.0.0.0:5000 app:app --keep-alive 10 -k eventlet

Connection 응답헤더2

[ec2-user@ip-172-31-37-40 ~]$ telnet 15.164.93.238 5000
Trying 15.164.93.238...
Connected to 15.164.93.238.
Escape character is '^]'.
GET /test/1 HTTP/1.1
Host: 15.164.93.238

HTTP/1.1 200 OK
Server: gunicorn
Date: Tue, 10 Oct 2023 10:34:18 GMT
Connection: keep-alive # 확인
Content-Type: text/html; charset=utf-8
Content-Length: 18

이번에는 ./siege -c 100 -b -t30s -H "Connection: Keep-Alive" http://127.0.0.1:5000/test/1 명령으로 벤치마킹 테스트를 진행하여 ss -s 또는 netstat -napo | grep -i time_wait으로 time_wait 상태의 소켓 수 변화를 확인합니다.

reverse proxy 설정

다음 내용으로는 nginx를 통해 reverse proxy를 설정하면서 nginx와 flask 서버 사이에서 발생할 수 있는 성능 문제와 이를 개선할 수 있는 성능 향상 방법에 대해서 다루고 있습니다.

nginx 실행시에 http_stub_status는 nginx_status를 통해서 현재 nginx의 TPS 등을 확인할 수 있으며, -with-debug 옵션은 nginx의 동작 과정을 확인하기 위해 디버깅 로그를 사용할 때 도움이 많이 된다고 합니다.

로컬 포트 할당 부족 (원인)

본문에서 nginx를 사용하여 reverse proxy 설정 후에 siege 테스트 결과 Availability가 100% 미만으로 떨어지게 되는데 이 경우는 일부 요청이 실패할 경우 낮아지는 결과이며 이에 대한 원인을 확인하기 위해 nginx의 error_log를 살펴본 뒤 nginx 서버에서 flask 서버로 요청 시에 클라이언트 로컬 포트 할당 부족으로 인한 요청 실패가 원인임을 찾아냈고 다시 말해, nginx가 gunicorn으로 사용자의 요청을 전달할 때 로컬 포트를 할당 받지 못해 요청이 전달되지 못한 것입니다.

두 가지 해결책 (결과)

이에 대한 해결책으로 net.ipv4.tcp_tw_reuse 커널 파라미터를 enable로 설정해서 재사용할 수 있게 하거나 nginx와 gunicorn 사이에도 keepalive로 동작할 수 있도록 두 가지 방법을 제시하고 있습니다.


실제로 nginx의 에러 로그를 살펴볼 때 worker_connections are not enough 로그를 발견하게 되는데 이는 nginx.conf에 설정해 둔 worker_connections의 개수가 모자랄 때 발생하는 에러로 시스템의 리소스는 남지만 요청을 처리하지 못하고 에러를 출력할 때의 상황임을 나타냅니다. 즉 시스템에서 더 많은 요청을 처리할 수 있음에도 소프트웨어 설정(nginx 설정)때문에 처리하지 못하는 것이기 때문에 실제로 애플리케이션 운영시에도 이러한 에러를 조심해야 한다고 합니다.

  • nginx에서는 보통 worker_connections를 설정 값을 1024로 권장한다고 합니다.

또한 nginx 사용 시 select 방식의 이벤트 처리 모듈보다 epoll 방식이 더 나은 성능을 보여준다고 하며 애플리케이션을 작성하는 코드도 중요하지만 어떤 프레임워크와 어떤 애플리케이션 서버를 이용해서 서비스하는지도 성능에 매우 중요하다고 합니다.

epoll

리눅스에서는 모든 것이 파일이기 때문에 네트워크 연결조차 내부적으로 파일로 존재합니다. 모든 프로세스마다 유니크한 File Descriptor가 있고 이 File Descriptor는 어느 파일이 읽거나 쓸 준비가 되었는지 식별하기 위해 자주 Polling해야 합니다. 즉, nginx는 싱글 스레드로 Non Blocking I/O를 사용하므로 단일 프로세스는 어떤 연결 파일을 읽거나 쓸 준비가 되었는지 식별이 필요한 것입니다. 운영 체제에는 이를 위해 세 가지 방법(select, poll, epoll)이 있으며 select와 poll을 사용할 경우 어떤 File Descriptor가 준비 되었는지 찾기 위해서 모든 File Polling하지만 이 방법은 효울적이지 못합니다. 효율적이지 않기에 너무 많은 커넥션이 발생할 경우 성능은 더 하락하게 됩니다.

예를 들어 한 번에 10,000개의 연결을 제공한다고 할때 이 중 한 개의 파일만 읽을 준비가 되었다고 가정한다면 하나의 File Descriptor가 읽을 준비가 되었음을 확인하기 위해 프로세스는 나머지 9,999 File Descriptor를 계속 스캔해야 합니다.

다시 말해, select/poll은 모든 연결을 순회하며 각각의 파일 디스크립터를 검사하여 준비된 것이 있는지 확인하므로 연결의 수가 증가함에 따라 성능이 선형적으로 감소하는 문제점을 야기합니다.

select와 Poll 방식 외에도 epoll이 존재하며 이 방식은 2.6 버전 이상의 Linux 커널에서만 사용 가능합니다. Epoll은 select 및 poll에 비해 효율적인 메커니즘을 사용합니다.

epoll은 이벤트 기반의 메커니즘을 사용하여 연결이 변경될 때만 운영체제로부터 알림을 받으므로 준비된 연결만을 빠르게 처리합니다. 따라서 대규모 연결시에 스캐닝 오버헤드 없이 바로 변경된 연결에 대한 정보를 얻을 수 있습니다.

결론적으로 epoll은 대규모 연결과 빈번한 I/O 이벤트가 발생하는 환경에서 select나 poll에 비해 훨씬 더 효율적이며 확장성 있게 동작하므로 현대의 대규모 웹 서버나 애플리케이션 서버에서는 주로 epoll 기반의 이벤트 처리 메커니즘을 선호합니다.

개인적 사견으로 잘못된 소프트웨어 설정으로 인해 시스템 리소스를 모두 활용하지 못하는 사례가 있다는 것을 보았을 때 성능을 향상시키기 위해 작은 설정 하나가 시스템 리소스에 어떤 영향을 미칠 수 있는지 파악하는 것이 중요하다고 생각되었으며 더 나아가 여러 설정들로 시스템의 전체적인 동작(복합적인 동작)이 어떻게 바뀌는지까지 이해해야 안정적인 성능 향상을 이끌 수 있다고 생각됩니다. 지금까지 하나의 전체적인 동작을 이해하고 파악하기 위해 제너럴리스트의 길을 걸었다면 앞으로 작은 차이가 시스템의 어떤 성능을 미칠 수 있는지 가늠할 수 있는 스페셜리스트가 되어야 할 것 같은 예감이 듭니다.