Zanzibar 是 google 开发和部署的一个全球授权系统,用于评估全球用户对 google 数百个应用的访问权限(包括:Calendar, Cloud, Drive, Maps, Photos, YouTube等)。目前已经存储了上万亿条 ACL(access control list),每秒钟处理来自数十亿用户的数百万个请求,并且在过去的三年里做到了将 95% 分位的请求响应时延控制在 10ms以内,系统的可用性大于99.999%。能够处理“每秒超过 1000 万个客户端查询”,但要实现这一目标,还有很多工作需要。
有多种方法可以向应用程序添加权限。最常见的方法是为构建的每个应用程序创建临时实现。对于动态权限,例如最终用户可以授予的权限,这通常在源代码中表现为解释存储在数据库中的行。此代码最终成为每个请求的关键部分,非常敏感,并且编写起来可能很棘手。此代码中的错误很容易导致应用程序中出现安全漏洞和漏洞。为了最大限度地减少所编写的代码的外围区域和大量代码,此代码通常被抽象到一个库中,该库可以在许多此类应用程序之间共享。
一些公司有一个通用的授权库,每当需要在其所有应用程序和服务中调用权限代码时,他们都会重复使用该库。这是 Google 在决定构建和部署Zanzibar 之前使用的方法。通常,这些库通过将它们放在 IPC 接口后面而变得更加通用和与编程语言无关,然后采用策略引擎的形式。然而,库和策略引擎在这种情况下有一个主要缺点:它们是无状态的。库和策略引擎的实现都要求它们获得策略和全套所需数据,然后将它们组合起来以做出权限决策。对于标准 Web 应用程序,这意味着加载表示关系的数据库行,并在每个权限决策时将它们传递给引擎。
由于此限制,下一个合乎逻辑的步骤是开始将与决策相关的信息存储在权限引擎可直接访问的位置。 现在,当需要做出权限决策时,应用程序只需向权限系统询问它已经可以访问的策略和数据。 例如,如果服务已经知道我是一篇文章的作者,并且应该允许作者编辑自己的文章,那么它已经拥有回答问题所需的所有信息:“杰克是否允许编辑这篇文章? 这确实意味着您需要不断“教导”权限系统有关用户与数据之间关系的更改。 另一方面,权限系统实际上可以成为这些关系的唯一规范存储! 当您将其作为权限服务实现时,它会解锁许多非常强大的属性。
谷歌Zanzibar 是这种服务的实现,谷歌提出了一个强有力的案例,说明为什么在服务中计算权限的属性对谷歌很重要。
在Zanzibar 的论文中,谷歌列出了决定他们将从权限服务中受益的许多原因。 首先,作为一项服务,他们已将代码重复和版本偏差的数量减少到最低限度。 其次,谷歌拥有大量的应用程序和服务,他们经常需要检查来自一个应用程序在另一个应用程序中的资源之间的权限。 例如,当您使用 Gmail 发送电子邮件并警告您收件人无法阅读电子邮件中链接的文档时,这有效,因为 Gmail 正在向Zanzibar 询问链接的 Google 文档的权限。类似的授权请求也发生在日历、云端硬盘、地图、照片、YouTube 等。 第三,谷歌在权限系统之上构建了通用基础设施,只有当你有一个一致的 API 来编程时,你才能做到这一点。 最后,也是最重要的一点,权限很难。
人们希望任何权限实现都遵守一些常见要求。 首先,它应该是正确的。 有了权限,就很容易定义正确性。 所有授权用户都应能够与受保护的资源进行交互,并且不应允许未经授权的用户与受保护的资源进行交互。 起初这似乎很容易,直到您开始考虑计算机(尤其是大规模托管应用程序)必须应对的挑战。 例如网络和复制延迟、节点故障和时钟同步。
其次,如果您要对所有事情使用一个权限系统,它应该合理地允许您对应用程序所需的所有不同类型的基元进行建模。 就 Google 而言,它们至少具有以下权限模型:文档中的点对点共享、YouTube 中的公开/私密/不公开视频以及 Cloud IAM 中的 RBAC。 Google Zanzibar 足够灵活,可以对不同类型的权限进行建模。
因为您通常需要检查每个请求的权限,并且未能收到肯定的权限检查成功必须解释为拒绝(失败关闭),因此您需要此系统具有高度的可靠性和速度。
最后,随着 Google 的运营规模极大,Google Zanzibar 还必须扩展到每秒数百万个授权请求,跨数十亿用户和数万亿个对象。
Google Zanzibar 的核心是一个全球分布式授权系统,能够处理“每秒超过 1000 万次客户端查询”,但从开发人员的角度来看,它是一个 API。该 API 允许您外包用户和数据关系,然后让您在访问点快速准确地做出权限决策。例如,当新用户注册时,您告诉 Google Zanzibar。当该用户创建受保护的资源(例如文档、视频或银行帐户)时,您告诉 Zanzibar。当该用户与其他用户共享资源或创建相关资源时,您告诉 Zanzibar。最后,当需要回答以下问题时:“是否允许 X 读取/写入/删除/更新 Y?”,桑给巴尔已经拥有快速回答问题所需的所有信息。
Zanzibar计算权限的方式有些新颖。 应用程序开发人员写入服务的关系信息用于构建用户、其他实体和资源之间关系的有向图表示形式。 一旦此图可用,权限检查就变成了一个图遍历问题。 我们试图通过图表找到从请求的资源和关系(例如所有者、读取者等)到用户(通常是发出请求的用户)的路径。
通常,一种关系意味着其他关系。 例如,如果允许用户写入一条数据,则几乎(但并非总是)意味着他们也可以读取相同的数据。 为了减少必须存储的冗余信息量,Zanzibar提供了一种称为关系重写的机制,该机制描述了一种重新解释图中某些边缘和关系的方法。 重写的另一个示例是说:“嵌套文档所在文件夹的读者也应被视为文档的读者。以这种方式消除冗余信息的过程更正式地称为规范化。
到目前为止,我们只讨论了如何检查权限,但现在我们有了应用程序中所有实体的规范化图版本,我们还可以对这些数据执行其他作,Zanzibar也公开了这些概念的 API。 在Zanzibar的论文中,第 2.4.1 和 2.4.5 节描述了读取和扩展 API,它们允许直接查询图数据,从而允许基于存储的数据构建 UI 和下游进程。 第 2.4.3 节描述了监视 API,它可用于通知底层图数据的更改,这是 Zanzibar 实现中描述的一些性能改进的基础。
本文中最后一个面向开发人员的部分是 Zookies。 Zookies(大概是Zanzibar和 cookie 的合成词)允许开发人员为允许的数据过时性设置下限。 这里的主要见解是,通过允许将稍微过时的数据用于常见权限检查,可以大大提高这些作的性能。 在某些情况下,您绝对不希望在计算权限时使用过时的数据,在这些情况下,您可以通过显式指定 Zookie 来强制 Zanzibar 使用更新的数据。 如果您想了解更多关于 Zookies 以及它们如何强制保持一致性的信息,通过原子方式将代表用于保护特定版本内容的确切权限的令牌与内容本身组合在一起,我们可以确保将来用于检查对该内容的访问权限的权限至少与创建内容时的权限一样新鲜。
具体来说,Authzed 允许人们在发出权限检查请求时传递 Zookie,并保证用于计算答案的策略和单个关系至少与所呈现的 Zookie 要求一样新鲜。 现在,每次内容更新时,我们的代码大致遵循以下伪代码约定:
def write_content(user, content_id, new_content): is_allowed, zookie = authzed.content_change_check(content_id, user) if is_allowed: storage.write_content(contentd_id, new_content, zookie) return success return forbidden
在访问数据时,我们使用以下伪代码:
def read_content(user, content_id): content, zookie = storage.get_content(content_id) is_allowed = authzed.check(content_id, user, zookie) if is_allowed: return content return forbidden
现在我们已经熟悉了Zanzibar 的 API,让我们来看看Zanzibar 是如何实现的,以实现大规模的低延迟。
由于这项服务不断被访问,并且处于处理请求的关键路径中,因此它必须速度很快。对于 Google 的Zanzibar ,检查请求的第 50 个和第 99 个百分位延迟分别为 3 毫秒和 20 毫秒,同时在峰值时每秒提供来自世界各地的 1240 万个权限检查和读取请求。Zanzibar 如何实现如此低延迟和高规模?
通过运行Zanzibar 服务器的许多副本来实现高规模:
Zanzibar 将这种负载分布在全球数十个集群中的 10,000 多台服务器上。每个集群的服务器数量从不到 100 台到超过 1,000 台不等,中位数接近 500 台。集群的大小与其地理区域的负载成比例。
全球分发是通过使用 Google 的全球规模数据库系统 Spanner 来处理的。借助 Spanner,写入地球上任何地方的数据可以立即获得,并且外部一致。虽然这些属性非常适合权限系统的存储层,但这并不意味着存储在 Spanner 中的数据在Zanzibar 的延迟要求范围内可用。F1(Google 的另一项服务)从 Spanner 感知到的读取延迟在中位数为 8.7 毫秒,标准差为 376.4 毫秒。Zanzibar 通常必须对数据存储进行多次往返才能计算一次权限检查。显然,如果没有一些严重的缓存,它就无法实现 20 毫秒的 99.9 个百分位延迟。
Zanzibar 在服务的多个层都有缓存。 缓存的第一层位于服务级别。 当服务收到最近计算的权限检查请求时,该请求的结果仍可被视为有效(这意味着计算它的时间不早于传递的 Zookie),可以直接返回该值。 这消除了对数据存储的所有往返。 服务级别缓存是提高性能的有效方法,但以Zanzibar 的运营规模而言,它本身并没有多大帮助。 如果允许从任何缓存提供任何请求,流经Zanzibar 的庞大数据量将导致非常低的命中率或令人望而却步的内存要求。
为了提高命中率,Zanzibar 使用一致的哈希将请求(以及生成的缓存条目)分发到特定服务器。 我们从中获得的第一个好处是缓存的命中率要高得多。 如果我们期望特定类型的请求仅由Zanzibar 副本的一小部分提供服务,那么我们更有可能在缓存中拥有该值。 这提供的第二个也是更微妙的改进是允许合并重复的请求,并且该值仅计算一次并返回给所有调用者。 在本例中,我们摊销所有重复数据删除请求的后端数据存储往返。
Zanzibar 执行的服务器端缓存的最终形式是特定于 Google 用例的一种特殊非规范化。 当工程师注意到组(如 Docs、Cloud IAM 和产品组所使用)通常嵌套很深时,他们创建了一个名为 Leopard Indexing System 的服务。 Leopard 保留所有组的内存中传递闭包,这些组是更高级别组的子组。 默认情况下,Zanzibar 的嵌套关系需要向后备 Spanner 数据库发出多个串行请求,因为您需要先加载直接子级,然后才能计算其子级。 通过将所有顶级和中间组的所有子组保留在内存中,Leopard 允许桑给巴尔将所有嵌套组解析减少到对索引的一次调用。 由于 Leopard 将数据存储在内存中,并且作为与Zanzibar 分开的服务运行,因此它使用本文第 2.4.3 节中的监视 API 来不断更新对底层组结构数据的更改。
Zanzibar 还做了一个巧妙的技巧来减少尾部延迟:请求对冲。当Zanzibar 检测到来自 Spanner 或 Leopard 的响应花费的时间比平时更长时,它会向一个或多个其他服务器发送另一个请求,要求完全相同的数据,这些服务器有望响应得更快。
Zanzibar (服务)正在大规模直接解决 Google 真正的灵活性、可扩展性、可测试性和可重用性问题。Zanzibar 的论文写得非常出色且易于理解,以至于当我第一次阅读它时,我认为所提出的解决方案可以解决我过去构建的系统中的权限灵活性和可扩展性问题。Authzed 是这一旅程的下一步,它将Zanzibar :Google 一致的全球授权系统......带给其他人。