从Nginx加虚拟机部署后端服务转向使用Docker部署时,Nginx配置转发可能面临一个问题:“每次运行的Docker容器的IP不固定,应如何配置Upstream让流量正确转发”。低成本的方式一般是使用
docker run -p ip:hostPort:containerPort
将容器端口映射到虚拟机,这样Upstream的配置几乎不变。
本文这次主要介绍另一种方式:“OpenResty基于服务发现的动态Upstream”,相比较端口映射方式在可靠性与灵活性上有所提升。
交互图
应用节点通过向Redis publish自己alive的消息,OpenResty则subscribe Redis维护应用以及其alive的节点信息,收到请求时动态转发到特定应用存活节点上。
应用即服务,后文并不明显区分两者
服务发现概述
存在中心网关场景下的服务发现在笔者的眼中并不复杂,但在阅读一些技术文章中的方案时,觉得被描述的过于高深和复杂化,从笔者角度来说,基于配置中心的服务发现内容主要有如下四点:
- 配置中心
- 服务上线,上报配置中心
- 服务离线,上报配置中心
- 健康检查
方案总结描述:“服务自身将状态(name,ip,port…)维护在配置中心(上线添加,离线删除),调用者读取配置中心确认服务调用路径,同时辅助以健康检查避免服务异常时未能更新到配置中心”。
至于配置中心的实现是什么,是否高可用,这些内容并不是理解服务发现所需的必要知识,前文提到的高深和复杂往往是因为展开分布式一致性协议来讨论配置中心所导致。
统一网关调用 VS 服务间直接调用
按照前文所描述的服务发现,方案实现后假设配置中心信息维护准确,那具体是谁作为调用者借助配置中心调用各服务,可以有两个选择:
- 服务直接调用
- 统一网关调用,应用调网关,网关根据配置中心信息正确调用到服务
笔者根据个人经验,得到的结论是:如果网关能支持服务发现,通过网关更优,因为服务间调用不利于全局管理和共有内容处理,这仅是个人经验体会而非标准答案。
借助Redis实现服务发现
大致理解服务发现的方案后,笔者在实现OpenResty动态Upstream时选择借助Redis,但不是按照常见方式将配置维护到Redis,而是借助Redis pubsub将配置信息动态维护在OpenResty中。
具体做法是,服务按照一定间隔持续向Redis指定channel publish心跳消息,同时OpenResty subscribe该channel解析收到的消息,维护服务存活节点信息到特定
lua_shared_dict
。服务离线时同样publish一条离线消息用于通知OpenResty节点状态变化。
// alive msg
{service}:{ip}:{port}
// offline msg
!{service}:{ip}:{port}
// lua_shared_dict
{
serviceA = {'ip1:portA', 'ip2:portA'},
serviceB = {'ip3:portB', 'ip4:portB'},
serviceX = {'ipX:portX'},
}
通过服务心跳消息与离线消息通常可保持配置字典信息正确,但还需要一个机制用于防止服务异常退出没有发送离线消息,笔者选择借助
lua_shared_dict
中key的有效期特性达到目的,思路是把接收的alive消息转存到中间字典并设置TTL(Time To Live)。
{
serviceA:ip1:portA = '',
serviceA:ip2:portA = '',
serviceB:ip3:portB = '',
serviceB:ip4:portB = '',
serviceX:ipX:portX = '',
}
接着将中间字典转换为便于查找的信息状态字典,此时如果TTL时间内没有收到节点心跳,自然会被剔除,也即OpenResty不会转发流量到该服务节点。
动态Upstream实现
OpenResty提供了一个指令:
balancer_by_lua_*
,当请求转发给Upstream时,通过该指令借助
ngx.balancer
,可动态设置流量到指定
ip:port
,这为开发者提供了编码实现动态Upstream的能力。
# 静态upstream:
upstream svc {
server 127.0.0.1:8080;
}
location / {
proxy_pass http://svc;
}
# 动态upstream
upstream dynamic-svc {
server {通过执行Lua代码,返回ip:host};
}
location / {
proxy_pass http://dynamic-svc;
}
剩下的是按照前文思路,进行代码编写与配置以达到目的,具体代码可以参考后一部分内容。
代码
- balancer.lua,调用dyupcl以达到动态upstream目的,包含超时和重试等个性化设置
- dyupcl.lua,根据Redis消息进行配置维护,提供pick server方法供外部使用
- init_worker.lua,初始化,提供dyupcl所需的配置信息,如Redis连接、接入的service列表、异常剔除超时时间等
- nginx.conf,申明共享字典以及配置使用上述的Lua插件
其他补充
Restart与Reload
完全使用上述OpenResty服务发现功能后,Restart与Reload的预期行为会如何?
- Restart
当使用 systemctl restart openresty
时,OpenResty重新启动,服务发现的配置信息会短暂丢失,请求出现“no available server”,由于应用心跳消息并未停止,经过一定时间后(取决于服务心跳间隔时间)请求将被正确转发
- Reload
当使用 systemctl reload openresty
时,lua_shared_dict的内容不会丢失,即服务发现的配置信息不会丢失,外部请求不会表现任何异常
Upstream重试
当一个服务节点异常且没有及时通知OpenResty处理离线时,请求可能被转发到该异常节点,从而出现错误。此种情况下可有选择性的使用
balancer.set_more_tries(cnt)
进行重试,可以提高服务可用性。但关键业务应用接口如果没有幂等性,这种重试机制需稍加留意。
负载均衡
目前
dyupcl.pick()
的规则比较简单,只是轮询分配转发,由于动态Upstream负责挑选每个请求的实际处理者,此处可以扩展实现一些更多样的负载均衡策略,让流量转发更智能。
这样做服务发现是否可靠
绝大部分场景和业务,笔者认为问题不大,也在线上环境实践了约半年时间。不过目前此类能力更多的被集成到云原生生态中,且是一种趋势,因此开发会更少有机会处理此类问题,转而由DevOps承担此类职责。
笔者所在项目团队近期已经由DevOps将应用迁移到K8S内,利用云原生能力实现服务发现。但实际体验发现服务滚动部署过程不平滑,容易出现程序收到
SIGTERM
退出过程中,继续有外部流量进入的情况,理应可以避免,待研究。
参考资料
- balancer_by_lua_*
- ngx.balancer
- lua_shared_dict
博客原文