NGINX 작업자 간의 통신 마법: 가장 중요한 데이터 구조 중 하나인 `shared dict`
API7.ai
October 27, 2022
이전 글에서 언급했듯이, 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"))
}
}
이 예제에서 mydata
는 Worker
프로세스에 의해 한 번만 로드되는 모듈이며, 이후에 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)
를 추가합니다. 여기서 sleep
은 yield
작업입니다.
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 dict
로 100M
의 공간을 요청하면 이 100M
가 충분한가요? 그 안에 저장된 키는 몇 개이며, 어떤 키인가요? 이는 모두 실제적인 질문입니다.
이러한 종류의 문제에 대해 OpenResty 공식은 사용자가 플레임 그래프를 사용하여 해결하기를 원합니다. 즉, 비침습적인 방식으로 코드베이스를 효율적이고 깔끔하게 유지하는 대신, 직접 결과를 반환하는 침습적인 API를 제공하지 않습니다.
그러나 사용자 친화적인 관점에서 이러한 관리 API는 여전히 필수적입니다. 결국 오픈소스 프로젝트는 기술 자체를 과시하기 위한 것이 아니라 제품 요구 사항을 해결하기 위한 것입니다. 따라서 나중에 추가될 다음 관리 API를 살펴보겠습니다.
먼저 get_keys(max_count?)
로, 기본적으로 처음 1024
개의 키만 반환합니다. max_count
를 0
으로 설정하면 모든 키를 반환합니다. 그 다음은 capacity
와 free_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_space
가 0
을 반환하더라도 할당된 페이지 내에 공간이 있을 수 있습니다. 따라서 그 반환 값은 공유 메모리가 얼마나 차지하는지를 나타내지 않습니다.
요약
실제로 우리는 종종 다단계 캐싱을 사용하며, OpenResty 공식 프로젝트에도 캐싱 패키지가 있습니다. 어떤 프로젝트인지 찾아볼 수 있나요? 또는 캐싱을 캡슐화한 다른 lua-resty
라이브러리를 알고 있나요?
이 글을 동료와 친구들과 공유하여 함께 소통하고 개선할 수 있도록 해주세요.