파트 3: OpenResty를 사용하여 마이크로서비스 API 게이트웨이 구축하기
API7.ai
February 3, 2023
이 글에서는 마이크로서비스 API 게이트웨이 구축을 마무리합니다. 이전에 선택한 컴포넌트들을 모아 설계된 청사진에 따라 실행해 보는 간단한 예제를 통해 살펴보겠습니다!
NGINX 설정 및 초기화
API 게이트웨이는 트래픽 진입을 처리하는 데 사용되므로, 먼저 nginx.conf
에서 간단한 설정을 해야 합니다. 이를 통해 모든 트래픽이 게이트웨이의 Lua 코드를 통해 처리되도록 합니다.
server {
listen 9080;
init_worker_by_lua_block {
apisix.http_init_worker()
}
location / {
access_by_lua_block {
apisix.http_access_phase()
}
header_filter_by_lua_block {
apisix.http_header_filter_phase()
}
body_filter_by_lua_block {
apisix.http_body_filter_phase()
}
log_by_lua_block {
apisix.http_log_phase()
}
}
}
여기서는 오픈소스 API 게이트웨이인 Apache APISIX를 예로 들었기 때문에, 위 코드 예제에는 apisix
라는 키워드가 포함되어 있습니다. 이 예제에서는 9080
포트를 리스닝하고, location /
을 통해 이 포트로 들어오는 모든 요청을 가로채어 access
, rewrite
, header filter
, body filter
, log
단계를 통해 처리하며, 각 단계에서 해당 플러그인 함수를 호출합니다. rewrite
단계는 apisix.http_access_phase
함수에 통합되어 있습니다.
시스템 초기화는 init_worker
단계에서 처리되며, 여기에는 설정 파라미터 읽기, etcd에 디렉토리 사전 설정, etcd에서 플러그인 목록 가져오기, 플러그인을 우선순위에 따라 정렬하는 작업 등이 포함됩니다. 여기서는 코드의 주요 부분을 나열하고 설명했으며, 더 완전한 초기화 함수는 GitHub에서 확인할 수 있습니다.
function _M.http_init_worker()
-- 라우팅, 서비스, 플러그인 초기화 - 가장 중요한 세 부분
router.init_worker()
require("apisix.http.service").init_worker()
require("apisix.plugins.ext-plugin.init").init_worker()
end
이 코드에서 볼 수 있듯이, 라우터와 플러그인 부분의 초기화는 조금 더 복잡하며, 주로 설정 파라미터를 읽고 이를 기반으로 몇 가지 선택을 하는 작업이 포함됩니다. 이 작업은 etcd에서 데이터를 읽어야 하므로, init_worker
단계에서 "cosocket을 사용할 수 없다"는 제한을 피하기 위해 ngx.timer
를 사용합니다. 이 부분에 관심이 있다면, 소스 코드를 읽어보는 것을 추천합니다.
라우트 매칭
access
단계의 시작 부분에서는 요청에 포함된 uri
, host
, args
, cookies
등을 기반으로 설정된 라우팅 규칙과 매칭해야 합니다.
router.router_http.match(api_ctx)
공개된 코드는 위의 한 줄뿐이며, api_ctx
에는 요청의 uri
, host
, args
, cookie
정보가 저장되어 있습니다. 매칭 함수의 구체적인 구현은 앞서 언급한 lua-resty-radixtree
를 사용합니다. 매칭되는 라우트가 없으면, 해당 요청에 대한 업스트림이 없으므로 404
를 반환합니다.
local router = require("resty.radixtree")
local match_opts = {}
function _M.match(api_ctx)
-- 요청의 파라미터를 ctx에서 가져와 라우트의 판단 조건으로 사용
match_opts.method = api_ctx.var.method
match_opts.host = api_ctx.var.host
match_opts.remote_addr = api_ctx.var.remote_addr
match_opts.vars = api_ctx.var
-- 라우트의 판단 함수 호출
local ok = uri_router:dispatch(api_ctx.var.uri, match_opts, api_ctx)
-- 매칭되는 라우트가 없으면 404 반환
if not ok then
core.log.info("not find any matched route")
return core.response.exit(404)
end
return true
end
플러그인 로딩
물론, 라우트가 매칭되면 플러그인 필터링 및 로딩 단계로 넘어가며, 이는 API 게이트웨이의 핵심입니다. 다음 코드부터 시작해 보겠습니다.
local plugins = core.tablepool.fetch("plugins", 32, 0)
-- etcd의 플러그인 목록과 로컬 설정 파일의 플러그인 목록을 교집합
api_ctx.plugins = plugin.filter(route, plugins)
-- rewrite 및 access 단계에 마운트된 플러그인 함수를 순서대로 실행
run_plugin("rewrite", plugins, api_ctx)
run_plugin("access", plugins, api_ctx)
이 코드에서는 먼저 테이블 풀을 통해 길이 32의 테이블을 요청하며, 이는 앞서 소개한 성능 최적화 기법입니다. 그 다음은 플러그인의 필터 함수입니다. 이 단계가 왜 필요한지 궁금할 수 있습니다. 플러그인의 init worker 단계에서 이미 etcd에서 플러그인 목록을 가져와 정렬하지 않았나요?
여기서 필터링은 로컬 설정과 비교하여 수행되며, 그 이유는 다음과 같습니다:
- 첫째, 새로 개발된 플러그인은 카나리 릴리스가 필요합니다. 이때 새 플러그인은 etcd 목록에 존재하지만 일부 게이트웨이 노드에서만 열려 있는 상태입니다. 따라서 추가적인 교집합 연산이 필요합니다.
- 디버그 모드를 지원하기 위함입니다. 클라이언트 요청이 어떤 플러그인을 처리했는지? 이러한 플러그인의 로딩 순서는 어떻게 되는지? 이 정보는 디버깅 시 유용하므로, 필터 함수는 디버그 모드인지 여부도 판단하고 이 정보를 응답 헤더에 기록합니다.
따라서 access
단계의 마지막에는 이러한 필터링된 플러그인을 가져와 우선순위에 따라 하나씩 실행합니다. 다음 코드와 같습니다.
local function run_plugin(phase, plugins, api_ctx)
for i = 1, #plugins, 2 do
local phase_fun = plugins[i][phase]
if phase_fun then
-- 핵심 호출 코드
phase_fun(plugins[i + 1], api_ctx)
end
end
return api_ctx
end
플러그인을 순회할 때, 2
간격으로 순회하는 것을 볼 수 있습니다. 이는 각 플러그인이 두 가지 구성 요소, 즉 플러그인 객체와 플러그인의 설정 파라미터를 가지기 때문입니다. 이제 위 샘플 코드의 핵심 코드를 살펴보겠습니다.
phase_fun(plugins[i + 1], api_ctx)
이 코드가 조금 추상적이라면, 구체적인 limit_count
플러그인으로 대체해 보면 훨씬 명확해질 것입니다.
limit_count_plugin_rewrite_function(conf_of_plugin, api_ctx)
이제 API 게이트웨이의 전체 흐름은 거의 완료되었습니다. 이 모든 코드는 동일한 파일에 있으며, 400줄 이상의 코드가 있지만, 코드의 핵심은 위에서 설명한 몇십 줄입니다.
플러그인 작성
이제 전체 데모를 실행하기 전에 남은 한 가지 작업은 플러그인을 작성하는 것입니다. limit-count
플러그인을 예로 들어보겠습니다. 전체 구현은 60줄이 조금 넘는 코드이며, 링크를 클릭하면 확인할 수 있습니다. 여기서는 주요 코드를 자세히 설명하겠습니다:
먼저, 요청 수를 제한하기 위한 기본 라이브러리로 lua-resty-limit-traffic
을 도입합니다.
local limit_count_new = require("resty.limit.count").new
그런 다음, rapidjson
의 json schema
를 사용하여 이 플러그인의 파라미터가 무엇인지 정의합니다:
local schema = {
type = "object",
properties = {
count = {type = "integer", minimum = 0},
time_window = {type = "integer", minimum = 0},
key = {type = "string",
enum = {"remote_addr", "server_addr"},
},
rejected_code = {type = "integer", minimum = 200, maximum = 600},
},
additionalProperties = false,
required = {"count", "time_window", "key", "rejected_code"},
}
이 플러그인의 파라미터는 대부분 resty.limit.count
의 파라미터와 일치하며, 여기에는 제한 키, 시간 창 크기, 제한할 요청 수가 포함됩니다. 또한 플러그인은 rejected_code
라는 파라미터를 추가로 포함하며, 이는 요청이 제한될 때 지정된 상태 코드를 반환합니다.
마지막 단계에서는 플러그인의 핸들러 함수를 rewrite
단계에 마운트합니다:
function _M.rewrite(conf, ctx)
-- 캐시에서 limit count 객체를 가져오고, 없으면 `create_limit_obj` 함수를 사용하여 새 객체를 생성하고 캐시에 저장
local lim, err = core.lrucache.plugin_ctx(plugin_name, ctx, create_limit_obj, conf)
-- `ctx.var`에서 키 값을 가져와 설정 유형 및 설정 버전 번호와 함께 새로운 키를 구성
local key = (ctx.var[conf.key] or "") .. ctx.conf_type .. ctx.conf_version
-- 제한이 적용되었는지 판단하는 함수
local delay, remaining = lim:incoming(key, true)
if not delay then
local err = remaining
-- 임계값을 초과하면 지정된 상태 코드 반환
if err == "rejected" then
return conf.rejected_code
end
core.log.error("failed to limit req: ", err)
return 500
end
-- 임계값을 초과하지 않으면 해제하고 해당 응답 헤더 설정
core.response.set_header("X-RateLimit-Limit", conf.count,
"X-RateLimit-Remaining", remaining)
end
위 코드에서 제한 판단을 하는 논리는 단 한 줄뿐이며, 나머지는 준비 작업과 응답 헤더 설정을 위한 것입니다. 임계값을 초과하지 않으면 우선순위에 따라 다음 플러그인을 계속 실행합니다.
요약
마지막으로, 생각해 볼 만한 질문을 남기겠습니다. API 게이트웨이는 Layer 7 트래픽뿐만 아니라 Layer 4 트래픽도 처리할 수 있습니다. 이를 기반으로 어떤 사용 시나리오를 생각해 볼 수 있을까요? 여러분의 의견을 남기고 이 글을 공유하여 더 많은 사람들과 학습하고 소통해 보세요.
이전 글: Part 1: OpenResty를 사용하여 마이크로서비스 API 게이트웨이 구축하기 Part 2: OpenResty를 사용하여 마이크로서비스 API 게이트웨이 구축하기