apisix没有rewrite阶段,rewrite和access阶段都在access中执行,也即虽然可以写rewrite的插件,但是会在access阶段执行,并且rewrite的插件会比access插件以更高优先级执行

https://cloudnative.to/blog/apisix-source-code-reading/#1-apisix-%E6%A6%82%E8%BF%B0

插件加载流程
插件内部结构

api路由是即时生效的,但是插件的更新、修改、新增需要手动reload,dashboard要重启

一些插件的介绍(服务发现、负载均衡等)

https://www.modb.pro/db/152237

编写插件需要最终制定插件的执行阶段,rewrite、access还是其他的。

而这些阶段都有两个传参:conf和ctx

https://apisix.apache.org/zh/docs/apisix/plugin-develop#%E7%BC%96%E5%86%99%E6%89%A7%E8%A1%8C%E9%80%BB%E8%BE%91

conf 参数是插件的相关配置信息(当前插件的配置数据),ctx 参数缓存了请求相关的数据信息(包含所有插件、route设置等的信息)。

官方的插件例子:

https://github.com/apache/apisix/blob/35269581e21473e1a27b11cceca6f773cad0192a/apisix/plugins/limit-count.lua#L177

官方使用方法介绍

https://apisix.apache.org/zh/docs/apisix/2.8/architecture-design/#service

使用admin api创建route、service、upstream等对象,并且介绍了核心常用概念

route就是路由设置,是最原子的一个api路由配置

service是upstream和插件的组合,为了避免重复设置相同的upstream和插件,可以创建一个service对象,然后route设置时引用

注意:当service和route都使用了同样的插件时,route的插件设置优先级更高,所以此时使用route中的插件设置

consumer是api消费方的抽象,我们有时候需要知道到底有谁使用了这个api,并且为不同的消费方制定不同的插件,consumer必须有鉴权,通过某个key鉴权通过后,apisix才会拿到consumer_name,进而查询etcd,拉取该消费方的配置信息,来完成整个http请求周期。

注意:consumer中也有插件设置,在apisix中,插件设置的优先级为consumer>route>service,使用相同的插件时,前者会覆盖后者。

script是http请求生命中求中执行的脚本,与plugin互斥,有script则会优先执行script,而不执行plugin,script是lua代码,我们也可以在script中执行一些插件的调用。

radixtree是一个匹配搜索引擎,主要用来匹配route中设置的uri,来确定使用的是哪个route的信息。

global rule是全局的插件设置,一旦创建某个global rule以后,就会对所有的route生效,所有的route都会使用这个插件设置。

plugin config是一组插件的配置,将重复使用到的插件组合抽象成一个plugin config对象,在设置route时进行引用,如果引用了一个没有设置的plugin config,则该路由报503错误,如果route也设置了插件,则会与plugin config的插件合并,都会在http请求周期内被调用,而如果route自身设置了与plugin config相同的插件,则优先使用plugin config中的配置(也即覆盖route中的设置)。

官方博客

https://apisix.apache.org/zh/blog/2020/12/11/beike-how-to-build-gateway-based-on-apache-apisix

官方插件开发文档

https://apisix.apache.org/zh/docs/apisix/plugin-develop#%E7%BC%96%E5%86%99%E6%89%A7%E8%A1%8C%E9%80%BB%E8%BE%91

新插件的优先级( priority 属性 )不能与现有插件的优先级相同,您可以使用 control API 的

/v1/schema 方法查看所有插件的优先级。另外,同一个阶段里面,优先级( priority )值大的插件,会优先执行,比如

example-plugin 的优先级是 0 ,

ip-restriction 的优先级是 3000 ,所以在每个阶段,会先执行

ip-restriction 插件,再去执行

example-plugin 插件。

为了实现路由动态配置,Apache APISIX 做了两件事:

  1. 在 Nginx 配置文件里面配置单个 server,这个 server 里面只有一个 location。我们把这个 location 作为主入口,这样所有的请求都会走到这个地方上来。

  2. 我们用 Lua 完成路由分发的工作。Apache APISIX 的路由分发模块,支持在运行时增减路由,这样就能动态配置路由了。

openresty的执行阶段概念

apisix和openresty的执行阶段较为相似,但存在一定的不同,例如apisix没有rewrite阶段,rewrite的相关操作都在access中一并执行,只是优先级更高。

apisix官方文档 md

https://www.bookstack.cn/read/apisix-2.8-zh/architecture-design-apisix.md

apisix discovery相关

在apisix/upstream.lua中调用了discovery的nodes函数,来使用服务发现获取下游节点:

该函数其中一个形参为api_ctx。

所有获取的nodes,会在apisix/balancer.lua中使用checker来检查健康度,过滤不健康的node,在apisix/balancer.lua中的pick_server函数来从nodes中选择最终访问的下游地址;可以理解成,discovery返回所有的地址,由apisix的负载均衡模块来具体选择某一个地址。

-- pick_server will be called:

-- 1. in the access phase so that we can set headers according to the picked server

-- 2. each time we need to retry upstream

pick_server函数会在access phase中被调用,以便设置一些headers。

server_picker就是我们route中设置的type指定的负载均衡类型,每个type都在apisix/balancer/xxx.lua中对应有代码:

server_picker在apisix中会做一个LRU缓存。

从服务发现返回的节点中,会做健康检查(apisix/balancer.lua的fetch_health_nodes函数),APISIX 的健康检查使用lua-resty-healthcheck实现,你可以在 upstream 中使用它。

注意:

  • 只有在 upstream 被请求时才会开始健康检查。 如果一个 upstream 被配置但没有被请求,那么就不会有健康检查。(也即我们的upstream要提供一个可以探活的接口)

  • 如果没有健康的节点,那么请求会继续发送给上游。

  • 如果 upstream 中只有一个节点,就不会有健康检查。 因为该唯一节点无论是否健康,请求都会发送给上游,

  • 主动健康检查是必须的,这样不健康的节点才会恢复。

健康检查分为主动和被动两类,主动是指请求指定的探活接口判断是否健康,而被动则是指根据请求upstream返回的状态码来判断(只是例子)。

https://www.bookstack.cn/read/apisix-2.8-zh/health-check.md

上面的链接解释了apisix的健康检查配置逻辑,以及每个配置项的意义。

lua-resty-healthcheck会对使用add_target添加的upstream进行get_target_status来判断健康与否,如果没有add就不会被判断,但目前看apisix的代码,都add了(apisix/upstream.lua):

看healthcheck的源码,即使我们的upstream配置没有给checks相关的设置项,都会被添加到健康检查的target_list中,然后在apisix/balancer.lua中调用get_target_status来获取健康状态,目前没有看到是在哪里主动探测健康度的(checks配置中可以设置主动检测的接口,目前不知道是哪里请求了)。

admin api使用文档

https://github.com/apache/apisix/blob/master/docs/en/latest/admin-api.md

https://apisix.apache.org/zh/docs/apisix/admin-api/ 中文版

ip-restriction插件

在插件中增加打印:

打印结果:

该route的插件设置为,(disable字段可以忽略,是dashboard自动加上的,false表示现在这个插件正在被使用):

所以可以看到,conf确实是插件的配置json内容,但ctx现在无法打印,报错说是一个函数无法被序列化。

使用core.log.warn(core.json.encode(conf))和core.log.warn(core.json.encode(ctx, true))能够打印conf和ctx。

local ngx = ngx
core.log.warn("in ip_restriction ngx.req.get_headers(): ",core.json.encode(ngx.req.get_headers(), true))
core.log.error("type of ngx.req.get_headers() is : ",type(ngx.req.get_headers()))

ngx.req中有请求的一些信息,如下所示,都是函数,可以用来获取请求信息,ngx是一个全局对象,应该是可以在任何一个插件中获取信息。

ngx.req的成员函数:

{
  "get_method": "function: 0x7f4482bd3680",
  "http_version": "function: 0x7f4482c160b8",
  "raw_header": "function: 0x7f4482c16120",
  "set_header": "function: 0x7f4482bd39f0",
  "set_uri": "function: 0x7f4482c161f0",
  "set_uri_args": "function: 0x7f4482c16250",
  "get_post_args": "function: 0x7f4482c162b8",
  "read_body": "function: 0x7f4482c16320",
  "discard_body": "function: 0x7f4482c16388",
  "get_body_data": "function: 0x7f4482c163f0",
  "get_body_file": "function: 0x7f4482c16458",
  "set_body_data": "function: 0x7f4482c164c0",
  "set_body_file": "function: 0x7f4482c16528",
  "start_time": "function: 0x7f4482bd2f98",
  "get_headers": "function: 0x7f4482bd1598",
  "append_body": "function: 0x7f4482c165f8",
  "finish_body": "function: 0x7f4482c16660",
  "socket": "function: 0x7f4482c166c8",
  "clear_header": "function: 0x7f4482be8d18",
  "is_internal": "function: 0x7f4482c16728",
  "get_uri_args": "function: 0x7f4482bd3540",
  "set_method": "function: 0x7f4482bd3798",
  "init_body": "function: 0x7f4482c16590"
}

获取请求体的数据代码snippet:

ngx.req.read_body()
local body_data = ngx.req.get_body_data()
core.log.error("body data is : ",body_data)

日志输出:

2022/02/10 15:46:11 [error] 52#52: *5498 [lua] init.lua:96: phase_func(): body data is : {"echo":"helloworld"}, client: 9.134.111.144, server: _, request: "POST /test_apisix HTTP/1.1", host: "9.134.111.144:9080"

也可以直接core.log.error("ngx is : ",core.json.encode(ngx, true))打印ngx所有的内容,主要是一些函数,日志打印没有完全显示,应该是apisix限制了日志输出的长度:

插件开发

插件的新增apisix是热加载的,只需要通过admin api来reload即可

curl http://127.0.0.1:9080/apisix/admin/plugins/reload -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT

X-API-KEY是conf/config.yaml中配置的admin的key:

(现在测试是要在容器内执行才行,在容器外执行会403)

如果想要在容器外访问admin api,需要设置allow_admin:

可以在allow_admin下添加一个新的元素,例如:

allow_admin:
  - 127.0.0.0/24
  - 9.11.22.33

这样,在容器外使用下列请求就可以正常使用admin api:

curl http://9.11.22.33:9080/apisix/admin/plugins/reload -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT

(遗留问题:ip-restriction插件,修改apisix/plugins/ip-restriction/init.lua代码,reload之后没有直接生效,有可能是因为init.lua的问题,并不是普通插件的代码加载,这种情况下还是要重启容器。。。)


插件代码文件需要放置在apisix/plugins/下面,或者在我们的apisix配置文件中添加外部lua脚本的加载路径,插件代码放在该路径下也可以。

简单的插件(print_conf_ctx.lua):

local ngx = ngx
local core = require("apisix.core")
local plugin_name = "print_conf_ctx"
local schema = {
    type = "object",
    properties = {
        labels = { type = "object" },
    },
    additionalProperties = false,
}
local _M = {
    version = 0.1,
    priority = 0,
    name = plugin_name,
    schema = schema,
}
function _M.check_schema(conf)
    return core.schema.check(schema,conf)
end
-- 打印请求内容
function _M.access(conf, ctx)
    ngx.req.read_body()
    local body_data = ngx.req.get_body_data()
    core.log.info("print_conf_ctx:\n conf is: ",core.json.encode(conf))
    core.log.info("print_conf_ctx:\n ctx is: ",core.json.encode(ctx,true))
end
return _M

插件开发完成后,我们还需要修改config.yaml配置文件,将插件添加到插件列表,不然插件不会加载。

# config.yaml
plugins:    
...
- print_conf_ctx

注意:

1、虽然conf/config-default.yaml中列了一系列的插件,在新增插件时,需要将default里面的插件都复制到conf/config.yaml中,不然找不到别的插件,相当于conf/config.yaml与config-default.yaml的同名对象是覆盖而不是append的关系。

2、config.yaml中的插件名就是我们lua中指定的插件名,一定要对应上,不然是找不到的(保证代码中的name、文件名、config.yaml中的插件名都是相同的就OK):

(补充一下,上述的properties就是插件配置的字段,default表示有默认值)

新写的插件,如果想要在dashboard上可视化的配置,需要根据下面的步骤实现:

https://apisix.apache.org/zh/docs/dashboard/FAQ

需要打开apisix的control api,修改conf/config.yaml文件:

apisix:
  ...
  enable_control: true
  control:
    ip: "127.0.0.1"
    port: 9090

如果是docker启动的apisix,则需要进入容器后,curl 127.0.0.1:9090/v1/schema才能获取当前的配置信息,在容器外9090端口没有暴露,没有被监听。

1、curl 127.0.0.1:9090/v1/schema > schema.json

2、将该schema.json文件复制到apisix dashboard容器的conf目录下(替换)

3、重启dashboard

apisix插件可用的打印全局变量的snippet

local tablePrinted = {}
local global_info = ""
function printTableItem(k, v, level)
    for i = 1, level do
        global_info = global_info .. "    "
        --core.log.error("    ")
    end
   global_info = global_info .. tostring(k) .. " = " .. tostring(v) .. "\n"
   -- core.log.error(tostring(k), " = ", tostring(v), "\n")
    if type(v) == "table" then
        if not tablePrinted[v] then
            tablePrinted[v] = true
            for k, v in pairs(v) do
                printTableItem(k, v, level + 1)
            end
        end
    end
end
printTableItem("_G", _G, 0)
core.log.error("global_info is:\n",global_info)

使用服务发现获取upstream时,ctx中有discovery_args等相关信息

因此,我们可以在某一个插件中,通过ngx获取headers/body的参数,然后改写ctx的upstream的内容,ctx应该是一个全局变量,可以测试下是否如此,如果这样可以的话,comm_cgi的原有能力就可以迁移(甚至基于规则路由的灰度发布也可以重现)

下面是一个使用插件改写服务发现参数的例子,为了保证获取完实例后,能正确转发到具体服务的接口上,使用了proxy-rewrite插件,改写了转发的uri。

https://www.madp.xyz/post/34

curl http://127.0.0.1:9080/apisix/admin/routes/333 -H 'X-API-KEY: 2aczvx5cwsexsd4o3g692v07yy7uhcae' -X PUT -i -d '
{
    "uri": "/comm_cgi/*",
    "plugins":{
        "replace_polaris":{
            "namespace": "Development",
            "discorvery_type": "polaris",
            "service_name": "event_svr"
        },
        "proxy-rewrite":{
            "regex_uri": ["^/comm_cgi/*","/$1"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "polaris",
        "service_name": "event_svr",
        "discovery_args": {
            "namespace": "Development"
        }
    },
    "status": 1,
    "desc": "测试自定义北极星路由参数"
}'

https://github.com/apache/apisix/blob/master/docs/zh/latest/admin-api.md

各个admin api的请求body参数说明

问题:

1、重启容器和./bin/apisix reload都可以实现插件的生效,但是admin api的reload是失败的。(2022.0225测试发现还是得可执行文件操作才生效)

按道理确实是两种方法都可以,但api的目前不行。。

调试发现内置的插件ldap-auth有问题,导致api reload失败,在config.yaml去掉后,api正常可用

2、access.log日志输出的upstream_host居然是容器所在的ip

access_log的输出格式:

其中,最后一项输出$upstream_host/upstream_uri为容器的ip/uri,结合下面route设置的参数,默认情况下,pass_host为pass,所以作为服务,收到apisix的请求头中的host就是容器的ip,也即导致acces_log打印了这个内容。

apisix使用etcd做缓存

有时候网关层需要缓存一些用户的数据,例如用户的ticket,ticket是用来鉴权的,但是ticket有过期时间,如果每次网关层鉴权都真实请求到鉴权服务器,一方面是会降低网关层的转发性能,另一方面是会导致下游鉴权服务压力过大。

因此,如果要在apisix做一层鉴权,那么要自己做一层缓存,而apisix与etcd是耦合的,可以直接使用etcd作为缓存组件。

下面是读写缓存的snippet:

local core = require("resty.core")
local function has_cache(ticket)
    local ticket_info, err = core.etcd.get(ticket)
    if not ticket_info or ticket_info.status ~= 200 then
        core.log.error("miss cache: ticket",ticket)
        return false
    end
    local prev_expire_time = core.json.decode(ticket_info.body.node.value).Data.ExpireTimestamp
    -- 缓存还有5s过期 重新进行
    if prev_expire_time - ngx.time() < 5 then
        return false
    end
    core.log.error("OA-auth: request OA-ticket hit valid cache")
    return true
end
local function set_cache(tof_resp)
    local expire_time = get_timestamp(tof_resp.Data.Expiration)
    tof_resp.Data.ExpireTimestamp = expire_time
    local now_time = ngx.time()
    if now_time < expire_time then
        core.etcd.set(ticket, core.json.encode(tof_resp), expire_time - now_time)
    end
end
local function has_expire_cache(ticket)
    local exp_ticket_info, err = core.etcd.get(ticket .. "_expired")
    if not exp_ticket_info or exp_ticket_info.status ~= 200 then
        core.log.error("miss expired cache: ticket",ticket)
        return false
    end
    core.log.error("OA-auth: request OA-ticket hit expired cache")
    return true
end
local function set_expire_cache(ticket)
    core.etcd.set(ticket .. "_expired", "expired", 3600)
end

resty.core已经继承了etcd的模块,可以直接用来访问etcd,set、get缓存对象,set函数的参数为(key,value,ttl),ttl为缓存超时的时间,这个比较好理解。

但是对于core.etcd.get(key)操作来说,返回的对象就比较复杂,分为两种情况,一种是缓存命中,一种是缓存未命中。

get返回的对象类似于lua.http的response对象,调试过程中,打印结果如下:

我们可以通过status判断,这次请求etcd获取数据是否成功,成功的话是200,此时

local res,err = core.etcd.get(key)

res.body就是存在etcd中的数据结构,如下所示:

{
  "node": {
    "key": "your_key",
    "value": "your_value",
    "modifiedIndex": 161094,
    "createdIndex": 161094
  },
  "action": "get",
  "count": "1",
  "header": {
    "revision": "161095",
    "member_id": "10276657743932975437",
    "raft_term": "2",
    "cluster_id": "14841639068965178418"
  }
}

我们如果想要获取数据就使用res.body.node.value,不过我们用之前一定要判断这个是拿到缓存的情况,我们可以这么判断:

local res,err = core.etcd.get(key)
if not res or res.status ~= 200 or not res.body.node then
    -- get cache failed
else
    local val = res.body.node.value
end

apisix插件进行http请求

https://github.com/ledgetech/lua-resty-http

使用这个库就可以

snippet

local http_client = require("resty.http").new()
-- 然后参考github的demo就行 注意要判断http status

apisix灰度发布插件traffic-split

https://apisix.apache.org/zh/docs/apisix/plugins/traffic-split/#%E5%B1%9E%E6%80%A7

简单来说,就是在路由配置中启用这个插件,在这个插件中配置匹配规则,而匹配的变量是从请求中取出来的,可以使用nginx的一些内部变量名;例如下面的arg_name,表示从请求的参数重,取出name,如果name=json,那么就命中了这条匹配规则,使用这条规则下的upstream

上面这种是从请求的header中取参数

https://apisix.apache.org/zh/docs/apisix/plugins/traffic-split/#%E7%81%B0%E5%BA%A6%E5%8F%91%E5%B8%83

使用traffic-split做灰度发布,理论上和北极星的规则路由很像,只是用于灰度发布的参数是nginx直接帮你取出来了而已。

nginx的内部变量:

https://blog.csdn.net/m0_37556444/article/details/84563520

猜测,arg_name,cookie_name,http_name分别对应从uri参数、cookie、header中取参数

有没有办法接入apisix,不需要过多的测试验证工作呢

apisix做tcp/udp代理

简单来说,apisix可以监听额外的端口,在这些端口上,请求apisix的流量将会被代理转发给下游,就是一个proxy。

https://apisix.apache.org/zh/docs/apisix/stream-proxy

稍微详细一些的看官方教程,这里备注下简单的例子。

首先想要开启tcp/udp代理,需要在conf/config.yaml文件中,配置stream_proxy选项,默认情况下是没有开放tcp/udp代理的。

apisix:
  stream_proxy: # TCP/UDP proxy
    tcp: # TCP proxy address list
      - 9100
      - "127.0.0.1:9101"
    udp: # UDP proxy address list
      - 9200
      - "127.0.0.1:9211"

上面地址的含义为,当客户端请求127.0.0.1:9101,该tcp请求将会被转发,具体转发的下游服务,是在stream_route中配置的。

mysql是使用tcp连接的,我们可以在stream_route中配置客户端的地址,知道哪些客户端的请求才需要被转发,以及配置转发到哪里去。

curl http://127.0.0.1:9080/apisix/admin/stream_routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
    "server_addr": "127.0.0.10",
    "server_port": 9101,
    "upstream": {
        "nodes": {
            "127.0.0.1:3306": 1
        },
        "type": "roundrobin"
    }
}'

例子中 APISIX 会把请求服务器地址为 127.0.0.10, 端口为 9101 代理到上游地址 127.0.0.1:3306。

这个例子是说,如果我们在本地部署了一个mysql,我们可以用下面的命令行直接连接mysql:

mysql --host=127.0.0.10 --port=9101 -u root -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 26
...
mysql>

tcp/udp代理还能处理TLS证书的代理,需要更复杂的配置,需要的时候搜索下看看文档。

apisix-grpc代理

https://apisix.apache.org/zh/docs/apisix/grpc-proxy

这个代理就和普通的route配置差不多,只是在upstream的配置中,要指定scheme字段为grpc/grpcs(有无TLS加密),以及route的uri格式为/service_name/method

curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
    "methods": ["POST", "GET"],
    "uri": "/helloworld.Greeter/SayHello",
    "upstream": {
        "scheme": "grpc",
        "type": "roundrobin",
        "nodes": {
            "127.0.0.1:50051": 1
        }
    }
}'

这种代理的用途是指用户请求就是grpc的包,如果是前端发起过来的,一般是json,所以还需要grpc-json的协议转换。

要支持grpc,说明apisix支持proto文件的管理:

apisix external plugin

https://apisix.apache.org/zh/docs/apisix/external-plugin

这里的external plugin指的是用lua以外的语言开发的插件,需要通过中间件sidecar的方式被加载和运行,因而扩充了语言的写法,可以支持python、go、java、js

支持的 Plugin Runner#

  • Java:

  • https://github.com/apache/apisix-java-plugin-runner

  • Go:

  • https://github.com/apache/apisix-go-plugin-runner

  • Python:

  • https://github.com/apache/apisix-python-plugin-runner

  • JavaScript:

  • https://github.com/zenozeng/apisix-javascript-plugin-runner

apisix grpc-transcode

https://apisix.apache.org/zh/docs/apisix/plugins/grpc-transcode/

curl http://127.0.0.1:9080/apisix/admin/proto/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
    "content" : "syntax = \"proto3\";
    package helloworld;
    service Greeter {
        rpc SayHello (HelloRequest) returns (HelloReply) {}
    }
    message HelloRequest {
        string name = 1;
    }
    message HelloReply {
        string message = 1;
    }"
}'

首先要在apisix中增加proto,使用admin/proto

然后在需要进行grpc-transcode的route中,选择grpc-transcode插件,并在插件的配置中选择proto_id,以及指定upstream为grpc

curl http://127.0.0.1:9080/apisix/admin/routes/111 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
    "methods": ["GET"],
    "uri": "/grpctest",
    "plugins": {
        "grpc-transcode": {
         "proto_id": "1",
         "service": "helloworld.Greeter",
         "method": "SayHello"
        }
    },
    "upstream": {
        "scheme": "grpc",
        "type": "roundrobin",
        "nodes": {
            "127.0.0.1:50051": 1
        }
    }
}'

不过看官方文档,好像请求包都是在url中的,这个要确认下:

$ curl -i "http://127.0.0.1:9080/zeebe/WorkflowInstanceCreate?bpmnProcessId=order-process&version=1&variables=\{\"orderId\":\"7\",\"ordervalue\":99\}"
HTTP/1.1 200 OK
Date: Wed, 13 Nov 2019 03:38:27 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
grpc-encoding: identity
grpc-accept-encoding: gzip
Server: APISIX web server
Trailer: grpc-status
Trailer: grpc-message

{"workflowKey":"#2251799813685260","workflowInstanceKey":"#2251799813688013","bpmnProcessId":"order-process","version":1}

要找下例子,看看有没有放在json里面的,还是说这种情况需要grpc-json转换插件。

看了下官方的插件代码,其中有一个函数,是从http请求中取数据的,如下所示,可以从json body中取,也可以是url_param。

local function get_request_table()
    local method = ngx.req.get_method()
    local content_type = ngx.req.get_headers()["Content-Type"] or ""
    if string.find(content_type, "application/json", 1, true) and
        (method == "POST" or method == "PUT" or method == "PATCH")
    then
        local req_body, _ = core.request.get_body()
        if req_body then
            local data, _ = json.decode(req_body)
            if data then
                return data
            end
        end
    end

    if method == "POST" then
        return ngx.req.get_post_args()
    end

    return ngx.req.get_uri_args()
end

grpc-transcode和grpc-json-transcode插件的区别

两者都可以将http请求经过apisix代理转发给下游grpc服务,grpc-transcode是官方自带的,具体看上一节笔记的文档。

两者的主要差别在于,grpc-transcode需要在apisix这里维护proto文件,apisix会去编译,并且会将编译的结果放到lrucache缓存中,第一次请求有一定的编译成本,有缓存的时候则请求比较快。

apisix会基于proto文件,对请求中的数据(在lua中是一个table,这个table可以是json body,也可以是url_param),然后将数据core.json.encode之后,在二进制数据前加上grpc的一些头部信息,转发到下游。

proto文件在里面的作用就是一个校验的标准,没有定义在proto文件中的请求字段不会带到下游,apisix这里做了一层根据proto定义的请求过滤,apisix知道用户请求的是grpc,也知道是哪个grpc服务下的哪个接口,也即知道下面的内容,apisix完全知道这次grpc相关的服务信息:

service HelloWorld{
    rpc method_name(req_type) returns (resp_type);
}

反过来也有一层协议转换。

而grpc-json-transcode只是截取了grpc-transcode插件中的grpc-json协议互转的部分,只需要对数据进行协议转换,而不需要管理proto文件,只管这么转,请求字段是否合法由客户端保证,apisix只做编解码,而且不知道这次请求的是grpc的什么service的什么method,以及该接口的协议都不知道。

全部由下游服务自己来处理,处理出错就出错返回,逻辑非常简单。

grpc-transcode是严格按照protobuf来编码数据部分的,传输的时候就是proto的二进制;

而grpc-json-transcode是简单的将json数据encode之后,加上grpc需要的界定信息(是否压缩、数据长度,总计五个字节)传给下游,是否正常处理请求,需要下游服务知道从请求中按照json反序列化,由下游网络框架保证。

apisix插件control metdata

https://apisix.apache.org/zh/docs/apisix/control-api

上面是官方control API的介绍,如果插件有开启control api,则有插件自身的control api;

可以获取当前apisix使用的schema.json、upstream的健康度检查状态、发起apisix的gc等操作。

control API 可以被用来:

  • 暴露 APISIX 内部状态信息

  • 控制单个 APISIX 的数据平面的行为

https://gitcode.net/apache/apisix/-/blob/master/apisix/admin/plugin_metadata.lua?from_codechina=yes

curl http://127.0.0.1:9080/apisix/admin/plugin_metadata/trpc-transcode -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
    "response_package": {
        "code_name": "ret",
        "msg_name": "err",
        "data_name": "body",
        "enable": true
    }
}'

plugin_metdata这个admin api可以控制插件的一些配置,例如trpc-transcode插件可以设置回包的字段名,通过上面这个就可以设置指定字段名,看plugin_metada.lua的代码(apisix/admin/plugin_metadata.lua),是设置在etcd里的。

https://github.com/apache/apisix-dashboard/issues/2148(关于plugin_metadata的issue)

apisix 实现http到https

https://www.bookstack.cn/read/apisix-2.9-zh/d90bbe53ca9ebc33.md

apisix 支持https请求

https://www.bookstack.cn/read/apache-apisix-1.4-zh/6345c5ad2e3521d2.md

需要配置证书,以及证书允许的域名

apisix网关层支持https请求之后,用户chrome上发起的https请求就能正确响应,避免有很多服务还没有支持https的异常无法响应,继续往下访问下游服务就没问题了。

apisix使用etcd的原因

https://juejin.cn/post/7025898649758466062

apisix和openresty

目前了解到的,apisix其实就是一套lua库,openresty集成了nginx和lua解释器,本身openresty已经提供了大量的好用的lua库;

apisix会生成nginx.conf,openresty的nginx会用这个配置文件进行启动;

在这个nginx.conf中我们用到的apisix的uri都有对应的处理location、server;

使用了nginx中的xxx_by_lua_block的功能,将我们的lua代码可以在nginx中执行;

我们写的所有插件,通过配置config.yaml的刷新,可以被apisix搜索到,从而扩展网关的能力;

本质上就是nginx+etcd+xxx_by_lua_block的架构

启动的nginx worker数量默认和cpu核数相同

https://openresty.org/cn/components.html

上面的链接包含了openresty的所有组件,这些组件都是在apisix的lua代码中可以去引用使用的

openresty启动的时候-p 指定了工作目录(set prefix),在启动的时候指定为apisix所在的目录,然后就可以使用apisix的nginx.conf来启动nginx;

-p 是用来指定文件前缀的,在指定了文件前缀后,OpenResty 启动时则会基于该前缀去拼接对应的 Nginx 配置文件、日志文件地址等。

openresty的ngx_lua模块,使得我们可以直接编写lua代码,来实现高性能的web服务,nginx是c写的,因此有了openresty,我们不需要去改变核心代码,以模块、插件化的形式编写我们基于nginx的web服务;

而apisix也是基于openresty提供的这一套机制,有一套自己的core代码(都是lua),来实现apisix自己的特性;

因为apisix是基于openresty的,openresty自身也是支持我们编写lua代码,然后指定代码要执行的阶段的;只是apisix在重新加载nginx,生成nginx.conf文件上比openresty方便,因为我们会把新的插件写入到config.yaml中,apisix的命令行reload后会自动根据这个新的yaml生成nginx.conf(可以查看apisix/cli/ops.lua的代码),我们自己不需要去改nginx.conf(可能openresty需要这个步骤);

apisix基于openresty,搭配etcd作为配置中心,提供比openresty更方便的执行,性能上其实和openresty相差无几,因为网络逻辑层都是一样的;

// openresty的常用指令和参数
--[[
参考文档: https://www.nginx.com/resources/wiki/modules/lua/#nginx-api-for-lua
Nginx Lua模块指令:
    Nginx共11个处理阶段,而相应的处理阶段是可以做插入式处理,即可插拔式架构;
    另外指令可以在http、server、server if、location、location if几个范围进行配置:
指令: init_by_lua/init_by_lua_file
    处理阶段:
        loading-config
    使用范围:
        http
    解释:
        nginx Master进程加载配置时执行;
        通常用于初始化全局配置/预加载Lua模块
指令: init_worker_by_lua/init_worker_by_lua_file
    处理阶段:
        starting-worker
    使用范围:
        http
    解释:
        每个NginxWorker进程启动时调用的计时器,
        如果Master进程不允许则只会在init_by_lua之后调用;
        通常用于定时拉取配置/数据,或者后端服务的健康检查
指令: set_by_lua/set_by_lua_file
    处理阶段:
        rewrite
    使用范围:
        server,server if,location,location if
    解释:
        设置nginx变量,可以实现复杂的赋值逻辑;此处是阻塞的,Lua代码要做到非常快;
指令: rewrite_by_lua/rewrite_by_lua_file
    处理阶段:
        rewrite tail
    使用范围:
        http,server,location,location if
    解释:
        rewrite阶段处理,可以实现复杂的转发/重定向逻辑;
指令: access_by_lua/access_by_lua_file
    处理阶段:
        access tail
    使用范围:
        http,server,location,location if
    解释:
        请求访问阶段处理,用于访问控制
指令: content_by_lua/content_by_lua_file
    处理阶段:
        content
    使用范围:
        location,location if
    解释:
        内容处理器,接收请求处理并输出响应
指令: header_filter_by_lua/header_filter_by_lua_file
    处理阶段:
        output-header-filter
    使用范围:
        http,server,location,location if
    解释:
        设置header和cookie
指令:body_filter_by_lua/body_filter_by_lua_file
    处理阶段:
        output-body-filter
    使用范围:
        http,server,location,location if
    解释:
        对响应数据进行过滤,比如截断、替换。
指令:log_by_lua/log_by_lua_file
    处理阶段:
        log
    使用范围:
        http,server,location,location if
    解释:
        log阶段处理,比如记录访问量/统计平均响应时间
]]
 
ngx.var
-- nginx变量,如果要赋值如ngx.var.b = 2,此变量必须提前声明;
-- 另外对于nginx location中使用正则捕获的捕获组可以使用ngx.var[捕获组数字]获取;
 
ngx.var.request_uri
-- 这个变量等于包含一些客户端请求参数的原始URI,它无法修改,请查看$uri更改或重写URI。
 
 
ngx.var.scheme -- ngx.var.server_addr
-- HTTP方法(如http,https)。按需使用
 
ngx.var.server_addruri
-- 服务器地址,在完成一次系统调用后可以确定这个值,如果要绕开系统调用,则必须在listen中指定地址并且使用bind参数。
 
ngx.var.uri
-- 请求中的当前URI(不带请求参数,参数位于$args),可以不同于浏览器传递的$request_uri的值,它可以通过内部重定向,或者使用index指令进行修改。
 
ngx.var.server_name
-- 服务器名称
 
ngx.var.server_port
-- 请求到达服务器的端口号。
 
ngx.var.remote_addr
-- 获取远程的IP地址
 
ngx.var.remote_port
-- 获取远程的端口号
 
ngx.header
-- 输出响应头;
 
ngx.print
-- 输出响应内容体;
 
ngx.say
-- 通ngx.print,但是会最后输出一个换行符;
 
ngx.exit
-- 指定状态码退出。
 
 
ngx.redirect
-- 重定向;
 
ngx.status = 301
-- 设置响应的状态码;
 
ngx.resp.get_headers()
-- 获取设置的响应状态码;
 
ngx.send_headers()
-- 发送响应状态码, ,当调用ngx.say/ngx.print时自动发送响应状态码;
 
ngx.headers_sent = true
-- 判断是否发送了响应状态码。
 
ngx.req.get_headers
-- 获取请求头,默认只获取前100,如果想要获取所以可以调用ngx.req.get_headers(0);
-- 获取带中划线的请求头时请使用如headers.user_agent这种方式;
-- 如果一个请求头有多个值,则返回的是table;
 
ngx.req.get_uri_args
-- 获取url请求参数,其用法和get_headers类似;
 
ngx.req.get_post_args
-- 获取post请求内容体,其用法和get_headers类似,但是必须提前调用ngx.req.read_body()来读取body体
--(也可以选择在nginx配置文件使用lua_need_request_body on;开启读取body体,但是官方不推荐);
 
ngx.req.raw_header
-- 未解析的请求头字符串;
 
ngx.req.get_body_data
-- 为解析的请求body体内容字符串。
 
ngx.req.set_uri(uri, false)
-- 可以内部重写uri(可以带参数),等价于 rewrite ^ /lua_rewrite_3;
-- 通过配合if/else可以实现 rewrite ^ /lua_rewrite_3 break;
-- 这种功能;此处两者都是location内部url重写,不会重新发起新的
 
ngx.req.set_uri_args
-- 重写请求参数,可以是字符串(a=1&b=2)也可以是table;
 
ngx.escape_uri
ngx.unescape_uri
-- uri编码解码;
 
ngx.encode_args
ngx.decode_args
-- 参数编码解码;
 
ngx.encode_base64
ngx.decode_base64
-- BASE64编码解码;
 
ngx.re.match
-- nginx正则表达式匹配;
 
ngx.timer.at
-- 延时调用相应的回调方法;
-- ngx.timer.at(秒单位延时,回调函数,回调函数的参数列表);
-- 可以将延时设置为0即得到一个立即执行的任务,任务不会在当前请求中执行不会阻塞当前请求,而是在一个轻量级线程中执行。
 
 
 
-- 指令: init_by_lua
--[[
    nginx.conf配置文件中的http部分添加如下代码,
    #共享全局变量,在所有worker间共享
    lua_shared_dict shared_data 1m;
    init_by_lua_file /usr/example/lua/init.lua;
]]
--init.lua
--初始化耗时的模块
local redis = require 'resty.redis'
local cjson = require 'cjson'
 
--全局变量,不推荐
count = 1
--共享全局内存
local shared_data = ngx.shared.shared_data
shared_data:set("count", 1)
 
-- test.lua
count = count + 1
ngx.say("global variable : ", count)
local shared_data = ngx.shared.shared_data
ngx.say(", shared memory : ", shared_data:get("count"))
shared_data:incr("count", 1)
ngx.say("hello world")
 
-- 访问如http://192.168.1.2/lua 会发现全局变量一直不变,而共享内存一直递增
-- global variable : 2 , shared memory : 8 hello world
-- 另外注意一定在生产环境开启lua_code_cache,否则每个请求都会创建Lua VM实例。
 
 
 
-- 指令: init_worker_by_lua
--[[
    用于启动一些定时任务,比如心跳检查,定时拉取服务器配置等等;
    此处的任务是跟Worker进程数量有关系的,比如有2个Worker进程那么就会启动两个完全一样的定时任务。
    nginx.conf配置文件中的http部分添加如下代码:
    init_worker_by_lua_file /usr/example/lua/init_worker.lua;
    另外根据实际情况设置如下指令
    lua_max_pending_timers 1024;  #最大等待任务数
    lua_max_running_timers 256;   #最大同时运行任务数
]]
 
-- init_worker.lua
local count = 0
local delayInSeconds = 3
local heartbeatCheck = nil
 
heartbeatCheck = function(args)
    count = count + 1
    ngx.log(ngx.ERR, "do check ", count)
    local ok, err = ngx.timer.at(delayInSeconds, heartbeatCheck)
    if not ok then
        ngx.log(ngx.ERR, "failed to startup heartbeart worker...", err)
    end
end
 
heartbeatCheck()
 
 
 
-- 指令: set_by_lua
--[[
    设置nginx变量,我们用的set指令即使配合if指令也很难实现负责的赋值逻辑;
    语法set_by_lua_file $var lua_file arg1 arg2...;
    在lua代码中可以实现所有复杂的逻辑,但是要执行速度很快,不要阻塞;
    example.conf配置文件:
    location /lua_set_1 {
        default_type "text/html";
        set_by_lua_file $num /usr/example/lua/test_set_1.lua;
        echo $num;
    }
]]
-- test_set_1.lua
local uri_args = ngx.req.get_uri_args()
local i = uri_args["i"] or 0
local j = uri_args["j"] or 0
return i + j
-- 访问如http://192.168.1.2/lua_set_1?i=1&j=10进行测试。 如果我们用纯set指令是无法实现的。

etcd访问方式

https://github.com/etcd-io/etcd

如果想本地搭建的话,下载prebuild release版本的内容(https://github.com/etcd-io/etcd/releases),整个程序是用go编译的,如果机器没有go编译器就下载二进制,etcd默认启动在2379端口,启动方式就不多赘述了,官方github文档有解释。

apisix默认的配置中心为etcd,所以有必要对etcd进行一定的学习,方便后续调试debug时,排除etcd的问题;

现在的etcd都是v3.xxx的版本,这里只记录v3的api,至于v2的api这里就不记录了

https://doczhcn.gitbook.io/etcd/index/index/interacting_v3

这个链接是官方的中文文档,里面有详细的restful api的调用方式,以及etcdctl的客户端交互方式,用于设置、获取key以及集群的一系列操作,还包括了如何搭建etcd集群(运维相关),这里只简单摘取部分常用的方法,其他的参见文档:

有两种方式和etcd交互,一种是通过etcdctl,自带的etcd client,另一种是通过restful api访问;

etcdctl的安装方式(需要有go编译器,并且注意这里安装的是v3版本,如果要兼容使用v2的,需要修改环境变量export ETCDCTL_API=2)

go get go.etcd.io/etcd/client/v3

下面的命令都是基于v3版本的

查看etcd集群的成员

etcdctl --write-out=table --endpoints=localhost:12379 member list

会以表的形式输出成员信息

向一个etcd实例设置key(有raft协议保证数据在多个实例的一致性)

etcdctl --endpoint=your_etcd_addr put key value

获取key的值

etcdctl --endpoint=your_etcd_addr get key

获取范围的key

etcdctl --endpoint=your_etcd_addr get key key2

返回key到key2的所有kv

获取前缀为xxx的kv并限制输出数量

etcdctl --endpoint=your_etcd_addr get --prefix --limit=2 xxx

注意上面的--endpoints是一个全局设置,设置etcdctl要交互的etcd实例地址

查看etcdctl的版本

etcdctl version

查看etcdctl的帮助

etcdctl help 或 etcdctl -h

etcd对每一个kv的设置都会生成一个版本,每一次的put操作都会生成一个新版本revision,我们可以使用这个信息来回滚到某个版本的kv

如果想要访问某个版本的kv值(下面是获取版本4的key为foo的kv)

etcdl --endpoint your_etcd_addr get --prefix foo --rev=4

获取大于等于某个值的kv

etcdctl --endpoint your_etcd_addr get --from-key foo

删除某个key

etcdctl --endpoint your_etcd_addr del foo

范围删除(删除foo-foo9的kv),还可以搭配prefix、from-key组合使用

etcdctl --endpoint your_etcd_addr del foo foo9

监听key的变化,例如某个客户端修改了key的值,能够显示出来(也可以搭配prefix)

etcdctl --endpoint your_etcd_addr watch foo

etcdctl --endpoint your_etcd_addr watch foo foo9 范围watch

观察多个key

etcdctl watch -i

watch foo

watch zoo

可以搭配rev,查看历史版本以来的key的修改

etcdctl --endpoint your_etcd_addr watch --rev=2 foo(查看版本2以来,foo这个key的修改)

压缩版本,为了减少空间占用,etcd会压缩版本,如果版本被压缩了,那么上面的rev就不能使用这些版本号了,否则都是报找不到这个版本的错误:

Error: etcdserver: mvcc: required revision has been compacted

etcdctl --endpoint your_etcd_addr compact 5 (压缩前五个版本)

如果想查看当前etcd的版本号,可以使用下方的方法,无需使用一个存在的key,不存的key也可以正常访问:

etcdctl --endpoint your_etcd_addr get mykey -w=json

{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":15,"raft_term":4}}

我们可以给kv设置ttl,也即etcd所谓的租约,租约的ttl是全局的,要先设置ttl,然后再去给kv设置租约,这样新建的key的ttl就是我们刚刚设置的ttl;

因此我们可能多次修改全局ttl的大小,所以集群中所有的kv的租约都大于等于历史设置过的最小的ttl。

授予租约,TTL为10秒

$ etcdctl lease grant 10

lease 32695410dcc0ca06 granted with TTL(10s)

附加键 foo 到租约32695410dcc0ca06

$ etcdctl put --lease=32695410dcc0ca06 foo bar

OK

--lease后面的一串是租约的id,是我们随便指定的,但是不能重复,我们可以通过这个id来撤销租约,注意撤销租约的同时会把这个kv给删掉

etcdctl lease revoke 32695410dcc0ca06

如果想要维持租约,那么可以keep-alive ttl

$ etcdctl lease keep-alive 32695410dcc0ca06

lease 32695410dcc0ca06 keepalived with TTL(10)

lease 32695410dcc0ca06 keepalived with TTL(10)

lease 32695410dcc0ca06 keepalived with TTL(10)

...

我们也可以根据租约id来获取kv的租约信息

etcdctl lease timetolive 694d5765fc71500b

如果租约不存在,则会报错

etcd restful api访问

etcd v3使用grpc协议来进行通信,但是我们的curl使用的是http,因此etcd还提供了一个grpc-gateway来实现json到grpc的协议转换,提供restful的api,翻译http/json到grpc的消息。

https://app.swaggerhub.com/apis/390311010/etcd_restful_api/v3

上面是所有提供的接口信息,从官方openapi导入的,很详细了,但是可能缺少一些解释

下面的文档可以提供一定的帮助

https://doczhcn.gitbook.io/etcd/index/index/api_grpc_gateway

忽略文档中的v3alpha,自己使用的使用改成v3即可

# 创建 root 用户
curl -L http://localhost:2379/v3alpha/auth/user/add \
    -X POST -d '{"name": "root", "password": "pass"}'
# {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}}

# 创建 root 角色
curl -L http://localhost:2379/v3alpha/auth/role/add \
    -X POST -d '{"name": "root"}'
# {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}}

# 授予 root 角色
curl -L http://localhost:2379/v3alpha/auth/user/grant \
    -X POST -d '{"user": "root", "role": "root"}'
# {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}}

# 开启认证
curl -L http://localhost:2379/v3alpha/auth/enable -X POST -d '{}'
# {"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"1","raft_term":"2"}}

etcdctl 获取apisix的key

etcdctl --endpoint your_etcd_addr get --prefix /apisix

这是默认的,至于前缀是什么,由conf/config.yaml中的etcd项指定

key就是我们admin api用后缀路径

例如我们admin api获取一个路由的设置是

curl "http://apisix_host:9091/apisix/admin/routes/1" -XGET- H "X-API-KEY: xxxxx"

那么在etcd中的key就是

/apisix/routes/1

/apisix/services/9

以此类推

插件的ctx参数需要注意的事项

虽然官方文档写了是缓存了请求的相关信息,但是这部分信息中,有一部分是同一个路由下共用的,例如ctx.matched_route,如果两个请求同时修改了这个对象的数据,就会出现不可预知的情况。也即ctx这个table下的一些元素,是通过etcd拉取回来的,而在lua中,table是引用的,所以如果请求A改变了这个元素的值,请求B又要用到,那么就出现不可预期的错误。

apisix的worker最大可打开的文件数

因为apisix是基于nginx的,所以这里的配置都是nginx的配置

黑体标粗的第一行表示worker可以打开的文件数量,而第二行表示worker可以处理的最大连接数,因为连接需要通过socket打开fd来处理,所以第一行的大小是第二行配置的上限,例如nofile为1000,而connections为2000,那么最大也就只能并发1000个连接。

查看配置的连接数是否生效,首先ps -elf | grep worker查到pid,然后 cat /proc/pid/limits

当然这里配置的上限还受linux的全局配置影响,可以通过ulimit -a查看进程的最大打开文件数量限制

想要全局修改并一直生效的话,需要vim /etc/security/limits.conf进行修改

在该配置文件的最后加上:

  • soft nofile 65535

  • hard nofile 65535

注意*号是要写入配置文件的,表示对于所有用户的进程都适用,soft表示应用程序的上限,hard表示内核的上限。

如果只是想本次会话生效,那么ulimit -n 65535即可