Non-blocking I/O - OpenResty 성능 향상의 핵심
API7.ai
December 2, 2022
성능 최적화 챕터에서는 OpenResty의 성능 최적화의 모든 측면을 살펴보고, 이전 챕터들에서 언급된 내용들을 종합하여 포괄적인 OpenResty 코딩 가이드로 요약할 것입니다. 이를 통해 더 나은 품질의 OpenResty 코드를 작성할 수 있도록 도와드리겠습니다.
성능을 개선하는 것은 쉽지 않습니다. 시스템 아키텍처 최적화, 데이터베이스 최적화, 코드 최적화, 성능 테스트, 플레임 그래프 분석 등 여러 단계를 고려해야 합니다. 하지만 성능을 저하시키는 것은 쉽습니다. 오늘 글의 제목에서도 알 수 있듯이, 단 몇 줄의 코드를 추가함으로써 성능을 10배 이상 저하시킬 수 있습니다. OpenResty를 사용하여 코드를 작성하고 있지만 성능이 개선되지 않았다면, 그 이유는 블로킹 I/O 때문일 가능성이 큽니다.
따라서 성능 최적화의 구체적인 내용에 들어가기 전에, OpenResty 프로그래밍에서 중요한 원칙인 Non-blocking I/O 우선에 대해 살펴보겠습니다.
우리는 어릴 때부터 부모님과 선생님께 불장난을 하지 말고, 플러그를 만지지 말라고 배웠습니다. 이는 위험한 행동이기 때문입니다. OpenResty에서도 같은 종류의 위험한 행동이 존재합니다. 코드에서 블로킹 I/O 작업을 해야 한다면, 성능이 급격히 떨어지게 되고, OpenResty를 사용하여 고성능 서버를 구축하려는 원래 목적이 무너지게 됩니다.
왜 블로킹 I/O 작업을 사용하면 안 될까요?
어떤 행동이 위험한지 이해하고 이를 피하는 것이 성능 최적화의 첫 번째 단계입니다. 먼저, 블로킹 I/O 작업이 OpenResty의 성능에 어떤 영향을 미치는지 살펴보겠습니다.
OpenResty가 높은 성능을 유지할 수 있는 이유는 NGINX의 이벤트 처리와 Lua의 코루틴을 빌려오기 때문입니다. 따라서:
- 네트워크 I/O와 같이 반환을 기다려야 하는 작업을 만나면, Lua 코루틴
yield
를 호출하여 자신을 중단시키고 NGINX에 콜백을 등록합니다. - I/O 작업이 완료되면(또는 타임아웃이나 오류가 발생하면), NGINX는
resume
을 호출하여 Lua 코루틴을 깨웁니다.
이러한 과정을 통해 OpenResty는 항상 CPU 자원을 효율적으로 사용하여 모든 요청을 처리할 수 있습니다.
이 처리 흐름에서 LuaJIT는 cosocket
과 같은 비블로킹 I/O 방식을 사용하지 않고 블로킹 I/O 함수를 사용하여 I/O를 처리하면, NGINX의 이벤트 루프에 제어권을 넘기지 않습니다. 이로 인해 다른 요청들은 블로킹 I/O 이벤트가 처리될 때까지 기다려야 하며, 그 후에야 응답을 받을 수 있습니다.
요약하자면, OpenResty 프로그래밍에서는 I/O를 블로킹할 수 있는 함수 호출에 특히 주의해야 합니다. 그렇지 않으면 단 한 줄의 블로킹 I/O 코드가 전체 서비스의 성능을 떨어뜨릴 수 있습니다.
아래에서는 몇 가지 일반적인 문제와 자주 오용되는 블로킹 I/O 함수를 소개하고, 어떻게 하면 가장 쉬운 방법으로 서비스 성능을 10배 이상 떨어뜨릴 수 있는지 경험해 보겠습니다.
외부 명령어 실행
많은 시나리오에서 개발자들은 OpenResty를 단순히 웹 서버로 사용하지 않고 더 많은 비즈니스 로직을 부여합니다. 이 경우, 외부 명령어와 도구를 호출하여 일부 작업을 완료하는 것이 필요할 수 있습니다.
예를 들어, 프로세스를 종료하는 경우입니다.
os.execute("kill -HUP " .. pid)
또는 파일 복사, OpenSSL을 사용하여 키 생성 등 시간이 많이 걸리는 작업을 수행하는 경우입니다.
os.execute(" cp test.exe /tmp ")
os.execute(" openssl genrsa -des3 -out private.pem 2048 ")
표면적으로 os.execute
는 Lua의 내장 함수이며, Lua 세계에서는 외부 명령어를 호출하는 방법입니다. 그러나 Lua는 임베디드 프로그래밍 언어이며, 다른 환경에서는 다른 권장 사용법이 있다는 것을 기억해야 합니다.
OpenResty 환경에서 os.execute
는 현재 요청을 블로킹합니다. 따라서 이 명령어의 실행 시간이 매우 짧다면 큰 영향을 미치지 않지만, 명령어가 수백 밀리초 또는 심지어 몇 초 동안 실행된다면 성능이 급격히 떨어지게 됩니다.
문제를 이해했으니, 어떻게 해결해야 할까요? 일반적으로 두 가지 해결책이 있습니다.
1. FFI
라이브러리가 있다면, FFI 방식으로 호출하는 것을 우선시합니다.
예를 들어, 위에서 OpenSSL 명령어를 사용하여 키를 생성한 경우, FFI
를 사용하여 OpenSSL C 함수를 호출하여 이를 우회할 수 있습니다.
프로세스를 종료하는 경우, OpenResty에 포함된 lua-resty-signal
라이브러리를 사용하여 비블로킹 방식으로 해결할 수 있습니다. 코드 구현은 다음과 같습니다. 여기서 lua-resty-signal
도 FFI
를 사용하여 시스템 함수를 호출하는 방식으로 해결합니다.
local resty_signal = require "resty.signal"
local pid = 12345
local ok, err = resty_signal.kill(pid, "KILL")
또한, LuaJIT 공식 웹사이트에는 특정 페이지가 있어 다양한 FFI 바인딩 라이브러리를 카테고리별로 소개하고 있습니다. 예를 들어, 이미지 처리, 암호화 및 복호화와 같은 CPU 집약적인 작업을 처리할 때, 먼저 이 페이지를 방문하여 직접 사용할 수 있는 라이브러리가 있는지 확인할 수 있습니다.
2. ngx.pipe
기반의 lua-resty-shell
라이브러리 사용
앞서 설명한 대로, shell.run
에서 명령어를 실행하여 비블로킹 I/O 작업을 수행할 수 있습니다.
$ resty -e 'local shell = require "resty.shell"
local ok, stdout, stderr, reason, status =
shell.run([[echo "hello, world"]])
ngx.say(stdout) '
디스크 I/O
이제 디스크 I/O를 처리하는 시나리오를 살펴보겠습니다. 서버 측 애플리케이션에서 로컬 설정 파일을 읽는 것은 일반적인 작업입니다. 예를 들어, 다음 코드와 같습니다.
local path = "/conf/apisix.conf"
local file = io.open(path, "rb")
local content = file:read("*a")
file:close()
이 코드는 io.open
을 사용하여 특정 파일의 내용을 가져옵니다. 그러나 이는 블로킹 I/O 작업이지만, 실제 시나리오에서 고려해야 할 사항이 있다는 것을 잊지 마세요. 따라서 이를 init
및 init worker
에서 호출한다면, 이는 클라이언트 요청에 영향을 미치지 않는 일회성 작업이므로 완전히 허용 가능합니다.
물론, 모든 사용자 요청이 디스크 읽기 또는 쓰기를 트리거한다면 이는 허용할 수 없게 됩니다. 이때는 해결책을 심각하게 고려해야 합니다.
첫째로, lua-io-nginx-module
이라는 서드파티 C 모듈을 사용할 수 있습니다. 이 모듈은 OpenResty에 비블로킹 I/O Lua API를 제공하지만, cosocket
처럼 마음대로 사용할 수는 없습니다. 왜냐하면 디스크 I/O 소비는 이유 없이 사라지지 않기 때문입니다. 단지 다른 방식으로 처리할 뿐입니다.
이 방식은 lua-io-nginx-module
이 NGINX 스레드 풀링을 활용하여 디스크 I/O 작업을 메인 스레드에서 다른 스레드로 이동시켜 처리하기 때문에 가능합니다. 이로 인해 메인 스레드가 디스크 I/O 작업에 의해 블로킹되지 않습니다.
이 라이브러리를 사용하려면 NGINX를 재컴파일해야 합니다. 왜냐하면 이는 C 모듈이기 때문입니다. 사용 방법은 Lua의 I/O 라이브러리와 동일합니다.
local ngx_io = require "ngx.io"
local path = "/conf/apisix.conf"
local file, err = ngx_io.open(path, "rb")
local data, err = file: read("*a")
file:close()
둘째로, 아키텍처 조정을 시도해 볼 수 있습니다. 이 유형의 디스크 I/O에 대해 우리의 방식을 변경하여 로컬 디스크에 읽기 및 쓰기를 중단할 수 있을까요?
예를 들어, 몇 년 전에 나는 로컬 디스크에 로깅을 해야 하는 프로젝트를 진행했었습니다. 이는 통계 및 문제 해결을 위한 목적이었습니다.
당시 개발자들은 ngx.log
를 사용하여 이러한 로그를 작성했습니다. 다음과 같이 말이죠.
ngx.log(ngx.WARN, "info")
이 코드는 OpenResty가 제공하는 Lua API를 호출하며, 문제가 없는 것처럼 보입니다. 그러나 단점은 이를 너무 자주 호출할 수 없다는 것입니다. 첫째, ngx.log
자체가 비용이 많이 드는 함수 호출입니다. 둘째, 버퍼가 있더라도 크고 빈번한 디스크 쓰기는 성능에 심각한 영향을 미칠 수 있습니다.
그렇다면 어떻게 해결할까요? 원래의 필요로 돌아가 봅시다 - 통계, 문제 해결, 그리고 로컬 디스크에 로그를 작성하는 것은 목표를 달성하기 위한 수단 중 하나였을 뿐입니다.
따라서 로그를 원격 로깅 서버로 보내 cosocket
을 사용하여 비블로킹 네트워크 통신을 수행할 수도 있습니다. 즉, 블로킹 디스크 I/O를 로깅 서비스에 던져 외부 서비스가 블로킹되지 않도록 하는 것입니다. 이를 위해 lua-resty-logger-socket
을 사용할 수 있습니다.
local logger = require "resty.logger.socket"
if not logger.initted() then
local ok, err = logger.init{
host = 'xxx',
port = 1234,
flush_limit = 1234,
drop_limit = 5678,
}
local msg = "foo"
local bytes, err = logger.log(msg)
위의 두 방법은 동일한 원칙을 따릅니다: 블로킹 I/O가 불가피하다면, 메인 워커 스레드를 블로킹하지 말고 다른 스레드나 외부 서비스에 던져버리는 것입니다.
luasocket
마지막으로, luasocket
에 대해 이야기해 보겠습니다. 이는 개발자들이 쉽게 사용할 수 있는 Lua 내장 라이브러리이며, 종종 OpenResty가 제공하는 cosocket
과 혼동됩니다. luasocket
은 네트워크 통신 기능을 수행할 수 있지만, 비블로킹의 이점이 없습니다. 결과적으로 luasocket
을 사용하면 성능이 급격히 떨어집니다.
그러나 luasocket
도 고유한 사용 시나리오가 있습니다. 예를 들어, cosocket
이 사용 불가능한 몇 가지 단계가 있다는 것을 기억하시나요? 보통 ngx.timer
를 사용하여 이를 우회할 수 있습니다. 또한, init_by_lua*
및 init_worker_by_lua*
와 같은 일회성 단계에서 cosocket
기능을 위해 luasocket
을 사용할 수 있습니다. OpenResty와 Lua의 유사점과 차이점을 더 잘 이해할수록, 이러한 흥미로운 해결책을 더 많이 찾을 수 있습니다.
또한, lua-resty-socket
은 luasocket
과 cosocket
을 호환 가능하게 만드는 오픈소스 라이브러리의 2차 래퍼입니다. 이 내용도 더 깊이 연구할 가치가 있습니다. 관심이 있다면, 추가 학습을 위한 자료를 준비해 두었습니다.
요약
일반적으로 OpenResty에서 블로킹 I/O 작업의 유형과 그 해결책을 인식하는 것은 좋은 성능 최적화의 기초입니다. 그렇다면 실제 개발에서 비슷한 블로킹 I/O 작업을 경험한 적이 있나요? 어떻게 발견하고 해결했나요? 댓글로 여러분의 경험을 공유해 주세요. 또한, 이 글을 자유롭게 공유해 주세요.