NGINX 작업자 간의 통신 마법: 가장 중요한 데이터 구조 중 하나인 `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

이전 글에서 언급했듯이, Lua에서 table은 유일한 데이터 구조입니다. 이는 OpenResty 프로그래밍에서 사용할 수 있는 가장 중요한 데이터 구조인 shared dict에 해당합니다. shared dict는 데이터 저장, 읽기, 원자적 카운팅, 큐 작업 등을 지원합니다.

shared dict를 기반으로 여러 Worker 간의 캐싱 및 통신, 속도 제한, 트래픽 통계 등의 기능을 구현할 수 있습니다. shared dict를 간단한 Redis처럼 사용할 수 있지만, shared dict의 데이터는 지속성이 없으므로 저장된 데이터의 손실을 고려해야 합니다.

데이터 공유의 여러 방법

OpenResty Lua 코드를 작성할 때, 요청의 다른 단계에서 다른 Worker 간의 데이터 공유를 피할 수 없습니다. 또한 Lua와 C 코드 간의 데이터 공유도 필요할 수 있습니다.

따라서 shared dict API를 공식적으로 소개하기 전에, OpenResty에서 일반적으로 사용되는 데이터 공유 방법을 이해하고 현재 상황에 따라 더 적절한 데이터 공유 방법을 선택하는 방법을 알아보겠습니다.

첫 번째는 NGINX의 변수입니다. 이는 NGINX C 모듈 간에 데이터를 공유할 수 있습니다. 당연히 C 모듈과 OpenResty가 제공하는 lua-nginx-module 간에도 데이터를 공유할 수 있습니다. 다음 코드와 같습니다.

location /foo {
     set $my_var ''; # 이 줄은 설정 시 $my_var를 생성하기 위해 필요합니다.
     content_by_lua_block {
         ngx.var.my_var = 123;
         ...
     }
 }

그러나 NGINX 변수를 사용하여 데이터를 공유하는 것은 느립니다. 이는 해시 조회와 메모리 할당을 포함하기 때문입니다. 또한 이 방법은 문자열만 저장할 수 있고 복잡한 Lua 타입을 지원하지 않는다는 한계가 있습니다.

두 번째는 ngx.ctx로, 동일한 요청의 다른 단계 간에 데이터를 공유할 수 있습니다. 이는 일반 Lua table이므로 빠르며 다양한 Lua 객체를 저장할 수 있습니다. 이의 생명주기는 요청 수준입니다. 요청이 끝나면 ngx.ctx는 소멸됩니다.

다음은 ngx.ctx를 사용하여 NGINX 변수와 같은 비용이 많이 드는 호출을 캐시하고 다양한 단계에서 사용하는 전형적인 사용 시나리오입니다.

location /test {
     rewrite_by_lua_block {
         ngx.ctx.host = ngx.var.host
     }
     access_by_lua_block {
        if (ngx.ctx.host == 'api7.ai') then
            ngx.ctx.host = 'test.com'
        end
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.host)
     }
 }

이 경우, curl을 사용하여 접근하면,

curl -i 127.0.0.1:8080/test -H 'host:api7.ai'

그러면 test.com이 출력되어 ngx.ctx가 다른 단계에서 데이터를 공유하고 있음을 보여줍니다. 물론, 위의 예제를 수정하여 간단한 문자열 대신 table과 같은 더 복잡한 객체를 저장하여 기대에 부합하는지 확인할 수도 있습니다.

그러나 여기서 특별히 주의할 점은 ngx.ctx의 생명주기가 요청 수준이므로 모듈 수준에서 캐시되지 않는다는 것입니다. 예를 들어, foo.lua 파일에서 다음과 같이 사용하는 실수를 저질렀습니다.

local ngx_ctx = ngx.ctx

local function bar()
    ngx_ctx.host =  'test.com'
end

함수 수준에서 호출하고 캐시해야 합니다.

local ngx = ngx

local function bar()
    ngx_ctx.host =  'test.com'
end

ngx.ctx에 대한 더 많은 세부 사항은 나중에 성능 최적화 섹션에서 계속 탐구할 것입니다.

세 번째 접근 방식은 모듈 수준 변수를 사용하여 동일한 Worker 내의 모든 요청 간에 데이터를 공유하는 것입니다. 이전의 NGINX 변수와 ngx.ctx와 달리, 이 접근 방식은 이해하기 조금 어렵습니다. 하지만 걱정하지 마세요. 개념은 추상적이지만 코드가 먼저 나오므로 예제를 통해 모듈 수준 변수를 이해해 보겠습니다.

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.get_age(name)
    return data[name]
end

return _M

nginx.conf의 구성은 다음과 같습니다.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata.get_age("dog"))
     }
 }

이 예제에서 mydataWorker 프로세스에 의해 한 번만 로드되는 모듈이며, 이후에 Worker가 처리하는 모든 요청은 mydata 모듈의 코드와 데이터를 공유합니다.

당연히 mydata 모듈의 data 변수는 모듈의 최상위 수준에 위치한 모듈 수준 변수로, 모든 함수에서 접근할 수 있습니다.

따라서 요청 간에 공유해야 할 데이터를 모듈의 최상위 변수에 넣을 수 있습니다. 그러나 쓰기 작업이 포함된 경우 매우 주의해야 합니다. race condition이 발생할 수 있기 때문이며, 이는 찾기 어려운 버그입니다.

다음의 가장 단순화된 예제로 이를 경험할 수 있습니다.

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.incr_age(name)
    data[name]  = data[name] + 1
    return data[name]
end

return _M

모듈에서 data 테이블의 데이터를 수정하는 incr_age 함수를 추가했습니다.

그런 다음 호출 코드에서 가장 중요한 줄인 ngx.sleep(5)를 추가합니다. 여기서 sleepyield 작업입니다.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata. incr_age("dog"))
         ngx.sleep(5) -- yield API
         ngx.say(mydata. incr_age("dog"))
     }
 }

sleep 코드 줄(또는 Redis 접근과 같은 다른 비차단 IO 작업)이 없으면 yield 작업이 없고 경쟁도 없으며 최종 출력은 순차적일 것입니다.

하지만 이 코드 줄을 추가하면, 5초 동안의 sleep 동안에도 다른 요청이 mydata.incr_age 함수를 호출하고 변수의 값을 수정할 가능성이 높아져 최종 출력 숫자가 불연속적이 될 수 있습니다. 실제 코드에서는 로직이 그렇게 간단하지 않으며 버그를 찾는 것이 훨씬 더 어렵습니다.

따라서 중간에 NGINX 이벤트 루프에 제어권을 넘기는 yield 작업이 없다고 확신하지 않는 한, 모듈 수준 변수를 읽기 전용으로 유지하는 것을 권장합니다.

네 번째이자 마지막 접근 방식은 shared dict를 사용하여 여러 작업자 간에 공유할 수 있는 데이터를 공유하는 것입니다.

이 접근 방식은 레드-블랙 트리 구현을 기반으로 하며 성능이 좋습니다. 그러나 이는 사전에 NGINX 구성 파일에서 공유 메모리의 크기를 선언해야 하며, 이는 런타임에 변경할 수 없다는 한계가 있습니다:

lua_shared_dict dogs 10m;

shared dict는 또한 string 데이터만 캐시하며 복잡한 Lua 데이터 타입을 지원하지 않습니다. 이는 table과 같은 복잡한 데이터 타입을 저장해야 할 때 JSON이나 다른 방법을 사용하여 직렬화 및 역직렬화해야 하므로 자연스럽게 많은 성능 손실이 발생함을 의미합니다.

어쨌든 여기에는 만병통치약이 없으며 데이터를 공유하는 완벽한 방법도 없습니다. 필요와 시나리오에 따라 여러 방법을 결합해야 합니다.

Shared dict

위에서 데이터 공유 부분에 대해 많은 시간을 들여 학습했으며, 일부 독자들은 궁금해할 수 있습니다: 그것들이 shared dict와 직접적으로 관련이 없는 것 같습니다. 이는 주제에서 벗어난 것이 아닌가요?

실제로 그렇지 않습니다. 생각해 보세요: 왜 OpenResty에 shared dict가 있는 걸까요? 첫 세 가지 데이터 공유 방법은 모두 요청 수준 또는 개별 Worker 수준에서 이루어집니다. 따라서 현재 OpenResty 구현에서는 shared dict만이 Worker 간의 데이터 공유를 가능하게 하여 Worker 간의 통신을 가능하게 합니다. 이것이 그 존재의 가치입니다.

제 생각에, 기술이 존재하는 이유를 이해하고 다른 유사 기술과의 차이점과 장점을 파악하는 것은 단순히 제공된 API를 호출하는 데 능숙해지는 것보다 훨씬 더 중요합니다. 이러한 기술적 통찰력은 예측력과 통찰력을 제공하며, 엔지니어와 아키텍트 간의 중요한 차이점이라고 할 수 있습니다.

shared dict로 돌아가서, 이는 20개 이상의 Lua API를 공개하며, 모두 원자적이므로 여러 Worker와 높은 동시성 상황에서 경쟁에 대해 걱정할 필요가 없습니다.

이러한 API는 모두 공식 문서에 자세히 설명되어 있으므로 모두 다루지는 않겠습니다. 다시 강조하지만, 어떤 기술 과정도 공식 문서를 꼼꼼히 읽는 것을 대체할 수 없습니다. 아무도 이러한 시간이 많이 걸리고 지루한 절차를 건너뛸 수 없습니다.

다음으로, shared dict API를 계속 살펴보겠습니다. 이는 dict 읽기/쓰기, 큐 작업, 관리의 세 가지 범주로 나눌 수 있습니다.

Dict 읽기/쓰기

먼저 dict 읽기 및 쓰기 클래스를 살펴보겠습니다. 원래 버전에는 dict 읽기 및 쓰기 클래스의 API만 있었으며, 이는 공유 사전의 가장 일반적인 기능입니다. 다음은 가장 간단한 예제입니다.

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                               dict:set("Tom", 56)
                               print(dict:get("Tom"))'

set 외에도 OpenResty는 safe_set, add, safe_add, replace라는 네 가지 쓰기 방법을 제공합니다. 여기서 safe 접두사의 의미는 메모리가 가득 차면 LRU에 따라 이전 데이터를 제거하는 대신 쓰기가 실패하고 no memory 오류를 반환한다는 것입니다.

get 외에도 OpenResty는 get_stale 메서드를 제공하여 데이터를 읽습니다. 이는 get 메서드에 비해 만료된 데이터에 대한 추가 반환 값이 있습니다.

value, flags, stale = ngx.shared.DICT:get_stale(key)

또한 지정된 키를 삭제하기 위해 delete 메서드를 호출할 수 있으며, 이는 set(key, nil)과 동일합니다.

큐 작업

큐 작업으로 넘어가면, 이는 OpenResty에 나중에 추가된 것으로 Redis와 유사한 인터페이스를 제공합니다. 큐의 각 요소는 ngx_http_lua_shdict_list_node_t로 설명됩니다.

typedef struct {
    ngx_queue_t queue;
    uint32_t value_len;
    uint8_t value_type;
    u_char data[1];
} ngx_http_lua_shdict_list_node_t;

이 큐 API의 PR을 글에 게시했습니다. 이에 관심이 있다면 문서, 테스트 케이스 및 소스 코드를 따라 구체적인 구현을 분석할 수 있습니다.

그러나 문서에는 다음 다섯 가지 큐 API에 대한 해당 코드 예제가 없으므로 여기서 간단히 소개하겠습니다.

  • lpush``/``rpush는 큐의 양쪽 끝에 요소를 추가합니다.
  • lpop``/``rpop은 큐의 양쪽 끝에서 요소를 꺼냅니다.
  • llen은 큐의 요소 수를 반환합니다.

지난 글에서 논의한 또 다른 유용한 도구인 테스트 케이스를 잊지 맙시다. 문서에 없는 경우 일반적으로 테스트 케이스에서 해당 코드를 찾을 수 있습니다. 큐 관련 테스트는 정확히 145-shdict-list.t 파일에 있습니다.

=== TEST 1: lpush & lpop
--- http_config
    lua_shared_dict dogs 1m;
--- config
    location = /test {
        content_by_lua_block {
            local dogs = ngx.shared.dogs

            local len, err = dogs:lpush("foo", "bar")
            if len then
                ngx.say("push success")
            else
                ngx.say("push err: ", err)
            end

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)
        }
    }
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]

관리

마지막 관리 API는 또한 나중에 추가된 것으로 커뮤니티에서 인기 있는 요구 사항입니다. 가장 전형적인 예 중 하나는 공유 메모리의 사용입니다. 예를 들어, 사용자가 shared dict100M의 공간을 요청하면 이 100M가 충분한가요? 그 안에 저장된 키는 몇 개이며, 어떤 키인가요? 이는 모두 실제적인 질문입니다.

이러한 종류의 문제에 대해 OpenResty 공식은 사용자가 플레임 그래프를 사용하여 해결하기를 원합니다. 즉, 비침습적인 방식으로 코드베이스를 효율적이고 깔끔하게 유지하는 대신, 직접 결과를 반환하는 침습적인 API를 제공하지 않습니다.

그러나 사용자 친화적인 관점에서 이러한 관리 API는 여전히 필수적입니다. 결국 오픈소스 프로젝트는 기술 자체를 과시하기 위한 것이 아니라 제품 요구 사항을 해결하기 위한 것입니다. 따라서 나중에 추가될 다음 관리 API를 살펴보겠습니다.

먼저 get_keys(max_count?)로, 기본적으로 처음 1024개의 키만 반환합니다. max_count0으로 설정하면 모든 키를 반환합니다. 그 다음은 capacityfree_space로, 둘 다 lua-resty-core 저장소의 일부이므로 사용하기 전에 require해야 합니다.

require "resty.core.shdict"

local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()

이들은 공유 메모리의 크기(lua_shared_dict에서 구성된 크기)와 사용 가능한 페이지의 바이트 수를 반환합니다. shared dict는 페이지 단위로 할당되므로 free_space0을 반환하더라도 할당된 페이지 내에 공간이 있을 수 있습니다. 따라서 그 반환 값은 공유 메모리가 얼마나 차지하는지를 나타내지 않습니다.

요약

실제로 우리는 종종 다단계 캐싱을 사용하며, OpenResty 공식 프로젝트에도 캐싱 패키지가 있습니다. 어떤 프로젝트인지 찾아볼 수 있나요? 또는 캐싱을 캡슐화한 다른 lua-resty 라이브러리를 알고 있나요?

이 글을 동료와 친구들과 공유하여 함께 소통하고 개선할 수 있도록 해주세요.