파트 3: OpenResty를 사용하여 마이크로서비스 API 게이트웨이 구축하기

API7.ai

February 3, 2023

OpenResty (NGINX + Lua)

이 글에서는 마이크로서비스 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에서 플러그인 목록을 가져와 정렬하지 않았나요?

여기서 필터링은 로컬 설정과 비교하여 수행되며, 그 이유는 다음과 같습니다:

  1. 첫째, 새로 개발된 플러그인은 카나리 릴리스가 필요합니다. 이때 새 플러그인은 etcd 목록에 존재하지만 일부 게이트웨이 노드에서만 열려 있는 상태입니다. 따라서 추가적인 교집합 연산이 필요합니다.
  2. 디버그 모드를 지원하기 위함입니다. 클라이언트 요청이 어떤 플러그인을 처리했는지? 이러한 플러그인의 로딩 순서는 어떻게 되는지? 이 정보는 디버깅 시 유용하므로, 필터 함수는 디버그 모드인지 여부도 판단하고 이 정보를 응답 헤더에 기록합니다.

따라서 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

그런 다음, rapidjsonjson 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 게이트웨이 구축하기

Share article link