使用 OpenResty 构建 Kubernetes 集群的 API Gateway


OpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,能用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。我们基于 OpenResty 构建了适用于 Kubernetes 集群的 api-gateway,它作为我们 Kubernetes 集群所有用户流量的入口承担了很重要的职责,包括用户认证、请求路由、限速保护、IP 黑/白名单、负载均衡、流量以及日志记录等。下面主要从架构、部分功能以及未来规划几个方面向大家介绍我们在 OpenResty 方面的实践。

架构

1.jpg

我们的 API Gateway 主要由两部分构成,一部分是 controller,负责获取 Kubernetes 集群的状态并写到 Redis,另一部分是 OpenResty 负责反向代理以及负载均衡。

API Gateway Controller

由于缺乏 lua 实现的 kubernetes client 而且重新实现的成本也比较高,所以我们决定使用 Go 来实现一个 controller 把集群的状态同步到 Redis。

controller 主要负责监听集群里以下资源的变化:

  1. 特定 namespace 下的 ConfigMap(我们扩展的 Ingress 资源,下面会提到),controller 会把 ConfigMap 里包含的信息像服务对外暴露的域名、服务的认证方式、API 路径关联的 Service 等信息写到 redis。

  2. Service、Endpoint,每当有 Service 新增或删除的时候,controller 会负责把它的 Endpoints 存到 Redis。另外,对于 ExternalName 类型的 Service,controller 会通过尝试解析它的 DNS name 来获得所有的 Endpoint,但如果 externalName 是一个 IP 的话,CoreDNS 是不会给这个 Service 创建 DNS 记录的(参考这个 issue,用 IP 作为 externalName 的话其实是不符合规范的,虽然可以使用 headless service + endpoint 绕过去,但有点麻烦),controller 也对这种情况做了兼容,直接以这个 IP 跟 Service 里指定的 port(s) 作为 Endpoint。


当 Pod 的健康状态发生变化后,controller 也会把新的 Endpoints 写到 Redis。

API Gateway

集群里的 api-gateway 的 Service 类型是 NodePort,没直接使用 LoadBalancer 是因为我们可能会不断调整阿里云的创建出来的 Load Balancer 即 SLB 的配置,比方说调整规格、监听、证书啥的,担心阿里云的 cloud controller manager 可能会修改我们原来的配置。

用户在访问我们的服务的时候,首先会访问到阿里云的 SLB,SLB 负责做 TLS Termination,然后把 HTTP 报文转发到 Kubernetes 集群中的 api-gateway 监听的 NodePort,api-gateway 根据 host 以及 path 转发到相应的 namespace 下的 service。

部分功能介绍

负载均衡(HTTP/gRPC)

最开始 api-gateway 是直接通过 Service 访问其他的 Pod 的,当时 kube-proxy 使用的是 iptables,虽然 iptables 在四层做了随机的负载均衡,但是为了省去处理每个请求都要建立 TCP 连接的开销,一般都会设置 openresty 启用 keepalive,在这种情况下 openresty 虽然是通过 Service 的 clusterIP 来访问后端服务,但由于 iptables/IPVS 做了 DNAT(目标地址转换),所以其实可以视为 openresty 直接跟后端服务的某一个 Pod 建立了连接,并且在连接没有中断的情况下后续的请求都会发到这个 Pod,这就使得负载均衡的效果不太好。
2.jpg

Openresty 只与服务里的其中一个 Pod 建立了连接

虽然可以通过调整 Openresty 里的 keepalive_requests 参数来缓解这个情况,但显然更好的解决办法是把请求均匀地发到每个 Pod。

为了解决这个问题,我们先是引入了 traefik ingress controller。首先实现了一个服务自动为集群里的每个 Service 创建 Ingress,然后让 api-gateway 通过 traefik 来访问 Service,这样就让 traefik 替我们实现了七层的负载均衡。
3.jpg

通过 traefik 实现七层的负载均衡

上面这个方法虽然可行,但从架构上来看不太优雅,经过一番调研发后现是可以直接通过 openresty 来实现多个 Pod 之间的负载均衡的。于是我们开始着手对 api-gateway 进行重构,主要分成两个部分:
  1. 实现上面提到的 api-gateway-controller 获取每个 Service 的 Endpoints 并存到 Redis
  2. 让 api-gateway 定期从 Redis 里拉取所有的 Endpoints,并且通过 balancer_by_lua_file 指令动态设置 upstream peer


大致做法是在 access_by_lua 里根据 host 跟 uri 找到相应的路由以及 Service:
...
local host = ngx.var.host
local matching_route = routers.find_matching_route(host, ngx.var.uri)
if not matching_route then
utils.exit_abnormally('no matching route: ' .. host , ngx.HTTP_NOT_FOUND)
end

...
local balancer = balancer_services.find_balancer(matching_route.service)
if balancer == nil then
utils.exit_abnormally('cannot find balancer', ngx.HTTP_SERVICE_UNAVAILABLE)
end
ngx.ctx.balancer = balancer
...

然后在 balancer_by_lua 里通过 ngx.balancer 设置要访问的 Endpoint:
...
local picked = ngx.ctx.balancer:balance()
local ok, err = ngx_balancer.set_current_peer(picked)
if not ok then
utils.exit_abnormally('failed to set current peer: ' .. err, ngx.HTTP_SERVICE_UNAVAILABLE)
end
...

经过重构后,我们完全去掉了 Traefik 只用 Openresty 实现了七层的多个 Pod 之间的负载均衡:
4.jpg

而且在性能基本不变的情况下,vCPU 资源占用减少了 ~30 cores(~50%),内存资源占用减少了 7GB(~70%)。PS:Nginx 在资源优化上真的做得太好了。

另外除了 HTTP 负载均衡外,我们还实现了 gRPC 负载均衡。因为我们内部有一些服务开始使用 gRPC 后发现一个问题,由于 gRPC 是基于 HTTP/2 的,而 HTTP/2 会复用同一个 TCP 连接,导致应用的多个 Pod 负载不均衡。已有的解决方法是:
  1. 把 Service 设置为 Headless Service,并通过内置的 dns resolver 每隔 30min 解析一下 Service 的域名来更新 Endpoints。这种方法的缺点是更新不及时,如果应用进行了滚动更新但要在 30min 后才能获取到新的 Endpoints 这在任何时候都是不可接受的。
  2. 使用 kuberesolver。这种方法虽然可以解决 Endpoint 更新不及时的问题,但由于需要让 resolver 可以 watch Endpoint 的变化,那要么给所有用到 gRPC 的 Pod 设置 ServiceAccount,要么给所有 namespace 下的 default ServiceAccount 都赋予 watch Endpoint 的权限,管理跟维护上都有点麻烦。
  3. 使用 ServiceMesh。目前还没有部署。


虽然现有方案都不能满足我们的需求,但好在经过调查,我们发现 nginx 从 1.13.10 版本开始就已经支持反向代理 gRPC 服务了,所以我们就通过 openresty 实现了 gRPC 服务的负载均衡。实现方法是在 gRPC metadata 里自定义一个 header,header 的值为目标服务,api-gateway 通过 balancer_by_lua_file 指令读取这个 header 并动态设置 upstream peer。

在负载均衡策略方面,我们现在只支持 round robin 以及 sticky 两种方式。round robin 即为普通的轮询,sticky 则会根据用户 ID(已登录)或用户 IP(未登录)进行哈希。

Ingress Controller

在 kubernetes 中,Ingress 是用来对外暴露集群里的 Service 一种方式。但原生的 Ingress 资源的表达力实在不够强(社区里已经在考虑 Ingress V2 了),一些较为复杂的需求不能通过 Ingress 体现出来,比方说鉴权方式、超时时间、限速、IP 白名单等。社区里常见的 ingress controller 如 nginx ingress controller、traefik 等采取的解决办法是给 Ingress 资源加上五花八门的 annotations,而我们采取的方法则是定义一种新的 Ingress 资源 Route 来满足我们的需求。出于历史原因,我们没有使用 CRD 而是通过 ConfigMap 来表示这种资源。

目前我们内部是通过 namespace 划分生产环境跟测试环境的,我们希望可以简单地通过切换域名来访问不同 namespace 下的服务。在 Route 里我们可以给对外暴露的域名加上 mark (一个占位符),并给不同的 mark 设置对应的 namespace,这样在访问不同的 mark 对应的域名的时候就能自动转发到相应的 namespace 下的服务。

举个例子,比方说我们设置:
hosts:
- foo{mark}.example.com
envs:
- mark: -prod
namespace: foo
- mark: -test
namespace: test

这样用户在访问 foo-prod.example.com 的时候就会被转发到 foo namespace 下的服务,访问 foo-test.example.com 的时候就会被转发到 test namespace 下的服务。

另外,我们可以设置 API 的认证方式、超时时间、限速策略等属性。这些属性可以作用于三个 level,分别是 Route、Service、Path,这三个 level 的层级逐层递减,低层级上的设置可以覆盖高层级上的设置。以下面的设置为例:
services:
- name: foo-service
port: 8080
access:
auth_type: public
paths:
- access:
  auth_type: login
timeout: 10
uri: /headers
- access:
  rate_limits:
  - burst: 0
    rate: 100
timeout: 5
uri: /

该设置的含义是:
  • foo-service:8080/ (除了 /headers 以外的任意路径)的超时时间是 5s,允许任意用户访问,限速策略是每秒不超过 100 个请求
  • foo-service:8080/headers 的超时时间是 10s,需要登录才能访问,没有限速


除此之外,我们也会自动把集群中的原生的 Ingress 资源转化为我们扩展的 Ingress 资源,从而可以直接对接依赖 Ingress 资源的社区应用。

限速模块

我们并没有打算实现精确的限速,因为如果要实现精确的限速,必须考虑多个 Openresty 容器之间的协调,比如把计数器保存在一个外部的 Redis 中,但是这样会让 redis 读写成为每个请求都必经的一步,在我们看来弊多于利。

限速的目的主要有两个:

  • 保护服务,防止过载。实现方法:
    • 对特定服务限制请求频率(不管来源 IP 或用户)

  • 拦截攻击。实现方法:
    • 全局限制来自同一个 IP 或 用户的请求的频率
    • 对特定服务限制来自同一个 IP 或 用户的请求的频率


我们通过 rate_limit 对象来描述限速策略:
{
rate: <int>,  # req / s,请求频率的限制,最终表现出来的频率会被限制到这个数字上
burst: <int>,  # 瞬时请求频率允许超过 rate 的数量。超过 rate 但小于 rate + burst 的部分会被 delay,超过 rate + burst 的部分会被 reject
selectors: [<str>],  # 选择器,后面有解释。注意在不同 level 上它会被 append 不同的值


selectors 一共支持四种取值:
  • ip:按来源 IP 计数
  • user:按来源 user_id 聚合计数
  • service:按 service 的 endpoint 计数,即 <service_name>:<service_port>
  • path:按 path 计数


rate_limit 对象可以在三个 level 上创建,从高到低依次是:
  • route:最高一层,对于整个 route 生效
  • service:selectors = list(set(selectors + ['service']))
  • path:selectors = list(set(selectors + ['path', 'service']))


Service Proxy

我们内部除了 Kubernetes 集群,还有一些服务部署在了同一个 VPC 内的其他虚拟机上,这些服务有的也需要访问 Kubernetes 里的 Service。所以我们给 api-gateway 添加了 service proxy 的功能,用于在内网暴露集群中的 Service。

实现也很简单,只要在 Openresty 里设置:
upstream service_proxy_balancer {
server 127.0.0.1;
balancer_by_lua_file /path/to/balancer.lua;
...
}

location ~ ^/__(?<up_service>[a-z0-9\\-]+)\\.(?<up_namespace>[a-z0-9\\-]+)\\.(?<up_port>\\d+)__(?<up_uri>.*) {
access_by_lua_file      /path/to/access.lua;
proxy_pass              http://service_proxy_balancer;
...


这样比方说如果在内网里想访问 Kubernetes 集群内的一个 Service,只需要访问 http://my.intranet/__<service-name>.<namespace>.<service-port>__/<path>,api-gateway 就能通过正则提取出目标 Service 的 name、namespace、port 以及访问的路径,然后就能反向代理目标服务了。

未来规划

随着 Service Mesh 发展得如火如荼,我们其实很看中 Service Mesh 在流量控制、流量调度、可观测性等方面的能力,目前我们在调研如何落地 Service Mesh,把限流、熔断、异常重试、服务发现、负载均衡、链路追踪等任务都交给数据平面来实现,让应用包括 api-gateway 本身都能更专注于自身业务逻辑。

以 Istio 为例,其实可以把限流、异常重试、服务发现、负载均衡等功能都下放到 envoy sidecar,api-gateway 本身只要着重关注路由、用户校验这些业务相关的逻辑,反向代理后端服务的时候也可以像一开始那样直接访问 Service 的 DNS name,api-gateway 内部可以变得非常精简。

再比方说目前我们内部进行蓝绿部署或 canary 部署都需要手工执行,如果能结合 Istio 来做的话,无疑是可以把这一过程自动化并且让其他应用无感知。事实上,进行一次 canary 部署其实相当于往 DestinationRule 里新增一个 subset 并调整 VirtualService 里的路由跟权重,同时可以根据 Istio 采集的相关的 metric 判断新版本应用的健康状况决定是否回滚。

另外,现在 api-gateway 是通过轮询 Redis 来更新配置的,为了能及时更新轮询的间隔设得比较短,但大多数情况下都不需要这么频繁地轮询,我们在考虑有没有可能通过借鉴 envoy 的 xDS protocol 让 api-gateway 与 api-gateway-controller 之间建立 gRPC stream,当配置有更新的时候 api-gateway-controller 就立刻推送新的配置。

原文链接:https://zhuanlan.zhihu.com/p/103011379

0 个评论

要回复文章请先登录注册