OpenResty에서 일반적으로 사용되는 API 소개

API7.ai

November 4, 2022

OpenResty (NGINX + Lua)

이전 글들에서 여러분은 OpenResty의 중요한 Lua API들에 대해 익숙해졌을 것입니다. 오늘은 정규 표현식, 시간, 프로세스 등과 관련된 몇 가지 일반적인 API들에 대해 배워보겠습니다.

정규 표현식 관련 API

가장 흔히 사용되고 중요한 정규 표현식부터 살펴보겠습니다. OpenResty에서는 Lua 패턴 매칭 대신 ngx.re.*에서 제공하는 API 세트를 사용하여 정규 표현식 관련 로직을 처리해야 합니다. 이는 성능상의 이유뿐만 아니라 Lua 정규 표현식이 자체적으로 포함되어 있고 PCRE 사양이 아니기 때문에 대부분의 개발자에게는 번거로울 수 있기 때문입니다.

이전 글들에서 이미 ngx.re.* API 중 일부를 접해보았고, 그 문서는 매우 상세합니다. 따라서 여기서는 두 가지 API를 별도로 소개하겠습니다.

ngx.re.split

첫 번째는 ngx.re.split입니다. 문자열 자르기는 매우 일반적인 기능이며, OpenResty도 이에 해당하는 API를 제공하지만 많은 개발자들이 이를 찾지 못하고 직접 구현하는 경우가 많습니다.

왜냐하면 ngx.re.split API는 lua-nginx-module에 있지 않고 lua-resty-core에 있으며, lua-resty-core 홈페이지 문서가 아닌 lua-resty-core/lib/ngx/re.md 세 번째 디렉토리의 문서에 있기 때문입니다. 결과적으로 많은 개발자들이 이 API의 존재를 전혀 모르고 있습니다.

마찬가지로 발견하기 어려운 API로는 ngx_resp.add_header, enable_privileged_agent 등이 있으며, 이전에 언급한 바 있습니다. 그렇다면 이 문제를 어떻게 빠르게 해결할 수 있을까요? lua-resty-core 홈페이지 문서를 읽는 것 외에도 lua-resty-core/lib/ngx/ 디렉토리의 *.md 문서를 꼼꼼히 읽어보는 것이 필요합니다.

lua_regex_match_limit

두 번째로 소개할 것은 lua_regex_match_limit입니다. 이전에는 OpenResty가 제공하는 NGINX 명령어에 대해 다루지 않았는데, 대부분의 경우 기본값으로 충분하며 런타임에 이를 수정할 필요가 없기 때문입니다. 예외는 정규 표현식과 관련된 lua_regex_match_limit 명령어입니다.

백트래킹 NFA 기반으로 구현된 정규 엔진을 사용하면 Catastrophic Backtracking의 위험이 있습니다. 이는 정규 표현식이 매칭 시 너무 많은 백트래킹을 하여 CPU가 100%가 되고 서비스가 차단되는 상황을 말합니다.

일단 Catastrophic Backtracking이 발생하면 gdb를 사용하여 덤프를 분석하거나 systemtap을 사용하여 온라인 환경을 분석하여 위치를 파악해야 합니다. 불행히도 이를 사전에 감지하는 것은 쉽지 않습니다. 특수한 요청만이 이를 유발하기 때문입니다. 이는 공격자가 이를 악용할 수 있게 하며, ReDoS(RegEx Denial of Service)는 이러한 유형의 공격을 말합니다.

여기서는 OpenResty에서 다음과 같은 코드를 사용하여 위의 문제를 간단하고 효과적으로 피하는 방법을 소개하겠습니다:

lua_regex_match_limitPCRE 정규 엔진의 백트래킹 횟수를 제한하는 데 사용됩니다. 이렇게 하면 Catastrophic Backtracking이 발생하더라도 그 결과가 CPU를 가득 차게 하지 않는 범위로 제한됩니다.

lua_regex_match_limit 100000;

시간 관련 API

가장 흔히 사용되는 시간 API는 ngx.now로, 현재 타임스탬프를 출력합니다. 예를 들어 다음과 같은 코드가 있습니다:

resty -e 'ngx.say(ngx.now())'

출력 결과에서 볼 수 있듯이, ngx.now는 소수 부분을 포함하므로 더 정확합니다. 관련된 ngx.time API는 값의 정수 부분만 반환합니다. 다른 API들인 ngx.localtime, ngx.utctime, ngx.cookie_time, ngx.http_time은 주로 다양한 형식의 시간을 반환하고 처리하는 데 사용됩니다. 이를 사용하려면 문서를 확인하면 되며, 이해하기 어렵지 않으므로 여기서는 다루지 않겠습니다.

그러나 주목할 점은 현재 시간을 반환하는 이러한 API들은 비차단 네트워크 IO 작업에 의해 트리거되지 않으면 항상 캐시된 값을 반환하며, 우리가 원하는 현재 실시간 시간을 반환하지 않는다는 것입니다. 다음 샘플 코드를 살펴보세요:

$ resty -e 'ngx.say(ngx.now())
os.execute("sleep 1")
ngx.say(ngx.now())'

ngx.now를 두 번 호출하는 사이에 Lua의 차단 함수를 사용하여 1초 동안 잠을 잤지만, 두 경우 모두 동일한 타임스탬프가 반환됩니다.

그렇다면 이를 비차단 sleep 함수로 대체하면 어떻게 될까요? 예를 들어 다음과 같은 새로운 코드입니다:

$ resty -e 'ngx.say(ngx.now())
ngx.sleep(1)
ngx.say(ngx.now())'

이 코드는 서로 다른 타임스탬프를 출력합니다. 이는 ngx.sleep이라는 비차단 sleep 함수를 사용하기 때문입니다. 이 함수는 지정된 시간 동안 잠을 자는 것 외에도 다른 특별한 용도가 있습니다.

예를 들어, 집중적인 계산을 수행하는 코드가 있어 많은 시간이 걸리는 경우, 이 코드에 해당하는 요청은 이 시간 동안 worker와 CPU 리소스를 계속 점유하게 되어 다른 요청들이 대기열에 쌓이고 제때 응답을 받지 못하게 됩니다. 이때 ngx.sleep(0)을 삽입하여 이 코드가 제어권을 포기하도록 하여 다른 요청들도 처리될 수 있게 할 수 있습니다.

Worker 및 프로세스 API

OpenResty는 ngx.worker.*ngx.process.* API를 제공하여 worker와 프로세스에 대한 정보를 얻을 수 있습니다. 전자는 Nginx worker 프로세스와 관련이 있으며, 후자는 일반적으로 모든 Nginx 프로세스를 말하며, worker 프로세스뿐만 아니라 master 프로세스, privileged 프로세스 등을 포함합니다.

truenull 값 문제

마지막으로 truenull 값 문제를 살펴보겠습니다. OpenResty에서 true 값과 null 값의 판단은 매우 번거롭고 혼란스러운 점이었습니다.

Lua에서 true 값의 정의를 살펴보겠습니다: nilfalse를 제외한 모든 값이 true 값입니다.

따라서 true 값에는 0, 빈 string, 빈 table 등도 포함됩니다.

Lua에서 nilundefined를 의미합니다. 예를 들어, 변수를 선언했지만 초기화하지 않았다면 그 값은 nil입니다.

$ resty -e 'local a
ngx.say(type(a))'

그리고 nil은 Lua에서 데이터 타입이기도 합니다. 이 두 가지를 이해했다면, 이제 이 두 정의에서 파생된 다른 문제들을 살펴보겠습니다.

ngx.null

첫 번째 문제는 ngx.null입니다. Lua의 niltable의 값으로 사용할 수 없기 때문에, OpenResty는 ngx.null을 도입하여 테이블의 null 값으로 사용합니다.

$ resty -e  'print(ngx.null)'
null
$ resty -e 'print(type(ngx.null))'
userdata

위의 두 코드에서 볼 수 있듯이, ngx.nullnull로 출력되며, 그 타입은 userdata입니다. 그렇다면 이를 false 값으로 간주할 수 있을까요? 물론 아닙니다. ngx.null의 불리언 값은 true입니다.

$ resty -e 'if ngx.null then
ngx.say("true")
end'

따라서, nilfalse만이 false 값이라는 점을 기억하세요. 이 점을 놓치면 쉽게 함정에 빠질 수 있습니다. 예를 들어, lua-resty-redis를 사용할 때 다음과 같은 판단을 할 때:

local res, err = red:get("dog")
if not res then
    res = res + "test"
end

반환 값 resnil이면 함수 호출이 실패한 것이고, resngx.null이면 redis에 dog 키가 존재하지 않는 것입니다. 이 경우 dog 키가 존재하지 않으면 코드가 충돌합니다.

cdata:NULL

두 번째 문제는 cdata:NULL입니다. LuaJIT FFI 인터페이스를 통해 C 함수를 호출할 때, 함수가 NULL 포인터를 반환하면 cdata:NULL이라는 또 다른 종류의 null 값을 만나게 됩니다.

$ resty -e 'local ffi = require "ffi"
local cdata_null = ffi.new("void*", nil)
if cdata_null then
    ngx.say("true")
end'

ngx.null과 마찬가지로, cdata:NULLtrue입니다. 그러나 더 혼란스러운 점은 다음 코드가 true를 출력한다는 것입니다. 이는 cdata:NULLnil과 동등하다는 것을 의미합니다.

$ resty -e 'local ffi = require "ffi"
local cdata_null = ffi.new("void*", nil)
ngx.say(cdata_null == nil)'

그렇다면 ngx.nullcdata:NULL을 어떻게 처리해야 할까요? 애플리케이션 레이어에서 이러한 문제를 신경 쓰는 것은 좋은 해결책이 아닙니다. 차라리 이차 래퍼를 만들어 호출자가 이러한 세부 사항을 알지 못하게 하는 것이 좋습니다.

cjson.null

마지막으로 cjson에서 나타나는 null 값을 살펴보겠습니다. cjson 라이브러리는 json의 NULL을 Lua lightuserdata로 디코딩하며, cjson.null로 표현합니다.

$ resty -e 'local cjson = require "cjson"
local data = cjson.encode(nil)
local decode_null = cjson.decode(data)
ngx.say(decode_null == cjson.null)'

Lua의 nil은 JSON으로 인코딩되고 디코딩된 후 cjson.null이 됩니다. 이는 ngx.null과 같은 이유로 도입된 것으로, niltable의 값으로 사용될 수 없기 때문입니다.

지금까지 OpenResty에서 이렇게 많은 종류의 null 값들에 대해 혼란스러웠나요? 걱정하지 마세요. 이 부분을 몇 번 더 읽고 스스로 정리해보면 혼란스럽지 않을 것입니다. 물론, 앞으로 if not foo then과 같은 코드를 작성할 때 작동하는지 더 생각해봐야 합니다.

요약

오늘의 글에서는 OpenResty에서 흔히 사용되는 Lua API들을 소개했습니다.

마지막으로, ngx.now 예제에서 yield 작업이 없을 때 ngx.now의 값이 수정되지 않는 이유는 무엇인지 질문을 남기겠습니다. 여러분의 의견을 댓글로 공유해주시고, 이 글을 공유하여 함께 소통하고 발전할 수 있기를 바랍니다.