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 对象进行比较,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 的自定义编组器来弥补。

迁移与 gRPC + grpc-gateway

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

用于“/api/getMessages”的 gRPC IDL

下面是我们为模拟 GCP 中的传统 Yik Yak API 而定义的 gRPC IDL。我们简化了示例,使其只包含客户端用于获取围绕其当前位置的消息集合的“/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 生成的 Go 接口,用于 YYAPI 服务

上面的 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 生成的 Go 代码,用于 YYAPI 服务的 REST 反向代理

通过在上述 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 — 我听说它有些不足之处?

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

早期使用者问题

我们遇到了问题 674,这是一个 Go gRPC 客户端代码中的资源泄漏,可能导致 gRPC 传输在高负载下挂起。gRPC 团队响应非常迅速,几天内就将修复合入主分支。

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

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

总而言之,考虑到我们当时使用 gRPC 的早期阶段,这些问题是相当合理的。

负载均衡

现在,如果你打算使用 gRPC,这绝对是一个需要仔细考虑的领域。默认情况下,gRPC 使用 HTTP2 而不是 HTTP1。HTTP2 能够与服务器建立连接并复用于多个请求等。如果以这种模式使用,你将无法在负载均衡池中的所有服务器之间分配请求。在我们执行迁移时,现有的负载均衡器对 HTTP2 流量的处理很差,甚至根本不处理。

当时 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 构建一个新的地理存储——这将是我们下一篇文章的主题。