基于OpenResty的弹性网关实践(二)

五、11个指令介绍

OpenResty 有 11 个 *_by_lua指令,它们和 NGINX 阶段的关系如下图所示

其中, init_by_lua 只会在 Master 进程被创建时执行,init_worker_by_lua 只会在每个 Worker 进程被创建时执行。其他的 *_by_lua 指令则是由终端请求触发,会被反复执行。

所以在 init_by_lua 阶段,我们可以预先加载 Lua 模块和公共的只读数据,这样可以利用操作系统的 COW(copy on write)特性,来节省一些内存。

对于业务代码来说,其实大部分的操作都可以在 content_by_lua 里面完成,但我更推荐的做法,是根据不同的功能来进行拆分,比如下面这样:

  • set_by_lua*: 流程分支处理判断变量初始化

  • rewrite_by_lua*: 转发、重定向、缓存等功能(例如特定请求代理到外网)

  • access_by_lua*: IP 准入、接口权限等情况集中处理(例如配合 iptable 完成简单防火墙)

  • content_by_lua*: 内容生成

  • header_filter_by_lua*: 响应头部过滤处理(例如添加头部信息)

  • body_filter_by_lua*: 响应体过滤处理(例如完成应答内容统一成大写)

  • log_by_lua*: 会话完成后本地异步完成日志记录(日志可以记录在本地,还可以同步到其他机器)

我们假设,你对外提供了很多明文 API,现在需要增加自定义的加密和解密逻辑。那么请问,你需要修改所有 API 的代码吗?

# 明文协议版本
location /mixed {
    content_by_lua '...';       # 处理请求
}

当然不用。事实上,利用阶段的特性,我们只需要简单地在 access 阶段解密,在 body filter 阶段加密就可以了,原来 content 阶段的代码是不用做任何修改的:

# 加密协议版本
location /mixed {
    access_by_lua '...';        # 请求体解密
    content_by_lua '...';       # 处理请求,不需要关心通信协议
    body_filter_by_lua '...';   # 应答体加密
}

真实代码示例:

下面是一个真实项目中权限校验的某个Lua脚本

nginx.conf

location ~ /paopao/(game/callback/recharge) {
        access_by_lua_file lua/util/commonVerifyNotUid.lua;
        proxy_pass   http://paopao;
        proxy_redirect off;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Remote-Addr $remote_addr;
        proxy_set_header X-Forwarded-For $http_x_forwarded_for;
}
​
​
​
upstream paopao{
        server xxx.xxx.xxx.xxx:port max_fails=1 fail_timeout=8s weight=5;
}

commonVerifyNotUid.lua

module("util.commonVerify", package.seeall)
local cjson = require"cjson"
local config = require"config"
local securityver = require"util.securityver"
​
local args = ngx.req.get_uri_args()
local headers = ngx.req.get_headers()
​
--安全验证 必须参数
local requestId = headers.requestId --流水号
local appid = headers.appId --  appid
local c = headers.c -- 平台
local vn = headers.vn -- 版本
local ua = headers.ua --ua
local sign = headers.sign -- 签名
local u = headers.u -- 渠道号
local subAppId = headers.subAppId -- 子AppId
​
ngx.req.read_body()
local postargs = ngx.req.get_post_args()
​
​
if not u or u == true or u == "" then
    ngx.log(ngx.ERR, "u header is nil")
    ngx.print(cjson.encode({ result = 1 , msg ='gateway parameter absent: u'}))
    ngx.exit(200)
    return
end
​
if not requestId or requestId == true or requestId == "" then
    ngx.log(ngx.ERR, "requestId header is nil")
    ngx.print(cjson.encode({ result = 1 , msg ='gateway parameter absent: requestId'}))
    ngx.exit(200)
    return
end
​
if not appid or appid == true or appid == "" then
    -- 同上
end
​
if not c or c == true or c == "" then
    -- 同上
end
​
if not vn or vn == true or vn == "" then
    -- 同上
end
​
if not ua or ua == true or ua == "" then
    -- 同上
end
​
if not sign or sign == true or sign == "" then
    -- 同上
end
​
-- 黑名单imei
local f = io.open("/data/paopao/paopao_gw/lua/black_list", "r")
local black_list = f:read("*all")
f:close()
​
--ngx.log(ngx.INFO, black_list)
black_list = loadstring("return " .. black_list)()
​
function getimei(s)
  for k,v in string.gmatch(s, "(%w+)=(%w+)") do
    if(k == 'imei')
    then
      return v
    end
  end
end
​
function ifblock(imei)
  for k, v in ipairs(black_list) do
    if v == imei then
      return true
    end
  end
  return false
end
 
​
imei_str = getimei(ua)
if ifblock(imei_str) then
  ngx.print(cjson.encode({ result = 1, msg = 'black list'}))
  ngx.log(ngx.INFO, "黑名单")
  ngx.exit(200)
  return
end
​
if ngx.var.args then
    ngx.var.args =  ngx.var.args .. '&pv=' .. config.PV[c] .. '&v=' .. vn .. '&appId=' .. appid .. '&u=' .. u
else
    ngx.var.args =  'pv=' .. config.PV[c] .. '&v=' .. vn .. '&appId=' .. appid .. '&u=' .. u
end
​
if subAppId ~= nil then
    ngx.var.args = ngx.var.args .. '&subAppId=' .. subAppId
end

六、API介绍

OpenResty 是基于 NGINX 的 Web 服务器,但它与 NGINX 却有本质的不同:NGINX 由静态的配置文件驱动,而 OpenResty 是由 Lua API 驱动的,所以能提供更多的灵活性和可编程性。

OpenResty 的 API 主要分为下面几个大类:

  • 处理请求和响应;

  • SSL 相关;

  • shared dict;

  • cosocket;

  • 处理四层流量;

  • process 和 worker;

  • 获取 NGINX 变量和配置;

  • 字符串、时间、编解码等通用功能。

penResty 的 API 不仅仅存在于 lua-nginx-module 项目中,也存在于 lua-resty-core 项目中,比如 ngx.ssl、ngx.base64、ngx.errlog、ngx.process、ngx.re.split、ngx.resp.add_header、ngx.balancer、ngx.semaphore、ngx.ocsp 这些 API 。

而对于不在 lua-nginx-module 项目中的 API,你需要单独 require 才能使用。举个例子,比如你想使用 split 这个字符串分割函数,就需要按照下面的方法来调用:

​
$ resty -e 'local ngx_re = require "ngx.re"
 local res, err = ngx_re.split("a,b,c,d", ",", nil, {pos = 5})
 print(res)
 '

下面我们介绍几个常用的API:

1.获取uri参数

获取一个 uri 有两个方法:ngx.req.get_uri_argsngx.req.get_post_args,二者主要的区别是参数来源有区别。

参考下面例子:

server {
   listen    80;
   server_name  localhost;
​
   location /print_param {
       content_by_lua_block {
           local arg = ngx.req.get_uri_args()
           for k,v in pairs(arg) do
               ngx.say("[GET ] key:", k, " v:", v)
           end
​
           ngx.req.read_body() -- 解析 body 参数之前一定要先读取 body
           local arg = ngx.req.get_post_args()
           for k,v in pairs(arg) do
               ngx.say("[POST] key:", k, " v:", v)
           end
       }
   }
}

输出结果:

➜  ~  curl '127.0.0.1/print_param?a=1&b=2' -d 'c=3&d=4'
[GET ] key:b v:2
[GET ] key:a v:1
[POST] key:d v:4
[POST] key:c v:3

从以上输入结果可以看出:前者来自 uri 请求参数,而后者来自 post 请求内容。

我们拿真实的案例来分析一下:

nginx文件中需要拦截的路径加入下面代码

access_by_lua_file lua/util/commonVerify.lua;

以下为Lua文件中部分代码

lua会获取到get和post的请求参数,然后会对参数加密得到一个sign,同时对客户端传递过来的sign进行比对,如果失败,则返回错误提示。

commonVerify.lua

local securityver = require"util.securityver"
​
local args = ngx.req.get_uri_args()
local postargs = ngx.req.get_post_args()
​
​
--安全验证
local status1, res = pcall(securityver.Verify, args, postargs)
if not status1 then
    ngx.log(ngx.ERR, "error in function securityver.Verify. status is nil")
    ngx.print(cjson.encode({ result = 3 , msg ='gateway verify: inner exception' }))
    ngx.exit(200)
   return
end

seurityver.lua

module("util.securityver", package.seeall)
​
package.path = package.path .. ';/data/uxin/http_gw_v3/lua/protobuf/?.lua'
package.cpath = package.cpath .. ';/data/uxin/http_gw_v3/lua/protobuf/?.so'
​
require 'ssid_pb'
local config = require"config"
​
local string = require("string")
local commonutil = require"util.commonutil"
​
​
local map = { d = 0, e = 1, y = 2, b = 3, i = 4, p = 5, v = 6, k = 7, z = 8, o = 9 }
​
local function sha1(src)
    local resty_sha1 = require"resty.sha1"
    local sha1 = resty_sha1:new()
    if not sha1 then
        return ""
    end
​
    local ok = sha1:update(src)
    if not ok then
        return ""
    end
​
    local digest = sha1:final() -- binary digest
​
    local str = require"resty.string"
    return str.to_hex(digest)
end
​
local function VerifySign(args,postargs)
    local appid =  ngx.req.get_headers().appid
    local ua =  ngx.req.get_headers().ua
    local requestId =  ngx.req.get_headers().requestId
    local vn =  ngx.req.get_headers().vn
    local paramsTable = {}
    local sign = ngx.req.get_headers().sign
    local signSrc = ""
​
    ngx.log(ngx.ERR, "----vn :" .. vn)
    if vn >= "1.0.0" then
        --table.merge(args, postargs)
            for k,v in pairs(postargs) do
                ngx.log(ngx.ERR, "args k" .. k) 
                ngx.log(ngx.ERR, "args type: " .. type(v)) 
                if type(v) ~= 'table' then
                    ngx.log(ngx.ERR, "args v" .. v) 
                    args[k] = v
                end
            end
    end
    
    ngx.log(ngx.ERR, "----vn :" .. vn)
    if args then
        for k, v in pairs(args) do
            if k ~= "sign" and v ~= true and v ~= "" then
            ngx.log(ngx.ERR, "args k" .. k) 
                table.insert(paramsTable, k)
            end
        end
        table.sort(paramsTable, function(a, b)
            return string.lower(a) < string.lower(b)
        end)
​
        for i = 1, #(paramsTable) do
            ngx.log(ngx.ERR, "arg:" .. paramsTable[i] .."--- "  ..  args[paramsTable[i]])
            signSrc = signSrc .. args[paramsTable[i]]
        end
    end
​
    --local SignKey = config.SIGNKEY
    ngx.log(ngx.INFO, "----------")
    ngx.log(ngx.INFO, config.SIGN_KEY[appid])
    ngx.log(ngx.INFO, "----------")
    local SignKey = config.SIGN_KEY[appid]
    local signStr = signSrc .. requestId .. ua .. appid .. SignKey
    local sing1 = sha1(signStr)
        --ngx.log(ngx.ERR, "----bad sign, signStr:" .. signStr)
        --ngx.log(ngx.ERR, "----bad sign, source sign:" .. sign)
        --ngx.log(ngx.ERR, "----bad sign, native sign:" .. sing1)
    if  sing1 ~= sign then
        ngx.log(ngx.ERR, "bad sign, signStr:" .. signStr)
        ngx.log(ngx.ERR, "bad sign, source sign:" .. sign)
        ngx.log(ngx.ERR, "bad sign, native sign:" .. sing1)
        return false
    end
    return true
end
​
-- 防止用户重复请求的方法,客户端会传一个requestId,为随机生成的字符串
local function VerifyRequestId(args)
    --make sure account+sn is different in one hour, then sign is different
    local uid = args.uid
    if not uid then
        args = ngx.req.get_uri_args()
        uid = args.uid
    end
    local snSet = ngx.shared.SnSet
    local sn = ngx.req.get_headers().requestId
​
    if uid then
        sn = uid .. sn
    end
    --ngx.log(ngx.ERR, "duplicate requestId" .. sn)
    local success, err, forcible = snSet:add(sn, 1, 5 * 60)
    if not success and err == "exists" then
        ngx.log(ngx.ERR, "duplicate requestId" .. sn)
       return false
       -- return true
    end
    return true
end
​
local function VerifyParam(args)
    return true
end
​
function Verify(args,postargs)
    -- 注销
    --return true
    if not VerifyParam(args) then
        ngx.log(ngx.ERR, "VerifyParam is error")
        return false
    end
    
    if not VerifyRequestId(args) then
        ngx.log(ngx.ERR, "verify requestId is error")
        return false
    end
​
    return VerifySign(args,postargs)
end

config.lua

我们将常量单独存放到一个lua类中保管

module("config", package.seeall)
​
SIGNKEY = "xxxxxxxxxxxxxxxxxxxxxxx"

2.获取请求body

在 Nginx 的典型应用场景中,几乎都是只读取 HTTP 头即可,例如负载均衡、正反向代理等场景。但是对于 API Server 或者 Web Application ,对 body 可以说就比较敏感了。由于 OpenResty 基于 Nginx ,所以天然的对请求 body 的读取细节与其他成熟 Web 框架有些不同。

我们先来构造最简单的一个请求,POST 一个名字给服务端,服务端应答一个 “Hello xxx”。

http {
    server {
        listen    80;
​
        location /test {
            content_by_lua_block {
                local data = ngx.req.get_body_data()
                ngx.say("hello ", data)
            }
        }
    }
}

测试结果:

➜  ~  curl 127.0.0.1/test -d jack
hello nil

大家可以看到 data 部分获取为空,如果你熟悉其他 web 开发框架,估计立刻就觉得 OpenResty 弱爆了。查阅一下官方 wiki 我们很快知道,原来我们还需要添加指令 lua_need_request_body 。究其原因,主要是 Nginx 诞生之初主要是为了解决负载均衡情况,而这种情况,是不需要读取 body 就可以决定负载策略的,所以这个点对于 API Server 和 Web Application 开发的同学有点怪。

参看下面例子:

http {
    server {
        listen    80;
​
        # 默认读取 body
        lua_need_request_body on;
​
        location /test {
            content_by_lua_block {
                local data = ngx.req.get_body_data()
                ngx.say("hello ", data)
            }
        }
    }
}

再次测试,符合我们预期:

➜  ~  curl 127.0.0.1/test -d jack
hello jack

3.日志输出

OpenResty 的标准日志输出原句为 ngx.log(log_level, ...) ,几乎可以在任何 ngx_lua 阶段进行日志的输出。

我们用泡泡真实项目中的日志模拟一下sign签名校验错误的日志输入

nginx中的日志配置如下:

        log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                                '$status $body_bytes_sent "$http_referer" '
                                '"$http_user_agent" "-"' '"upstream_addr:' '$upstream_addr' '-response_time:' '$upstream_response_time"';
​
server{
    access_log   logs/httpserver.access.log main;
    error_log    logs/httpserver.error.log error;
}

httpserver.error.log日志如下:

2020/09/24 15:09:19 [error] 29978#0: *793273 [lua] securityver.lua:95: bad sign, signStr:50492122955321ssadaadf1200rko753*qpsd5vbalt#$%^19plmo!@&kn, client: 114.249.22.19, server: localhost, request: "GET /v1/paopao/game?gameId=1 HTTP/1.1", host: "xxx.xxx.xxx.xxx:12345"
2020/09/24 15:09:19 [error] 29978#0: *793273 [lua] securityver.lua:96: bad sign, source sign:asadfdfa3124321asd, client: 114.249.22.19, server: localhost, request: "GET /v1/paopao/game?gameId=1 HTTP/1.1", host: "xxx.xxx.xxx.xxx:12345"
2020/09/24 15:09:19 [error] 29978#0: *793273 [lua] securityver.lua:97: bad sign, native sign:7b5a29291b78e9d7432e0a2bca39b94a1c8857e1, client: 114.249.22.19, server: localhost, request: "GET /v1/paopao/game?gameId=1 HTTP/1.1", host: "xxx.xxx.xxx.xxx:12345"
2020/09/24 15:09:19 [error] 29978#0: *793273 [lua] commonVerify.lua:103: error in function securityver.Verify. res is nil, client: 114.249.22.19, server: localhost, request: "GET /v1/paopao/game?gUid=122955&gameId=50492 HTTP/1.1", host: "xxx.xxx.xxx.xxx:12345"

4.发起http请求

OpenResty 最主要的应用场景之一是 API Server,有别于传统 Nginx 的代理转发应用场景,API Server 中心内部有各种复杂的交易流程和判断逻辑。

① 引用 resty.http 库资源,它来自 github https://github.com/pintsized/lua-resty-http

② 参考 resty-http 官方 wiki 说明,我们可以知道 request_uri 函数完成了连接池、HTTP 请求等一系列动作。

下面的代码是我们利用http库自定义的一个简单的发起http请求的方法:

module("util.httplib", package.seeall)
​
local os = require("os")
​
function geturl(purl)
    local http = require"resty.http"
    local hc = http:new()
    local startTime = os.time()
    local ok, code, headers, status, body = hc:request{
        url = purl,
        timeout = 4000,
        method = "GET"
    }
    if not ok then
        ngx.log(ngx.ERR, "WARN: " .. purl .. " code: " .. (code or "nil") .. " status: " .. (status or "nil"))
    end
    if (os.time() - startTime) >= 4 then
        ngx.log(ngx.ERR, "WARN: call remote server timeout. the url: " .. purl)
    end
    return ok, code, headers, status, body
end
​
​
function posturl(purl, pbody)
    local http = require"resty.http"
    local hc = http:new()
    local startTime = os.time()
    local ok, code, headers, status, body = hc:request{
        url = purl,
        timeout = 4000,
        method = "POST",
        body = pbody,
        -- add post content-type and cookie
        headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
    }
    if not ok then
        ngx.log(ngx.ERR, "WARN: " .. purl .. " pbody: " .. pbody .. " code: " .. (code or "nil") .. " status: " .. (status or "nil"))
    end
    if (os.time() - startTime) >= 4 then
        ngx.log(ngx.ERR, "WARN: call remote server timeout. the url: " .. purl .. pbody)
    end
    return ok, code, headers, status, body
end

七、OpenResty缓存

ngx.shared.DICT

我们在nginx.conf中配置

lua_shared_dict SnSet 300m;
lua_shared_dict Threshold 300m;

我们可以用 shared dict 来共享数据,这些数据可以在多个 worker 之间共享。内部使用的 LRU 算法(最近最少使用)来判断缓存是否在内存占满时被清除。

在上面的多处代码中我们都使用了配置文件所定义好的全局变量。

它对外提供了 20 多个 Lua API,不过所有的这些 API 都是原子操作,你不用担心多个 worker 和高并发的情况下的竞争问题。

这些 API 都有官方详细的文档,使用的时候可以查阅:shared_dict

继续看 shared dict 的 API,这些 API 可以分为下面三个大类,也就是字典读写类、队列操作类和管理类这三种。

1.字典读写类

首先来看字典读写类。在最初的版本中,只有字典读写类的 API,它们也是共享字典最常用的功能。下面是一个最简单的示例:

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                               dict:set("Tom", 56)
                               print(dict:get("Tom"))'

除了 set 外,OpenResty 还提供了 safe_set、add、safe_add、replace 这四种写入的方法。这里safe 前缀的含义是,在内存占满的情况下,不根据 LRU 淘汰旧的数据,而是写入失败并返回 no memory 的错误信息。

除了 get 外,OpenResty 还提供了 get_stale 的读取数据的方法,相比 get 方法,它多了一个过期数据的返回值:

value, flags, stale = ngx.shared.DICT:get_stale(key)

还可以调用 delete 方法来删除指定的 key,它和 set(key, nil) 是等价的。

2.管理类

用户申请了 100M 的空间作为 shared dict,那么这 100M 是否够用呢?里面存放了多少 key?具体是哪些 key 呢?

首先是 get_keys(max_count?),它默认也只返回前 1024 个 key;如果你把 max_count 设置为 0,那就返回所有 key。

然后是 capacity 和 free_space,这两个 API 都属于 lua-resty-core 仓库,所以需要你 require 后才能使用:

​
require "resty.core.shdict"
​
 local cats = ngx.shared.cats
 local capacity_bytes = cats:capacity()
 local free_page_bytes = cats:free_space()

它们分别返回的,是共享内存的大小(也就是 lua_shared_dict 中配置的大小)和空闲页的字节数。因为 shared dict 是按照页来分配的,即使 free_space 返回为 0,在已经分配的页面中也可能存在空间,所以它的返回值并不能代表共享内存实际被占用的情况。

3.队列操作类

  • lpush/rpush,表示在队列两端增加元素;

  • lpop/rpop,表示在队列两端弹出元素;

  • llen,表示返回队列的元素数量。

下面是代码示例:

=== TEST 1: lpush & lpop
--- http_config
    lua_shared_dict dogs 1m;
--- config
    location = /test {
        content_by_lua_block {
            local dogs = ngx.shared.dogs
​
            local len, err = dogs:lpush("foo", "bar")
            if len then
                ngx.say("push success")
            else
                ngx.say("push err: ", err)
            end
​
            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)
​
            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)
​
            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)
​
            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)
        }
    }
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]

八、典型的应用场景

  • 在 Lua 中混合处理不同 Nginx 模块输出(proxy, drizzle, postgres, Redis, memcached 等)。

  • 在请求真正到达上游服务之前,Lua 中处理复杂的准入控制和安全检查。

  • 比较随意的控制应答头(通过 Lua)。

  • 从外部存储中获取后端信息,并用这些信息来实时选择哪一个后端来完成业务访问。

  • 在内容 handler 中随意编写复杂的 web 应用,同步编写异步访问后端数据库和其他存储。

  • 在 rewrite 阶段,通过 Lua 完成非常复杂的处理。

  • 在 Nginx 子查询、location 调用中,通过 Lua 实现高级缓存机制。

  • 对外暴露强劲的 Lua 语言,允许使用各种 Nginx 模块,自由拼合没有任何限制。该模块的脚本有充分的灵活性,同时提供的性能水平与本地 C 语言程序无论是在 CPU 时间方面以及内存占用差距非常小。所有这些都要求 LuaJIT 2.x 是启用的。其他脚本语言实现通常很难满足这一性能水平。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值