Apache APISIX는 어떻게 빠를까요?
June 12, 2023
"고속," "최소 지연 시간," "궁극의 성능"은 종종 Apache APISIX를 설명하는 데 사용됩니다. 누군가가 APISIX에 대해 물어볼 때, 저의 대답은 항상 "고성능 클라우드 네이티브 API 게이트웨이"를 포함합니다.
성능 벤치마크(Kong, Envoy 대비)는 이러한 특성이 정확하다는 것을 확인시켜 줍니다(직접 테스트).
테스트 실행은 Standard D8s v3(8 vCPUs, 32 GiB 메모리)에서 5000개의 고유 경로로 10회 수행되었습니다.
그렇다면 APISIX는 어떻게 이를 달성할까요?
이 질문에 답하기 위해서는 etcd, 해시 테이블, 그리고 radix 트리 세 가지를 살펴봐야 합니다.
이 글에서는 APISIX의 내부를 살펴보고, 이들이 무엇인지, 그리고 어떻게 함께 작동하여 APISIX가 상당한 트래픽을 처리하면서도 최고의 성능을 유지하는지 알아보겠습니다.
etcd를 구성 센터로 사용
APISIX는 etcd를 사용하여 구성을 저장하고 동기화합니다.
etcd는 대규모 분산 시스템의 구성을 위한 키-값 저장소로 설계되었습니다. APISIX는 처음부터 분산되고 확장 가능하도록 설계되었으며, 전통적인 데이터베이스 대신 etcd를 사용함으로써 이를 용이하게 합니다.
API 게이트웨이의 또 다른 필수 기능은 고가용성으로, 다운타임과 데이터 손실을 방지하는 것입니다. 여러 etcd 인스턴스를 배포하여 내결함성 있는 클라우드 네이티브 아키텍처를 효과적으로 달성할 수 있습니다.
APISIX는 최소 지연 시간으로 etcd에서 구성을 읽고 쓸 수 있습니다. 구성 파일의 변경 사항은 즉시 알림되므로, APISIX는 데이터베이스를 자주 폴링할 필요 없이 etcd 업데이트만 모니터링하면 됩니다. 이는 성능 오버헤드를 줄여줍니다.
이 차트는 etcd가 다른 데이터베이스와 어떻게 비교되는지 요약합니다.
IP 주소를 위한 해시 테이블
IP 주소 기반의 허용 목록/차단 목록은 API 게이트웨이의 일반적인 사용 사례입니다.
고성능을 달성하기 위해 APISIX는 IP 주소 목록을 해시 테이블에 저장하고 이를 사용하여 매칭(O(1))합니다. 이는 목록을 순회하는 것(O(N))보다 효율적입니다.
목록에 있는 IP 주소의 수가 증가함에 따라 해시 테이블을 사용한 저장 및 매칭의 성능 영향이 분명해집니다.
내부적으로 APISIX는 lua-resty-ipmatcher 라이브러리를 사용하여 이 기능을 구현합니다. 아래 예제는 이 라이브러리가 어떻게 사용되는지 보여줍니다:
local ipmatcher = require("resty.ipmatcher")
local ip = ipmatcher.new({
"162.168.46.72",
"17.172.224.47",
"216.58.32.170",
})
ngx.say(ip:match("17.172.224.47")) -- true
ngx.say(ip:match("176.24.76.126")) -- false
이 라이브러리는 해시 테이블인 Lua 테이블을 사용합니다. IP 주소는 해시되어 테이블의 인덱스로 저장되며, 주어진 IP 주소를 검색하려면 테이블을 인덱싱하고 nil인지 여부를 테스트하면 됩니다.
IP 주소를 검색하기 위해 먼저 해시(인덱스)를 계산하고 그 값을 확인합니다. 비어 있지 않으면 매칭된 것입니다. 이는 상수 시간 O(1)에 수행됩니다.
라우팅을 위한 Radix 트리
데이터 구조 수업에 여러분을 속여서 죄송합니다! 하지만 들어보세요, 여기서 흥미로운 부분이 시작됩니다.
APISIX가 성능을 최적화하는 주요 영역 중 하나는 라우트 매칭입니다.
APISIX는 요청의 URI, HTTP 메서드, 호스트 및 기타 정보를 통해 라우트를 매칭합니다(라우터). 그리고 이는 효율적이어야 합니다.
이전 섹션을 읽었다면, 해시 알고리즘을 사용하는 것이 명백한 해결책일 것입니다. 하지만 라우트 매칭은 까다롭습니다. 여러 요청이 동일한 라우트와 매칭될 수 있기 때문입니다.
예를 들어, /api/*
라우트가 있다면, /api/create
와 /api/destroy
모두 이 라우트와 매칭되어야 합니다. 하지만 이는 해시 알고리즘으로는 불가능합니다.
정규 표현식이 대안이 될 수 있습니다. 라우트를 정규식으로 구성하면 각 요청을 하드코딩할 필요 없이 여러 요청을 매칭할 수 있습니다.
이전 예제를 사용하면, /api/[A-Za-z0-9]+
정규식을 사용하여 /api/create
와 /api/destroy
를 모두 매칭할 수 있습니다. 더 복잡한 정규식은 더 복잡한 라우트를 매칭할 수 있습니다.
하지만 정규식은 느립니다! 그리고 APISIX는 빠릅니다. 그래서 APISIX는 radix 트리를 사용합니다. 이는 압축된 접두사 트리(trie)로, 빠른 조회에 매우 적합합니다.
간단한 예를 살펴보겠습니다. 다음과 같은 단어들이 있다고 가정해 봅시다:
- romane
- romanus
- romulus
- rubens
- ruber
- rubicon
- rubicundus
접두사 트리는 이를 다음과 같이 저장합니다:
강조된 순회는 "rubens"라는 단어를 보여줍니다.
radix 트리는 접두사 트리를 최적화하여 자식 노드가 하나만 있는 경우 노드를 병합합니다. 우리의 예제 트리는 radix 트리로 다음과 같이 보일 것입니다:
강조된 순회는 여전히 "rubens"라는 단어를 보여줍니다. 하지만 트리가 훨씬 작아졌습니다!
APISIX에서 라우트를 생성하면, APISIX는 이를 이러한 트리에 저장합니다.
APISIX는 라우트를 매칭하는 데 걸리는 시간이 요청의 URI 길이에만 의존하고 라우트 수와는 무관하기 때문에(O(K), K는 키/URI의 길이) 원활하게 작동할 수 있습니다.
따라서 APISIX는 처음 시작할 때 10개의 라우트를 매칭하는 것만큼 빠르고, 확장할 때 5000개의 라우트를 매칭하는 것도 동일하게 빠릅니다.
이 간단한 예제는 APISIX가 radix 트리를 사용하여 라우트를 저장하고 매칭하는 방법을 보여줍니다:
강조된 순회는 /user/*
라우트를 보여줍니다. 여기서 *
는 접두사를 나타냅니다. 따라서 /user/navendu
와 같은 URI는 이 라우트와 매칭됩니다. 아래 예제 코드는 이러한 아이디어를 더 명확히 해줄 것입니다.
APISIX는 lua-resty-radixtree 라이브러리를 사용하며, 이는 C로 구현된 rax를 래핑합니다. 이는 순수 Lua로 구현된 라이브러리보다 성능을 향상시킵니다.
아래 예제는 이 라이브러리가 어떻게 사용되는지 보여줍니다:
local radix = require("resty.radixtree")
local rx = radix.new({
{
paths = { "/api/*action" },
metadata = { "metadata /api/action" }
},
{
paths = { "/user/:name" },
metadata = { "metadata /user/name" },
methods = { "GET" },
},
{
paths = { "/admin/:name" },
metadata = { "metadata /admin/name" },
methods = { "GET", "POST", "PUT" },
filter_fun = function(vars, opts)
return vars["arg_access"] == "admin"
end
}
})
local opts = {
matched = {}
}
-- 첫 번째 라우트와 매칭
ngx.say(rx:match("/api/create", opts)) -- metadata /api/action
ngx.say("action: ", opts.matched.action) -- action: create
ngx.say(rx:match("/api/destroy", opts)) -- metadata /api/action
ngx.say("action: ", opts.matched.action) -- action: destroy
local opts = {
method = "GET",
matched = {}
}
-- 두 번째 라우트와 매칭
ngx.say(rx:match("/user/bobur", opts)) -- metadata /user/name
ngx.say("name: ", opts.matched.name) -- name: bobur
local opts = {
method = "POST",
var = ngx.var,
matched = {}
}
-- 세 번째 라우트와 매칭
-- `arg_access`의 값은 `ngx.var`에서 얻어짐
ngx.say(rx:match("/admin/nicolas", opts)) -- metadata /admin/name
ngx.say("admin name: ", opts.matched.name) -- admin name: nicolas
대규모 프로젝트에서 많은 수의 라우트를 효율적으로 관리할 수 있는 능력은 APISIX를 많은 대규모 프로젝트의 API 게이트웨이로 선택되게 했습니다.
내부 살펴보기
한 글에서 APISIX의 내부 작동에 대해 설명할 수 있는 것은 한계가 있습니다.
하지만 가장 좋은 점은 여기서 언급된 라이브러리와 Apache APISIX가 완전히 오픈 소스라는 것입니다. 즉, 여러분이 직접 내부를 살펴보고 수정할 수 있습니다.
그리고 APISIX를 개선하여 최종 성능을 끌어올릴 수 있다면, 변경 사항을 기여하여 모두가 여러분의 작업으로부터 혜택을 받을 수 있도록 할 수 있습니다.