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 代码,因为它看起来更直接。但是,随着你需要支持的客户端类型数量增加,这种方法的维护成本也会非线性地增加。假设你最初有一个由 Web 浏览器调用的服务。在某个时候,需求更新了,现在你必须支持 Android 和 iOS 客户端。你的服务器可能没问题,但是客户端现在需要能够使用相同的 RPC 方言,而且通常会有差异潜入。如果服务器必须补偿客户端之间的差异,情况可能会变得更糟。另一方面,使用 gRPC,你只需添加协议编译器插件,它们就会生成 Android 和 iOS 客户端存根。这消除了整整一类问题。作为奖励,如果你不修改生成的代码(而且你不应该这样做),那么生成的代码中的任何性能改进都将被采纳。

gRPC 具有紧凑的序列化

gRPC 使用 Google 协议缓冲区来序列化消息。这种序列化格式非常紧凑,因为,除其他外,字段名称不包含在序列化形式中。将其与 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

下面是我们定义的 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…
}

YYAPI 服务的 Protoc 生成的 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..
}

YYAPI 服务的 REST 反向代理的 Grpc-gateway 生成的 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 — 我听说有一些棘手的问题?

我们在 2016 年第一季度末开始使用 Go 的 gRPC,当时肯定存在一些不完善之处。

早期采用者问题

我们遇到了 Issue 674,这是 Go gRPC 客户端代码中的一个资源泄漏,当负载很重时可能会导致 gRPC 传输挂起。gRPC 团队的反应非常迅速,并且在几天内将修复程序合并到了主分支中。

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

我们遇到的最后一个早期采用者类型的问题是 Go 的 gRPC 客户端不支持 gRPC 协议规范中的 GOAWAY 数据包。幸运的是,这个问题并没有影响我们在生产环境中的使用。它仅在我们为 Issue 674 组装的 repo 案例中显现出来。

总而言之,考虑到我们当时的使用阶段,这是相当合理的。

负载均衡

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

当时,gRPC 团队还没有负载均衡提案,所以我们花费了大量时间试图强制我们的系统进行某种类型的客户端负载均衡。最后,由于我们的大部分原始 gRPC 通信都发生在数据中心内,并且一切都是使用 Kubernetes 部署的,因此每次都拨打远程服务器更简单,从而迫使系统将负载分散到 Kubernetes 服务中的服务器上。鉴于我们的设置,它只增加了大约 1 毫秒的整体响应时间,所以这是一个简单的变通方法。

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

即使经过几分钟的预热,负载最重的服务器的 CPU 使用率也只有 50% 左右,而负载最轻的服务器的 CPU 使用率只有 20% 左右。事实证明,即使我们每次都进行拨号,我们的网络拓扑中也包含一个 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 的基础上构建新的地理存储——这是我们下一篇文章的主题。