Why Does lua-resty-core Perform Better?

API7.ai

September 30, 2022

OpenResty (NGINX + Lua)

이전 두 강의에서 말했듯이, Lua는 핵심을 짧고 간결하게 유지하는 임베디드 개발 언어입니다. Lua를 Redis와 NGINX에 임베디드하여 비즈니스 로직을 유연하게 처리할 수 있습니다. 또한 Lua는 기존의 C 함수와 데이터 구조를 호출하여 반복 작업을 피할 수 있게 해줍니다.

Lua에서는 Lua C API를 사용하여 C 함수를 호출할 수 있으며, LuaJIT에서는 FFI를 사용할 수 있습니다. OpenResty의 경우:

  • lua-nginx-module 코어에서는 C 함수를 호출하기 위한 API가 Lua C API를 사용하여 구현됩니다.
  • lua-resty-core에서는 lua-nginx-module에 이미 존재하는 일부 API가 FFI 모델을 사용하여 구현됩니다.

아마도 왜 FFI로 구현해야 하는지 궁금할 것입니다.

걱정하지 마세요. 간단한 API인 ngx.base64_decode를 예로 들어 Lua C API와 FFI 구현이 어떻게 다른지 살펴보겠습니다. 이를 통해 그들의 성능에 대한 직관적인 이해를 얻을 수 있습니다.

Lua CFunction

먼저 lua-nginx-module에서 Lua C API를 사용하여 어떻게 구현되었는지 살펴보겠습니다. 프로젝트 코드에서 decode_base64를 검색하면 ngx_http_lua_string.c 파일에서 그 구현을 찾을 수 있습니다.

lua_pushcfunction(L, ngx_http_lua_ngx_decode_base64);
lua_setfield(L, -2, "decode_base64");

위 코드는 보기 어렵지만, 다행히도 lua_로 시작하는 두 함수와 그들의 인자의 구체적인 역할을 파헤칠 필요는 없습니다. 여기서 중요한 것은 ngx_http_lua_ngx_decode_base64라는 CFunction이 등록되어 있으며, 이는 ngx.base64_decode에 해당하는 API라는 점입니다.

이제 이 CFunction을 따라가서 ngx_http_lua_ngx_decode_base64를 검색해보겠습니다. 이 함수는 파일의 시작 부분에 정의되어 있습니다.

static int ngx_http_lua_ngx_decode_base64(lua_State *L);

Lua에서 호출할 수 있는 C 함수는 Lua가 요구하는 형식을 따라야 합니다. 즉, typedef int (*lua_CFunction)(lua_State* L)입니다. 이는 lua_State 타입의 포인터 L을 인자로 포함하며, 반환 값은 반환 값 자체가 아니라 반환 값의 개수를 나타내는 정수입니다.

이 함수는 다음과 같이 구현됩니다 (여기서는 오류 처리 코드를 제거했습니다).

static int
 ngx_http_lua_ngx_decode_base64(lua_State *L)
 {
     ngx_str_t p, src;

    src.data = (u_char *) luaL_checklstring(L, 1, &src.len);

     p.len = ngx_base64_decoded_length(src.len);

     p.data = lua_newuserdata(L, p.len);

     if (ngx_decode_base64(&p, &src) == NGX_OK) {
         lua_pushlstring(L, (char *) p.data, p.len);

     } else {
         lua_pushnil(L);
     }

     return 1;
 }

이 코드에서 주요한 부분은 ngx_base64_decoded_lengthngx_decode_base64입니다. 이 둘은 NGINX가 제공하는 C 함수입니다.

C로 작성된 함수는 반환 값을 Lua 코드에 직접 전달할 수 없으며, Lua와 C 사이에서 호출 매개변수와 반환 값을 스택을 통해 전달해야 합니다. 이 때문에 처음 보기에는 이해하기 어려운 코드가 많습니다. 또한 이 코드는 JIT에 의해 추적될 수 없으므로, LuaJIT에게는 이 작업들이 블랙박스처럼 보여 최적화할 수 없습니다.

LuaJIT FFI

FFI는 다릅니다. FFI의 상호작용 부분은 Lua에서 구현되며, 이는 JIT에 의해 추적되고 최적화될 수 있습니다. 물론 코드도 더 간결하고 이해하기 쉽습니다.

base64_decode를 예로 들어보겠습니다. FFI 구현은 lua-resty-corelua-nginx-module 두 저장소에 걸쳐 있으며, 전자의 구현 코드를 살펴보겠습니다.

ngx.decode_base64 = function (s)
     local slen = #s
     local dlen = base64_decoded_length(slen)

     local dst = get_string_buf(dlen)
     local pdlen = get_size_ptr()
     local ok = C.ngx_http_lua_ffi_decode_base64(s, slen, dst, pdlen)
     if ok == 0 then
         return nil
     end
     return ffi_string(dst, pdlen[0])
 end

CFunction과 비교했을 때, FFI 구현의 코드가 훨씬 깔끔하다는 것을 알 수 있습니다. 이의 구체적인 구현은 lua-nginx-module 저장소의 ngx_http_lua_ffi_decode_base64입니다. 여기서 관심이 있다면 이 함수의 성능을 직접 확인해보세요. 매우 간단하므로 여기서 코드를 더 보여드리지는 않겠습니다.

그런데 위 코드 조각에서 함수 이름 규칙을 발견하셨나요?

네, OpenResty의 모든 함수는 명명 규칙을 따르며, 이름을 통해 그 사용법을 유추할 수 있습니다. 예를 들어:

  • ngx_http_lua_ffi_, FFI를 사용하여 NGINX HTTP 요청을 처리하는 Lua 함수.
  • ngx_http_lua_ngx_, C 함수를 사용하여 NGINX HTTP 요청을 처리하는 Lua 함수.
  • ngx_lua_로 시작하는 다른 함수들은 각각 NGINX와 Lua의 내장 함수입니다.

더 나아가, OpenResty의 C 코드는 엄격한 코드 규칙을 따르며, 공식 C 코드 스타일 가이드를 읽어보는 것을 추천합니다. 이는 OpenResty의 C 코드를 배우고 PR을 제출하려는 개발자에게 필수 문서입니다. 그렇지 않으면, 코드 스타일 문제로 인해 PR이 잘 작성되었더라도 반복적으로 코멘트를 받고 수정을 요청받을 수 있습니다.

FFI에 대한 더 많은 API와 세부 사항은 공식 LuaJIT 튜토리얼문서를 읽어보시기 바랍니다. 기술 칼럼은 공식 문서를 대체할 수 없으며, 저는 제한된 시간 내에 학습 경로를 안내하고, 덜 돌아가도록 도울 뿐입니다. 어려운 문제는 여전히 여러분이 해결해야 합니다.

LuaJIT FFI GC

FFI를 사용할 때, 우리는 혼란스러울 수 있습니다: FFI에서 요청한 메모리를 누가 관리할까요? C에서 수동으로 해제해야 할까요, 아니면 LuaJIT가 자동으로 회수할까요?

여기 간단한 원칙이 있습니다: LuaJIT는 자신이 할당한 리소스만 관리합니다; ffi.

예를 들어, ffi.C.malloc을 사용하여 메모리 블록을 요청했다면, 이에 대응하는 ffi.C.free로 해제해야 합니다. 공식 LuaJIT 문서에는 이에 대한 예제가 있습니다.

local p = ffi.gc(ffi.C.malloc(n), ffi.C.free)
 ...
 p = nil -- Last reference to p is gone.
 -- GC will eventually run finalizer: ffi.C.free(p)

이 코드에서 ffi.C.malloc(n)은 메모리 섹션을 요청하고, ffi.gc는 소멸 콜백 함수 ffi.C.free를 등록합니다. ffi.C.free는 cdata p가 LuaJIT에 의해 GC될 때 자동으로 호출되어 C 레벨 메모리를 해제합니다. 그리고 cdata는 LuaJIT에 의해 GC됩니다. LuaJIT는 위 코드에서 p를 자동으로 해제합니다.

OpenResty에서 큰 메모리 청크를 요청하려면 ffi.new 대신 ffi.C.malloc을 사용하는 것을 추천합니다. 그 이유는 명확합니다.

  1. ffi.newcdata를 반환하며, 이는 LuaJIT가 관리하는 메모리의 일부입니다.
  2. LuaJIT GC는 메모리 관리의 상한선이 있으며, OpenResty의 LuaJIT는 GC64 옵션이 활성화되어 있지 않습니다. 따라서 단일 작업자의 메모리 상한선은 2G입니다. LuaJIT 메모리 관리의 상한선을 초과하면 오류가 발생합니다.

FFI를 사용할 때는 메모리 누수에 특히 주의해야 합니다. 하지만 모두가 실수를 하며, 인간이 작성한 코드에는 항상 버그가 있습니다.

이때 OpenResty의 강력한 테스트 및 디버깅 도구 체인이 유용합니다.

먼저 테스트에 대해 이야기해보겠습니다. OpenResty 시스템에서는 Valgrind를 사용하여 메모리 누수를 감지합니다.

이전 강의에서 언급한 테스트 프레임워크 test::nginx는 메모리 누수 감지 모드로 단위 테스트 케이스 세트를 실행할 수 있습니다. 이 모드를 사용하려면 환경 변수 TEST_NGINX_USE_VALGRIND=1을 설정해야 합니다. 공식 OpenResty 프로젝트는 버전을 출시하기 전에 이 모드에서 전체 테스트를 수행하며, 나중에 테스트 섹션에서 더 자세히 다루겠습니다.

OpenResty의 CLI 도구인 resty도 --valgrind 옵션을 제공하며, 이를 통해 테스트 케이스를 작성하지 않았더라도 단독으로 Lua 코드를 실행할 수 있습니다.

이제 디버깅 도구를 살펴보겠습니다.

OpenResty는 systemtap 기반 확장을 제공하여 OpenResty 프로그램의 실시간 동적 분석을 수행할 수 있습니다. 이 프로젝트의 도구 세트에서 gc 키워드를 검색하면 lj-gclj-gc-objs 두 도구를 찾을 수 있습니다.

core dump와 같은 오프라인 분석을 위해 OpenResty는 GDB 도구 세트를 제공하며, 여기서도 gc를 검색하여 lgc, lgcstat, lgcpath 세 도구를 찾을 수 있습니다.

이 디버깅 도구들의 구체적인 사용법은 나중에 디버깅 섹션에서 자세히 다루겠습니다. 일단은 OpenResty가 이러한 문제를 찾고 해결하는 데 도움을 주는 전용 도구 세트가 있다는 것을 기억해두세요.

lua-resty-core

위 비교에서 볼 수 있듯이, FFI 방식은 코드가 더 깔끔할 뿐만 아니라 LuaJIT에 의해 최적화될 수 있어 더 나은 선택입니다. OpenResty는 CFunction 구현을 더 이상 사용하지 않으며, 성능이 코드베이스에서 제거되었습니다. 새로운 API들은 이제 lua-resty-core 저장소에서 FFI를 통해 구현됩니다.

2019년 5월에 출시된 OpenResty 1.15.8.1 이전에는 lua-resty-core가 기본적으로 활성화되지 않았으며, 이로 인해 성능 손실과 잠재적인 버그가 발생했습니다. 따라서 역사적인 버전을 사용 중이라면 lua-resty-core를 수동으로 활성화하는 것을 강력히 권장합니다. init_by_lua 단계에 한 줄의 코드만 추가하면 됩니다.

require "resty.core"

물론, 1.15.8.1 릴리스에서는 lua_load_resty_core 지시어가 추가되었으며, lua-resty-core가 기본적으로 활성화됩니다.

개인적으로 OpenResty가 lua-resty-core 활성화에 대해 너무 조심스러운 것 같습니다. 오픈소스 프로젝트는 가능한 한 빨리 이러한 기능을 기본적으로 활성화해야 합니다.

lua-resty-corelua-nginx-module 프로젝트의 일부 API를 재구현할 뿐만 아니라, ngx.re.match, ngx.md5 등과 같은 새로운 API도 구현했습니다. 예를 들어 ngx.ssl, ngx.base64, ngx.errlog, ngx.process, ngx.re.split, ngx.resp.add_header, ngx.balancer, ngx.semaphore 등이 있으며, 이는 나중에 OpenResty API 챕터에서 다루겠습니다.

결론

이 모든 것을 말씀드린 후, FFI가 좋지만 성능의 만병통치약은 아니라는 결론을 내리고 싶습니다. FFI가 효율적인 주된 이유는 JIT에 의해 추적되고 최적화될 수 있기 때문입니다. 만약 JIT되지 않고 인터프리터 모드에서 실행되어야 하는 Lua 코드를 작성한다면, FFI는 덜 효율적일 것입니다.

그렇다면 어떤 작업이 JIT될 수 있고, 어떤 작업이 JIT될 수 없을까요? JIT되지 않는 코드를 작성하지 않으려면 어떻게 해야 할까요? 이에 대해서는 다음 섹션에서 밝히겠습니다.

마지막으로, 실습 과제를 드리겠습니다: lua-nginx-modulelua-resty-core에서 각각 하나 또는 두 개의 API를 찾아 성능 테스트를 비교해보세요. FFI의 성능 향상이 얼마나 큰지 확인할 수 있을 것입니다.

댓글을 남겨주시면, 여러분의 생각과 얻은 점을 공유하겠습니다. 이 글을 동료와 친구들과 공유하여 함께 교류하고 발전하길 바랍니다.