OpenResty FAQ | 동적 로드, NYI, 공유 Dict 캐싱

API7.ai

January 19, 2023

OpenResty (NGINX + Lua)

Openresty 시리즈가 지금까지 업데이트되었으며, 성능 최적화에 관한 부분은 우리가 배운 모든 내용입니다. 여러분이 뒤처지지 않고 여전히 적극적으로 학습하고 실천하며, 열정적으로 의견을 남겨주신 것을 축하드립니다.

우리는 더 전형적이고 흥미로운 질문들을 많이 수집했으며, 그 중 다섯 가지를 살펴보겠습니다.

질문 1: Lua 모듈의 동적 로딩을 어떻게 구현할 수 있나요?

설명: OpenResty에서 구현된 동적 로딩에 대해 궁금한 점이 있습니다. 파일이 교체된 후에 새로운 파일을 로드하기 위해 loadstring 함수를 어떻게 사용할 수 있나요? loadstring은 문자열만 로드할 수 있다는 것을 이해하고 있습니다. 그래서 Lua 파일/모듈을 다시 로드하려면 OpenResty에서 어떻게 해야 할까요?

우리가 알다시피, loadstring은 문자열을 로드하는 데 사용되며, loadfile은 지정된 파일을 로드할 수 있습니다. 예를 들어: loadfile("foo.lua"). 이 두 명령은 동일한 결과를 달성합니다. Lua 모듈을 로드하는 방법에 대한 예시는 다음과 같습니다:

resty -e 'local s = [[
local ngx = ngx
local _M = {}
function _M.f()
    ngx.say("hello world")
end
return _M
]]
local lua = loadstring(s)
local ret, func = pcall(lua)
func.f()'

문자열 s의 내용은 완전한 Lua 모듈입니다. 따라서 이 모듈의 코드가 변경되었을 때, loadstring 또는 loadfile을 사용하여 다시 로드할 수 있습니다. 이렇게 하면 그 안의 함수와 변수들이 함께 업데이트됩니다.

한 걸음 더 나아가, 변경 사항을 가져오고 다시 로드하는 것을 code_loader 함수로 감쌀 수도 있습니다.

local func = code_loader(name)

이렇게 하면 코드 업데이트가 훨씬 더 간결해집니다. 동시에, code_loader는 일반적으로 lru cache를 사용하여 s를 캐시하여 매번 loadstring을 호출하지 않도록 합니다.

질문 2: OpenResty는 왜 블로킹 연산을 금지하지 않나요?

설명: 몇 년 동안, 이러한 블로킹 호출이 공식적으로 권장되지 않는다면, 왜 그냥 비활성화하지 않나요? 아니면 사용자가 비활성화할 수 있도록 플래그를 추가하는 것은 어떨까요?

개인적인 의견을 말씀드리겠습니다. 첫째, OpenResty 주변의 생태계가 완벽하지 않기 때문에 때로는 블로킹 라이브러리를 호출하여 일부 기능을 구현해야 합니다. 예를 들어, 1.15.8 버전 이전에는 외부 명령을 호출하기 위해 lua-resty-shell 대신 Lua 라이브러리 os.execute를 사용해야 했습니다. 예를 들어, OpenResty에서 파일 읽기와 쓰기는 여전히 Lua I/O 라이브러리로만 가능하며, 논블로킹 대안이 없습니다.

둘째, OpenResty는 이러한 최적화에 대해 매우 신중합니다. 예를 들어, lua-resty-core는 오랫동안 개발되었지만 기본적으로 활성화되지 않았으며, 수동으로 require 'resty.core'를 호출해야 했습니다. 최신 1.15.8 릴리스까지 기본적으로 활성화되지 않았습니다.

마지막으로, OpenResty 유지 관리자들은 컴파일러와 DSL을 통해 고도로 최적화된 Lua 코드를 자동으로 생성하여 블로킹 호출을 표준화하는 것을 선호합니다. 따라서 OpenResty 플랫폼 자체에서 플래그 옵션과 같은 것을 추가하려는 노력은 없습니다. 물론, 이 방향이 문제를 해결할 수 있는지는 확실하지 않습니다.

외부 개발자의 관점에서 볼 때, 더 실질적인 문제는 이러한 블로킹을 어떻게 피할 수 있는가입니다. 우리는 Lua 코드 검사 도구를 확장하여, 예를 들어 luacheck를 사용하여 일반적인 블로킹 연산을 찾고 경고할 수 있습니다. 또는 _G를 재작성하여 특정 함수를 직접 비활성화하거나 재작성할 수 있습니다. 예를 들어:

resty -e '_G.ngx.print = function()
ngx.say("hello")
end
ngx.print()'

# hello

이 샘플 코드를 사용하면 ngx.print 함수를 직접 재작성할 수 있습니다.

질문 3: LuaJIT의 NYI 연산이 성능에 큰 영향을 미치나요?

설명: loadstring은 LuaJIT의 NYI 목록에서 never로 표시됩니다. 이것이 성능에 큰 영향을 미칠까요?

LuaJIT의 NYI에 대해 너무 엄격할 필요는 없습니다. JIT가 가능한 연산에 대해서는 JIT 방식이 자연스럽게 최선이지만, 아직 JIT가 불가능한 연산에 대해서는 계속 사용할 수 있습니다.

성능 최적화를 위해서는 통계 기반의 과학적인 접근 방식을 취해야 하며, 이것이 바로 플레임 그래프 샘플링의 핵심입니다. 조기 최적화는 모든 악의 근원입니다. 우리는 많은 호출을 하고 많은 CPU를 소비하는 핫 코드에 대해서만 최적화를 해야 합니다.

loadstring으로 돌아가면, 코드가 변경되었을 때만 다시 로드하기 위해 호출하며, 요청 시에는 호출하지 않으므로 빈번한 연산이 아닙니다. 이 시점에서는 시스템 전체 성능에 미치는 영향에 대해 걱정할 필요가 없습니다.

두 번째 블로킹 문제와 연관하여, OpenResty에서는 때로는 initinit worker 단계에서 블로킹 파일 I/O 연산을 호출하기도 합니다. 이 연산은 NYI보다 성능이 더 저하되지만, 서비스가 시작될 때 한 번만 수행되므로 허용 가능합니다.

항상 그렇듯이, 성능 최적화는 거시적인 관점에서 봐야 하며, 이 점에 특히 주의를 기울여야 합니다. 그렇지 않으면 특정 세부 사항에 집착하여 오랜 시간 동안 최적화를 해도 좋은 효과를 얻지 못할 수 있습니다.

질문 4: 동적 업스트림을 직접 구현할 수 있나요?

설명: 동적 업스트림에 대해, 제 접근 방식은 서비스에 대해 2개의 업스트림을 설정하고, 라우팅 조건에 따라 다른 업스트림을 선택하며, 머신 IP가 변경될 때 업스트림의 IP를 직접 수정하는 것입니다. 이 방식은 balancer_by_lua를 직접 사용하는 것과 비교했을 때 어떤 단점이나 함정이 있나요?

balancer_by_lua의 장점은 사용자가 로드 밸런싱 알고리즘을 선택할 수 있다는 것입니다. 예를 들어, roundrobin을 사용할지 chash를 사용할지, 아니면 사용자가 구현한 다른 알고리즘을 사용할지 선택할 수 있어 유연하고 고성능입니다.

라우팅 규칙 방식으로 하면 결과적으로는 동일합니다. 하지만 업스트림 상태 확인은 직접 구현해야 하며, 이는 많은 추가 작업을 필요로 합니다.

이 질문을 확장하여 abtest와 같은 시나리오를 어떻게 구현해야 하는지 물어볼 수도 있습니다. 이는 다른 업스트림이 필요합니다.

balancer_by_lua 단계에서 uri, host, parameters 등을 기반으로 사용할 업스트림을 결정할 수 있습니다. 또한 API 게이트웨이를 사용하여 이러한 판단을 라우팅 규칙으로 전환하고, 초기 access 단계에서 사용할 라우트를 결정한 다음, 라우트와 업스트림 간의 바인딩 관계를 통해 지정된 업스트림을 찾을 수 있습니다. 이는 API 게이트웨이의 일반적인 접근 방식이며, 나중에 실습 섹션에서 더 구체적으로 이야기하겠습니다.

질문 5: shared dict 캐싱은 필수인가요?

설명:

실제 프로덕션 애플리케이션에서, 저는 shared dict 캐시 계층이 필수라고 생각합니다. 모두가 lru cache의 장점만 기억하는 것 같습니다. 데이터 형식에 제한이 없고, 역직렬화가 필요 없으며, k/v 볼륨에 따라 메모리 공간을 계산할 필요가 없고, 작업자 간 경쟁이 없으며, 읽기/쓰기 잠금이 없고 고성능입니다.

그러나 가장 치명적인 약점 중 하나는 lru cache의 생명 주기가 Worker를 따르기 때문에 NGINX가 재로드될 때 이 캐시 부분이 완전히 손실된다는 점을 간과하지 마세요. 이 시점에서 shared dict가 없다면 L3 데이터 소스가 몇 분 안에 다운될 것입니다.

물론, 이는 더 높은 동시성의 경우이지만, 캐싱을 사용한다면 비즈니스 볼륨이 작지 않다는 것을 의미하며, 이는 앞서 언급한 분석이 여전히 적용된다는 것을 의미합니다. 제가 이 관점에서 옳다면?

어떤 경우에는, 당신이 말한 대로 shared dict는 재로드 중에 손실되지 않으므로 필요합니다. 하지만 특별한 경우에는 L3 데이터 소스에서 모든 데이터를 init 단계 또는 init_worker 단계에서 능동적으로 사용할 수 있다면 lru cache만으로도 충분합니다.

예를 들어, 오픈소스 API 게이트웨이 APISIX는 데이터 소스가 etcd에 있으며, etcd에서만 데이터를 가져옵니다. init_worker 단계에서 lru cache에 캐시하고, 이후 캐시 업데이트는 etcdwatch 메커니즘을 통해 능동적으로 가져옵니다. 이렇게 하면 NGINX가 재로드되어도 캐시 스탬피드가 발생하지 않습니다.

따라서, 기술 선택에 있어 선호도를 가질 수 있지만 절대적으로 일반화하지 않는 것이 좋습니다. 모든 캐싱 시나리오에 적합한 만능 해결책은 없기 때문입니다. 실제 시나리오의 요구에 따라 최소한의 사용 가능한 솔루션을 구축한 후 점진적으로 증가시키는 것이 좋은 방법입니다.