本文是“将 NGINX 开源版和 NGINX Plus 部署为 API 网关”系列博文的第三篇。
- 第 1 部分详细说明了 NGINX 开源版和 NGINX Plus 作为基于 HTTP 的 RESTful API 的 API 网关的一些用例。
- 第 2 部分对这些用例进行了扩展,探讨了一系列可用于保护生产环境中后端 API 服务的安全措施。
- 本文解释了如何将 NGINX 开源版和 NGINX Plus 部署为 gRPC 服务的 API 网关。文章最初发布于 2018 年,随着 NGINX Plus Release 23 中引入了对原生 gRPC 健康检查协议的支持,特此更新,以方便大家充分利用 NGINX 开源版和 NGINX Plus。更新详情请参阅下文“实施健康检查”一节。
注:除非另有说明,否则本文中的所有信息都适用于 NGINX Plus 和 NGINX 开源版。为了便于阅读,当讨论内容同时适用于两个版本时,下文将它们统称为“NGINX”。
近年来,介绍微服务应用架构的概念和优势的文章非常多,其中以 NGINX 博文居首。微服务应用的核心是 HTTP API,本系列博文的前两篇文章使用了一个假设的 REST API 来说明 NGINX 如何处理此类应用。
尽管基于 JSON 消息格式的 REST API 在现代应用中非常流行,但它并不是所有场景或所有企业的理想之选。最常见的挑战是:
- 文档标准 —— 如果没有良好的开发者制度或强制性的文档要求,最后很容易产生大量缺乏准确定义的 REST API。Open API 规范 已成为 REST API 的通用接口描述语言,但其使用却不是强制性的,需要开发组织内部的有力治理。
- 事件和长连接 —— REST API 以及它们使用 HTTP 传输,几乎决定了所有 API 调用都是请求 – 响应模式。当客户端应用需要服务器反馈消息时,使用 HTTP 长轮询和 WebSocket 等解决方案会有所帮助,但使用此类解决方案最终都需要构建一个单独、相邻的 API。
- 复杂事务 —— REST API 是围绕唯一资源的概念构建的,每个资源都由一个 URI 表示。当应用需要调用多个资源更新时,要么需要多个 API 调用(效率低下),要么必须在后端实现复杂的事务(与 REST 的核心原则相悖)。
近年来,gRPC 已发展成为构建分布式应用,尤其是微服务应用的替代方法。gRPC 最初由 Google 开发,并于 2015 年开源,现已成为云原生计算基金会的一个项目。值得注意的是,gRPC 使用 HTTP/2 作为传输机制,并利用其二进制数据格式和多路复用流功能。
gRPC 的主要优势包括:
- 紧耦合的接口定义语言(协议缓冲区)
- 对流数据的原生支持(双向)
- 高效的二进制数据格式
- 自动生成多语言的代码,支持真正的多语言开发环境,且不会产生互操作性问题
定义 gRPC 网关
本系列博文的前两篇描述了如何通过单个入口点(例如 https://api.example.com)交付多个 API。当 NGINX 部署为 gRPC 网关时,gRPC 流量的默认行为和特征促使 NGINX 也要采用这种方法。虽然 NGINX 可以在同一主机名和端口上共享 HTTP 和 gRPC 流量,但最好还是将它们分开,主要有以下原因有:
- REST 和 gRPC 应用的 API 客户端需要不同格式的错误响应
- REST 和 gRPC 访问日志的相关字段有所不同
- 因为 gRPC 不涉及旧版 Web 浏览器,因此它可以实施更严格的 TLS 策略
为了实现这种分离,我们需要修改 gRPC 网关主配置文件 grpc_gateway.conf 的 server{}
模块,它位于 /etc/nginx/conf.d 目录。
我们首先定义 gRPC 流量访问日志中的条目格式(第 1-4 行)。在本例中,我们使用 JSON 格式从每个请求中捕获最相关的数据。请注意,HTTP method 不包括在内,因为所有 gRPC 请求都使用 POST
。我们还记录了 gRPC 状态代码和 HTTP 状态代码。然而,gRPC 状态代码可通过不同的方式生成。在正常情况下,grpc-status
从后端返回 HTTP/2 消息头,但在一些错误情况下,它可能会被后端或 NGINX 自己返回 HTTP/2 消息头。为了简化访问日志,我们使用 map
块(第 6-9 行)来评估新变量 $grpc_status
并从产生该变量的地方获取 gRPC 状态。
此配置包含两个监听
指令(第 12 行和第 13 行),所以我们可以测试明文(端口 50051)和受 TLS 保护的(端口 443)流量。http2
参数将 NGINX 配置为接受 HTTP/2 连接 —— 请注意,这与 ssl
参数无关。另请注意,端口 50051 是 gRPC 的常规明文端口,但不推荐在生产环境中使用。
TLS 配置是常规配置,但 ssl_protocols
指令(第 23 行)除外,该指令将 TLS 1.2 指定为最弱的可接受协议。HTTP/2 规范要求使用 TLS 1.2(或更高版本),以保证所有客户端都支持对 TLS 的 SNI (Server Name Indication) 扩展。这意味着 gRPC 网关可以与其他 server{}
模块中定义的虚拟服务器共享端口 443。
运行示例 gRPC 服务
为了解 NGINX 的 gRPC 功能,我们使用了一个简单的测试环境,该环境代表了 gRPC 网关的关键组件,并部署了多个 gRPC 服务。我们使用官方 gRPC 指南中的两个示例应用: helloworld (用 Go 编写)和 RouteGuide(用 Python 编写)。RouteGuide 应用特别有用,因为它包含了四种 gRPC 服务方法:
- 简单 RPC(单一请求 – 响应)
- 响应流 RPC
- 请求流 RPC
- 双向流 RPC
所有 gRPC 服务都作为 Docker 容器安装在我们的 NGINX 主机上。有关构建该测试环境的完整说明,请参阅附录。
我们配置 NGINX 以了解 RouteGuide 和 helloworld service,以及可用容器的地址。
我们为每个 gRPC 服务添加一个 upstream
模块(第 40-45 和 47-51 行),并使用运行 gRPC 服务器代码的各个容器的地址填充它们。
路由 gRPC 请求
通过 NGINX 监听 gRPC 的常规明文端口 (50051) ,我们将路由信息添加到配置中,以便客户端请求能够到达正确的后端 service 。但首先我们需要了解 gRPC method 调用如何表示为 HTTP/2 请求。下图为 RouteGuide service 的 route_guide.proto 文件的缩略版,说明了 package、service 和 RPC method 如何形成 URI,如 NGINX 所见。
因此,HTTP/2 请求中携带的信息只需匹配包名(此处为 routeguide
或 helloworld
)即可用于路由。
第一个 location
模块(第 26 行),不包含任何修饰符,定义了一个前缀匹配,以便 /routeguide.
匹配该包对应的 .proto 文件中定义的所有 service 和 RPC method。因此,grpc_pass
指令(第 27 行)将来自 RouteGuide 客户端的所有请求传递给上游 group routeguide_service。该配置(以及第 29 行和第 30 行的 helloworld 服务的并行配置)提供了 gRPC 包与其后端 service 之间的简单映射。
请注意,grpc_pass
指令的参数以 grpc://
方式请求,该请求方式使用明文 gRPC 连接代理请求。如果后端配置了 TLS,我们可以使用 grpcs://
通过端到端加密来保护 gRPC 连接。
运行 RouteGuide 客户端后,我们可以通过查看日志文件来确认路由行为。此处,我们看到 RouteChat RPC method 被路由到在端口 10002 上运行的容器。
$ python route_guide_client.py
...
$ tail -1 /var/log/nginx/grpc_log.json | jq
{
"timestamp": "2021-01-20T12:17:56+01:00",
"client": "127.0.0.1",
"uri": "/routeguide.RouteGuide/RouteChat",
"http-status": 200,
"grpc-status": 0,
"upstream": "127.0.0.1:10002",
"rx-bytes": 161,
"tx-bytes": 212
}
精确路由
如上所示,将多个 gRPC 服务简单、高效的路由到不同后端,只需要少数几行配置。然而,生产环境中的路由要求可能更加复杂,需要基于 URI 中的其他元素(gRPC 服务甚至单个 RPC method)进行路由。
以下配置片段扩展了前面的示例,以便将双向流式 RPC method RouteChat
路由到同一个后端,而将其他所有 RouteGuide
方法路由到不同的后端。
第二个 location
指令(第 7 行)使用 “=
”(等号)来表示这是 RouteChat
RPC method 的 URI 上的精确匹配。精确匹配在前缀匹配之前进行处理,这意味着 RouteChat
URI 不会考虑其他 location
块。
响应错误
gRPC 错误与传统 HTTP 流量的错误有些不同。客户端期望错误条件表示为 gRPC 响应,这使得当 NGINX 配置为 gRPC 网关时,默认的 NGINX 错误页面集(HTML 格式)将不适合使用。我们的解决方法是为 gRPC 客户端指定一组自定义的错误响应。
完整的 gRPC 错误响应集是一个相对较长且大部分是静态响应的配置,因此我们将它们保存在一个单独的文件 errors.grpc_conf 中,并使用 include
指令(第 34 行)引用它们。与 HTTP/REST 客户端不同,gRPC 客户端应用不需要处理大量的 HTTP 状态代码。gRPC 文档指定了 NGINX 等中间代理必须如何将 HTTP 错误代码转换为 gRPC 状态代码,以便客户端始终能够接收到合适的响应。我们使用 error_page
指令来执行这个映射。
每个标准 HTTP 状态代码都使用 @
前缀传递到指定 location,这样就可以生成符合 gRPC 要求的响应。例如,HTTP 404
响应在内部被重定向到 @grpc_unimplemented
location,该 location 文件定义如下:
@grpc_unimplemented
命名 location 仅可用于内部 NGINX 处理 —— 由于没有可路由的 URI,客户端无法直接请求该 location。在 location 中,我们填充强制性 gRPC 标头并使用 HTTP 状态代码 204
(No
Content
) 发送它们(不包含响应正文),从而构造 gRPC 响应。
我们可以使用 curl(1)
命令模拟一个行为不端的 gRPC 客户端去请求一个不存在的 gRPC method。但是请注意,由于协议缓冲区使用二进制数据格式,curl
通常不适合作为 gRPC 测试客户端。要在命令行上测试 gRPC,可考虑使用 grpc_cli
。
$ curl -i --http2 -H "Content-Type: application/grpc" -H "TE: trailers" -X POST https://grpc.example.com/does.Not/Exist
HTTP/2 204
server: nginx/1.19.5
date: Wed, 20 Jan 2021 15:03:41 GMT
grpc-status: 12
grpc-message: unimplemented
上面引用的 grpc_errors.conf 文件还包含 NGINX 可能生成的其他错误响应的 HTTP 到 gRPC 状态代码映射,例如超时和客户端证书错误。
使用 gRPC 元数据验证客户端
gRPC 元数据允许客户端在 RPC method 调用的同时发送附加信息,而无需将这些数据作为协议缓冲区规范文件(.proto 文件)的一部分。元数据是一个简单的键值对(key-value)列表,每个键值对都作为单独的 HTTP/2 标头传输。因此,NGINX 访问元数据非常容易。
在元数据的众多用例中,客户端身份验证对 gRPC API 网关来说是最常见的。以下配置片段显示了 NGINX Plus 如何使用 gRPC 元数据执行 JWT 身份验证(JWT 身份验证是 NGINX Plus 的独有功能)。在此示例中,JWT 在 auth-token
元数据中发送。
对 NGINX Plus 来说,每个 HTTP 请求标头都可作为一个名为 $http_header
的变量来使用。标头名称中的连字符 (-
) 转换为变量名称中的下划线 ( _
),因此 JWT 可用作 $http_auth_token
(第 2 行)。
如果 API 密钥用于身份验证(可能是现有的 HTTP/REST API),那么这些密钥也可以在 gRPC 元数据中携带,并由 NGINX 验证。本博客系列的第 1 部分提供了 API 密钥身份验证的配置。
实施健康检查
当对多个后台服务器进行负载均衡时,一定要避免将请求发送到已关闭或不可用的后台服务器。借助 NGINX Plus,我们可以使用主动健康检查主动向后台服务器发送带外请求,并在它们未按预期响应健康检查时将其从负载均衡轮换中移除。通过这种方式,我们可以确保客户端请求永远不会被传输到停止服务的后台服务器。
以下配置片段为 RouteGuide 和 helloworld gRPCservice 启用了主动健康检查;为了突出显示相关配置,该片段省略了一些指令,这些指令包含在前面几节中使用的 grpc_gateway.conf 文件中。
对于每个路由,我们现在还指定 health_check
指令(第 17 和 21 行)。正如 type=grpc
参数所指定的,NGINX Plus 使用 gRPC 健康检查协议向上游 group 中的每个服务器发送健康检查。但是,我们简单的 gRPC 服务没有实现 gRPC 健康检查协议,因此我们希望它们使用表示“unimplemented”(grpc_status=12
) 的状态代码进行响应。当它们使用这种状态代码进行响应时,就足以表明我们正在与一个活动的 gRPC 服务进行通信。
有了这个配置,我们可以关闭任何后端容器,且 gRPC 客户端不会出现延迟或超时。主动健康检查是 NGINX Plus 的独有功能;有关 gRPC 健康检查的更多信息,请阅读我们的博客。
应用速率限制和其他 API 网关控制
grpc_gateway.conf 中的示例配置适合生产环境使用,其中对 TLS 进行了一些小的修改。基于package、 service 或 RPC method 路由 gRPC 请求的能力表明现有的 NGINX 功能可以以HTTP/REST API 或常规 Web 流量完全相同的方式应用于 gRPC 流量。在每种情况下,相关的 location
模块都可以通过进一步的配置(例如速率限制或带宽控制)进行扩展。
总结
在关于将 NGINX 开源版和 NGINX Plus 部署为 API 网关系列博文的第三篇也是最后一篇博文中,我们重点介绍了将 gRPC 作为构建微服务应用的云原生技术。我们展示了 NGINX 如何能够像交付 HTTP/REST API 一样有效地交付 gRPC 应用,以及如何通过 NGINX 作为多用途 API 网关发布这两种 API。
有关本文使用的测试环境的说明位于下面的附录中,您可以从我们的 GitHub Gist 存储库中下载所有文件。
查看本系列博文的其他文章:
如欲试用作为 API 网关 的 NGINX Plus ,请立即下载 30 天免费试用版 ,或与我们联系以讨论您的用例。在试用期间,您可以使用位于我们的 GitHub Gist 存储库的完整配置文件集。
附录:设置测试环境
以下说明将测试环境安装在一个虚拟机上,方便隔离和重复使用。当然也如果有条件也可以安装在物理服务器上。
为了简化测试环境,我们使用 Docker 容器来运行 gRPC 服务。这么做的的好处是我们不需要在测试环境中使用多个主机,但仍然可以像在生产环境中一样,让 NGINX 通过网络调用建立代理连接。
Docker 还支持我们在不同的端口上运行每个 gRPC 服务的多个实例,而无需修改代码。每个 gRPC 服务监听容器内的端口 50051,该端口映射到虚拟机上唯一的 localhost 端口。这反过来释放了端口 50051,NGINX 可以将其用作监听端口。因此,当测试客户端使用其预配置的端口 50051 连接时,它们会连接到 NGINX。
安装 NGINX 开源版或 NGINX Plus
-
根据 NGINX Plus 管理员指南中的说明安装 NGINX 开源版或 NGINX Plus。
-
将以下文件从 GitHub Gist 存储库复制到 /etc/nginx/conf.d:
- grpc_gateway.conf
- errors.grpc_conf
注意:如果未使用 TLS,则注释掉 grpc_gateway.conf 中的
ssl_*
指令。 -
3.启动 NGINX 开源版或 NGINX Plus。
$ sudo nginx
安装 Docker
对于 Debian 和 Ubuntu,运行:
$ sudo apt-get install docker.io
对于 CentOS、RHEL 和 Oracle Linux,运行:
$ sudo yum install docker
安装 RouteGuide 服务容器
-
通过以下 Dockerfile 为 RouteGuide 容器构建 Docker 镜像。
您可以在构建之前将 Dockerfile 复制到本地子目录,也可以将 Dockerfile 的 Gist 的 URL 指定为
docker
build
命令的参数:$ sudo docker build -t routeguide https://gist.githubusercontent.com/nginx-gists/87ed942d4ee9f7e7ebb2ccf757ed90be/raw/ce090f92f3bbcb5a94bbf8ded4d597cd47b43cbe/routeguide.Dockerfile
下载和构建镜像可能需要几分钟时间。出现消息
Successfully
built
和一个十六进制字符串(image ID)即表示构建完成。 -
确认镜像是通过运行
docker
images
构建的。$ sudo docker images REPOSITORY TAG IMAGE ID CREATED SIZE routeguide latest 63058a1cf8ca 1 minute ago 1.31 GB python latest 825141134528 9 days ago 923 MB
-
启动 RouteGuide 容器。
$ sudo docker run --name rg1 -p 10001:50051 -d routeguide $ sudo docker run --name rg2 -p 10002:50051 -d routeguide $ sudo docker run --name rg3 -p 10003:50051 -d routeguide
每个命令执行成功时,都会出现一个长的十六进制字符串,代表正在运行的容器。
-
运行
docker
ps
,检查三个容器是否都已启动。(为了便于阅读,我们将示例输出拆分成了多行。)$ sudo docker ps CONTAINER ID IMAGE COMMAND STATUS ... d0cdaaeddf0f routeguide "python route_g..." Up 2 seconds ... c04996ca3469 routeguide "python route_g..." Up 9 seconds ... 2170ddb62898 routeguide "python route_g..." Up 1 minute ... ... PORTS NAMES ... 0.0.0.0:10003->50051/tcp rg3 ... 0.0.0.0:10002->50051/tcp rg2 ... 0.0.0.0:10001->50051/tcp rg1
输出中的
PORTS
列显示了每个容器如何将不同的本地端口映射到容器内的端口 50051。
安装 helloworld Service 容器
-
通过以下 Dockerfile 为 helloworld 容器构建 Docker 镜像。
您可以在构建之前将 Dockerfile 复制到本地子目录,也可以将 Dockerfile 的 Gist 的 URL 指定为
docker
build
命令的参数:$ sudo docker build -t helloworld https://gist.githubusercontent.com/nginx-gists/87ed942d4ee9f7e7ebb2ccf757ed90be/raw/ce090f92f3bbcb5a94bbf8ded4d597cd47b43cbe/helloworld.Dockerfile
下载和构建镜像可能需要几分钟时间。出现消息
Successfully
built
和一个十六进制字符串(image ID)即表示构建完成。 -
确认镜像是通过运行
docker
images
构建的。$ sudo docker images REPOSITORY TAG IMAGE ID CREATED SIZE helloworld latest e5832dc0884a 10 seconds ago 926MB routeguide latest 170761fa3f03 4 minutes ago 1.31GB python latest 825141134528 9 days ago 923MB golang latest d0e7a411e3da 3 weeks ago 794MB
-
启动 helloworld 容器。
$ sudo docker run --name hw1 -p 20001:50051 -d helloworld $ sudo docker run --name hw2 -p 20002:50051 -d helloworld
每个命令执行成功时,都会出现一个长的十六进制字符串,代表正在运行的容器。
-
运行
docker
ps
,检查两个 helloworld 容器是否都已启动。$ sudo docker ps CONTAINER ID IMAGE COMMAND STATUS ... e0d204ae860a helloworld "go run greeter..." Up 5 seconds ... 66f21d89be78 helloworld "go run greeter..." Up 9 seconds ... d0cdaaeddf0f routeguide "python route_g..." Up 4 minutes ... c04996ca3469 routeguide "python route_g..." Up 4 minutes ... 2170ddb62898 routeguide "python route_g..." Up 5 minutes ... ... PORTS NAMES ... 0.0.0.0:20002->50051/tcp hw2 ... 0.0.0.0:20001->50051/tcp hw1 ... 0.0.0.0:10003->50051/tcp rg3 ... 0.0.0.0:10002->50051/tcp rg2 ... 0.0.0.0:10001->50051/tcp rg1
安装 gRPC 客户端应用
-
安装编程语言的先决条件,其中一些可能已安装在测试环境中。
-
对于 Ubuntu 和 Debian,运行:
$ sudo apt-get install golang-go python3 python-pip git
-
对于 CentOS、RHEL 和 Oracle Linux,运行:
$ sudo yum install golang python python-pip git
请注意,
python-pip
需要启用 EPEL 存储库(根据需要先运行sudo
yum
install
epel-release
)。 -
-
下载 helloworld 应用:
$ go get google.golang.org/grpc
-
下载 RouteGuide 应用:
$ git clone -b v1.14.1 https://github.com/grpc/grpc $ pip install grpcio-tools
测试设置
-
运行 helloworld 客户端:
$ go run go/src/google.golang.org/grpc/examples/helloworld/greeter_client/main.go
-
运行 RouteGuide 客户端:
$ cd grpc/examples/python/route_guide $ python route_guide_client.py
-
检查 NGINX 日志,确认测试环境可正常运行:
$ tail /var/log/nginx/grpc_log.json