Non-blocking I/O - OpenResty 성능 향상의 핵심

API7.ai

December 2, 2022

OpenResty (NGINX + Lua)

성능 최적화 챕터에서는 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-signalFFI를 사용하여 시스템 함수를 호출하는 방식으로 해결합니다.

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 작업이지만, 실제 시나리오에서 고려해야 할 사항이 있다는 것을 잊지 마세요. 따라서 이를 initinit 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-socketluasocketcosocket을 호환 가능하게 만드는 오픈소스 라이브러리의 2차 래퍼입니다. 이 내용도 더 깊이 연구할 가치가 있습니다. 관심이 있다면, 추가 학습을 위한 자료를 준비해 두었습니다.

요약

일반적으로 OpenResty에서 블로킹 I/O 작업의 유형과 그 해결책을 인식하는 것은 좋은 성능 최적화의 기초입니다. 그렇다면 실제 개발에서 비슷한 블로킹 I/O 작업을 경험한 적이 있나요? 어떻게 발견하고 해결했나요? 댓글로 여러분의 경험을 공유해 주세요. 또한, 이 글을 자유롭게 공유해 주세요.