Openresty实战应用
OpenResty实践
OpenResty日志
- 每个软件都有日志系统,记录软件的运行状态以及出错日志,可以帮助开发者更好的调试程序定位问题。
- OpenResty 提供函数
ngx.log(log_level, ...)
记录 OpenResty 的运行日志,用法很类似 Lua 的标准库函数print
,可以接受任意多个参数,记录任意信息。OpenResty 同时替换了全局函数print
,它等价于ngx.log(ngx.NOTICE, ...)
。
日志等级
- 日志是分等级的,特定级别的日志才会真正写入到日志文件,默认是 error级别才写入log文件。
- 日志等级从高到低依次是:
ngx.STDERR :日志直接打印到标准输出,最高级别
ngx.EMERG :紧急错误
ngx.ALERT :严重错误,需要报警给运维系统
ngx.CRIT :严重错误
ngx.ERR :普通错误
ngx.WARN :警告
ngx.NOTICE :提醒
ngx.INFO :一般信息
ngx.DEBUG :调试信息,debug版本才会生效
- 在日常开发中,关键点使用
INFO
或NOTICE
级别的日志来调试代码即可。ERR用来做错误异常捕获。 - 只要改动配置文件中的
error_log
设置,就可以开启低等级的日志。 - error.log 是直接写磁盘的阻塞操作,没有缓冲也没有异步,对性能有很大影响。
- 正式环境下,一定要将日志级别设置到默认,减少日志写入。
日志示例
- 新建 lua 脚本 testlog.lua:
ngx.log(ngx.DEBUG,"debug log")
ngx.log(ngx.INFO,"info log")
ngx.log(ngx.NOTICE,"notice log")
ngx.log(ngx.WARN,"warn log")
ngx.log(ngx.ERR,"error log")
ngx.log(ngx.CRIT,"crit log")
ngx.log(ngx.ALERT,"alert log")
ngx.log(ngx.EMERG,"emerg log")
ngx.log(ngx.STDERR,"stderr log")
ngx.say("testlog")
- 配置转发:
location /testlog {
default_type text/html;
content_by_lua_file /openresty-test/testlog.lua;
}
- 访问地址:http://localhost/testlog
- 可以看到,只有 ERR 以及更高级别的日志,输出到了日志文件里。
开启更多日志
- 在配置文件中,日志的设置语句格式如下:
error_log file level;
- level就是日志等级,有以下等级可以设置:
debug|info|notice|warn|error|crit|alert|emerg
- 只有不低于这个等级的日志,才会记录到日志文件中。
worker_processes 1;
error_log logs/error.log debug;
events {
worker_connections 1024;
}
http {
server {
listen 80;
location ~ ^/(\w+) {
default_type text/html;
content_by_lua_file /openresty-test/$1.lua;
}
}
}
OpenResty请求处理阶段划分(流水线)
- OpenResty把请求处理划分成
rewrite
access
content
等若干个阶段,每个阶段都可以指定单独的 lua 脚本来做处理。 - 假如我要实现黑名单功能,那么在
access
阶段,就可以对客户端的 IP 进行判定,来决定拒绝还是放行,而不用等到content
阶段。
OpenResty请求处理阶段划分
- 在收到客户端的请求后,OpenResty对每个请求都会使用一个专门的“流水线”顺序进行处理,“流水线”上就是OpenResty定义的处理阶段,包括:
ssl SSL/TLS安全通信和验证
prehead 在正式处理之前 预读数据,接受http请求头
rewrite 检查/改写url,实现跳转 重定向
access 访问权限控制
content 产生实际响应内容
filter 对content产生的内容进行过滤加工
log 请求处理完毕,记录日志,收尾
- 这些处理阶段在OpenResty进程里的关系如图:
- 如果只是用来做web服务,那么关注 content 阶段即可。
OpenResty阶段执行程序
- 在不同的阶段,可以使用特定的标签,执行lua脚本。常用的有:
rewrite_by_lua_file --rewrite阶段,检查,改写uri
access_by_lua_file --检查权限,例如ip地址 限制访问次数
content_by_lua_file --主要的逻辑,产生内容,返回给客户端的。
body_filter_by_lua_file --filter阶段,对数据编码 加密 附加额外数据等
log_by_lua_file --可以向后端发送处理完成的回执
示例
- 下面通过一个实例,来了解OpenResty请求处理阶段划分。
- 修改配置文件,为每个阶段,指定lua脚本。
location ~ ^/(\w+) {
default_type text/html;i
rewrite_by_lua_file /openresty-test/rewrite.lua;
access_by_lua_file /openresty-test/access.lua;
content_by_lua_file /openresty-test/content.lua;
body_filter_by_lua_file /openresty-test/filter.lua;
log_by_lua_file /openresty-test/log.lua
}
- 分别创建上面的脚本文件,内容都是输出当前阶段的名称:
# rewrite.lua
ngx.log(ngx.INFO,"rewrite")
# access.lua
ngx.log(ngx.INFO,"access")
# content.lua
ngx.log(ngx.INFO,"content")
# filter.lua
ngx.log(ngx.INFO,"filter")
# log.lua
ngx.log(ngx.INFO,"log")
- 从日志,就可以更加直观的看出各个阶段的顺序。
OpenResty IP黑白名单功能
- 针对
access
阶段进行处理,实现 ip黑名单功能。修改 access.lua 文件如下:这个代码的功能是判断客户端的ip,如果是127.0.0.1
,就返回403 Forbidden
,就是说禁止本机访问。
ngx.log(ngx.INFO,"access")
-- 获取客户端IP
local clientIp = ngx.var.remote_addr
ngx.log(ngx.DEBUG, "clientIp: " .. clientIp)
-- 判断是否是黑名单
if clientIp == '127.0.0.1' then
return ngx.exit(ngx.HTTP_FORBIDDEN)
else
ngx.say(clientIp)
end
- 当使用 localhost 进行访问时,会被拦截
403 Forbidden
。当使用 IP 时,就可以正常访问。 - 至此,就通过 OpenResty实现了 ip黑名单功能。当然正式应用,需要从mysql/redis 读取黑名单列表,然后还需要一个后台,来更新黑名单。
- 以上就是 OpenResty IP 黑白名单实现的原理。
磁盘缓存
- nginx 的一个缓存策略是 nginx_proxy_cache 策略:
# 在server配置上面声明一个缓存,和upstream同级的位置进行配置
proxy_cache_path /usr/local/etc/openresty/cache_tmp levels=1:2 keys_zone=cache_tmp:100m inactive=7d max_size=10g;
# 说明:
# levels = 1:2 使用2级目录存储缓存,减少寻址消耗,防止一个目录存储缓存文件过多,导致查询缓存效率低下
# keys_zone nginx内存中开辟了100m的空间,存储缓存
# max_size : 文件系统最多存储10g,所有的文件存储超过10g,采用lru淘汰算法
# 在location中增加以下配置
# 指定proxy_cache_path定义的缓存空间
proxy_cache cache_tmp;
# 指定缓存的key
proxy_cache_key $uri;
# 只有200 206 304 302状态的请求才被缓存,其他不被缓存
proxy_cache_valid 200 206 304 302 7d;
# nginx 文件级别的缓存,缓存访问的是本地磁盘文件,效率比较低,从磁盘中读取数据,效率反而变低了....
- 完整配置 proxy_cache 如下所示:
内存字典
- nginx_proxy_cache缓存每次从磁盘读取文件,即便使用SSD高性能磁盘进行文件存储,也改变不了这个缓存是读取文件的效率低下的问题。因此使用nginx+lua方式进行缓存扩展,使得缓存更高效。
- OpenResty 是一个nginx和它的各种三方模块的一个打包而成的软件平台。最重要的一点是它将lua/luajit打包了进来,使得我们可以使用lua脚本来进行web的开发。有了lua,我们可以借助于nginx的异步非阻塞的功能,达到使用 lua 异步并发访问后端的 MySQL, PostgreSQL, Memcached, Redis等等服务。
- 特别是
特有的 ngx.location.capture_multi 功能让人印象深刻,其可以达到极大的减少浏览器的http连接数量,并且可以异步并发的访问后台 Java/PHP/Python 等等接口
。OpenResty 架构的web可以轻松超越Node.js的性能,并且对后端语言没有限制
,你可以使用Java/PHP/Python等等各种语言。OpenResty(nginx+lua)可以替代node.js的前端渲染的功能。 - 定义一个缓存: shared dic就是一个共享内存,所有的 worker 进程可见,使用 lru 淘汰策略。
# 在nginx的配置文件 nginx.conf 的 http 端下面加入指令:ngx_cache 为缓存的名称,可以自定义
lua_shared_dict ngx_cache 128m;
# 名称为 ngx_cache 大小为128m的内存用于缓存,注意该缓存是所有nginx work process所共享的
# 下面测试一下,首先在 nginx.conf的server端中加入:
location /cache {
content_by_lua_file lua/cache.lua;
}
- 在lua脚本中访问缓存:
local ngx_cache = ngx.shared.ngx_cache
local value = ngx_cache.get(key)
local succ, err, forcible = ngx_cache:set(key, value, expire)
- 一个更简单的例子:
function get_from_cache(key)
local cache_ngx = ngx.shared.my_cache
local value = cache_ngx:get(key)
return value
end
function set_to_cache(key,value,exptime)
if not exptime then
exptime = 0
end
local cache_ngx = ngx.shared.my_cache
local succ, err, forrcible = cache_ngx:set(key,value,exptime)
return succ
end
local args = ngx.req.get_uri_args()
local id = args["id"]
local item_model = get_from_cache("item_"..id)
if item_model == nil then
local resp = ngx.location.capture("/item/get?id="..id)
item_model = resp.body
ngx.log(ngx.ERR, resp.body)
set_to_cache("item_" .. id, item_model, 60)
end
ngx.say(item_model)
- 这段代码的意思是:写一个get 和set方法,从 nginx 中读取我们配置的my_cache 参数,设置和放入,类似Map的机制。然后下面,获取请求参数构造 item_id 的形式,放入内存词典中,可以理解为放入 map;然后有新的请求会先从内存词典中查找,找不到再传入后面,获取返回值,再放入内存词典中。
lua+redis
- lua-resty-redis 模块:https://github.com/openresty/lua-resty-redis (有文档可以参考)。
- 编写 cache-redis.lua 脚本:
local redis = require "resty.redis"
local red = redis:new()
function set_to_cache(key, value, expire)
if not expire then
expire = 0
end
local ngx_cache = ngx.shared.ngx_cache
local succ, err, forcible = ngx_cache:set(key, value, expire)
return succ
end
function get_from_cache(key)
local ngx_cache = ngx.shared.ngx_cache
local value = ngx_cache:get(key)
if not value then
-- 注意此处新建的redv,不能使用上面的value,会冲突
local redv = get_from_redis(key)
if not redv then
ngx.say("redis cache not exists")
return
end
set_to_cache(key, redv, 60)
return redv
end
ngx.say("get from cache")
return value
end
function set_to_redis(key, value)
red:set_timeout(100000)
local ok, err = red:connect("192.168.254.128", 6379)
if not ok then
ngx.say("failed to connect: ", err)
return
end
local ok, err = red:set(key, value)
if not ok then
ngx.say("failed set to redis: ", err)
return
end
return ok
end
function get_from_redis(key)
red:set_timeout(1000)
local ok, err = red:connect("192.168.254.128", 6379)
if not ok then
ngx.say("failed to connect: ", err)
return
end
local res, err = red:get(key)
if not res then
ngx.say("failed get redis cache: ", err)
return ngx.null
end
ngx.say("get cache from redis.")
return res
end
set_to_redis('dog', 'Bob')
local rs = get_from_cache('dog')
ngx.say(rs)
- 配置location:
注意问题
- 先重温下 Lua 里的真值与假值:除了 nil 和 false 为假,其他值都是真。“其他值”这个概念包括0、空字符串、空表,等等。
- 在 Lua 里,通常使用
and
和or
作为逻辑操作符。比如true and false
返回false
,而false or true
返回true
。
第一个坑
- 在 Lua 代码里,作为给参数设置默认值的惯用法,我们通常能看到
xx = xx or value
的语句。如果没有给 xx 入参指定值,它的取值为 nil,该语句就会赋值 value 给它。 - 如果写代码的时候,把前面设置默认值的语句顺手复制一份;抑或由于业务变动,原来的入参变成布尔类型,也许一下子就掉到这个坑里了。所以这种惯用法,虽然便利了书写,但是也得注意一下,多留点心。
第二个坑
- 看到前面的第一个坑,有些小伙伴可能想到一个跳过坑的办法:改成
xx = (xx == nil) and xx or value
。实际跑下会发现,这个语句也跑不过 xx 为 false 的 case。这就是第二个坑了。 - Lua 没有三元操作符!Lua 没有三元操作符!Lua 没有三元操作符!
- 重要的东西说三遍!虽然你可能在 Lua 代码中见过各种三元操作符的模拟,但是它们都是模拟。既然是模拟,也不过是赝品,只不过有些是以假乱真的高仿品。
a and b or c
模式是这些高仿品中的一员。这个模式其实包含两个表达式:先a and b
得到 x,再执行x or c
得到最终结果 y。在大多数时候,它表现得像是三元操作符,但可惜它不是。- 如果 b 的值为假,那么
a and b
的执行结果恒假;如果 x 恒假,则x or c
的执行结果恒为 c。所以只要 b 的值为假,那么最终结果恒为 c。 - 上面例子里面,xx 是一个传进来的变量,所以你只需跑下 case,就能看出这是一个坑。但如果 b 的位置上是一个函数的返回值呢?例如
expr and func1() or func2()
的形式,如果 func1 只是偶尔返回假值,一颗定时炸弹就埋下了。
还是像第一个坑一样的结论,惯用法可以用,但是要多留点心。
第三个坑
- 由于 Lua 里面 nil 不能作为占位符,为了表示数据空缺,比如 redis 键对应值为空,OpenResty 引入了
ngx.null
这个常量。ngx.null
是一个值为 NULL 的 userdata。
$ resty -e 'print(tostring(ngx.null))'
userdata: NULL
- ngx.null 虽然带了个 null 字,但是它并不等于 nil。 根据开头的规则(其他值都是真),ngx.null 的布尔值为真。 一个布尔值为真的,表示空的常量,说实话,我还没在其他编程环境中见过。这又是一个潜在的坑。
- 因为在思考的时候,一不小心就会把它当作假值考虑了。举个例子,从 redis 获取特定键,如果不存在,调用函数A。如果一时半会想不起
ngx.null
的特殊性,可能会直接判断返回值是否为真(或者是否为 nil),然后就掉到坑里了。尤其是如果底层逻辑没有把 ngx.null 包装好,上层调用的人也许压根没想到除了 nil 和 value 之外,还有一个 ngx.null 的存在!
local res, err = redis.get('key1')
if not res then
...
end
-- 大部分情况都是好的,直到有一天 key1 不存在…… 500 Internal Server Error!
-- res = res + 1
-- 正确做法
if res ~= ngx.null then
res = res + 1
- 所以这种时候就需要给在底层跟外部数据服务打交道的代码拦上一道岗,确保妥善处理好 ngx.null 。至于具体怎么处理,是把 ngx.null 转换成 nil 呢,还是改成业务相关的默认值,这就看具体的业务逻辑了。
第四个坑
- 到目前为止,我们已经见识了 ngx.null 这个特立独行的空值了。OpenResty 里还有另外一个空值,来源于 LuaJIT FFI 的 cdata:NULL。正如 ngx.null 是 userdata 范畴内的 NULL,cdata:NULL 是 cdata 范畴内的 NULL。当你通过一个 FFI 接口调用 C 函数,而这个函数返回一个 NULL 指针,在 Lua 代码看来,它收到的是一个 cdata:NULL 值。你可能会想当然地认为,这时候返回的值应该是 nil,因为 Lua 里面的 nil 对应的,不正是 C 里面的 NULL 嘛。但天不遂人意,这时候返回的却是 cdata:NULL,一个怪胎。
- 为什么说它是个怪胎呢?因为它跟 nil 相等,而 ngx.null 就不等于 nil。但是,跟 nil 相等并不意味着它可替换 nil。cdata:NULL 依然服从开头提到的规则 —— 意味着它的布尔值为真。又一个布尔值为真的,表示空的常量!而且这次更诡异了,这个空常量跟 nil 是相等的!
- 体会一下下面的代码:
#!/usr/bin/env luajit
local ffi = require "ffi"
local cdata_null = ffi.new("void*", nil)
print(tostring(cdata_null))
if cdata_null == nil then
print('cdata:NULL is equal to nil')
end
if cdata_null then
print('...but it is not nil!')
end
- 怎么处理呢?大多数情况下,只要判断 FFI 调用返回的是不是
cdata:NULL
就够了。我们可以利用cdata:NULL
跟nil
相等这一点:
#!/usr/bin/env luajit
local ffi = require "ffi"
local cdata_null = ffi.new("void*", nil)
if cdata_null == nil then
print('cdata:NULL found')
end
- 跟上一个坑一样的,应该在底层跟 C 接口打交道的时候消除掉 cdata:NULL,不要让它扩散出去,“祸害”代码的其他部分。
OpenResty网关
API网关有什么用?
- 让我们先来看下微服务 API 网关的作用。下面这张图,是一个简要的说明:
- 众所周知,API 网关并非一个新兴的概念,在十几年前就已经存在了,它的作用主要是作为流量的入口,统一处理和业务相关的请求,让请求更加安全、快速和准确地得到处理。它有以下几个传统功能:
- 反向代理和负载均衡,这和 Nginx 的定位和功能是一致的;
- 动态上游、动态 SSL 证书和动态限流限速等运行时的动态功能,这是开源版本 Nginx 并不具备的功能;
- 上游的主动和被动健康检查,以及服务熔断功能;
- 在 API 网关的基础上进行扩展,成为全生命周期的 API 管理平台。
- 在最近几年,业务相关的流量,不再仅仅由 PC 客户端和浏览器发起,更多的来自手机、IoT 设备等,未来随着 5G 的普及,这些流量会越来越多。同时,随着微服务架构的结构变迁,服务之间的流量也开始爆发性地增长。在这种新的业务场景下,自然也催生了 API 网关更多、更高级的功能:
- 云原生友好,架构要变得轻巧,便于容器化;
- 对接 Prometheus、Zipkin、Skywalking 等统计、监控组件;
- 支持 gRPC 代理,以及 HTTP 到 gRPC 之间的协议转换,把用户的 HTTP 请求转为内部服务的 gPRC 请求;
- 承担 OpenID Relying Party 的角色,对接 Auth0、Okta 等身份认证提供商的服务,把流量安全作为头等大事来对待;
- 通过运行时动态执行用户函数的方式来实现 Serverless,让网关的边缘节点更加灵活;
- 不锁定用户,支持混合云的部署架构;
- 最后,网关节点要状态无关,可以随意地扩容和缩容。
- 当一个微服务 API 网关具备了上述十几项功能时,就可以让用户的服务只关心业务本身;而和业务实现无关的功能,比如服务发现、服务熔断、身份认证、限流限速、统计、性能分析等,就可以在独立的网关层面来解决。
- 从这个角度来看,API 网关既可以替代 Nginx 的所有功能,处理南北向的流量;也可以完成 Istio 控制面和 Envoy 数据面的角色,处理东西向的流量。
为什么要新造轮子?
- 正因为微服务 API 网关的地位如此重要,所以它一直处于兵家必争之地,传统的 IT 巨头在这个领域很早就都有布局。根据 2018 年 Gartner 发布的 API 全生命周期报告,谷歌、CA、IBM、红帽、Salesforce 都是处于领导地位的厂商,开发者更熟悉的 Kong 则处于远见者的区间内。
- 那么,问题就来了,为什么我们还要新造一个轮子呢?
- 简单来说,这是因为当前的微服务 API 网关都不足以满足我们的需求。我们首先来看闭源的商业产品,它们的功能都很完善,覆盖了 API 的设计、多语言 SDK、文档、测试和发布等全生命周期管理,并且提供 SaaS 服务,有些还与公有云做了集成,使用起来非常方便。但同时,它们也带来了两个痛点。
- 第一个痛点,平台锁定问题。 API 网关是业务流量的入口,它不像图片、视频等 CDN 加速的这种非业务流量可以随意迁移,API 网关上会绑定不少业务相关的逻辑。你一旦使用了闭源的方案,就很难平滑和低成本地迁移到其他平台。
- **第二个痛点,无法二次开发的问题。**一般的大中型企业都会有自己独特的需求,需要定制开发,但这时候你只能依靠厂商,而不能自己动手去做二次开发。
- 这也是为什么开源的 API 网关方案开始流行的一个原因。不过,现有的开源产品也不是万能的,自身也有很多不足。
- 第一,依赖 PostgreSQL、MySQL 等关系型数据库。这样,在配置发生变化的时候,网关节点只能轮询数据库。这不仅造成配置生效慢,也给代码增加了复杂度,让人难以理解;同时,数据库也会成为系统的单点和性能瓶颈,无法保证整体的高可用。如果你把 API 网关用于 K8s 环境下,关系型数据库会显得更加笨重,不利于快速伸缩。
- 第二,插件不能热加载。当你新增一个插件或者修改现有插件的代码后,必须要重载服务才能生效,这和修改 Nginx 配置后需要重载是一样的,显然会影响用户的请求。
- 第三,代码结构复杂, 难以掌握。有些开源项目做了多层面向对象的封装,一些简单的逻辑也变得雾里看花。但其实,对于 API 网关这种场景,直来直去的表达会更加清晰和高效,也更有利于二次开发。
- 所以,我们需要一个更轻巧、对云原生和开发友好的 API 网关。当然,我们也不能闭门造车,需要先深入了解已有 API 网关各自的特点,这时候,云原生软件基金会(CNCF)的全景图就是一个很好的参考:
- 这张图筛选出了业界常见的 API 网关,以开源的方案为主,可以为我们下面的技术选型提供不少有价值的内容。
API网关的核心概念
- 当然,在具体实现之前,我们还需要了解 API 网关有哪些核心组件。根据我们前面提到的 API 网关具备的功能点,它至少需要下面几个组件才能开始运行。
路由
- 首先是路由。它通过定义一些规则来匹配客户端的请求,然后根据匹配结果,加载、执行相应的插件,并把请求转发给到指定的上游。这些路由匹配规则可以由 host、uri、请求头等组成,我们熟悉的 Nginx 中的 location,就是路由的一种实现。
插件
- 其次是插件。这是 API 网关的灵魂所在,身份认证、限流限速、IP 黑白名单、Prometheus、Zipkin 等这些功能,都是通过插件的方式来实现的。既然是插件,那就需要做到即插即用;并且,插件之间不能互相影响,就像我们搭建乐高积木一样,需要用统一规则的、约定好的开发接口,来和底层进行交互。
schema
- 接着是 schema。既然是处理 API 的网关,那么少不了要对 API 的格式做校验,比如数据类型、允许的字段内容、必须上传的字段等,这时候就需要有一层 schema 来做统一、独立的定义和检查。
存储
- 最后是存储。它用于存放用户的各种配置,并在有变更时负责推送到所有的网关节点。这是底层非常关键的基础组件,它的选型决定了上层的插件如何编写、系统能否保持高可用和可扩展性等,所以需要我们审慎地决定。
- 另外,在这些核心组件之上,我们还需要抽象出几个 API 网关的常用概念,它们在不同的 API 网关之间都是通用的。
网关技术选型
- 在明白了微服务 API 网关的核心组件和抽象概念后,我们就要开始技术选型,并动手去实现它了。今天,我们就分别来看下,路由、插件、schema 和存储这四个核心组件的技术选型问题。
存储
- 存储是底层非常关键的基础组件,它会影响到配置如何同步、集群如何伸缩、高可用如何保证等核心的问题,所以,我们把它放在最开始的位置来选型。
- 我们先来看看,已有的 API 网关是把数据存储在哪里的。Kong 是把数据储存在 PostgreSQL 或者 Cassandra 中,而同样基于 OpenResty 的 Orange,则是存储在 MySQL 中。不过,这种选择还是有很多缺陷的。
- 第一,储存需要单独做高可用方案。PostgreSQL、MySQL 数据库虽然有自己的高可用方案,但你还需要 DBA 和机器资源,在发生故障时也很难做到快速切换。
- 第二,只能轮询数据库来获取配置变更,无法做到推送。这不仅会增加数据库资源的消耗,同时变更的实时性也会大打折扣。
- 第三,需要自己维护历史版本,并考虑回退和升级。如果用户发布了一个变更,后续可能会有回滚操作,这时候你就需要在代码层面,自己做两个版本之间的 diff,以便配置的回滚。同时,在系统自身升级的时候,还可能会修改数据库的表结构,所以代码层面就需要考虑到新旧版本的兼容和数据升级。
- 第四,提高了代码的复杂度。在实现网关的功能之外,你还需要为了前面 3 个缺陷,在代码层面去打上补丁,这显然会让代码的可读性降低不少。
- 第五,增加了部署和运维的难度。部署和维护一个关系型数据库并不是一件简单的事情,如果是一个数据库集群那就更加复杂了,并且我们也无法做到快速扩容和缩容。
针对这样的情况,我们应该如何选择呢?
- 我们不妨回到 API 网关的原始需求上来,这里存储的都是简单的配置信息,uri、插件参数、上游地址等,并没有涉及到复杂的联表操作,也不需要严格的事务保证。显然,这种情况下使用关系型数据库,可不就是“杀鸡焉用宰牛刀”吗?
- 事实上,本着最小化够用并且更贴近 K8s 的原则,etcd 就是一个恰到好处的选型了:
- API 网关的配置数据每秒钟的变化次数不会很多,etcd 在性能上是足够的;
- 集群和动态伸缩方面,更是 etcd 天生的优势;
- etcd 还具备 watch 的接口,不用轮询去获取变更。
- 其实还有一点,可以让我们更加放心地选择 etcd——它已经是 K8s 体系中保存配置的默认选型了,显然已经经过了很多比 API 网关更加复杂的场景的验证。
路由
- 路由也是非常重要的技术选型,所有的请求都由路由筛选出需要加载的插件列表,逐个运行后,再转发给指定的上游。不过,考虑到路由规则可能会比较多,所以路由这里的技术选型,我们需要着重从算法的时间复杂度上去考量。
- 我们先来看下,在 OpenResty 下有哪些现成的路由可以拿来使用。老规矩,让我们在
awesome-resty
的项目中逐个查找一遍,这其中就有专门的Routing Libraries
:
• lua-resty-route — A URL routing library for OpenResty supporting multiple route matchers, middleware, and HTTP and WebSockets handlers to mention a few of its features
• router.lua — A barebones router for Lua, it matches URLs and executes Lua functions
• lua-resty-r3 — libr3 OpenResty implementation, libr3 is a high-performance path dispatching library. It compiles your route paths into a prefix tree (trie). By using the constructed prefix trie in the start-up time, you may dispatch your routes with efficiency
• lua-resty-libr3 — High-performance path dispatching library base on libr3 for OpenResty
- 你可以看到,这里面包含了四个路由库的实现。前面两个路由都是纯 Lua 实现,相对比较简单,所以有不少功能的欠缺,还不能达到生成的要求。
- 后面两个库,其实都是基于 libr3 这个 C 库,并使用 FFI 的方式做了一层封装,而 libr3 自身使用的是前缀树。这种算法和存储了多少条规则的数目 N 无关,只和匹配数据的长度 K 有关,所以时间复杂度为 O(K)。
- 但是, libr3 也是有缺点的,它的匹配规则和我们熟悉的 Nginx location 的规则不同,而且不支持回调。这样,我们就没有办法根据请求头、cookie、Nginx 变量来设置路由的条件,对于 API 网关的场景来说显然不够灵活。
- 不过,虽说我们尝试从
awesome-resty
中找到可用路由库的努力没有成功,但 libr3 的实现,还是给我们指引了一个新的方向:用 C 来实现前缀树以及 FFI 封装,这样应该可以接近时间复杂度和代码性能上的最优方案。 - 正好, Redis 的作者开源了一个基数树,也就是压缩前缀树的 C 实现。顺藤摸瓜,我们还可以找到 rax 在 OpenResty 中可用的 FFI 封装库,它的示例代码如下:
local radix = require("resty.radixtree")
local rx = radix.new({
{
path = "/aa",
host = "foo.com",
method = {"GET", "POST"},
remote_addr = "127.0.0.1",
},
{
path = "/bb*",
host = {"*.bar.com", "gloo.com"},
method = {"GET", "POST", "PUT"},
remote_addr = "fe80:fe80::/64",
vars = {"arg_name", "jack"},
}
})
ngx.say(rx:match("/aa", {host = "foo.com",
method = "GET",
remote_addr = "127.0.0.1"
}))
- 从中你也可以看出,
lua-resty-radixtree
支持根据 uri、host、http method、http header、Nginx 变量、IP 地址等多个维度,作为路由查找的条件;同时,基数树的时间复杂度为 O(K),性能远比现有 API 网关常用的“遍历 +hash 缓存”的方式,来得更为高效。
schema
- schema 的选择其实要容易得多,我们在前面介绍过的
lua-rapidjson
,就是非常好的一个选择。这部分你完全没有必要自己去写一个,json 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"},
}
插件
- 有了上面存储、路由和 schema 的基础,上层的插件应该如何实现,其实就清晰多了。插件并没有现成的开源库可以使用,需要我们自己来实现。插件在设计的时候,主要有三个方面需要我们考虑清楚。
- 首先是如何挂载。我们希望插件可以挂载到
rewrite
、access
、header filer
、body filter
和log
阶段,甚至在balancer
阶段也可以设置自己的负载均衡算法。所以,我们应该在 Nginx 的配置文件中暴露这些阶段,并在对插件的实现中预留好接口。 - 其次是如何获取配置的变更。由于没有关系型数据库的束缚,插件参数的变更可以通过 etcd 的 watch 来实现,这会让整体框架的代码逻辑变得更加明了易懂。
- 最后是插件的优先级。具体来说,比如,身份认证和限流限速的插件,应该先执行哪一个呢?绑定在 route 和绑定在 service 上的插件发生冲突时,又应该以哪一个为准呢?这些都是我们需要考虑到位的。
- 在梳理清楚插件的这三个问题后,我们就可以得到插件内部的一个流程图了:
- Nginx 本身在处理一个用户请求时,会按照不同的阶段进行处理,总共会分为 11个阶段。而 openresty 的执行指令,就是在这 11 个步骤中挂载 lua 执行脚本实现扩展,我们分别看看每个指令的作用。
- initbylua:当 Nginx master 进程加载 nginx 配置文件时会运行这段 lua 脚本,一般用来注册全局变量或者预加载 lua 模块;
- init_worker_by_lua:每个 Nginx worker 进程启动时会执行的 lua 脚本,可以用来做健康检查;
- ssl_certificate_by_lua:ssl认证;
- set_by_lua:设置一个变量;
- rewrite_by_lua:在 rewrite 阶段执行,为每个请求执行指定的 lua 脚本;
- access_by_lua:为每个请求在访问阶段调用 lua 脚本;
- content_by_lua:前面演示过,通过 lua 脚本生成 content 输出给 http 响应;
- balancer_by_lua:实现动态负载均衡,如果不是走 content_by_lua,则走 proxy_pass,再通过 upstream 进行转发
- header_filter_by_lua:通过 lua 来设置 headers 或者 cookie;
- body_filter_by_lua:对响应数据进行过滤;
- log_by_lua:在 log 阶段执行的脚本,一般用来做数据统计,将请求数据传输到后端进行分析。
网关灰度发布
- 灰度发布就是新版本刚上线时,可以只对某一部分用户开放,比如90%的客户还是只能访问到旧版本,另外10%的用户被添加到灰度名单中可以访问最新版本。
- 创建配置:
# /usr/local/etc/openresty目录下
# vim conf/gray.conf
1;
error_log logs/error.log;
events{
worker_connections 1024;
}
http{
lua_package_path "$prefix/lualib/?.lua;;";
lua_package_cpath "$prefix/lualib/?.so;;";
upstream prod {
server 192.168.254.128:8080;
}
upstream pre {
server 192.168.254.128:8081;
}
server {
listen 80;
server_name localhost;
location /api {
content_by_lua_file lua/gray.lua;
}
location @prod {
proxy_pass http://prod;
}
location @pre {
proxy_pass http://pre;
}
}
server {
listen 8080;
location / {
content_by_lua_block {
ngx.say("I'm prod env");
}
}
}
server {
listen 8081;
location / {
content_by_lua_block {
ngx.say("I'm pre env");
}
}
}
}
- 新增 gray.lua文件如下,实现逻辑:在名单中的ip 访问 pre环境 否则访问prod环境:
# vim lua/gray.lua
local redis=require "resty.redis";
local red=redis:new();
red:set_timeout("1000");
local ok,err=red:connect("192.168.254.128",6379);
if not ok then
ngx.say("failed to connect redis",err);
return;
end
local ip=ngx.var.remote_addr;
local ip_lists=red:get("gray");
if string.find(ip_lists,ip) == nil then
ngx.exec("@prod");
else
ngx.exec("@pre");
end
local ok,err=red:close();
- 重启openresty,连接redis 将 gray 设置为 192.168.254.128 即将该ip加入到名单中 如下:
# ./redis-cli
127.0.0.1:6379> set gray 192.168.254.128
- 这个时候访问
http://192.168.254.128/api
,返回结果是I'm pre env
。 - 再修改 redis 配置:
# ./redis-cli
127.0.0.1:6379> set gray 192.168.254.130
- 再访问
http://192.168.254.128/api
,返回结果是I'm prod env
。