NGINX 의 스레드 풀은 성능을 9배 향상합니다.
NGINX 가 연결을 처리하기 위해 비동기식 이벤트 기반 접근 방식을 사용한다는 것은 잘 알려져 있습니다. 이는 전통적인 아키텍처를 가진 서버와 달리 각 요청마다 별도의 프로세스나 스레드를 생성하는 대신, 하나의 worker process에서 여러 연결과 요청을 처리합니다. 이를 위해 NGINX는 소켓을 non-blocking 모드로 처리하고, epoll과 kqueue와 같은 효율적인 방법을 사용합니다.
Full-weight process의 개수가 적고(일반적으로 CPU 코어당 하나만 있음) 일정하므로 메모리 소모가 적고 CPU 사이클이 작업 전환에 낭비되지 않습니다. 이와 같은 접근 방식의 장점은 NGINX 자체를 통항 예시를 통해 잘 알려져 있습니다. NGINX는 수백만 개의 동시 요청을 성공적으로 처리하며 확장성이 매우 우수합니다.

각 프로세스는 추가 메모리를 사용하고 이들 사이의 각 스위치는 CPU 주기를 사용하고 L-캐시를 폐기합니다.
하지만 비동기, 이벤트 기반 접근 방식에는 여전히 문제가 있습니다. 또는 이를 “적”이라고 생각할 수 있습니다. 이 적의 이름은 “블로킹“입니다. 안타깝게도 많은 제3자 모듈은 블로킹 호출을 사용하며, 사용자들(가끔은 모듈 개발자들조차도)은 이러한 단점에 대해 인식하지 못합니다. 블로킹 작업은 NGINX의 성능을 망치는 요인이 될 수 있으며, 필요한 경우 반드시 피해야 합니다.
심지어 현재의 공식 NGINX 코드에서도 모든 경우에 블로킹 작업을 피하는 것은 불가능하며, 이 문제를 해결하기 위해 “스레드 풀(thread pools)” 매커니즘이 NGINX 버전 1.7.11과 NGINX Plus R7에 도입되었습니다. 이것이 무엇이며 어떻게 사용해야 하는지에 대해서는 나중에 다루도록 하겠습니다. 이제 적과 직면해보겠습니다.
목차
1. 문제점
2. NGINX 스레드 풀
3. NGINX 벤치마킹
4. 여전히 은총알은 아니다.
5. 스레드 풀 구성
6. 결론
1. 문제점
먼저 문제를 더 잘 이해하기 위해 NGINX 작동 방식에 대해 몇 마디 말씀드리겠습니다.
일반적으로 NGINX는 이벤트 핸들러로, 연결에서 발생하는 모든 이벤트에 대한 정보를 커널로부터 받은 다음 운영체제를 조정함으로써 모든 어려운 작업을 처리하고, 운영체제는 일상적인 바이트 읽기와 전송 작업을 수행합니다. 따라서 NGINX가 빠르고 적시에 응답하는 것은 매우 중요합니다.

Worker process는 커널에서 이벤트를 수신하고 처리합니다.
이벤트는 타임아웃, 읽기 또는 쓰기 준비가 된 소켓에 대한 알림, 또는 발생한 오류에 대한 알림 등이 포함될 수 있습니다. NGINX는 이벤트들을 받아 한 번에 하나씩 처리하면서 필요한 작업을 수행합니다. 따라서 모든 처리는 단일 스레드 내에서 큐를 기반으로 한 간단한 루프를 통해 이루어집니다. NGINX는 큐에서 이벤트를 디큐하고, 예를 들어 소켓을 읽거나 쓰는 등 해당 이벤트에 대응합니다. 대부분의 경우, 이는 매우 빠르며(메모리 내의 데이터를 복사하는 데 몇 개의 CPU 사이클만 필요할 수도 있음), NGINX는 큐의 모든 이벤트를 즉시 처리합니다.

모든 처리는 하나의 스레드에 의해 간단한 루프에서 수행됩니다.
그러나 길고 무거운 작업이 발생하면 어떻게 될까요? 이벤트 처리의 전체 주기는 이 작업이 완료될 때까지 대기하면서 중단됩니다.
따라서 “블로킹 작업”이란 상당한 시간 동안 이벤트 처리 주기를 중지하는 모든 작업을 의미합니다. 작업이 블로킹 상태가 되는 이유는 다양할 수 있습니다. 예를 들어, NGINX가 오랜 시간 동안 CPU를 많이 사용하는 처리로 바쁜 경우나 하드 드라이브에 대한 액세스를 기다려야 하는 경우 등이 있을 수 있습니다. 또한, 데이터베이스와의 동기식 통신이나 mutex 또는 library 작업과 같이 외부 시스템과의 동기식 상호작용을 포함하는 작업도 블로킹 될 수 있습니다.
가게 앞에 긴 줄이 있는 영업 사원을 상상해 보십시오. 대기열의 첫 번째 사람은 매장에는 없지만, 창고에 있는 물건을 요청합니다. 판매원은 물건을 배달하기 위해 창고로 가게 됩니다. 이제 전체 줄은 이 배달을 위해 몇 시간을 기다려야 하며, 줄 안의 모든 사람들은 불만을 품고 있습니다. 줄에 있는 각 사람의 대기 시간이 몇 시간만큼 늘어나지만, 구매하려는 물건들은 실제로 가게 안에 바로 있을 수 있습니다.

대기열에 있는 모든 사람은 첫 번째 사람의 주문을 기다려야 합니다.
NGINX에서도 거의 비슷한 상황이 발생합니다. 메모리에 캐시되지 않은 파일을 읽으려고 할 때 디스크에서 읽어와야 하는 경우입니다. 하드 드라이브는 느리며(특히 회전하는 드라이브는 더욱 느립니다), 대기열에 있는 다른 요청들이 드라이브에 접근할 필요가 없더라도 그들은 여전히 기다려야 합니다. 이로 인해 지연 시간이 증가하고 시스템 리소스가 완전히 활용되지 않게 됩니다.

단 한 번의 차단 작업으로 모든 후속 작업이 상당한 시간 동안 지연될 수 있습니다.
일부 운영체제는 파일을 읽고 전송하기 위한 비동기 인터페이스를 제공하며, NGINX는 이 인터페이스를 사용할 수 있습니다(aio 지시문 참조). 여기서 좋은 예는 FreeBSD입니다. Linux는 동일한 기능을 제공하지는 않습니다. Linux는 파일 읽기를 위한 비동기 인터페이스를 제공하지만, 몇 가지 중요한 단점이 있습니다. 그중 하나는 파일 액세스와 버퍼의 정렬 요구 사항인데, NGINX는 이를 잘 처리합니다. 그러나 두 번째 문제가 더 심각합니다. 비동기 인터페이스는 파일 디스크립터에 O_DIRECT 플래그를 설정해야 하는데, 이는 파일에 대한 모든 액세스가 메모리 캐시를 우회하고 하드 디스크에 부하를 증가시킨다는 의미입니다. 이는 많은 경우에 최적화되지 않습니다.
특히 이 문제를 해결하기 위해 NGINX 1.7.11 및 NGINX Plus R7에서 스레드 풀이 도입되었습니다.
이제 스레드 풀이 무엇이며, 어떻게 작동하는지 살펴보겠습니다.
2. NGINX 스레드 풀
불행한 판매원에게 돌아가 봅시다. 먼 창고에서 물건을 배달하던 그는 더 똑똑해졌습니다. (아니면 분노한 고객들의 군중에게 맞아서 더 똑똑해졌을까?) 그리고 배송 서비스를 고용했습니다. 이제 누군가가 먼 창고에서 무언가를 요구하면, 판매원은 직접 창고로 가는 대신에 배송 서비스를 맡기고 그들이 주문을 처리합니다. 그동안 판매원은 다른 고객들을 계속해서 서비스를 제공합니다. 따라서 가게 안에 물건이 없는 클라이언트만 배송을 기다리게 되고, 다른 사람들은 즉시 서비스를 받을 수 있습니다.

배달 서비스에 주문을 전달하면 대기열 차단이 해제됩니다.
NGINX에서는 스레드 풀이 배송 서비스의 역할을 수행합니다. 스레드 풀은 작업 큐와 큐를 처리하는 여러 개의 스레드로 구성됩니다. worker process가 잠재적으로 오랜 시간이 걸리는 작업을 처리해야 할 때, 작업을 직접 처리하는 대신에 풀의 큐에 작업을 넣어두고 여유 스레드 중 어느 것이든 가져와 처리할 수 있게 합니다.

Worker process는 차단 작업을 스레드 풀로 오프로드 합니다.
맞습니다. 이 경우에는 다른 큐가 추가로 생기게 됩니다. 그러나 이번에는 큐가 특정 리소스로 제한됩니다. 드라이브가 생성할 수 있는 데이터 속도보다 더 빠르게 읽을 수는 없습니다. 이제 적어도 드라이브는 다른 이벤트의 처리를 지연시키지 않고, 파일에 액세스해야 하는 요청만 대기하게 됩니다.
“디스크에서 읽기” 작업은 종종 블로킹 작업의 가장 일반적인 예로 사용되지만, 사실 NGINX의 스레드 풀 구현은 주 작업 주기에서 처리하기에 적합하지 않은 모든 작업에 사용될 수 있습니다.
현재는 스레드 풀에 오프로딩을 적용한 핵심 작업은 세 가지로 구현되어 있습니다. 대부분 운영체제에서의 read() syscall, Linux에서의 sendfile(), 그리고 캐시용 임시 파일과 같은 일부 파일을 쓸 때 사용되는 Linux의 aio_write(). 계속해서 구현을 테스트하고 벤치마킹할 것이며, 명확한 이점이 있다면 향후 릴리스에서 다른 작업들을 스레드 풀에 오프로딩할 수도 있을 것입니다.
3. NGINX 벤치마킹
이제 이론에서 실전으로 넘어가는 시간입니다. 스레드 풀 사용의 효과를 보여주기 위해 블로킹 및 논블로킹 작업의 최악의 조합을 시뮬레이션하는 합성 벤치마크를 수행할 것입니다.
이 작업에는 메모리에 맞지 않는 데이터 세트가 필요합니다. 48GB의 RAM을 가진 컴퓨터에서 4MB 파일에 256GB의 무작위 데이터를 생성한 후 NGINX를 구성하여 제공하도록 설정했습니다.
구성은 매우 간단합니다.
worker_processes 16;
events {
accept_mutex off;
}
http {
include mime.types;
default_type application/octet-stream;
access_log off;
sendfile on;
sendfile_max_chunk 512k;
server {
listen 8000;
location / {
root /storage;
}
}
}
보시다시피, 성능을 개선하기 위해 몇 가지 조정 작업이 이루어졌습니다. 로깅과 accept_mutex가 비활성화되었고, sendfile이 활성화되었으며, sendfile_max_chunk가 설정되었습니다. 마지막 지시문은 블로킹 sendfile() 호출에 소요되는 최대 시간을 줄일 수 있습니다. NGINX는 파일 전체를 한 번에 보내려고 하지 않고, 512KB 단위로 보내게 됩니다.
해당 시스템은 두 개의 Intel Xeon e5645 프로세서(총 12 코어, 24 HT 스레드)와 10-Gbps 네트워크 인터페이스를 가지고 있습니다. 디스크 하위 시스템은 네 개의 Western Digital WD1003FBYX 하드 드라이브로 구성된 RAID10 배열로 표현됩니다. 이 모든 하드웨어는 Ubuntu Server 14.04.1 LTS에서 운영됩니다.

벤치마크를 위한 부하 생성기 및 NGINX 구성
클라이언트는 동일한 사양을 가진 두 대의 컴퓨터로 표현됩니다. 이 중 하나의 컴퓨터에는 wrk가 Lua 스크립트를 사용하여 부하를 생성합니다. 이 스크립트는 200개의 병렬 연결을 사용하여 서버에서 파일을 무작위로 요청하며, 각 요청은 캐시 미스와 디스크로부터의 블로킹 읽기가 발생할 가능성이 있습니다. 이러한 부하는 “랜덤 부하”라고 하겠습니다.
두 번째 클라이언트 컴퓨터에서는 50개의 병렬 연결을 사용하여 동일한 파일을 여러번 요청하는 wrk의 다른 복사본을 실행할 것입니다. 이 파일은 자주 액세스되기 때문에 항상 메모리에 유지됩니다. 일반적인 상황에서는 NGINX가 이러한 요청을 매우 빠르게 처리하지만, worker process가 다른 요청에 의해 블로킹되면 성능이 저하됩니다. 이러한 부하를 “고정 부하”라고 하겠습니다.
성능은 서버 컴퓨터의 처리량을 ifstat을 사용하여 모니터링하고, 두 번째 클라이언트로부터 wrk 결과를 얻어 측정할 것입니다.
이제 스레드 풀이 없는 첫 번째 실행은 매우 흥미로운 결과를 제공하지 않습니다.
% ifstat -bi eth2
eth2
Kbps in Kbps out
5531.24 1.03e+06
4855.23 812922.7
5994.66 1.07e+06
5476.27 981529.3
6353.62 1.12e+06
5166.17 892770.3
5522.81 978540.8
6208.10 985466.7
6370.79 1.12e+06
6123.33 1.07e+06
보시다시피 이 구성으로 서버는 총 약 1Gbps의 트래픽을 처리할 수 있습니다. top의 출력에서는 모든 worker process가 대부분의 시간을 I/O 블로킹에 소비하는 것을 볼 수 있습니다(D 상태에 있음).
top - 10:40:47 up 11 days, 1:32, 1 user, load average: 49.61, 45.77 62.89
Tasks: 375 total, 2 running, 373 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.0 us, 0.3 sy, 0.0 ni, 67.7 id, 31.9 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem: 49453440 total, 49149308 used, 304132 free, 98780 buffers
KiB Swap: 10474236 total, 20124 used, 10454112 free, 46903412 cached Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4639 vbart 20 0 47180 28152 496 D 0.7 0.1 0:00.17 nginx
4632 vbart 20 0 47180 28196 536 D 0.3 0.1 0:00.11 nginx
4633 vbart 20 0 47180 28324 540 D 0.3 0.1 0:00.11 nginx
4635 vbart 20 0 47180 28136 480 D 0.3 0.1 0:00.12 nginx
4636 vbart 20 0 47180 28208 536 D 0.3 0.1 0:00.14 nginx
4637 vbart 20 0 47180 28208 536 D 0.3 0.1 0:00.10 nginx
4638 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.12 nginx
4640 vbart 20 0 47180 28324 540 D 0.3 0.1 0:00.13 nginx
4641 vbart 20 0 47180 28324 540 D 0.3 0.1 0:00.13 nginx
4642 vbart 20 0 47180 28208 536 D 0.3 0.1 0:00.11 nginx
4643 vbart 20 0 47180 28276 536 D 0.3 0.1 0:00.29 nginx
4644 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.11 nginx
4645 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.17 nginx
4646 vbart 20 0 47180 28204 536 D 0.3 0.1 0:00.12 nginx
4647 vbart 20 0 47180 28208 532 D 0.3 0.1 0:00.17 nginx
4631 vbart 20 0 47180 756 252 S 0.0 0.1 0:00.00 nginx
4634 vbart 20 0 47180 28208 536 D 0.0 0.1 0:00.11 nginx<
4648 vbart 20 0 25232 1956 1160 R 0.0 0.0 0:00.08 top
25921 vbart 20 0 121956 2232 1056 S 0.0 0.0 0:01.97 sshd
25923 vbart 20 0 40304 4160 2208 S 0.0 0.0 0:00.53 zsh
이 경우 CPU가 대부분 유휴 상태인 동안 처리량은 디스크 하위 시스템에 의해 제한됩니다. wrk의 결과도 매우 낮습니다.
Running 1m test @ http://192.0.2.1:8000/1/1/1
12 threads and 50 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 7.42s 5.31s 24.41s 74.73%
Req/Sec 0.15 0.36 1.00 84.62%
488 requests in 1.01m, 2.01GB read
Requests/sec: 8.08
Transfer/sec: 34.07MB
기억해 주세요, 이는 메모리에서 제공되어야 하는 파일에 관 결과입니다! 지나치게 큰 지연 시간은 모든 worker process가 첫 번째 클라이언트의 200개 연결로 생성된 랜덤 부하를 처리하기 위해 드라이브에서 파일을 읽는 데 바쁘기 때문에 발생하며, 우리의 요청을 적시에 처리할 수 없는 상황입니다.
스레드 풀을 사용할 때입니다. 이를 위해 우리는 location 블록에 aio threads 지시문을 추가하기만 하면 됩니다.
location / {
root /storage;
aio threads;
}
그리고 NGINX에 구성을 다시 로드하도록 요청합니다.
그 후, 테스트를 반복합니다.
% ifstat -bi eth2
eth2
Kbps in Kbps out
60915.19 9.51e+06
59978.89 9.51e+06
60122.38 9.51e+06
61179.06 9.51e+06
61798.40 9.51e+06
57072.97 9.50e+06
56072.61 9.51e+06
61279.63 9.51e+06
61243.54 9.51e+06
59632.50 9.50e+06
이제 우리 서버는 스레드 풀이 없는 ~1Gbps에 비해 9.5Gbps를 생성합니다!
아마도 더 많은 처리량을 생산할 수도 있겠지만, 이미 실용적인 최대 네트워크 용량에 도달했기 때문에 이 테스트에서는 NGINX가 네트워크 인터페이스로 제한되었습니다. Worker process는 대부분의 시간을 자고 새로운 이벤트를 기다리며 보내고 있습니다. (top에서 S 상태에 있음).
top - 10:43:17 up 11 days, 1:35, 1 user, load average: 172.71, 93.84, 77.90
Tasks: 376 total, 1 running, 375 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.2 us, 1.2 sy, 0.0 ni, 34.8 id, 61.5 wa, 0.0 hi, 2.3 si, 0.0 st
KiB Mem: 49453440 total, 49096836 used, 356604 free, 97236 buffers
KiB Swap: 10474236 total, 22860 used, 10451376 free, 46836580 cached Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4654 vbart 20 0 309708 28844 596 S 9.0 0.1 0:08.65 nginx
4660 vbart 20 0 309748 28920 596 S 6.6 0.1 0:14.82 nginx
4658 vbart 20 0 309452 28424 520 S 4.3 0.1 0:01.40 nginx
4663 vbart 20 0 309452 28476 572 S 4.3 0.1 0:01.32 nginx
4667 vbart 20 0 309584 28712 588 S 3.7 0.1 0:05.19 nginx
4656 vbart 20 0 309452 28476 572 S 3.3 0.1 0:01.84 nginx
4664 vbart 20 0 309452 28428 524 S 3.3 0.1 0:01.29 nginx
4652 vbart 20 0 309452 28476 572 S 3.0 0.1 0:01.46 nginx
4662 vbart 20 0 309552 28700 596 S 2.7 0.1 0:05.92 nginx
4661 vbart 20 0 309464 28636 596 S 2.3 0.1 0:01.59 nginx
4653 vbart 20 0 309452 28476 572 S 1.7 0.1 0:01.70 nginx
4666 vbart 20 0 309452 28428 524 S 1.3 0.1 0:01.63 nginx
4657 vbart 20 0 309584 28696 592 S 1.0 0.1 0:00.64 nginx
4655 vbart 20 0 30958 28476 572 S 0.7 0.1 0:02.81 nginx
4659 vbart 20 0 309452 28468 564 S 0.3 0.1 0:01.20 nginx
4665 vbart 20 0 309452 28476 572 S 0.3 0.1 0:00.71 nginx
5180 vbart 20 0 25232 1952 1156 R 0.0 0.0 0:00.45 top
4651 vbart 20 0 20032 752 252 S 0.0 0.0 0:00.00 nginx
25921 vbart 20 0 121956 2176 1000 S 0.0 0.0 0:01.98 sshd
25923 vbart 20 0 40304 3840 2208 S 0.0 0.0 0:00.54 zsh
CPU 리소스는 여전히 충분합니다.
wrk의 작업 결과:
Running 1m test @ http://192.0.2.1:8000/1/1/1
12 threads and 50 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 226.32ms 392.76ms 1.72s 93.48%
Req/Sec 20.02 10.84 59.00 65.91%
15045 requests in 1.00m, 58.86GB read
Requests/sec: 250.57
Transfer/sec: 0.98GB
4MB 파일을 제공하는 평균 시간은 7.42초에서 226.32ms(33배 감소)로 단축되었으며 초당 요청 수는 31배(250 대 8) 증가했습니다!
이것은 우리의 요청이 이제 worker process가 읽기 작업에 차단되는 동안 이벤트 큐에서 대기하지 않고 빈 스레드에 의해 처리되기 때문입니다. 디스크 하위 시스템이 첫 번째 클라이언트 컴퓨터의 랜덤 부하를 최대한으로 처리하는 동안, NGINX는 나머지 CPU 리소스와 네트워크 용량을 활용하여 두 번째 클라이언트의 요청을 메모리에서 처리합니다.
4. 여전히 은총알은 아니다.
모든 블로킹 작업과 몇 가지 흥미로운 결과에 대한 우려를 한 번에 물리치고, 아마도 대부분의 분들은 이미 서버에 스레드 풀을 구성하려고 할 것입니다. 하지만 서두르지 마십시오.
사실은 다행히도 대부분의 읽기 및 파일 전송 작업은 느린 하드 드라이브와 관련이 없습니다. 데이터 세트를 저장하기에 충분한 RAM이 있다면 운영체제는 “페이지 캐시”라는 곳에 자주 사용되는 파일을 캐시하는데 충분히 똑똑합니다.
페이지 캐시는 아주 잘 작동하며, NGINX는 거의 모든 일반적인 사용 사례에서 탁월한 성능을 발휘할 수 있습니다. 페이지 캐시에서 읽는 것은 상당히 빠르며, 이러한 작업을 “블로킹”이라고 부르기는 어렵습니다. 반면에 스레드 풀에 처리를 위임하는 것은 약간의 오버헤드가 있습니다.
따라서 합리적인 양의 RAM이 있고 작업 데이터 세트가 매우 크지 않다면, NGINX는 이미 스레드 풀을 사용하지 않고 가장 최적의 방식으로 작동합니다.
스레드 풀로 읽기 작업을 오프로드 하는 것은 매우 특정한 작업에 적용 가능한 기술입니다. 이는 자주 요청되는 콘텐츠의 양이 운영체제의 VM 캐시에 맞지 않는 경우에 가장 유용합니다. 예를 들어, 부하가 많은 NGINX 기반의 스트리밍 미디어 서버의 경우 이러한 상황이 발생할 수 있습니다. 이는 우리가 벤치마크에서 시뮬레이션한 상황입니다.
읽기 작업을 스레드 풀로 효율적으로 오프로드하기 위해서는 필요한 파일 데이터가 메모리에 있는지 여부를 효율적으로 알 수 있는 방법만 있다면 됩니다. 만약 데이터가 메모리에 없는 경우에만 읽기 작업을 별도의 스레드로 오프로드하면 됩니다.
다시 판매 비유로 돌아와서, 현재 판매원은 요청된 물건이 상점에 있는지 알 수 없으며, 모든 주문을 항상 배송 서비스에 넘기거나 항상 직접 처리해야 합니다.
원인은 운영체제가 이러한 기능을 제공하지 않기 때문입니다. Linux에 “fincore()” syscall로 이를 추가하는 최초의 시도는 2010년에 있었지만 이루어지지 않았습니다. 나중에 “preadv2()” syscall로 이를 구현하기 위해 RWF_NONBLOCK 플래그를 사용하는 여러 시도가 있었습니다 (자세한 내용은 LWN.net의 “Nonblocking buffered file read operations” 및 “Asynchronous buffered read operations“를 참조하십시오). 이러한 패치들의 운명은 여전히 불분명합니다. 안타까운 점은 이러한 패치들이 아직 커널에 받아들여지지 않은 주된 이유가 지속적인 논의 때문으로 보입니다.
반면에 FreeBSD 사용자는 전혀 걱정할 필요가 없습니다. FreeBSD 는 이미 충분히 좋은 비동기 파일 읽기 인터페이스를 갖고 있으며, 이를 스레드 풀 대신 사용하면 됩니다.
5. 스레드 풀 구성
만약 사용 사례에서 스레드 풀을 사용하여 어떤 이득을 얻을 수 있다고 확신한다면, 이제 구성에 대해 자세히 알아볼 차례입니다.
구성은 매우 간단하고 유연합니다. 먼저, 사용해야 할 것은 NGINX 1.7.11 버전 이상이며, configure 명령에 –with-threads 인자를 사용하여 컴파일된 NGINX 버전입니다. NGINX Plus 사용자는 릴리스 7 이상을 사용해야 합니다. 가장 간단한 경우에는 구성이 매우 단순합니다. aio threads 지시문을 적절한 컨텍스트에 포함기만 하면 됩니다.
# in the 'http', 'server', or 'location' context
aio threads;
이것은 스레드 풀의 최소한의 구성입니다. 실제로 다음 구성의 짧은 버전입니다.
# in the 'main' context
thread_pool default threads=32 max_queue=65536;
# in the 'http', 'server', or 'location' context
aio threads=default;
이 구성은 32개의 작업 스레드를 가지고 있는 default라는 이름의 스레드 풀을 정의하며, 작업 큐의 최대 길이는 65536개의 작업으로 설정합니다. 작업 큐가 과부하 상태가 되면 NGINX는 해당 요청을 거부하고 이 오류를 로깅합니다.
thread pool "NAME" queue overflow: N tasks waiting
이 오류는 스레드가 작업을 큐에 추가되는 속도만큼 빠르게 처리하지 못할 수 있다는 것을 의미합니다. 최대 큐 크기를 늘려보는 것도 시도해볼 수 있지만, 그래도 문제가 해결되지 않으면 시스템이 그만큼 많은 요청을 처리하기에는 적합하지 않은 것을 나타냅니다.
이미 알고 있듯이, thread_pool 지시문을 사용하여 스레드 수, 큐의 최대 길이, 특정 스레드 풀의 이름을 구성할 수 있습니다. 이는 여러 개의 독립적인 스레드 풀을 구성하고, 다른 목적을 위해 구성 파일의 다른 위치에서 사용할 수 있다는 것을 의미합니다.
# in the 'main' context
thread_pool one threads=128 max_queue=0;
thread_pool two threads=32;
http {
server {
location /one {
aio threads=one;
}
location /two {
aio threads=two;
}
}
# ...
}
만약 max_queue 매개변수가 지정되지 않았다면, 기본값으로 65536이 사용됩니다. 위의 예시에서는 max_queue를 0으로 설정할 수 있습니다. 이 경우, 스레드 풀은 설정된 스레드 수만큼의 작업을 처리할 수 있으며, 작업은 큐에서 대기하지 않습니다.
이제 세 개의 하드 드라이브를 가진 서버가 있다고 상상해보겠습니다. 이 서버를 “캐싱 프록시”로 작동시켜서 백엔드로부터의 모든 응답을 캐시로 저장하고자 합니다. 예상되는 캐시 데이터의 양은 실제로 사용 가능한 RAM을 훨씬 초과합니다. 실제로 이는 개인 CDN을 위한 캐싱 노드입니다. 물론 이 경우 드라이브로부터 최대의 성능을 달성하는 것이 가장 중요합니다.
하나의 옵션은 RAID 배열을 구성하는 것입니다. 이 접근 방식에는 장단점이 있습니다. NGINX를 사용하면 다른 옵션을 선택할 수 있습니다.
# We assume that each of the hard drives is mounted on one of these directories:
# /mnt/disk1, /mnt/disk2, or /mnt/disk3
# in the 'main' context
thread_pool pool_1 threads=16;
thread_pool pool_2 threads=16;
thread_pool pool_3 threads=16;
http {
proxy_cache_path /mnt/disk1 levels=1:2 keys_zone=cache_1:256m max_size=1024G
use_temp_path=off;
proxy_cache_path /mnt/disk2 levels=1:2 keys_zone=cache_2:256m max_size=1024G
use_temp_path=off;
proxy_cache_path /mnt/disk3 levels=1:2 keys_zone=cache_3:256m max_size=1024G
use_temp_path=off;
split_clients $request_uri $disk {
33.3% 1;
33.3% 2;
* 3;
}
server {
# ...
location / {
proxy_pass http://backend;
proxy_cache_key $request_uri;
proxy_cache cache_$disk;
aio threads=pool_$disk;
sendfile on;
}
}
}
이 구성에서 thread_pool 지시문이 각각의 디스크에 대한 독립적이고 별도의 스레드 풀을 정의하고, proxy_cache_path 지시문이 각각의 디스크에 대한 독립적이고 별도의 캐시를 정의합니다.
split_clients 모듈은 캐시 간 (그리고 결과적으로 디스크 간) 부하 분산을 위해 사용되며, 이 작업에 완벽하게 적합합니다.
proxy_cache_path 지시문에 use_temp_path=off 매개변수를 사용하여 NGINX에 임시 파일을 해당 캐시 데이터가 위치한 디렉토리에 저장하도록 지시합니다. 이는 캐시를 업데이트할 때 하드 드라이브 간에 응답 데이터를 복사하지 않도록 하려면 필요합니다.
이 모든 것들을 함께 사용하면 NGINX가 디스크와 병렬로 독립적으로 작동하여 현재 디스크 하위 시스템에서 최대 성능을 얻을 수 있습니다. 각 디스크는 16개의 독립적인 스레드와 파일을 읽고 전송하기 위한 별도의 작업 큐로 처리됩니다.
클라이언트가 이 맞춤형 접근 방식을 좋아할 것이라 확신합니다. 하드 드라이브도 마찬가지입니다.
NGINX는 하드웨어에 특화된 최적화를 위해 유연하게 조정할 수 있는 좋은 예시입니다. 마치 NGINX에게 기계와 데이터셋과의 상호작용에 대한 최적의 방법을 지시하는 것과 같습니다. 사용자 공간에서 NGINX를 세밀하게 조정함으로써 소프트웨어, 운영체제, 하드웨어가 시스템 리소스를 최대한 효과적으로 활용할 수 있도록 할 수 있습니다.
6. 결론
요약하면, NGINX의 스레드 풀은 성능을 새로운 수준으로 끌어올리는 훌륭한 기능입니다. 특히 실제로 대용량 콘텐츠를 다룰 때 이 기능은 잘 알려진 문제인 블로킹을 제거하는 데 도움을 줍니다.
그리고 앞으로 더 많은 가능성이 열립니다. 이 새로운 인터페이스를 통해 장기적이고 블로킹되는 작업을 효과적으로 오프로드할 수 있게 되었습니다. 이로 인해 NGINX는 새로운 모듈과 기능을 개발할 수 있는 새로운 지평을 엽니다. 많은 인기 있는 라이브러리는 아직 비동기적이고 논블로킹 인터페이스를 제공하지 않아 이전에는 NGINX와 호환되지 않았습니다. 우리는 특정 라이브러리에 대해 비동기적이고 논블로킹 프로토타입을 개발하는 데 많은 시간과 리소스를 소비할 수 있습니다. 그러나 항상 그런 노력이 가치가 있을까요? 이제 스레드 풀을 사용하면 비교적 쉽게 해당 라이브러리를 사용할 수 있으며, 성능에 영향을 주지 않고 모듈을 구현할 수 있습니다.
NGINX Plus에서 스레드 풀을 직접 사용해 보십시오. 오늘 무료 30일 평가판을 시작하거나 NGINX STORE에 문의하여 사용 사례에 대해 논의하십시오.
사용 사례에 대해 최신 소식을 빠르게 전달받고 싶으시면 아래 뉴스레터를 구독하세요.
댓글을 달려면 로그인해야 합니다.