이 가이드는 CRuby로도 알려진 Ruby의 표준 구현인 MRI를 실행한다고 가정합니다. JRuby나 TruffleRuby와 같은 다른 Ruby 구현을 사용하는 경우 이 가이드의 대부분이 적용되지 않습니다. 필요한 경우 Ruby 구현에 특화된 자료를 확인하세요.
1 애플리케이션 서버 선택하기
Puma는 Rails의 기본 애플리케이션 서버이며 커뮤니티에서 가장 널리 사용되는 서버입니다. 대부분의 경우에 잘 작동합니다. 경우에 따라 다른 서버로 변경하고 싶을 수 있습니다.
애플리케이션 서버는 특정한 동시성 방식을 사용합니다. 예를 들어, Unicorn은 프로세스를 사용하고, Puma와 Passenger는 프로세스와 스레드 기반 동시성의 하이브리드 방식을 사용하며, Falcon은 fiber를 사용합니다.
Ruby의 동시성 방식에 대한 전체 논의는 이 문서의 범위를 벗어나지만, 프로세스와 스레드 사이의 주요 트레이드오프는 설명할 것입니다. 프로세스와 스레드 이외의 방식을 사용하고 싶다면 다른 애플리케이션 서버를 사용해야 합니다.
이 가이드는 Puma를 튜닝하는 방법에 중점을 둘 것입니다.
2 무엇을 최적화할 것인가?
본질적으로 Ruby 웹 서버를 튜닝하는 것은 메모리 사용량, 처리량, 지연 시간과 같은 여러 속성 간의 트레이드오프를 만드는 것입니다.
처리량은 서버가 처리할 수 있는 초당 요청 수를 측정하는 것이고, 지연 시간은 개별 요청이 걸리는 시간(응답 시간이라고도 함)을 측정하는 것입니다.
일부 사용자는 호스팅 비용을 낮게 유지하기 위해 처리량을 최대화하기를 원할 수 있고, 다른 사용자들은 지연 시간을 최소화하기를 원할 수 있습니다.
최상의 사용자 경험을 제공하기 위해 많은 사용자들은 절충점을 찾으려고 할 것입니다.
한 가지 속성을 최적화하면 일반적으로 다른 속성에 영향을 미친다는 점을 이해하는 것이 중요합니다.
2.1 Ruby의 Concurrency와 Parallelism 이해하기
CRuby는 흔히 GVL 또는 GIL이라 불리는 Global Interpreter Lock을 가지고 있습니다. GVL은 단일 프로세스에서 여러 스레드가 동시에 Ruby 코드를 실행하는 것을 방지합니다. 여러 스레드가 네트워크 데이터, 데이터베이스 작업, 또는 일반적으로 I/O 작업이라 불리는 다른 Ruby가 아닌 작업을 기다릴 수 있지만, 한 번에 하나의 스레드만이 Ruby 코드를 실행할 수 있습니다.
이는 스레드 기반 동시성이 I/O 작업을 수행할 때마다 웹 요청을 동시에 처리함으로써 처리량을 증가시킬 수 있지만, I/O 작업이 완료될 때마다 지연 시간이 늘어날 수 있다는 것을 의미합니다. I/O 작업을 수행한 스레드는 Ruby 코드 실행을 재개하기 전에 기다려야 할 수 있습니다. 마찬가지로, Ruby의 garbage collector는 "stop-the-world" 방식이므로 이것이 실행될 때 모든 스레드가 멈춰야 합니다.
이는 또한 Ruby 프로세스가 얼마나 많은 스레드를 포함하고 있든, 절대로 하나 이상의 CPU 코어를 사용하지 않는다는 것을 의미합니다.
이러한 이유로, 만약 애플리케이션이 시간의 50%만 I/O 작업을 수행한다면, 프로세스당 2-3개 이상의 스레드를 사용하는 것은 지연 시간을 크게 악화시킬 수 있으며, 처리량의 증가는 빠르게 수확 체감의 법칙에 도달하게 됩니다.
일반적으로, 느린 SQL 쿼리나 N+1 문제를 겪지 않는 잘 만들어진 Rails 애플리케이션은 시간의 50% 이상을 I/O 작업에 소비하지 않으므로, 3개 이상의 스레드로부터 이점을 얻을 가능성이 낮습니다. 하지만, 외부 API를 인라인으로 호출하는 일부 애플리케이션은 시간의 매우 큰 부분을 I/O 작업에 소비할 수 있으며, 이보다 더 많은 스레드로부터 이점을 얻을 수 있습니다.
Ruby로 진정한 병렬성을 달성하는 방법은 여러 프로세스를 사용하는 것입니다. 사용 가능한 CPU 코어가 있는 한, Ruby 프로세스는 I/O 작업이 완료된 후 실행을 재개하기 전에 서로를 기다릴 필요가 없습니다. 하지만, 프로세스들은 copy-on-write를 통해 메모리의 일부만 공유하므로, 추가 프로세스 하나는 추가 스레드보다 더 많은 메모리를 사용합니다.
스레드가 프로세스보다 비용이 적게 들긴 하지만 무료는 아니며, 프로세스당 스레드 수를 늘리면 메모리 사용량도 증가한다는 점에 유의하세요.
2.2 실용적 의미
처리량과 서버 활용도를 최적화하고자 하는 사용자는 CPU 코어당 하나의 프로세스를 실행하고, 대기 시간에 미치는 영향이 너무 크다고 판단될 때까지 프로세스당 스레드 수를 늘리고자 할 것입니다.
대기 시간을 최적화하고자 하는 사용자는 프로세스당 스레드 수를 낮게 유지하고자 할 것입니다.
대기 시간을 더욱 최적화하기 위해, 사용자는 프로세스당 스레드 수를 1
로 설정하고 프로세스가 I/O 작업을 기다리며 유휴 상태일 때를 고려하여 CPU 코어당 1.5
또는 1.3
프로세스를 실행할 수 있습니다.
일부 호스팅 솔루션은 CPU 코어당 상대적으로 적은 양의 메모리(RAM)만을 제공하여, 모든 CPU 코어를 사용하는 데 필요한 만큼의 프로세스를 실행하지 못할 수 있다는 점에 주의하는 것이 중요합니다. 하지만 대부분의 호스팅 솔루션은 메모리와 CPU의 비율이 다른 다양한 플랜을 제공합니다.
고려해야 할 또 다른 점은 Ruby 메모리 사용량이 copy-on-write 덕분에 규모의 경제 효과를 누린다는 것입니다.
따라서 Ruby 프로세스가 각각 32
개인 2
개의 서버가 Ruby 프로세스가 각각 4
개인 16
개의 서버보다 CPU 코어당 메모리 사용량이 더 적습니다.
3 구성
3.1 Puma
Puma 설정은 config/puma.rb
파일에 있습니다.
가장 중요한 두 가지 Puma 설정은 프로세스당 스레드 수와 Puma에서 workers
라고 부르는 프로세스 수입니다.
프로세스당 스레드 수는 thread
지시어를 통해 설정됩니다.
기본 생성된 설정에서는 3
으로 설정되어 있습니다.
RAILS_MAX_THREADS
환경 변수를 설정하거나 설정 파일을 직접 수정하여 이를 변경할 수 있습니다.
프로세스 수는 workers
지시어로 설정됩니다.
프로세스당 하나 이상의 스레드를 사용하는 경우, 서버에서 사용 가능한 CPU 코어 수로 설정하거나,
서버에서 여러 애플리케이션이 실행 중인 경우 애플리케이션이 사용하길 원하는 코어 수로 설정해야 합니다.
worker당 하나의 스레드만 사용하는 경우, worker가 I/O 작업을 기다리며 유휴 상태일 때를 고려하여 프로세스당 하나 이상으로 증가시킬 수 있습니다.
WEB_CONCURRENCY
환경 변수를 설정하여 Puma worker 수를 구성할 수 있습니다. WEB_CONCURRENCY=auto
로 설정하면 사용 가능한 CPU 수에 맞춰 Puma worker 수가 자동으로 조정됩니다. 하지만 이 설정은 공유 CPU가 있는 클라우드 호스트나 CPU 수를 잘못 보고하는 플랫폼에서는 부정확할 수 있습니다.
3.2 YJIT
최신 Ruby 버전들은 YJIT
이라고 하는 Just-in-time 컴파일러와 함께 제공됩니다.
자세한 내용을 다루지는 않겠지만, JIT 컴파일러는 더 많은 메모리를 사용하는 대신 코드를 더 빠르게 실행할 수 있게 해줍니다. 이 추가 메모리 사용을 정말로 허용할 수 없는 경우가 아니라면, YJIT을 활성화하는 것을 강력히 권장합니다.
Rails 7.2부터는 애플리케이션이 Ruby 3.3 이상에서 실행되는 경우, YJIT이 Rails에 의해 기본적으로 자동 활성화됩니다.
이전 버전의 Rails나 Ruby에서는 수동으로 활성화해야 하며, 활성화 방법은 YJIT 문서
를 참조하시기 바랍니다.
추가 메모리 사용이 문제가 된다면, YJIT을 완전히 비활성화하기 전에 the --yjit-exec-mem-size
설정을 통해 더 적은 메모리를 사용하도록 조정해볼 수 있습니다.
3.3 Memory Allocator와 구성
대부분의 Linux 배포판에서 기본 memory allocator가 작동하는 방식으로 인해, 여러 thread로 Puma를 실행하면 메모리 단편화로 인해 예기치 않은 메모리 사용량 증가가 발생할 수 있습니다. 이로 인해 증가된 메모리 사용량은 애플리케이션이 서버 CPU 코어를 완전히 활용하는 것을 방해할 수 있습니다.
이 문제를 완화하기 위해, 대체 memory allocator인 jemalloc을 사용하도록 Ruby를 구성하는 것을 강력히 권장합니다.
Rails에 의해 생성된 기본 Dockerfile은 이미 jemalloc
을 설치하고 사용하도록 사전 구성되어 있습니다. 하지만 호스팅 솔루션이 Docker 기반이 아닌 경우, jemalloc을 설치하고 활성화하는 방법을 찾아보아야 합니다.
만약 어떤 이유로 그것이 불가능하다면, 덜 효율적인 대안으로 환경에서 MALLOC_ARENA_MAX=2
를 설정하여 메모리 단편화를 줄이는 방식으로 기본 allocator를 구성할 수 있습니다.
하지만 이는 Ruby를 더 느리게 만들 수 있으므로, jemalloc
이 선호되는 해결책입니다.
4 성능 테스트
모든 Rails 애플리케이션이 다르고, 모든 Rails 사용자가 다른 속성을 최적화하길 원할 수 있기 때문에, 모두에게 가장 잘 작동하는 기본 구성이나 가이드라인을 제공하는 것은 불가능합니다.
따라서, 애플리케이션의 설정을 선택하는 가장 좋은 방법은 애플리케이션의 성능을 측정하고, 목표에 맞게 만족스러울 때까지 구성을 조정하는 것입니다.
이는 시뮬레이션된 프로덕션 워크로드로 수행하거나, 실제 프로덕션에서 라이브 애플리케이션 트래픽으로 직접 수행할 수 있습니다.
성능 테스트는 깊이 있는 주제입니다. 이 가이드는 간단한 지침만 제공합니다.
4.1 무엇을 측정할 것인가
Throughput은 애플리케이션이 성공적으로 처리하는 초당 요청 수입니다. 모든 좋은 로드 테스트 프로그램은 이를 측정합니다. Throughput은 일반적으로 "초당 요청 수"로 표현되는 단일 숫자입니다.
Latency는 요청이 전송된 시점부터 응답이 성공적으로 수신될 때까지의 지연 시간으로, 일반적으로 밀리초 단위로 표현됩니다. 각각의 개별 요청은 자체적인 latency를 가집니다.
Percentile latency는 특정 비율의 요청들이 더 나은 latency를 보이는 지점의 latency를 나타냅니다.
예를 들어, P90
은 90번째 백분위 latency입니다.
P90
은 단일 로드 테스트에서 10%의 요청만이 더 오래 걸린 지점의 latency입니다.
P50
은 요청의 절반이 더 느린 latency를 보인 지점으로, 중간값 latency라고도 합니다.
"Tail latency"는 높은 백분위의 latency를 의미합니다.
예를 들어, P99
는 단 1%의 요청만이 더 나쁜 성능을 보인 지점의 latency입니다.
P99
는 tail latency입니다.
P50
은 tail latency가 아닙니다.
일반적으로, 평균 latency는 최적화하기에 좋은 지표가 아닙니다.
중간값(P50
)과 tail(P95
또는 P99
) latency에 집중하는 것이 가장 좋습니다.
4.2 Production 측정
Production 환경이 여러 대의 서버를 포함한다면, 해당 환경에서 A/B 테스팅을 수행하는 것이 좋은 방법이 될 수 있습니다.
예를 들어, 서버의 절반은 프로세스당 3
개의 thread로 실행하고 나머지 절반은 프로세스당 4
개의 thread로 실행한 다음, application performance monitoring 서비스를 사용하여 두 그룹의 처리량(throughput)과 지연 시간(latency)을 비교할 수 있습니다.
Application performance monitoring 서비스는 매우 많이 있으며, 일부는 self-hosted이고 일부는 cloud 솔루션이며, 대부분 무료 tier 플랜을 제공합니다. 특정 서비스를 추천하는 것은 이 가이드의 범위를 벗어납니다.
4.3 Load Tester
애플리케이션에 요청을 보내기 위해서는 load testing 프로그램이 필요합니다. 이는 전용 load testing 프로그램이 될 수도 있고, HTTP 요청을 보내고 소요 시간을 추적하는 작은 애플리케이션을 직접 작성할 수도 있습니다. 일반적으로 Rails 로그 파일에서 시간을 확인해서는 안 됩니다. 그 시간은 Rails가 요청을 처리하는 데 걸린 시간일 뿐입니다. 애플리케이션 서버가 소요한 시간은 포함되지 않습니다.
많은 동시 요청을 보내고 시간을 측정하는 것은 어려울 수 있습니다. 미세한 측정 오류가 발생하기 쉽습니다. 일반적으로 직접 작성하기보다는 load testing 프로그램을 사용해야 합니다. 많은 load tester들은 사용하기 쉽고 무료로 제공되는 우수한 load tester들이 많이 있습니다.
4.4 변경할 수 있는 것들
애플리케이션의 처리량과 지연 시간 사이의 최적의 균형을 찾기 위해 테스트에서 스레드 수를 변경할 수 있습니다.
더 많은 메모리와 CPU 코어가 있는 대형 호스트는 최적의 사용을 위해 더 많은 프로세스가 필요합니다. 호스팅 제공업체에서 호스트의 크기와 유형을 다양하게 선택할 수 있습니다.
반복 횟수를 늘리면 일반적으로 더 정확한 결과를 얻을 수 있지만, 테스트에 더 많은 시간이 필요합니다.
프로덕션 환경에서 실행될 것과 동일한 유형의 호스트에서 테스트해야 합니다. 개발 머신에서 테스트하면 해당 개발 머신에 가장 적합한 설정만 알 수 있습니다.
4.5 Warmup
애플리케이션은 시작 후 최종 측정에 포함되지 않는 여러 요청들을 처리해야 합니다. 이러한 요청들을 "warmup" 요청이라고 하며, 일반적으로 이후의 "steady-state" 요청들보다 훨씬 느립니다.
로드 테스트 프로그램은 보통 warmup 요청을 지원합니다. 여러 번 실행하고 첫 번째 시간 세트를 버릴 수도 있습니다.
warmup 요청 수를 늘려도 결과가 크게 변하지 않을 때 충분한 warmup 요청을 수행한 것입니다. 이에 대한 이론은 복잡할 수 있지만 대부분의 일반적인 상황은 간단합니다: 다양한 양의 warmup으로 여러 번 테스트하십시오. 결과가 대략적으로 동일하게 유지되기 전까지 몇 번의 warmup 반복이 필요한지 확인하십시오.
매우 긴 warmup은 많은 요청 후에만 발생하는 메모리 단편화 및 기타 문제를 테스트하는 데 유용할 수 있습니다.
4.6 어떤 Request 들을
당신의 애플리케이션은 아마도 많은 다양한 HTTP request를 받아들일 것입니다. 처음에는 몇 개의 request로만 load test를 시작해야 합니다. 시간이 지나면서 더 많은 종류의 request를 추가할 수 있습니다. 실제 production 애플리케이션에서 특정 종류의 request가 너무 느리다면, 이를 load testing 코드에 추가할 수 있습니다.
synthetic workload는 애플리케이션의 production 트래픽과 완벽하게 일치할 수는 없습니다. 그래도 configuration을 테스트하는 데는 도움이 됩니다.
4.7 검토해야 할 사항들
load testing 프로그램은 백분위수와 tail latency를 포함한 latency를 확인할 수 있어야 합니다.
서로 다른 프로세스와 스레드 수, 또는 일반적으로 다른 설정들에 대해 throughput과 P50
, P90
, P99
같은 latency를 확인하세요.
스레드를 늘리면 어느 정도까지는 throughput이 향상되지만 latency는 악화됩니다.
애플리케이션의 요구사항에 따라 latency와 throughput 사이의 적절한 절충점을 선택하세요.