初识Apisix(Apisix 入门)

1,094 阅读7分钟

1. 数据平面和管理平面

默认的配置中数据平面和管理平面都监听在同一个端口上,HTTP的是9080端口,HTTPS的是9443端口。这个和Kong是有区别的,Kong的数据平面和管理平面的端口分别在不同的端口上,Apisix复用一个端口,通过路径区分功能(数据平台和管理平面)的做法,会导致用户不能配置管理功能占用的路径作为路由,并且也会把管理功能暴露到外部(如果数据端口需要暴露在外部的情况下),单独一个ApiKey的做鉴权是有安全风险的, Apisix是支持单独配置管理端口的,配置apisix.admin_listen即可,在生成环境建议数据端口和管理端口分开。

启动后,Apisix会生成配置文件,在路径conf/nginx.conf中,Nginx的listen指令用于暴露外部的端口,Apisix的外部服务端口在nginx.conf中配置如下

listen 0.0.0.0:9080 default_server reuseport;
listen [::]:9080 default_server reuseport;
listen 0.0.0.0:9443 ssl default_server http2 reuseport;
listen [::]:9443 ssl default_server http2 reuseport;

可以看出,HTTP的端口是9080,HTTPS的端口是9443。并且同时支持IPv4和IPv6的协议。

访问数据端口,响应没有Route匹配到

➜ apisix git:(master) ✗ curl 127.0.0.1:9080 {"error_msg":"404 Route Not Found"}

访问HTTPS的接口:

curl --resolve 'test.com:9443:127.0.0.1' test.com:9443/hello -k

{"error_msg":"404 Route Not Found"}

在访问HTTPS的接口前,需要提前配置好证书。Apisix的证书可以通过管理接口配置。配置的例子如下:

import http.client
import json
​
conn = http.client.HTTPSConnection("127.0.0.1", undefined)
payload = json.dumps({
  "cert": "your cert",
  "key": "your private key",
  "snis": [
    "test.com"
  ]
})
headers = {
  'X-API-KEY': 'edd1c9f034335f136f87ad84b625c8f1',
  'Content-Type': 'application/json'
}
conn.request("PUT", "/apisix/admin/ssl/1", payload, headers)
res = conn.getresponse()
data = res.read()
print(data.decode("utf-8"))

这里可以看到Apisix的Admin API的设计上的缺陷,创建一个证书(其他资源),还需要调用者明确地指定ID。那新建一个证书的时候,需要新明确知道哪些ID是存在的,否则新建就会变成了覆盖。

2. 配置文件加载和启动

Apisix为了解决测试/开发/生成的环境的配置不一样的问题,引入APISIX_PROFILE的环境变量。默认情况,没有设置环境变量APISIX_PROFILE,默认使用以下三个配置文件:

  • conf/config.yaml
  • conf/apisix.yaml
  • conf/debug.yaml

当设置APISIX_PROFILE为prod时候,使用以下三个配置文件:

  • conf/config-prod.yaml
  • conf/apisix-prod.yaml
  • conf/debug-prod.yaml

APISIX_PROFILE的处理在文件profile.lua中,

local _M = {
    version = 0.1,
    profile = os.getenv("APISIX_PROFILE"),
    apisix_home = (ngx and ngx.config.prefix()) or ""
}
function _M.yaml_path(self, file_name)
    local file_path = self.apisix_home  .. "conf/" .. file_name
    if self.profile and file_name ~= "config-default" then
        file_path = file_path .. "-" .. self.profile
    end
​
    return file_path .. ".yaml"
end

Apisix根据配置文件,生成nginx.conf文件。启动Apisix的命令bin/apisix start ,最终运行的是ops.lua中的start函数启动nginx。ops.lua的start函数的逻辑(假设没有设置环境变量APISIX_PROFILE):

  • 检查当前时候有apisix在运行,如果有,则报错。

  • 假如命令行参数-c或--config指定了配置文件config,那么就备份原来的conf/config.yaml文件,并且链接-c指定的文件为conf/config.yaml 。这里面涉及到配置文件的恢复,我认为这个备份和链接的方式不好,因为需要在quit或stop apisix的时候,恢复备份文件到conf/config.yaml中,对于异常退出的场景,会有配置文件丢失的风险。

  • 调用init初始化配置。

    • 首先是读取配置文件,config.yaml文件中的配置优先级比config-default.yaml高,可以理解config-default.yaml是Apisix默认的出厂配置,用户修改通常需要在config.yaml中完成:

      • 读取配置文件config/config-default.yaml
      • 读取配置文件config/config.yaml
      • 读取配置文件config/apisix.yaml (如果配置apisix.config_center等于yaml的话,默认使用etcd,不会读取此配置文件)
    • 检查配置,如果要开启admin api,必须配置中心是ETCD。

    • 检查openresty的版本,必须大于1.17.8

    • 检查配置文件中,外部注册中心使用了哪些?比如nacos/kubernetes 等

    • 根据读取回来的配置文件,最终生成nginx使用的配置文件conf/nginx.conf

  • 调用init_etcd初始化ETCD, 主要检查ETCD连接配置是否正确,网络是否正常

3. 路由匹配

应用层Apisix支持三种路由匹配算法,默认是radixtree_uri。还有另外两种是radixtree_host_uri和radixtree_uri_with_parameter。

传输层上的匹配算法(SNI的匹配算法)目前仅支持一种:radixtree_sni

路由匹配算法是Apisix的性能优于Kong的一个很重要的原因,因为每个请求都需要经过路由匹配,因此路由匹配是整个网关的一个关键路径。由于Kong的历史原因,设计之初的路由规则太过于灵活,Host/Uri/Method可以任意组合,后面又支持了Header的匹配,导致一个路由的匹配可能会经过大量的Lua代码层面的计算。Apisix作为一个参考Kong设计的网关,在路由的匹配算法上,肯定是做过慎重的思考。Apisix使用的是基数树的匹配算法,是Nginx底层支持的,性能好很多。关于Apisix相比于Kong的优化点,可以另开一篇文章讨论。

4. 配置与ETCD同步

Apisix的宣传中,经常出现自己是云原生的,大概就是因为它用了ETCD作为存储配置的组件吧?(使用数据库作为存储就不是云原生了?)

以路由的资源为例子,路由存储在ETCD中,存储的路径前缀是/apisix/routes/ 。在源码apisix/http/route.lua的init_worker的函数中,注册了对ETCD中/apisix/routes的定时更新的逻辑。

function _M.init_worker(filter)
    local user_routes, err = core.config.new("/routes", {
            automatic = true,
            item_schema = core.schema.route,
            checker = check_route,
            filter = filter,
        })
    if not user_routes then
        error("failed to create etcd instance for fetching /routes : " .. err)
    end
​
    return user_routes
end

core.config.new(),注册了对ETCD中/apisix/routes的定时更新,其中core.config是在apisix/core.lua中完成初始化,config_center默认是etcd或者在配置文件中指定。目前支持三种配置方式:etcd / yaml/ xds

local config_center = local_conf.apisix and local_conf.apisix.config_center or "etcd"

local config = require("apisix.core.config_" .. config_center) config.type = config_center

core.config.new会注册定时读取etcd的逻辑,主要代码如下,ngx_timer_at(0, _automatic_fetch, obj)注册了一个timer,非阻塞地调用automatic_fetch,读取etcd中配置,更新到obj对象中。 automatic_fetch关键是使用etcd的库提供的readdir接口,读取整个目录的Nodes,接着调用load_full_data更新到obj对象中。

function _M.new(key, opts)
      ...省略...
    local obj = setmetatable({
        etcd_cli = nil,
        key = key and prefix .. key,
        automatic = automatic,
        item_schema = item_schema,
        checker = checker,
        sync_times = 0,
        running = true,
        conf_version = 0,
        values = nil,
          ...省略...
        single_item = single_item,
        filter = filter_fun,
    }, mt)
​
    if automatic then
        if not key then
            return nil, "missing `key` argument"
        end
​
        if loaded_configuration[key] then
          ...省略...
        end
​
        ngx_timer_at(0, _automatic_fetch, obj)
​
    else
      ...省略...
    end
​
    if key then
        created_obj[key] = obj
    end
​
    return obj
end

综上所述,可以得知apisix/http/route.lua中的user_routes对象,会由后台的timer,定时去etcd读取配置,并且更新到user_routes对象中。那么,一个真实的请求到达网关后,如何匹配路由的呢?首先,我们需要从nginx.conf的配置文件入手,在location /存在一个access_by_lua_block的入口:

access_by_lua_block {
        apisix.http_access_phase()
}

可以看到,所有请求的入口都需要经过apisix中的http_access_phase完成路由匹配。接着我们再看一下http_access_phase中怎么做路由匹配以及路由怎么更新。

function _M.http_access_phase()
    -- 配置好api_ctx的参数,此参数会用于路由匹配中
    local api_ctx = core.tablepool.fetch("api_ctx", 0, 32)
    ngx_ctx.api_ctx = api_ctx
​
    core.ctx.set_vars_meta(api_ctx)
​
    debug.dynamic_debug(api_ctx)
​
    local uri = api_ctx.var.uri
    if local_conf.apisix and local_conf.apisix.delete_uri_tail_slash then
        if str_byte(uri, #uri) == str_byte("/") then
            api_ctx.var.uri = str_sub(api_ctx.var.uri, 1, #uri - 1)
            core.log.info("remove the end of uri '/', current uri: ",
                          api_ctx.var.uri)
        end
    end
    -- 请求的uri填充
    api_ctx.var.real_request_uri = api_ctx.var.request_uri
    api_ctx.var.request_uri = api_ctx.var.uri .. api_ctx.var.is_args .. (api_ctx.var.args or "")
    
    -- 执行路由匹配
    router.router_http.match(api_ctx)
    
end

路由匹配是调用router_http中的match方法,再深入看一下router_http。在router.lua的http_init_worker的函数中,会通过配置的路由匹配算法,加载对应的匹配算法模块。

local router_http = require("apisix.http.router." .. router_http_name)
attach_http_router_common_methods(router_http)
router_http.init_worker(filter)
_M.router_http = router_http

前文有提到,默认的路由匹配算法是radixtree_uri。因此我们看一下radixtree_uri.lua中的match方法,就知道路由是如何进行匹配的。

function _M.match(api_ctx)
		-- _M.user_routes就是前面提到的,定时从ETCD中,会更新这个对象。
    local user_routes = _M.user_routes
    -- get_services()的逻辑与user_routes 一样,"/apisix/services"这个目录,apisix也会定时从ETCD中读取回来并且更新
    local _, service_version = get_services()
    -- 如果是启动后首次匹配请求或者services或routes资源有发生任何变化,则重新更新uri_router这个对象,即重建内存中的路由
    if not cached_router_version or cached_router_version ~= user_routes.conf_version
        or not cached_service_version or cached_service_version ~= service_version
    then
        uri_router = base_router.create_radixtree_uri_router(user_routes.values,
                                                             uri_routes, false)
        cached_router_version = user_routes.conf_version
        cached_service_version = service_version
    end

    if not uri_router then
        core.log.error("failed to fetch valid `uri` router: ")
        return true
    end
	
		-- 执行路由匹配
    return base_router.match_uri(uri_router, match_opts, api_ctx)
end

至于base_router中的match_uri的实现如下:

function _M.match_uri(uri_router, match_opts, api_ctx)
    core.table.clear(match_opts)
    match_opts.method = api_ctx.var.request_method
    match_opts.host = api_ctx.var.host
    match_opts.remote_addr = api_ctx.var.remote_addr
    match_opts.vars = api_ctx.var
    match_opts.matched = core.tablepool.fetch("matched_route_record", 0, 4)
		
		-- 可以看到,最终还是uri_router对象中的dispatch方法完成路由匹配
    local ok = uri_router:dispatch(api_ctx.var.uri, match_opts, api_ctx, match_opts)
    return ok
end

分析到这里,可以总结如下:

  • apisix会定义从etcd中读取匹配更新到user_routes和services的对象中
  • apisix在收到请求的时候,会判断是否需要重建路由
  • 完成路由匹配

apisix匹配的核心逻辑是resty.radixtree的模块,相较于kong的路由匹配模式,快很多。