
当云原生网关遇上图数据库,NebulaGraph 的 APISIX 最佳实践
API 网关介绍
什么是 API 网关
API 网关是位于客户端和服务器之间的“中间人”,用于管理、监控和保护 API。它可以在 API 之前执行一些操作,例如:身份验证、授权、缓存、日志记录、审计、流量控制、安全、防火墙、压缩、解压缩、加密、解密等。
API 网关可以工作在 TCP/IP 4 层和 OSI 7 层。跑在 7 层的 API 网关可以使用多种协议,例如:HTTP、HTTPS、WebSocket、gRPC、MQTT 等。在这些应用层协议中做一些操作,比如,请求的重写、转发、合并、重试、缓存、限流、熔断、降级、鉴权、监控、日志、审计等等。
这里举例一下借助 API 网关可以做的具体的事:
- 在网关层增加认证层,比如:JWT 认证、OAuth2 认证、OpenID 认证等等,这样不需要在每个服务中都做具体的认证集成工作,进而节省许多开发成本。
- 借助网关给跳板机 SSH 流量增加无需客户端修改的复杂认证,比如:跳转任何客户端的 SSH 登录,给出一个网址或者输入框,引导登陆者通过网页的 SSO 认证(包含多因素认证),再通过网关转发到 SSH 服务。
- 甚至在网关层做 Serverless 数据库!TiDB 社区的同学们就在做这个事儿,他们从普通的 MySQL 客户端的登录请求中解析能推断出转到需要的 TiDB 示例的信息,并且在需要 cold start 唤醒实例的时候把连接保持住,可以参考这篇文章:TiDB Gateway (https://disksing.com/tidb-gateway/)。
- 如果你特别惨在维护屎山项目,不得不针对旧版本的应用程序对新版本的服务端进行兼容,这时候 API 网关也可以通过一些请求重写,把旧版本的请求转换成新版本的请求。
只要脑洞大,理论上 API 网关可以做很多事。但显然不是所有的事情都是适合在这一层去做的,通常那些比较通用的事情才适合在这一层去做,上面我只是给出一些典型和极端的具体例子。
Apache APISIX
API 网关是从 LB、Reverse Proxy(https://en.wikipedia.org/wiki/Reverse_proxy) 项目演进过来的。随着云原生的兴起,API 网关也逐渐成为了云原生的一部分,流行的开源网关有:
- Nginx:https://confluence.nebula-graph.io/pages/github.com/Nginx/Nginx
- Apache APISIX:https://confluence.nebula-graph.io/pages/github.com/apache/apisix
- Kong:https://confluence.nebula-graph.io/pages/github.com/Kong/kong
- Lura:https://confluence.nebula-graph.io/pages/github.com/luraproject/lura
- OpenResty:https://confluence.nebula-graph.io/pages/github.com/openresty/openresty
- Tyk:https://confluence.nebula-graph.io/pages/github.com/TykTechnologies/tyk
- Traefik:https://confluence.nebula-graph.io/pages/github.com/traefik/traefik
- Istio:https://confluence.nebula-graph.io/pages/github.com/istio/istio
- Envoy:https://confluence.nebula-graph.io/pages/github.com/envoyproxy/envoy
而且其中很多都是基于 Nginx/OpenResty 的下游项目。这里就以 Apache APISIX 为例,介绍一下 NebulaGraph 借助 API 网关的几个实践。
NebulaGraph 介绍
NebulaGraph 是一个开源的分布式图数据库,它的特点是:
- 高性能:可达到每秒百万级的读写,具有极高的扩展性,在千亿点、万亿边的数据规模下支持毫秒级的查询。
- 易扩展:分布式的架构可在多台机器上扩展。每台机器上可以运行多个服务进程,它的查询层是无状态的计算存储分离架构,可以容易地引入不同配置、不同类型的计算层,实现同一集群上 TP、AP、图计算等不同负载的混合查询。
- 易使用:类 SQL 的原生查询语言,易于学习和使用,同时支持 openCypher。
- 丰富生态:NebulaGraph 的生态系统正在不断壮大,目前已经有了多个客户端,包括 Java、Python、Go、C++、JavaScript、Spark、Flink 等,同时也有了多个可视化工具,包括 NebulaGraph Studio、NebulaGraph Dashboard、NebulaGraph Explorer 等。
本文讨论的问题
本文给出了基于 NebulaGraph 集群应用中涉及到 API 网关的几个场景。
- 查询接口的负载均衡
- 底层存储接口的暴露
- 传输层的加密
查询接口负载均衡
首先是图数据库查询接口 graphd 的负载均衡与高可用的问题。
NebulaGraph 内核由三种服务组成:graphd、metad 和 storaged:
所以,在默认情况下,集群只会暴露 graphd 的接口,提供给客户端连接,执行 nGQL 的查询。其中,graphd 是无状态的,这意味着可以在多个 graphd 之间做负载均衡。这里,我们有两种方法:基于客户端的(Client-Side LB)与基于代理的。
客户端的负载均衡
客户端的负载均衡,就是在客户端,也就是应用程序中,实现负载均衡的逻辑。NebulaGraph 的各个语言的客户端里边已经内置了轮询(Round-Robin)负载均衡,我们只需要在客户端配置多个 graphd 的地址就可以了。比如,我们在创建连接池的时候,指定了两个不同的 graphd 的地址(对应不同进程实例),下面以 Python 代码为例:
在取得连接的时候,就会从连接池中随机取得一个连接:
这种客户端负载均衡的问题在于配置、实现细节与应用代码耦合在一起,如果需要修改负载均衡的策略,就要修改应用代码,这样就会增加应用的复杂度。
代理的负载均衡
基于代理的负载均衡,就是在应用程序之前,增加一个代理层,来实现负载均衡的逻辑。这样,应用程序就不需要关心负载均衡的问题了。在 K8s 里的话,我们可以使用 K8s 的 Service 来实现这个代理层。
这是一个在 Minikube 中为 NebulaGraph 集群中 graphd 创建的 Service:
创建后,我们就可以通过它暴露的单独端口来访问 NebulaGraph 集群中的 graphd 了:
可以看到,在连接层面上来看,客户端只知道代理的地址,而不知道 NebulaGraph 集群中的 graphd 的地址,这样就实现了客户端与 NebulaGraph 集群中的 graphd 的解耦。
然而,当我们在 Connection 之上创建 Session 的时候,就能看到实际上客户端的不同请求是落在了不同的 graphd 上的:
底层存储接口的暴露
在 NebulaGraph 中,可以通过 StorageClient 来访问底层的存储接口,这个接口可以用来做一些分析型、数据全扫描计算的工作。
然而,存储层的分布式服务实例不像 graphd 那样,它们是有状态的。这其实与 K8s 或者 Docker Compose 的部署模型是相违背的。如果访问的应用 storaged 客户端在集群外部,我们需要在 NebulaGraph 集群中的每一个存储实例上都部署一个代理 Service。这非常不方便,有时候还是一种浪费。
此外,由于 NebulaGraph 内部服务发现机制和 storaged 客户端的实现机制决定,每一个 storaged 服务实体都是由其内部的 host:port 唯一确定和寻址的,这给我们中间的代理工作也带来了一些麻烦。
总结来看,我们的需求是:
- 能够从集群外部访问 NebulaGraph 的存储层每一个实例
- 每一个实例的访问地址(host:port)和内部的地址是完全一致的
为了实现这个需求,我之前的做法是为每一个实例单独部署一个 graphd 代理(消耗一个地址,保证端口不变),再在外部手动搭一个 Nginx 作为代理,配合 DNS 把内部的地址解析 Nginx 上,然后通过域名找到上游(每一个单独的 graphd 代理)。本文的延伸阅读 1、2 中给出了相关的实验步骤。
最近,我找到了一个相对优雅的可维护的方式:
- 在 NebulaGraph 集群同一个命名空间下引入一个 APISIX 网关;
- 利用 APISIX 中的 Nginx TCP 代理的封装 stream-proxy 来暴露 storaged 的接口;
- 为了最终只利用一个集群的出口(Service,我们利用其支持的 TLSv1.3 中的 extend host name 字段:SNI 来路由上游),做到用不同域名的 TCP over TLS 指向后端的不同 storaged
- 只需要 Storage 客户端能支持 TLSv1.3(发送 SNI),并且能解析所有 storaged 的地址到 APISIX 的 Service 上即可;
示例图:
这样做的好处是:
- 在 APISIX 中比较优雅地维护代理的配置,并且可以用到 APISIX 现代化的流量管理能力;
- 不需要为每一个 storaged 单独创建 Service,只需要一个 Service、集群地址就可以了;
- 为流量增加了 TLSv1.3 的加密,提高了安全性。同时,没有给 NebulaGraph 集群内部的南北流量带来的性能损耗;
在本文的结尾,给出了实验过程,包含了本文提到的所有要点和细节。
传输层的加密
我们在前一个问题中提及到了,在 APISIX 网关中 terminate TLSv1.3 的连接,借助 SNI 信息路由 storaged 的方法。其实,单独将 graphd 接口的 TLS 交给网关来做,好处也是非常明显的:
- 证书管理在统一的网关控制面做,更加方便;
- 证书运维无 NebulaGraph 集群配置侵入(NebulaGraph 原生支持 TLS 加密,但是加密之后带来了集群内部通信的开销,而且配置和集群其他层面配置在一起,证书更新涉及进程重启,不够灵活);
具体的方法在后边实操中也是有体现的。
实操:利用 APISIX 的 stream-proxy 暴露 storaged 的接口
实验环境:Minikube
本实验在本地的 Minikube 上做。首先,启动一个 Minikube。因为 APISIX 内部的 etcd 需要用到 storageclass,我们带上穷人版的 storageclass 插件。同时,为了在 K8s 外部访问 storaged 的时候用和内部相同的域名和端口,将把 node-port 允许的端口扩充到小于 9779 的范围。
实验环境:NebulaGraph on K8s
这里,我们使用 Nebula Operator 来部署 NebulaGraph 集群,具体的部署方法可以参考 Nebula Operator 文档:https://docs.nebula-graph.com.cn/3.3.0/nebula-operator/1.introduction-to-nebula-operator/。
咱们做实验,就偷个懒,用我写的 Nebula-Operator-KinD 来一键部署:
实验环境:APISIX on K8s
首先,是安装。在 Helm 参数中指定打开 stream-proxy 的开关:
因为截止到现在,APISIX 的 Helm Chart 之中并没有提供 stream-proxy TCP 的监听端口的 TLS 支持的配置格式,见:https://github.com/apache/apisix-helm-chart/issues/348。我们需要手动更改 APISIX 的 ConfigMap,把 stream-proxy 的 TLS 配置加上:
我们编辑把 stream_proxy.tcp 改写成这样:
这里我们需要重建 APISIX Pod,因为 APISIX 的 stream-proxy 的 TLS 配置是在启动的时候加载的,所以我们需要重建 APISIX Pod:
开始实验
这个实验的目标是把 NebulaGraph 的 storaged 的接口暴露出来,让外部的客户端可以访问到,而暴露的方式如图:
我们已经有了所有的框架,我们要往里填箭头和圆圈就行。
配置 APISIX 的 stream-proxy
参考 APISIX 文档:https://apisix.apache.org/docs/apisix/stream-proxy/#accept-tls-over-tcp-connection。
我们用 APISIX 的 API 来配置 stream-proxy:
这里需要注意,目前,APISIX 的 stream-proxy 上游节点不支持域名解析。是受限于上游的 lua 库,详见 issue:https://github.com/apache/apisix/issues/8334。理想情况下,这里应该给出每一个 storaged 的 SNI 相同的地址作为 upstream.nodes。像这样:
配置 APISIX 中 storaged 地址的 TLS 证书
在生产环境下,我们应该以云原生的方式去管理自签或者公共信任的证书。这里,我们就手动利用 MKCert(https://mkcert.org/) 工具来做这件事儿。
安装 MKCert:
签发证书:
利用 APISIX Dashboard 将证书导入到 APISIX 之中。单独开一个终端,运行:
浏览器访问:http://10.1.1.168:8080/ssl/list,账号密码都是 admin。点击 Create 按钮,将刚刚生成的证书导入到 APISIX 之中。
增加 APISIX 的 NodePort Service
创建一个 NodePort Service,用于暴露 APISIX 的 9779 端口。这样,我们就可以通过外部的 IP 地址访问到 APISIX 了。
因为前边 Minikube 中我们配置了端口的范围覆盖到了 9779,所以我们可以看到,这个 NodePort Service 的端口在宿主机上也可以从 Minikube ip 的同一个端口访问到:
当然,Minikube 假设我们的服务都是 HTTP 的,给出的 URL 是 HTTP:// 的。不用理会它,我们心里知道它是 TCP over TLS 就好了。
配置 K8s 外部 DNS
这里需要配置一个 DNS 服务,让我们可以通过 nebula-storaged-0.nebula-storaged-headless.default.svc.cluster.local 等三个域名通过 Minikube 的 NodePort Service 访问到 NebulaGraph 的 storaged 服务。
获得 Minikube 的 IP 地址:
配置 /etc/hosts
验证 NebulaGraph Storage Client 可以从所有的节点中获取到数据
这里,为了方便,我们用到 Python 的客户端。
由于在写本文的时候,NebulaGraph Python 客户端的 StorageClient 尚未支持 TLS,对它支持的 PR 刚好是我为了本实验写的:https://github.com/vesoft-inc/nebula-python/pull/239。
所以,这里从个人分支安装这个客户端:
我们在 iPython 中交互式验证:
结果✅:
总结
- NebulaGraph 查询接口的负载均衡可以借助 K8s Service来做;
- NebulaGraph 底层存储接口的暴露在 K8s 中可以利用 APISIX Stream Proxy 和 SNI 来优雅实现;
- 利用 API 网关对出口传输层的加密是一个很好的选择,相较于用 NebulaGraph 原生的 TLS 的方式。
本文转载自公众号:Nebula Graph Community
