Uber 边缘网关的设计

Sep 1, 2022 23:00 · 4584 words · 10 minute read Engineering Architecture

原文 https://www.uber.com/blog/gatewayuberapi


高可用和可扩展的边缘网关(Edge Gateway),用于配置、管理和监控 Uber 每个业务领域的 API。

Uber API 网关的演变

2014 年十月,Uber 已经开始起飞,最终成为了这家公司最令人震撼的增长阶段。我们每个月都非线性地扩张工程团队,并在全球范围内获得几百万个用户。

在这篇文章中,我们将回顾 Uber 的 API 网关演变的不同阶段,它驱动着 Uber 产品。我们将沿着时间线来了解在这爆炸式增长阶段的同时发生的架构模式的革命。我们将聊聊三个大版本网关系统的进化,探索它们的挑战和能力。

第一代:有机进化

2014 年 Uber 架构有两个关键服务:dispatchapidispatch 服务负责连接乘客与司机,api 服务是我们用户和行程的长期商店。除此以外还有几个微服务来支持消费者 app 的关键流程。

乘客 app 和司机 app 都通过托管在 / 的单一端点连接 dispatch 服务。请求体中有个特殊的字段叫做 messageType,决定了调用特定处理函数的 RPC 命令。处理函数应答一个 JSON 负载。

Simplified high-level illustration

在 RPC 指令集中预留了 15 个关键的实时操作例如允许司机接受行程、拒绝行程和乘客请求行程。一个特殊的 messageType 被命名为 ApiCommand,表示将所有请求附带着从 dispatch 服务来的上下文代理至 api 服务。

在 API 网关的背景下,看上去 ApiCommand 是进入 Uber 的网关。第一代网关是一个单体服务有机进化的结果,从它开始为真正的用户服务,并找到了扩展额外的微服务的办法。dispatch 服务有公共 API 作为手机接口——包括了调度系统和一个代理,将所有其他流量路由到 Uber 其他微服务。

到 2015 年一月全新的 API 网关蓝图诞生,允许 Uber 乘客 app 通过 RESTful API 搜索目的地,几千 QPS。这是朝着正确的方向迈出的一步。

第二代:无所不能的网关

Uber 早就采用了微服务架构。截至 2019 年增长到 2200+ 个微服务驱动 Uber 的产品。

High-level illustration of RTAPIs Service as part of the overall company stack

API 网关层被命名为 RTAPI(Real Time-API 的缩写)。它从 2015 年早期的一个 RESTful API 开始,成长为驱动 20 多个移动和 web 客户端,有着许多公共 API 的网关。该服务是一个单一的代码仓库,随着指数级增长,被拆分为多个专门的部署组。

这个 API 网关是 Uber 最大的 NodeJS 应用程序之一,有惊人的数据:

  • 多达 100 个 endpint 组
  • 40% 的工程师为它提交过代码
  • 峰值每秒 800000 条请求
  • 在 5 分钟内对每次 diff 执行 50000 个集成测试
  • 几乎每天都要部署
  • 约 100 万行代码处理最关键的用户流
  • 约 20% 的移动应用程序构建是由该层中定义的 schema 生成的代码
  • 与 400+ 个下游服务通信,Uber 内 100 多个团队维护这些服务

第二代网关的目标

公司内的每个基础设置都是为了满足需求,有些在设计之初就确定,有些是后来的。

  • 解耦

    一百多个团队并行开发功能,由后端团队开发的提供基础功能的微服务数量呈爆炸式增长;前端和移动团队也以同样快的速度打造产品体验。网关提供了所需的解耦,应用程序依赖稳定的 API 网关和它提供的合约。

  • 协议转换

    移动端到服务器的通信主要用 HTTP/JSON。Uber 内部也推出了一个新协议,这是提供一个多路双向的传输层协议。有那么一个节点 Uber 每项新服务都采用了这个协议,这使后端系统的服务在两个协议之间变得支离破碎。当时的网络栈也处于石器时代,网关使得我们的团队免受底层网络变化的影响。

  • 跨领域的关注点

    公司使用的所有 API 需要一定的功能集合,这些功能应该通用和健壮。我们专注于认证、监控(延迟,错误和有效载荷大小)、数据校验、安全审计日志、按需调试日志、基线告警、SAL 测算、数据中心粘性、CORS 配置、本地化、缓存、限流、负载削峰。

  • 流式有效载荷

    在此期间,大量 app 采用从服务器推送数据至移动端的功能。这些有效载荷被建模为上面讨论过的 API。最终由我们的流基础设施管理推送。

  • 减少来回次数

    过去十年里互联网一直在着 HTTP 协议栈的各种缺点发展。减少 HTTP 来回次数被前端广泛采用。在微服务架构中被结合进网关层,从各种下游服务“分散-收集”数据,减少 app 和后端直接的通信来回次数。这对我们在拉美、印度等低蜂窝数据网络带宽国家的用户来说非常重要。

  • 前端的后端

    开发速度是所有成功产品的一个显著特征。贯穿整个 2016 年,我们的新硬件基础设施没有 Docker 化,上新服务很容易,然而硬件分配却略显复杂。网关为团队提供了一个奇妙的地方,他们的功能往往在一天内就可以完成开发。因为它是我们的 app 调用的系统,服务有一个灵活的开发空间来编码,并可以访问公司内数百个微服务。第一代 Uber Eats 是完全在网关内开发的。随着产品的成熟,部分被移出了网关。在 Uber 有很多功能基于其他现有的微服务,完全建立在网关层。

面临的挑战

  • 技术挑战

    网关最初的目标很大程度上与 IO 绑定,有个团队专门支持 NodeJS。经过多轮审核,NodeJS 成为网关的首选语言。随着时间的推移,为 1500 名工程师在 Uber 架构的关键层提供自由编码的空间成为越来越大的挑战。

    有时,每个代码变化都要跑 5W 个测试,要创建一个由动态加载机制的增量测试框架是很复杂的。随着 Uber 的一些业务转向将 Golang 和 Java 作为主力语言,新来的后端工程师和 NodeJS 的异步模式拖慢了我们的节奏。

    网关本身变得相当庞大,它还是一个单体仓库(被部署为 40 多个独立的服务),将 2500 个 npm 库升级到较新的 Node.js 版本的工作量成倍地增加。这意味着我们无法使用很多库的最新版本。这时 Uber 开始采用 gRPC 作为首选协议,我们的 Node.js 对此毫无帮助。

    代码审核无法阻止空指针(NPE),导致关键的网关部署停滞了好几天,直到 NPE 被修复。这进一步拖慢了我们的工程速度。

    网关中代码的复杂性偏离了 IO,甚至有些 API 的性能开倒车导致网关变慢。

  • 非技术挑战

    网关的天性导致了这个系统的大量压力。减少来回次数和前端的后端是大量业务代码泄漏至网关的“罪魁祸首”。有时这种泄漏是可选择的,有时毫无理由。在超过一百万行代码中,很难分辨出业务逻辑。

    由于网关是 Uber 业务的关键基础设施,网关团队开始成为 Uber 产品开发的瓶颈。我们通过 API 分片部署和去中心化审核来缓解这个问题,但问题始终存在。

    此时我们不得不重新思考下一代 API 网关战略。

第三代:自服务、去中心化、分层

到了 2018 年初,Uber 有了全新的业务线,有了众多新应用程序。业务的数量只会持续增加。在每个业务中,都有团队管理着他们的后台系统和 app。我们为了快速开发产品,需要这些系统是垂直独立的。网关要提供正确的功能集合,能够真正地加速产品开发,并避免上述挑战。

第三代网关的目标

公司本身已经和我们设计第二代网关时截然不同。

  • 分离关注点

    这种新架构鼓励采用分层的方式进行产品开发。

    • 边缘层:网关系统提供第二代系统网关目标中的所有功能,除了“前端的后端”和“减少来回”。
    • 表示层:微服务专门为功能和产品的前端提供后端服务。这种方式的结果是产品团队管理他们自己的表示和编排服务,以实现 app 所需要的 API。这些服务的代码针对视图生成,由许多下游服务的数据汇聚而成。有单独的 API 来修改至满足特定消费者的响应。举个栗子,与标准的 Uber 乘客 app 相比,Uber Lite 无需太多地图相关的信息。没一个都涉及不同数量的下游调用,以计算所需的响应载荷与一些视图逻辑。
    • 产品层:这些微服务也提供 API 来描述它们的产品/功能。可以被其他团队重用,来打造新的产品。
    • 领域层:包含的微服务是图中的叶子节点,为产品专供。

Layered Architecture

减少边缘层要实现的目标

导致复杂的关键因素之一是第二代网关中由视图生成和业务逻辑组成的特定代码。在新的架构下,这两个功能已经被转移到其他微服务中,由独立的团队在 Uber 标准库和框架上构建。边缘层很纯粹,没有定制的代码。

一些刚起步的团队可以用单一的服务来满足表示、产品和服务层的职责。随着功能的增加,可以被解构到不同层去。

这种结构提供了灵活性,从小开始,进化到我们所有产品都用的北极星架构。

技术栈

  • 边缘网关

    原本由第二代网关系统提供服务的边缘层被一个单独的 Golang 服务和 UI 界面取代。边缘网关作为 API 生命周期的管理层在内部开发。所有 Uber 工程师都可以通过 UI 来配置、创建和修改面向产品的 API。该 UI 能够简单配置认证、请求转换和请求头。

    End-to-end user flow

  • 服务框架

    鉴于所有团队都要维护和管理一批微服务(可能在这个架构的每一层为他们的功能/产品服务),边缘团队和语言平台团队合作,共同开发一个名为 Glue 的标准服务框架,在 Uber 内使用。Glue 是一个建立在 Fx 依赖注入框架之上的 MVCS 框架。

  • 服务 lib

    网关中属于“减少来回”和“前端的后端”的代码需要一个 Golang 的轻量级 DAG 执行系统。我们使用 Golang 构建了一个流控框架(CCF)系统,允许工程师在服务处理程序中为业务逻辑开发复杂的无状态工作流。

    A CFF taskflow

  • 组织调整

    架构迁移是一个非常大的挑战,它影响到 40% 的 Uber 工程师的工作。做这件事最好的办法是建立共识和对目标的认知。有几点要关注。

    • 建立信任

      团队将一些大规模的 API 和关键的 endpoint 迁移迁移到新技术栈中,来验证尽可能多的用例,并验证我们是否可以开始让外部团队迁移他们的 endpoint 和逻辑。

    • 寻找业主

      因为有许多 API,我们必须明确识别所有权。这并不简单,因为大量 API 有跨团队的逻辑。对于那些明确映射到具体产品/功能的 API,我们自动分配它们,但对于复杂 API 只能逐案处理并协商 API 所有权。

    • 承诺

      在将其拆分成团队后,我们将终端团队分成许多组(通常按更大的公司组织架构,例如 Rider、Driver、Payments、Safety 等等),并联系 leader 工程师,在整个 2019 年找到项目和程序 POC 来引导其团队。

      • 训练

        集中对工程和项目负责人培训,让他们了解“如何”迁移、“注意”什么、“何时”迁移。我们建立了支持渠道,其他团队的开发者可以在迁移中提问和寻求援助。我们还建立了一个自动化的追踪系统,可视化进度。

      • 迭代策略

        在迁移的过程中,我们遇到了一些边缘案例和有挑战的假设。有时我们引入新功能,有时我们选择不向新架构中加入与无关的功能。

        随着迁移的进行,我们的团队继续思考未来和技术的发展方向,并确保这一年中对技术指导做出调整。

        最终我们履行了承诺,走向自服务的 API 网关和分层的服务架构。

总结

在 Uber 耗费时间开发和管理了三代网关系统后,得到了一些 API 网关方面的高水平见解。

如果可以选择,移动端应用程序和内部服务尽量使用单一的协议。多种协议和序列化格式最终会导致网关系统巨大的开销。拥有单一的协议为你提供灵活的网关层,可以是简单的代理层也可以是功能丰富极其复杂的网关,使用自定义的 DSL 实现 graphQL。如果要转译多套协议,网关层就不得不复杂化,以实现将 HTTP 请求路由到另种协议的服务这一最简单的过程。

设计网关系统以横向扩展是极其关键的。对于像我们第二、第三代这样的复杂网关系统来说尤为重要。为 API 组建立独立的二进制文件的能力是我们第二代网关能够横向扩展的关键功能。一个单一的二进制文件太大了,无法运行 1600+ 复杂的 API。

基于 UI 的 API 配置非常适合增量变更现有的 API,但创建新 API 通常是要多个步骤的。作为工程师,有时直接动签出的代码库比 UI 来得快。

从第二代开发和迁移到第三代长达两年之久。在项目过渡中持续的调研是至关重要的。保持可持续发展对这种长期的项目的成功极其关键。

最终每个新系统无需偿还旧系统的所有技术债,有时要有意识地选择放弃支持。

回顾我们网关的演进,有人会想是否可以跳过一代直达当前的架构。任何在公司没有经过这段旅程的人可能也会想是否他们应该从自服务的 API 网关开始。这是一个艰难的决定,因为这些演变并不是独立的决定,在很大程度上取决于整个公司,如基础设施、语言平台、产品团队、增长、产品的规模等等。

在 Uber,我们已经证明了这个最新架构是成功的。第三代系统中每天 API 的变更次数已经超过了第二代。这直接反映到更快的产品开发周期。迁移到一个基于 Golang 的系统已经大大改善了资源利用率和请求核心指标。大多数 API 的延迟显著下降。随着架构的成熟和旧系统被重写成新的分层架构,还有很长的路要走。