RSS

迁移到 Google Cloud Platform — gRPC 与 grpc-gateway

在我们上一篇博客文章中,我们概述了从 Amazon Web Services 到 Google Cloud Platform 的迁移。在本文中,我们将深入探讨 gRPCgrpc-gateway 在此次迁移中发挥的作用,并分享在此过程中学到的一些经验教训。

大多数人都有 REST API,不是吗?有什么问题?

是的,我们实际上仍然有客户端使用的 REST API,因为迁移客户端 API 超出了范围。公平地说,你可以让 REST API 工作,而且市面上有很多有用的 REST API。话虽如此,我们遇到的 REST 相关问题在于细节。

没有规范的 REST 规范

没有单一的、权威的 REST 规范。有最佳实践,但没有真正的标准。因此,关于何时使用特定的 HTTP 方法和响应码,没有一致的意见。此外,并非所有平台都支持所有可能的 HTTP 方法和响应码…… 这迫使 REST API 实现者使用适合他们但会导致 REST API 在总体上存在更多差异的技术来弥补这些不足。充其量,REST API 更像是 REST 方言。

对开发者更不友好

从开发者的角度来看,REST API 也并非完美无缺。首先,因为 REST 绑定到 HTTP,所以在我的首选语言中没有简单的 API 映射。如果我使用 Go 或 Java,代码中没有一个可以直接使用的“接口”来生成桩代码。我可以创建一个,但这超出了 REST API 定义本身的范畴。

其次,REST API 将解释请求意图所需的信息分散在请求的各个组成部分中。包括 HTTP 方法、请求 URI、请求负载,如果请求头也涉及到语义,则会变得更加复杂。

第三,虽然可以使用命令行工具 curl 来调用 API 非常方便,但这需要将 API 生硬地塞入该生态系统,是有代价的。通常,这种用例只在让人们快速尝试 API 时才有意义 — 如果这是你高度重视的需求,那么请随意使用 REST…… 但请保持简单。

没有声明性的 REST API 描述

REST API 的第四个问题是,至少在 Swagger 出现之前,没有一种声明性的方式来定义 REST API 并包含类型信息。这听起来可能有些迂腐,但通常来说,有一个包含类型信息的规范定义是有正当理由的。为了强调这一点,请看下面从不同文件中提取的 PHP 服务器代码行,这些代码设置了返回给客户端的“yak”上的“hidePin”字段。服务器上实际执行的代码行取决于多个参数,因此可以想象,最终运行的是哪一行代码基本上是随机选择的

// Code omitted…
$yak->hidePin=false;

// Code omitted…
$yak->hidePin=true;

// Code omitted…
$yak->hidePin=0;

// Code omitted…
$yak->hidePin=1;

字段 hidePin 的类型是什么?你无法确定。它可能是一个布尔值、一个整数,或者服务器在那里写入的任何东西,但在任何情况下,你的客户端现在都必须能够处理这些可能性,这使得它们更加复杂。

当客户端对类型的定义与服务器期望的不同时,也可能出现问题。请看下面处理客户端发送的 JSON 负载的服务器代码

// Code omitted...
switch ($fieldName) {
  // Code omitted...
  case “recipientID”:
  // This is being added because iOS is passing the recipientID
  // incorrectly and we still want to capture these events
  // … expected fall through …

  case “Recipientid”:
    $this->yakkerEvent->recipientID = $value;
    break;
  // Code omitted...
}
// Code omitted...

在这个例子中,服务器必须处理一个 iOS 客户端发送的 JSON 对象,该对象的字段名使用了意想不到的大小写格式。同样,这不是无法克服的,但所有这些小的不一致之处累积起来,共同耗费了本应用于真正推动项目进展的问题的时间。

gRPC 可以解决 REST 的问题

如果您不熟悉 gRPC,它是一个“高性能、开源的通用远程过程调用 (RPC) 框架”,它使用 Google Protocol Buffers 作为接口描述语言 (IDL) 来描述服务接口以及交换的消息结构。然后,这个 IDL 可以被编译生成特定语言的客户端和服务端存根。如果这听起来有点难以理解,我将重点介绍重要的方面。

gRPC 是声明性的、强类型的,且语言无关

gRPC 定义使用一种独立于任何特定编程语言的接口描述语言编写,但其概念可以映射到所有支持的语言。这意味着您可以描述您理想的服务 API、它支持的消息,然后使用“protoc”(协议编译器)为您的 API 生成客户端和服务端存根。开箱即用地,您可以生成 C/C++、C#、Node.js、PHP、Ruby、Python、Go 和 Java 语言的客户端和服务端存根。您还可以获取额外的 protoc 插件,这些插件可以为 Objective-C 和 Swift 创建存根。

上面关于“hidePin”和“recipientID”与“Recipientid”字段的问题不复存在,因为我们有一个单一的、规范的声明来确定所使用的类型,并且特定语言的代码生成确保了无论客户端或服务器代码使用何种实现语言,都不会出现拼写错误。

gRPC 意味着无需手动编写 RPC 代码

这是 gRPC 生态系统一个非常强大的方面。开发者经常会手动编写 RPC 代码,因为它看起来更直接。然而,随着你需要支持的客户端类型数量增加,这种方法的维护成本也会非线性地增长。想象一下,你最初的服务是由网页浏览器调用的。后来,需求更新了,现在你必须支持 Android 和 iOS 客户端。你的服务器可能没问题,但客户端现在需要能够使用相同的 RPC 方言,并且经常会有一些差异悄悄出现。如果服务器必须弥补客户端之间的差异,情况会变得更糟。另一方面,使用 gRPC,你只需添加协议编译器插件,它们就会生成 Android 和 iOS 客户端存根。这解决了整类问题。另外,如果你不修改生成的代码——而且你本不应该修改——那么生成的代码中的任何性能改进都会被自动获得。

gRPC 具有紧凑的序列化

gRPC 使用 Google protocol buffers 对消息进行序列化。这种序列化格式非常紧凑,因为,除其他优点外,字段名不包含在序列化后的形式中。相比之下,一个 JSON 对象中的每个实例都携带着其字段名的完整副本,包含额外的花括号等等。对于低流量应用来说,这可能不是问题,但很快就会累积起来。

gRPC 工具链是可扩展的

gRPC 框架的另一个非常有用的特性是它是可扩展的。如果您需要支持当前尚未支持的语言,可以为协议编译器创建插件,从而添加所需的功能。

gRPC 支持契约更新

服务 API 中一个经常被忽视的方面是它们如何随时间演变。充其量,这通常是次要考虑。如果您使用 gRPC 并遵循一些基本规则,您的消息可以向前和向后兼容。

Grpc-gateway — 因为 REST 还会与我们共存一段时间…

您可能在想:gRPC 很好,但我有很多 REST 客户端需要处理。好吧,这个生态系统中还有一个工具,叫做 grpc-gateway。Grpc-gateway“生成一个反向代理服务器,将 RESTful JSON API 转换为 gRPC”。所以,如果你想支持 REST 客户端,你可以做到,而且这并不会花费你额外的精力。如果你的现有 REST 客户端与正常的 REST API 相差较大,你可以使用 grpc-gateway 的自定义 marshallers 进行弥补。

迁移与 gRPC + grpc-gateway

如前所述,我们有大量的 PHP 代码和 REST 端点,我们想将其作为迁移的一部分进行重构。通过结合使用 gRPC 和 grpc-gateway,我们能够定义遗留 REST API 的 gRPC 版本,然后使用 grpc-gateway 暴露客户端习惯使用的精确 REST 端点。有了这些替代实现方案,我们能够使用 DNS 更新以及我们的实验和配置系统,在旧系统和新系统之间迁移流量,而不会对现有客户端造成任何中断。我们甚至能够利用现有的测试套件来验证功能,并在新旧系统之间建立对等性。让我们来看看这些组成部分以及它们如何协同工作。

“/api/getMessages” 的 gRPC IDL

下面是我们定义的 gRPC IDL,用于模拟 GCP 中的遗留 Yik Yak API。我们简化了示例,仅包含客户端用于获取围绕其当前位置的消息集合的“/api/getMessages”端点。

// APIRequest Message — sent by clients
message APIRequest {
  // userID is the ID of the user making the request
  string userID = 1;
  // Other fields omitted for clarity…
}

// APIFeedResponse contains the set of messages that clients should
// display.
message APIFeedResponse {
  repeated APIPost messages = 1;
  // Other fields omitted for clarity…
}

// APIPost defines the set of post fields returned to the clients.
message APIPost {
  string messageID = 1;
  string message = 2;
  // Other fields omitted for clarity…
}

// YYAPI service accessed by Android, iOS and Web clients.
service YYAPI {
  // Other endpoints omitted…

  // APIGetMessages returns the list of messages within a radius of
  // the user’s current location.
  rpc APIGetMessages (APIRequest) returns (APIFeedResponse) {
    option (google.api.http) = {
      get: /api/getMessages // Option tells grpc-gateway that an HTTP
                              // GET to /api/getMessages should be
                              // routed to the APIGetMessages gRPC
                              // endpoint.
    };
  }

  // Other endpoints omitted…
}

Protoc 生成的 YYAPI Service 的 Go 接口

上面的 IDL 然后由 protoc 编译器编译成 Go 文件,如下所示生成客户端代理和服务端存根。

// Client API for YYAPI service
type YYAPIClient interface {
  APIGetMessages(ctx context.Context, in *APIRequest, opts ...grpc.CallOption) (*APIFeedResponse, error)
}

// NewYYAPIClient returns an implementation of the YYAPIClient interface  which
// clients can use to call the gRPC service.
func NewYYAPIClient(cc *grpc.ClientConn) YYAPIClient {
  // Code omitted for clarity..
}

// Server API for YYAPI service
type YYAPIServer interface {
  APIGetMessages(context.Context, *APIRequest) (*APIFeedResponse, error)
}

// RegisterYYAPIServer registers an implementation of the YYAPIServer with an
// existing gRPC server instance.
func RegisterYYAPIServer(s *grpc.Server, srv YYAPIServer) {
  // Code omitted for clarity..
}

Grpc-gateway 生成的 YYAPI Service REST 反向代理的 Go 代码

通过使用上面 IDL 中的 google.api.http 选项,我们告诉 grpc-gateway 系统,它应该将针对“/api/getMessages”的 HTTP GET 请求路由到 APIGetMessages gRPC 端点。反过来,它会创建 HTTP 到 gRPC 的反向代理,并允许您通过调用下面生成的函数来设置它。

// RegisterYYAPIHandler registers the http handlers for service YYAPI to “mux”.
// The handlers forward requests to the grpc endpoint over “conn”.
func RegisterYYAPIHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error {
  // Code omitted for clarity
}

所以,再次强调,从一个单一的 gRPC IDL 定义中,您可以获得您选择的语言的客户端和服务端接口和实现存根,以及免费的 REST 反向代理。

gRPC — 我听说有一些不足之处?

我们开始使用 Go 语言的 gRPC 在 2016 年第一季度末,当时肯定有一些不足之处。

早期采用者遇到的问题

我们遇到了Issue 674,Go gRPC 客户端代码中的一个资源泄露,这可能导致 gRPC 传输在高负载下挂起。gRPC 团队响应非常迅速,并且修复程序在几天内合并到了 master 分支。

我们在 grpc-gateway 生成的代码中遇到了一个资源泄露。然而,当我们发现这个问题时,它已经被该团队修复并合并到了 master 分支。

我们遇到的最后一个早期采用者类问题是关于 Go gRPC 客户端不支持 gRPC 协议规范中的 GOAWAY 数据包。幸运的是,这个问题没有影响我们的生产环境。它只在我们为 Issue 674 构建的复现案例中出现。

总的来说,考虑到我们当时采用得比较早,这些问题还是相当合理的。

负载均衡

现在,如果您打算使用 gRPC,这绝对是您需要仔细考虑的一个方面。默认情况下,gRPC 使用 HTTP/2 而不是 HTTP/1。HTTP/2 能够打开与服务器的连接,并重复用于多个请求等。如果您在这种模式下使用它,您将无法将请求分发到负载均衡池中的所有服务器。在我们执行迁移时,现有的负载均衡器对 HTTP/2 流量的支持非常有限,甚至根本不支持。

当时 gRPC 团队还没有负载均衡提案,所以我们花费了很多精力试图强制我们的系统进行某种客户端负载均衡。最终,由于我们的大部分原始 gRPC 通信发生在数据中心内部,并且所有东西都使用 Kubernetes 部署,更简单的方法是每次都拨号调用远程服务器,从而强制系统将负载分散到 Kubernetes Service 中的服务器之间。考虑到我们的设置,这只增加了约 1 毫秒的总响应时间,因此这是一个简单的权宜之计。

那么负载均衡问题就此结束了吗?不完全是。一旦我们的基本 gRPC 系统启动并运行,我们就开始对其进行负载测试,并注意到了一些有趣的现象。下面是随时间变化的每个 gRPC 服务器的 CPU 负载图,您注意到什么奇怪的地方了吗?

负载最重的服务器运行在约 50% 的 CPU 使用率,而负载最轻的服务器运行在约 20% 的 CPU 使用率,即使在预热了几分钟之后也是如此。结果发现,即使我们每次都进行拨号,我们的网络拓扑中包含了一个 nghttp2 入口,它倾向于将入站请求发送到已经连接过的服务器,从而导致负载分布不均。移除 nghttp2 入口后,我们的 CPU 图显示负载分布的方差大大降低。

结论

REST API 存在一些问题,但它们短期内不会消失。如果您愿意尝试一种更简洁的方式,那么绝对应该考虑使用 gRPC(如果您仍然需要暴露 REST API,则可以结合使用 grpc-gateway)。尽管我们早期遇到了一些问题,但 gRPC 对我们来说是一个净收益。它为我们提供了一条通向更严格定义的 API 的路径。它还使我们能够在 GCP 中建立遗留 REST API 的新实现,从而使我们能够以受控的方式将流量从 AWS 实现无缝迁移到新的 GCP 实现。

在讨论了我们对 Go、gRPC 和 Google Cloud Platform 的使用后,我们准备讨论如何基于 Google Bigtable 和 Google S2 Library 构建一个新的地理信息存储——这将是我们下一篇文章的主题。