OpenResty가 특별한 이유는 무엇인가요?
API7.ai
October 14, 2022
이전 글에서 OpenResty의 두 기둥인 NGINX와 LuaJIT에 대해 배웠고, 이제 OpenResty가 제공하는 API를 배울 준비가 되셨을 거라 생각합니다.
하지만 너무 서두르지 마세요. 그 전에 OpenResty의 원리와 기본 개념을 좀 더 익히는 데 시간을 투자해야 합니다.
원리
OpenResty의 Master
와 Worker
프로세스 모두 LuaJIT VM을 포함하고 있으며, 이는 동일한 프로세스 내의 모든 코루틴에 의해 공유되며, Lua 코드가 실행되는 곳입니다.
그리고 동일한 시간대에 각 Worker
프로세스는 한 명의 사용자 요청만 처리할 수 있습니다. 이는 한 번에 하나의 코루틴만 실행된다는 것을 의미합니다. 여기서 의문이 들 수 있습니다: NGINX가 C10K(수만 개의 동시성)를 지원한다면, 10,000개의 요청을 동시에 처리해야 하는 것 아닌가요?
물론 아닙니다. NGINX는 epoll
을 사용하여 이벤트를 구동하여 대기와 유휴 시간을 줄이고, 가능한 많은 CPU 자원을 사용자 요청 처리에 사용합니다. 결국 개별 요청이 충분히 빠르게 처리될 때 전체 시스템이 고성능을 달성할 수 있습니다. 만약 다중 스레드 모드를 사용하여 하나의 요청이 하나의 스레드에 대응된다면, C10K 상황에서 자원이 쉽게 고갈될 수 있습니다.
OpenResty 수준에서 Lua의 코루틴은 NGINX의 이벤트 메커니즘과 협력하여 작동합니다. Lua 코드에서 MySQL 데이터베이스 조회와 같은 I/O 작업이 발생하면, 먼저 Lua 코루틴의 yield
를 호출하여 자신을 중단하고, NGINX에 콜백을 등록합니다. I/O 작업이 완료되면(타임아웃이나 오류일 수도 있음), NGINX 콜백 resume
이 Lua 코루틴을 깨웁니다. 이렇게 하여 Lua 동시성과 NGINX 이벤트 드라이버 간의 협력이 완료되며, Lua 코드에서 콜백을 작성할 필요가 없습니다.
다음 다이어그램은 이 전체 과정을 설명합니다. lua_yield
와 lua_resume
은 모두 Lua가 제공하는 lua_CFunction
의 일부입니다.
반면에, Lua 코드에 I/O나 sleep
작업이 없다면, 예를 들어 모든 집중적인 암호화 및 복호화 작업이라면, LuaJIT VM은 Lua 코루틴에 의해 점유되어 전체 요청이 처리될 때까지 계속됩니다.
이를 더 명확히 이해하기 위해 ngx.sleep
의 소스 코드 일부를 제공합니다. 이 코드는 lua-nginx-module
프로젝트의 src
디렉토리에 있는 ngx_http_lua_sleep.c
에 위치합니다.
ngx_http_lua_sleep.c
에서 sleep
함수의 구체적인 구현을 볼 수 있습니다. 먼저 Lua API ngx.sleep
을 C 함수 ngx_http_lua_ngx_sleep
에 등록해야 합니다.
void ngx_http_lua_inject_sleep_api(lua_State *L)
{
lua_pushcfunction(L, ngx_http_lua_ngx_sleep);
lua_setfield(L, -2, "sleep");
}
다음은 sleep
의 주요 함수이며, 여기서는 주요 코드 몇 줄만 추출했습니다.
static int ngx_http_lua_ngx_sleep(lua_State *L)
{
coctx->sleep.handler = ngx_http_lua_sleep_handler;
ngx_add_timer(&coctx->sleep, (ngx_msec_t) delay);
return lua_yield(L, 0);
}
여기서 볼 수 있듯이:
- 먼저 콜백 함수
ngx_http_lua_sleep_handler
를 추가합니다. - 그런 다음 NGINX가 제공하는 인터페이스인
ngx_add_timer
를 호출하여 NGINX의 이벤트 루프에 타이머를 추가합니다. - 마지막으로
lua_yield
를 사용하여 Lua 동시성을 중단하고, NGINX 이벤트 루프에 제어권을 넘깁니다.
sleep
작업이 완료되면 ngx_http_lua_sleep_handler
콜백 함수가 트리거됩니다. 이 함수는 ngx_http_lua_sleep_resume
을 호출하고, 최종적으로 lua_resume
을 사용하여 Lua 코루틴을 깨웁니다. 코드에서 호출의 세부 사항을 직접 확인할 수 있으므로 여기서는 자세히 설명하지 않겠습니다.
ngx.sleep
은 가장 간단한 예제이지만, 이를 분석함으로써 lua-nginx-module
모듈의 기본 원리를 이해할 수 있습니다.
기본 개념
원리를 분석한 후, OpenResty의 두 가지 중요한 개념인 단계와 논블로킹에 대해 다시 한 번 상기해 보겠습니다.
OpenResty는 NGINX와 마찬가지로 단계 개념이 있으며, 각 단계는 고유한 역할을 합니다:
set_by_lua
: 변수를 설정하는 데 사용됩니다.rewrite_by_lua
: 전달, 리디렉션 등에 사용됩니다.access_by_lua
: 접근, 권한 등에 사용됩니다.content_by_lua
: 반환 내용을 생성하는 데 사용됩니다.header_filter_by_lua
: 응답 헤더 필터 처리에 사용됩니다.body_filter_by_lua
: 응답 본문 필터링에 사용됩니다.log_by_lua
: 로깅에 사용됩니다.
물론, 코드의 논리가 너무 복잡하지 않다면 모든 것을 rewrite
또는 content
단계에서 실행할 수도 있습니다.
하지만 OpenResty의 API는 단계 사용 제한이 있습니다. 각 API는 사용할 수 있는 단계 목록이 있으며, 범위를 벗어나 사용하면 오류가 발생합니다. 이는 다른 개발 언어와 매우 다릅니다.
예를 들어 ngx.sleep
을 사용해 보겠습니다. 문서에서 알 수 있듯이, 이 API는 다음 컨텍스트에서만 사용할 수 있으며 log
단계는 포함되지 않습니다.
context: rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_
그리고 이를 모르고 지원하지 않는 log
단계에서 sleep
을 사용하면:
location / {
log_by_lua_block {
ngx.sleep(1)
}
}
NGINX 오류 로그에 error
수준의 표시가 나타납니다.
[error] 62666#0: *6 failed to run log_by_lua*: log_by_lua(nginx.conf:14):2: API disabled in the context of log_by_lua*
stack traceback:
[C]: in function 'sleep'
따라서 API를 사용하기 전에 항상 문서를 참조하여 코드 컨텍스트에서 사용할 수 있는지 확인하세요.
단계 개념을 다시 살펴본 후, 논블로킹에 대해 다시 상기해 보겠습니다. 먼저 OpenResty가 제공하는 모든 API는 논블로킹입니다.
1초 sleep 요구사항을 예로 들어보겠습니다. Lua에서 이를 구현하려면 다음과 같이 해야 합니다.
function sleep(s)
local ntime = os.time() + s
repeat until os.time() > ntime
end
표준 Lua에는 sleep
함수가 없으므로 여기서는 루프를 사용하여 지정된 시간에 도달했는지 계속 확인합니다. 이 구현은 블로킹이며, sleep
이 실행되는 동안 Lua는 아무 작업도 하지 않고, 처리해야 할 다른 요청들은 그냥 기다립니다.
하지만 ngx.sleep(1)
로 전환하면, 위에서 분석한 소스 코드에 따르면 OpenResty는 이 1초 동안 다른 요청(예: request B
)을 처리할 수 있습니다. 현재 요청(예: request A
)의 컨텍스트는 저장되었다가 NGINX 이벤트 메커니즘에 의해 깨어나고, 다시 request A
로 돌아가므로 CPU는 항상 자연스럽게 작동 상태를 유지합니다.
변수와 생명 주기
이 두 가지 중요한 개념 외에도, 변수의 생명 주기는 OpenResty 개발에서 쉽게 실수할 수 있는 부분입니다.
앞서 말했듯이, OpenResty에서는 모든 변수를 로컬 변수로 선언하고, luacheck
및 lua-releng
과 같은 도구를 사용하여 전역 변수를 감지하는 것을 권장합니다. 이는 모듈에도 동일하게 적용됩니다. 예를 들어 다음과 같습니다.
local ngx_re = require "ngx.re"
OpenResty에서는 init_by_lua
와 init_worker_by_lua
두 단계를 제외하고, 모든 단계에 대해 전역 변수의 독립적인 테이블이 설정되어 처리 중 다른 요청을 오염시키지 않습니다. 이 두 단계에서도 전역 변수를 정의하는 것을 가능한 한 피해야 합니다.
일반적으로 전역 변수로 해결하려는 문제는 모듈의 변수로 해결하는 것이 더 좋고 훨씬 명확합니다. 다음은 모듈의 변수 예제입니다.
local _M = {}
_M.color = {
red = 1,
blue = 2,
green = 3
}
return _M
hello.lua
라는 파일에 모듈을 정의했으며, 이 모듈에는 color
테이블이 포함되어 있습니다. 그리고 nginx.conf
에 다음 구성을 추가했습니다.
location / {
content_by_lua_block {
local hello = require "hello"
ngx.say(hello.color.green)
}
}
이 구성은 content
단계에서 모듈을 요청하고, HTTP 응답 본문으로 green
의 값을 출력합니다.
모듈 변수가 왜 이렇게 놀라운지 궁금할 수 있습니다.
모듈은 동일한 Worker
프로세스에서 한 번만 로드됩니다. 이후 해당 Worker
가 처리하는 모든 요청은 모듈의 데이터를 공유합니다. "전역" 데이터는 모듈로 캡슐화하는 것이 적합하다고 말하는 이유는 OpenResty의 Worker
들이 서로 완전히 격리되어 있기 때문입니다. 따라서 각 Worker
는 모듈을 독립적으로 로드하며, 모듈의 데이터는 Worker
간에 공유되지 않습니다.
Worker
간에 공유해야 하는 데이터를 처리하는 방법은 나중 장에서 다룰 예정이므로 여기서는 깊이 파고들지 않아도 됩니다.
하지만 여기서 한 가지 실수할 수 있는 부분이 있습니다: 모듈 변수에 접근할 때는 읽기 전용으로 유지하고 수정하려고 하지 않는 것이 좋습니다. 그렇지 않으면 높은 동시성 상황에서 race
가 발생할 수 있으며, 이는 단위 테스트로 감지할 수 없는 버그로, 가끔 온라인에서 발생하며 위치를 파악하기 어렵습니다.
예를 들어, 모듈 변수 green
의 현재 값이 3
이고, 코드에서 1
을 더하는 작업을 한다면, green
의 값은 이제 4
일까요? 반드시 그렇지는 않습니다. 4
, 5
, 또는 6
일 수 있습니다. 왜냐하면 OpenResty는 모듈 변수에 쓰기를 할 때 잠금을 걸지 않기 때문입니다. 따라서 여러 요청이 동시에 모듈 변수의 값을 업데이트할 수 있습니다.
전역, 로컬, 모듈 변수에 대해 말했으니, 이제 단계를 넘나드는 변수에 대해 논의해 보겠습니다.
단계를 넘나들며 읽고 쓸 수 있는 변수가 필요한 상황이 있습니다. NGINX에서 익숙한 $host
, $scheme
등의 변수는 동적으로 생성할 수 없으며, 구성 파일에서 먼저 정의해야 사용할 수 있습니다. 예를 들어 다음과 같이 작성하면:
location /foo {
set $my_var ; # 먼저 $my_var 변수를 생성해야 함
content_by_lua_block {
ngx.var.my_var = 123
}
}
OpenResty는 이러한 문제를 해결하기 위해 ngx.ctx
를 제공합니다. 이는 현재 요청과 동일한 생명 주기를 가진 Lua 테이블로, 요청 기반의 Lua 데이터를 저장하는 데 사용할 수 있습니다. 공식 문서의 예제를 살펴보겠습니다.
location /test {
rewrite_by_lua_block {
ngx.ctx.foo = 76
}
access_by_lua_block {
ngx.ctx.foo = ngx.ctx.foo + 3
}
content_by_lua_block {
ngx.say(ngx.ctx.foo)
}
}
여기서 ngx.ctx
에 저장된 변수 foo
를 정의했습니다. 이 변수는 rewrite
, access
, content
단계를 넘나들며, 최종적으로 content
단계에서 값을 출력하며, 예상대로 79
가 출력됩니다.
물론 ngx.ctx
에도 제한이 있습니다.
예를 들어, ngx.location.capture
로 생성된 자식 요청은 부모 요청의 ngx.ctx
와 독립적인 별도의 ngx.ctx
데이터를 가집니다.
또한 ngx.exec
로 생성된 내부 리디렉션은 원래 요청의 ngx.ctx
를 파괴하고, 빈 ngx.ctx
로 다시 생성합니다.
이 두 가지 제한 사항은 공식 문서에 상세한 코드 예제가 있으므로 관심이 있다면 직접 확인할 수 있습니다.
요약
마지막으로 몇 마디 더 하겠습니다. 우리는 OpenResty의 원리와 몇 가지 중요한 개념을 배우고 있지만, 이를 외울 필요는 없습니다. 결국 이들은 실제 요구사항과 코드와 결합할 때 의미를 갖고 생생해집니다.
여러분은 어떻게 이해하셨나요? 댓글을 남겨 저와 토론해 보시고, 이 글을 동료와 친구들과 공유해 보세요. 함께 소통하며 함께 성장합시다.