原文出处:https://www.infoq.cn/article/wjkNGoGaaHyKs7xIyTSB
作者:敖小剑
发表时间:2021 年 11 月 18 日 19:11
Dapr 作为新兴的云原生项目,以"应用运行时"之名围绕云原生应用的各种分布式需求,致力于打造一个通用而可移植的抽象能力层。这个愿景有着令人兴奋而向往的美好前景,然而事情往往没这么简单,API 的标准化之路异常的艰辛而痛苦,Dapr 的分布式能力抽象在实践中会遇到各种挑战和困扰。
Dapr 的全称是 “Distributed Application Runtime”,即 “分布式应用运行时”, 是一个由微软发起的开源项目,目前已经进入 CNCF 孵化器。
我们首先来对 Dapr 进行一个简单的介绍。首先来看 ServiceMesh,和传统 RPC 框架相比,ServiceMesh 的创新之处在于引入了 Sidecar 模式:
而 ServiceMesh 只解决了服务间通讯的需求,而现实中的分布式应用存在更多的需求,Multi-Runtime 理论将这些需求归结为四大类:
在 ServiceMesh 初步成功并且 Sidecar 模式被云原生社区普遍接受之后,各企业效仿 ServiceMesh 将应用需要的其他分布式能力外移到各种 Runtime,这逐渐演变成了一个趋势。这些 Runtime 会逐渐整合,最后和应用 Runtime 共同组成微服务,形成所谓的 “Multi-Runtime” 架构,又称 Mecha:
Dapr 项目是目前业界第一个 Multi-Runtime / Mecha 实践项目,下图来自 Dapr 官方,比较完善的概括了 Dapr 的能力和层次架构:
在快速回顾完 Dapr 之后,我们来正式展开本文的内容。首先需要回答这样一个问题:Dapr 和 ServiceMesh 有什么区别?
这个问题也是大多数读者在了解 Dapr 之后最常问到的一个问题,原因是 Dapr 和 ServiceMesh 在架构上实在太像了,都是以 Sidecar 模式为核心。
Dapr 的基本思路也是和 ServiceMesh 保持一致:通过引入 Sidecar 来实现 关注点分离 + 独立维护。
Dapr 和 ServiceMesh 最明显的不同之处是 Dapr 的场景比 ServiceMesh 要复杂:
ServiceMesh 关注于服务间通讯,即下图中虚线部分;而 Dapr 除了服务间通讯之外,还适用于诸多其他的场景,如状态存储、消息的发布订阅、资源绑定、配置等。
下图是 Dapr 目前已有的构建块,以及对他们所提供的能力的简单描述(其中 Service Invocation 对应 ServiceMesh 的服务间通讯能力):
功能的差异是很直白的,容易理解。但是,如果只是功能上的差异,那么问题来了:如果 ServiceMesh 也提供同样的能力,是不是就和 Dapr 一样了?
我们来看 Envoy 的功能,Envoy 原生支持 HTTP1.1/REST 和 HTTP2 / gRPC 协议,社区增加了对 Dubbo、Thrift 等 RPC 协议的支持,而 Envoy 也在提供对 Kafka、Redis 等原生协议的支持,未来 Envoy 提供更多原生协议支持也是可以预期的。那是不是意味着随着功能的增加,以 Envoy、Istio 为代表的 ServiceMesh 就和 Dapr 一样了呢?
答案当然是否定的—— Dapr 和 ServiceMesh 的本质差异在于其【工作模式】。ServiceMesh 的工作模式是【原协议转发】:
ServiceMesh 的 Sidecar 接收到是 App 发出的原生协议的请求,然后转发给另一端的 Sidecar 进而转发给目标 App;或者,对于 Redis/Kafka 等的支持是 Sidecar 将原协议转发给 Redis/Kafka 服务器。在这个过程中,ServiceMesh 的 Sidecar 原则上不修改协议,只做 转发(“Forwarding”),Sidecar 扮演的是 代理(“Proxy”)的角色。
而 Dapr 的工作模式是 能力抽象:
Dapr Sidecar 接收到是 App 发出的遵从标准 API 的请求,这些标准 API 对能力进行了抽象;对于服务间通讯的场景,Dapr Sidecar 会将请求转发给另一端的 Dapr Sidecar;对于服务间通讯之外的能力的支持则需要将标准 API 转换为底层组件对应的原生协议。在这个过程中,Dapr Sidecar 原则上对应用只暴露抽象之后的分布式能力,屏蔽了底层具体的实现和通讯协议,不做"转发"而是提供"能力",Dapr Sidecar 扮演的是运行时 (“Runtime”) 的角色。
工作模式不同的背后,反映出 Dapr 和 ServiceMesh 在设计目标上的差异:
【总结一下 Dapr 和 ServiceMesh 的差异以及适用场景】
ServiceMesh 强调对应用无侵入,支持原协议转发和流量劫持,不仅仅适用于新应用,相比 Dapr 也更加适用于老应用。而 Dapr 提供 “标准 API”、“语言 SDK” 和 “Runtime”,需要应用进行适配(这意味着老应用需要进行改造),侵入性比较大。因此 Dapr 更适合新应用开发 (所谓 Green Field),对于现有的老应用(所谓 Brown Field)则需要付出较高的改造代价。但在付出这些代价之后,Dapr 就可以提供跨云跨平台的可移植性,这是 Dapr 的核心价值之一。
因此,在决策是否该采用 Dapr 时,可移植性是一个非常关键的考虑因素。
在上一篇文章 《Dapr v1.0 展望:从 ServiceMesh 到云原生 》 中,我曾经指出: Dapr 的本质是面向云原生应用的【分布式能力抽象层】:
可移植性 是 Dapr 的重要目标和核心价值。Dapr 的愿景, “any language, any framework, anywhere”,这里的 anywhere 包括公有云、私有云、混合云和边缘网络。
而 Dapr 可移植性的基石在于 标准 API + 可拔插可替换的组件,下面这张来自 Dapr 官方网站的图片非常形象的展示了 Dapr 的这一特性:
从架构设计的角度看,Dapr 的精髓在于:通过抽象 / 隔离 / 可替换,解耦能力和实现,从而实现可移植性。
在传统的应用开发方式中,应用需要面向具体的实现编程,即当应用需要使用到某个能力时,就需要找到能提供该能力的底层组件,如上图中的 Redis / Consul / Memcached / Zookeeper 都可以提供分布式状态的存储能力。应用在选择具体组件之后,就需要针对该组件进行编程开发,典型如引入该组件的客户端 SDK,然后基于这些 SDK 实现需要的分布式能力,如缓存、状态、锁、消息通讯等具体功能。
在 Dapr 中,Dapr 倡导 “面向能力编程",即:
而这些组件都是可替换的,可以在运行时才进行绑定。
Dapr 通过这样的方式,实现了能力和实现的解耦,并给出了一个美好的愿景:在有一个业界普遍认可并遵循的标准化 API 的基础上,用户可以自由选择编程语言开发云原生,这些云原生可以在不同的平台上运行,不被厂商和平台限制——终极目标是使得云原生应用真正具备跨云跨平台的可移植性。
理想很美好,但现实依然残酷:和 ServiceMesh 相比,Dapr 在落地时存在一个无可回避的问题——应用改造是有成本的。
从落地的角度来看, ServiceMesh 的低侵入性使得应用在迁移到 ServiceMesh 时无需太大的改动,只需要像往常一样向 sidecar 发出原生协议的请求即可,甚至在流量劫持的帮助下可以做到应用完全无感知。从工作模式上说,基于原生协议转发的 ServiceMesh 天然对旧应用友好。而 Dapr 出于对可移植性目标的追求,需要为应用提供一个标准的分布式能力抽象层来屏蔽底层分布式能力的具体实现方式,应用需要基于这个抽象层进行开发,才能获得跨云跨平台无厂商绑定等可移植性方面的收益。因此,在 Dapr 落地过程中,新应用需要基于 Dapr API 全新开发,老应用则不可避免的需要进行改造以对接 Dapr API。
API 标准化是 Dapr 成败的关键,为 Dapr 的发展建立起良性循环:
理想情况下, “标准化” / “组件支持” / “可移植性” 之间的相互促进和支撑将成为 Dapr 发展源源不断的动力。反之,如果 API 标准化出现问题,则组件的支持必然受影响,大大削弱可移植性,Dapr 存在的核心价值将受到强烈挑战。
既然 API 标准化如此重要,那 Dapr 该如何去定义 API 并推动其标准化呢?我们以 Dapr State API 为例,介绍在 API 定义和标准化过程中常见的问题。
State 形式上是 key-value 存储,即状态信息被序列化为 byte[] 然后以 value 的形式存储并关联到 key,当然实践中非 k-v 存储也可以实现 State 的功能,比如 mysql 等关系型数据库。Dapr 的 State API 的定义非常简单明了,除了基于 key 的 CRUD 基本操作外,还有 CRUD 的批量操作,以及一个原子执行多个操作的事务操作:
上述 API 定义貌似非常简单,毕竟 k-v 基本操作的语义非常容易理解。但是,一旦各种高级特性陆续加入之后,API 就会逐渐复杂:数据一致性 / 并发保护 / 过期时间 / 批量操作等。
以 GetState() 为例,我们展开 GetStateRequest 和 GetStateResponse 这两个消息的定义,了解一下数据一致性 / 并发保护这两个高级特性:
可以看到 GetStateRequest 中的字段 key 和 GetStateResponse 中以 bytes[] 格式定义的字段 data,对应于 key-value 中的 key 和 value。GetState() 的基本语义非常明显的呈现:请求中给出 key,在应答中返回对应的 value。除此之外,在 API 的设计中还有三个字段:
请求中的 consistency 字段用于数据一致性:
当组件支持多副本时,consistency 字段将用于指定对数据 一致性的要求 ,其取值有两种:eventual:(最终一致性)和 strong:(强一致性)。除了 getState() 方法外,这个参数也适用于 saveState() 和 deleteState() 方法。
乐观锁的工作原理如上图所示,假定有三个请求同时查询同一个 key,三个应答中都会返回当前 key 的 etag(值为"10”)。当这三个线程同时进行并发修改时,在 saveState()的请求中需要设置之前获取到的 etag,第一个 save 请求将被接受然后对应 key 的 etag 将修改为"11”,而后续的两个 save 请求会因为 etag 不匹配而被拒绝。
etag 参数在 getState() 方法中返回,在 saveState() 方法中设置,每次对 key 进行写操作都要求必须修改 etag。
concurrency 参数在 saveState() 方法中设置,有两个值可选:first-write-wins(启用乐观锁) 和 last-write-wins(无乐观锁,简单覆盖)。
类型定义为 map,可以方便的传递未在 API 中定义的参数,为 API 提供扩展性:即提供实现个性化功能(而不是通用功能)的扩展途径。
State API 提供的批量操作,用于一次性操作多个 key,和应用多次调用单个操作的 API 相比,减少了多次往返的性能开销和延迟。考虑到组件原生对批量操作的支持程度,Dapr 中的批量操作的实现方式有两种:
展开细节看一下 Dapr 中 GetBulkState() 方法的代码实现,忽略细节代码和加密处理,只看主体逻辑:
func (a *api) GetBulkState(ctx context.Context, in *runtimev1pb.GetBulkStateRequest) (*runtimev1pb.GetBulkStateResponse, error) {
// try bulk get first
bulkGet, responses, err := store.BulkGet(reqs)
// if store supports bulk get
if bulkGet {
return bulkResp, nil
}
// if store doesn't support bulk get, fallback to call get() method one by one
limiter := concurrency.NewLimiter(int(in.Parallelism))
n := len(reqs)
for i := 0; i < n; i++ {
fn := func(param interface{}) {
req := param.(*state.GetRequest)
r, err := store.Get(req)
item := &runtimev1pb.BulkStateItem{
......
}
}
limiter.Execute(fn, &reqs[i])
}
limiter.Wait()
......
}
Dapr 的 GetBulkState() 方法先尝试调用组件实现的 BulkGet(),如果组件支持批量操作则直接返回结果。而当组件不支持批量操作时,GetBulkState() 方法会做两个事情:
Dapr 这样做的好处是:对于支持批量操作的组件可以充分发挥其功能,同时对于不支持批量操作的组件由 Dapr 模拟出了批量操作的功能并提供了基本的性能优化。最终使得批量操作的 API 可以被所有组件都支持,从而让使用者在使用批量 API 时可以有统一的体验。
相对于批量操作的简单处理方式,事务的支持在 Dapr 中就要麻烦的多,是目前 State API 在实现中最大的挑战,其根源在于:很多组件不支持事务!而且,事务性也无法像批量操作那边在 Dapr 侧进行简单补救。
以下是实现了 Dapr State API 的组件对事务支持的情况:
支持事务的组件有:
Cosmosdb、Mongodb、Mysql、Postgresql、Redis、Rethinkdb 和 Sqlserver。
不支持事务的组件有:
Aerospike、Aws/dynamodb、Azure/blobstorage、Azure/tablestorage、Cassandra、Cloudstate、Couchbase、Gcp/firestore、Hashicorp/consul、Hazelcase、Memcached 和 ZooKeeper。
因此,Dapr State API 的组件被是 否支持事务 分成了两大类。这些组件在开发时和运行时调用上需要就是否支持事务进行区分:
这直接导致了一个严重的后果:当用户使用 Dapr State API 时,就必须先明确自己是否会使用到事务操作,如果是,则只能选择支持事务的组件。
在前面我们讲述 API 标准化的价值 时,是基于一个 基本假设 :在能力抽象和 API 标准化之后,各种组件都可以提供对 Dapr API 的良好实现,从而使得基于这些标准 API 开发的应用在功能得到满足的同时也可以获得可移植性。
这是一个非常美好的想法,但这个假设的成立是有前提条件的:
而现实是残酷的:特性越是高级,就越难于让所有组件都支持。以 Dapr State API 为例,如上图所示从做向右的各种特性,组件的支持程度越来越差:
但实际上,在 API 定义和标准化的过程中,我们不得不面对这样一个残酷的现实:API 定义全部特性 和 所有组件都完美支持 无法同时满足!
这导致在定义 Dapr API 时不得不面对这么一个痛苦的抉择:向左?还是向右?
向左,只定义基本特性,最终得到的 API 倾向于功能最小集;
优点:所有组件都支持,可移植性好 缺点:功能有限,很可能不满足需求;
向右,定义各种高级特性,最终得到的 API 倾向于功能最大集;
优点:功能齐全,很好的满足需求 缺点:组件只提供部分支持,可移植性差;
Dapr API 定义的核心挑战在于:功能丰富性和组件支持度难于兼顾。
如下图所示,当 API 定义的功能越丰富时,组件的支持度越差,越来越多的组件出现无法支持某个定义的高级特性,导致可移植性下降:
在 Dapr 现有的设计中,为了在标准 API 定义之外提供扩展功能,引入请求级别的 metadata 来进行自定义扩展:Dapr 现有的各种 API,包括上面我们详细介绍的 State API,基本都经历过这样一个流程:
因此 Dapr API 在定义和后续演进时需要做权衡和取舍:
在 Dapr 现有的设计中,为了在标准 API 定义之外提供扩展功能,引入请求级别的 metadata 来进行自定义扩展:
metadata 字段的类型定位为 map,可以方便的携带任意的 key-value,在不改变 API 定义的情况下,组件和使用者可以约定在请求级别的 metadata 中通过传递某些参数来使用更多的底层能力。
下图是在阿里云在内部落地 Dapr 时,对 Dapr State API 的各种 metadata 自定义扩展:
注意:State API 中,expire 的功能在通过名为 ttlInSeconds 的 metadata 来实现,而没有直接在 getStateRequest 中定义固定字段。
metadata 的引入解决了 API 功能不足的问题,但是也造成了另外一个严重问题:破坏可移植性。
metadata 自定义扩展
- 优点:满足功能需求,metadata 的使用扩展了功能,使得底层组件的能力得以释放;
- 缺点:严重破坏可移植性,自定义扩展越多,在迁移到其他组件时可能丢失的功能就越多,可移植性就越差;
从图上看,当从可移植性为出发点进行 API 设计时,由于功能缺失迫使引入 metadata 进行自定义扩展,在解决功能问题的同时,造成了可移植性的严重破坏。从而偏离了我们的初衷,也造成整个 API 设计和落地打磨的流程无法形成闭环,无法建立良性循环。
阿里是 Dapr 最早期的用户之一,在阿里内部落地 Dapr 时,遭遇了重大挑战:之前有的功能现在都要有!
这是来自业务团队的普遍需求,其背景是落地时遇到的三个现状:
其结果就是在落地时不得不引入 request 级别 metadata —— 这在短期内满足了功能需求,但从长期考虑损害了可移植性。
回顾前面我们谈及的 Dapr API 定义的核心挑战:功能丰富性和组件支持度难于兼顾。因此,在 Dapr API 定义和标准化过程中,在功能丰富性和组件支持度(可移植性)上如何取舍,就成为必须慎重考虑和严谨对待的关键问题。
空想无益,实践为先,让我们以目前 state API 为例分析 Dapr 在 API 定义和标准化上的一些实践,让我们对此有更加深刻的理解。
首先,让我们换一个角度看待这个问题,功能丰富性和组件支持度难于兼顾的核心在于组件提供的能力不是平齐的。
如上图所示,不同组件所能提供的能力是不同的,因此他们能够支持的 API 高级特性也有所不同:有些组件支持的特性多一些,有些组件支持的特性少一些。体现在上图的左侧,当我们把这些组件的能力都罗列出来时,得到的时一个高低不平的图形。
最小功能集和最大功能集对应了这个图形中的最低处和最高处(简单起见我们假定最小功能集和最大功能集都刚好有组件可以直接对应),而 Dapr API 的设计关键就在于如何权衡功能丰富性和组件支持度,最终选择一个合适的功能集合。
还记得我们前面详细看过的 GetBulkState() 方法的实现代码吗?为了弥补部分组件无法支持批量操作的问题,Dapr 采用的方式是在 Dapr Runtime 中通过多次调用来模拟出批量操作的功能:
func (a *api) GetBulkState(ctx context.Context, in *runtimev1pb.GetBulkStateRequest) (*runtimev1pb.GetBulkStateResponse, error) {
// try bulk get first
bulkGet, responses, err := store.BulkGet(reqs)
// if store supports bulk get
if bulkGet {
return bulkResp, nil
}
// if store doesn't support bulk get, fallback to call get() method one by one
limiter := concurrency.NewLimiter(int(in.Parallelism))
n := len(reqs)
for i := 0; i < n; i++ {
fn := func(param interface{}) {
req := param.(*state.GetRequest)
r, err := store.Get(req)
item := &runtimev1pb.BulkStateItem{
......
}
}
limiter.Execute(fn, &reqs[i])
}
limiter.Wait()
......
}
用"让子弹飞"的话来解释这段代码的功能,就是这个画风:我给了他(组件)一把手枪 (部署了 Dapr Sidecar),他要是体面(支持批量操作),你就让他体面(调用组件的批量方法);他要是不体面(不支持批量操作),你就帮他体面(多次调用单个操作的方法来模拟批量操作)。
前面我们在介绍 Dapr 和 ServiceMesh 的 本质差异 时强调过:Dapr 的工作模式是 能力抽象,Sidecar 原则上对应用只暴露抽象之后的分布式能力,屏蔽了底层具体的实现和通讯协议,不做 “转发” 而是提供 “能力” ,Sidecar 扮演的是运行时(“Runtime”)的角色。
所谓 “组件能力缺失”,是指底层组件原生无法提供 API 定义的功能,如上面 State API 中定义的批量操作,现实中就是有不少 Dapr State 的组件原生不支持批量操作。而 Dapr 的工作模式使得 Dapr 有机会对组件缺失的能力进行弥补,请牢记:Dapr 的 Sidecar 是 Runtime, 而不是 Proxy。
如上图右侧所示,Dapr Sidecar 对批量操作的实现进行了 “干预”:当发现底层组件不支持批量操作时,Dapr Runtime 会改用多次调用组件的单个操作方法的方式来模拟批量操作,从而在功能上实现了 State API 定义中的批量操作。
这是 Dapr 中解决功能缺失比较理想的方式,应优先采纳,当然这个代价是 Dapr 需要做更多的工作。但成效时非常明显的,如下图所示,在 Dapr 进行功能补齐之后,左侧组件功能不对齐的问题会得以缓解,相关的这些功能也就可以放心的纳入 Dapr API 的范围而无需担心组件支持度。
思路和前面是一致的,功能出现缺失时由 Dapr Sidecar 来进行补救。但差异在于进行补救的地方的不是 Dapr 的通用代码,而是 Dapr 中该组件对应的组件实现代码,因此这个补救只能在当前组件中生效,无法复用到其他组件。
我们以 并发支持 为例,Dapr State API 通过 etag 的方式定义了乐观锁的功能,但是在使用 Redis 实现 State API 时就会遇到这样一个问题:Redis 原生是不支持 etag (或者叫做 version)的。而且这个功能缺失还无法在 Dapr 中通过通用的方式(如前面通过多次调用单个操作的方式来模拟批量操作)来进行弥补。为了支持 etag,Dapr 中的 Redis state component 在代码实现中就采用了 hashmap 类型的 value,通过 data 和 version 两个 hashmap key 来实现 etag 的功能:
而为保证对 data 和 version 这两个 hashmap 值操作的原子性,引入了 LUA 脚本:
类似的实现在 Dapr 各组件的代码中有不少,这种方式也可以很好的弥补功能缺失。
State API 中的 saveState() 方法,请求中的 consistency 字段用于数据一致性,当组件支持多副本时,consistency 字段将用于指定对数据一致性的要求,其取值有两种:eventual:(最终一致性)和 strong:(强一致性)。
下面是支持强一致性的 Redis state 实现代码:
func (r *StateStore) setValue(req *state.SetRequest) error {
......
if req.Options.Consistency == state.Strong && r.replicas > 0 {
_, err = r.client.Do(r.ctx, "WAIT", r.replicas, 1000).Result()
if err != nil {
return fmt.Errorf("Redis waiting for %v replicas to acknowledge write, err: %s", r.replicas, err.Error())
}
}
......
}
如果组件不支持强一致性,或者当前组件并没有配置集群不存在多副本,则可以忽略 consistency 参数。即使请求中的 consistency 字段明确要求强一致性,在不能实现或者无需实现时,可以简单忽略该参数而无需报错。
类似的,超时的实现在 State API 中是通过名为 ttlInSeconds 的 metadata 来实现。如果组件不支持超时,则可以简单的忽略该 metadata 。
有些功能的缺失,是前面三种解决方式都无法解决的,例如前面提到的 State API 中的事务支持:
Dapr State API 目前的做法是按照是否支持事务来区分 state components :用户如果需要事务支持,必须选择支持事务的组件。而需要事务支持时,可移植性的范围被限制为支持事务的组件列表。
这个解决方案的缺陷在于会对可移植性造成灾难性后果:如上面 State API,一旦要求支持事务,则只有约 40% 的组件可以支持。因此必须严格限制使用:只能为个别关键特性开特例,不能滥用,不然使用者选择组件时会非常痛苦。
虽然我们前面详细列出了在 State API 中为了实现各种高级特性而进行的各种解决方式,但 State API 其实本质上还是一个比较简单的 API,高级特性也屈指可数。
而 Dapr API 中还有其他比 State API 要复杂的多的 API,如 Dapr 社区在今年开始深入讨论和尝试加入的 Configuration API。由于目前各种配置产品的差异性实在太大,甚至连最基本的配置模型都存在巨大差异,导致 Configuration API 在制定时遇到非常大的阻力。简而言之,和 State API 相比, Configuration API 要复杂 10 倍。
对此感兴趣的同学请浏览:https://github.com/dapr/dapr/issues/2988
虽然 Dapr 还很稚嫩,虽然多运行时(Mecha)的理论还在早期实践的过程中,但我坚信 Dapr 作为多运行时理论的第一个实践项目是符合云原生的大方向,Dapr 能为云原生应用带来巨大的价值。而从产品形态来说,目前 Dapr 是走在云原生社区的前面,作为 Dapr 的早期实践者,可以很骄傲的说:我们是云原生的开拓者,我们正在创造云原生新的历史。
而 Dapr API 是 Dapr 成败的关键之一,从 云原生发展的角度未来也需要这么一个通用的分布式能力的 API 标准,诚然目前的 Dapr API 需要在不断实践中补充和完善,而且这个过程注定会很艰难,就像前面这个迟迟未能顺产的 Configuration API。
欢迎更多的公司和个人参与到 Dapr 项目。
Dapr github 地址:https://github.com/dapr