alibaba / sentinel-golang

Sentinel Go enables reliability and resiliency for Go microservices
https://sentinelguard.io/
Apache License 2.0
2.76k stars 431 forks source link

[Feature] 添加离群摘除功能 | Add Outlier Ejection #575

Open wooyang2018 opened 1 month ago

wooyang2018 commented 1 month ago

Issue Description

Type: feature request

Describe what feature you want

目前Sentinel Go (后文均用 Sentinel 表示 Sentinel Go) 主要提供分布式场景下的流量治理能力,但是当发生网络或节点异常时,服务可能会调用到失败的节点。所以本issue希望在 Sentinel 中增加服务实例的离群摘除功能。离群检测和摘除是动态确定上游集群中的一些异常节点并将它们从健康的负载均衡集中移除的过程。离群的规则需要考虑不同方面,例如连续失败次数、成功率、时间延迟等。

这里通过具体的示例阐述离群摘除功能,比如服务A会调用服务B,服务B由节点B1、B2、B3组成。当B1、B2、B3中的某些实例异常时,如果服务A无法感知到,会导致部分调用失败。甚至如果触发了熔断,所有对服务B的调用都会失败,这会极大影响服务A的可用性。所以为了保护服务A的性能和可用性,本issue希望为服务A配置离群实例摘除,从而让其负载均衡器感知到失效节点,从而避免调用到B1、B2、B3中的异常节点。

image

预期提供的API

本issue预期会提供离群检测和摘除功能相关的如下成果:

从用户的角度,接入Sentinel提供的离群摘除功能,需要以下几步:

  1. 对 Sentinel 的运行环境进行相关配置并初始化。

  2. 埋点(定义资源),该步骤主要是将客户端发起RPC的函数调用和sentinelApi.Entry实现的资源埋点采用Wrapper的方式组合起来。如下例所示,我们将只会提供ResourceType=RPCTrafficType=Outbound的资源类型。

    entry, blockErr := sentinelApi.Entry(
       resourceName,
       sentinelApi.WithResourceType(base.ResTypeRPC),
       sentinelApi.WithTrafficType(base.Outbound),
    )

    需要注意的是,用户现有代码中的resourceName无需修改,我们的adapter会自动将离群摘除的resourceName映射到具体的服务,而不是映射到具体的节点或者方法。此处adapter的操作对用户而言是无感的,并不会影响到Sentinel中其他规则的resourceName。

  3. 配置规则,规则配置项可能包括:资源名称、错误率上限、异常类型、摘除实例比例上限、恢复检测单位时间、未恢复累计次数上限。该步骤可以通过 LoadRules(rules) 函数加载离群摘除的静态规则,本issue后期实现基于注册中心的动态配置加载。如下是配置规则的示例,具体字段含义在后文阐述。

    _, err = outlier.LoadRules([]*outlier.Rule{
       {
           Resource:                     resourceName, // 资源名称
           Strategy:                     outlier.ErrorRatio, // 离群摘除采用的策略
           RetryTimeoutMs:               3000, // 触发摘除后持续的时间
           MinRequestAmount:             10, // 触发摘除的最小请求数目
           MaxEjectionPercent:           0.3, // 摘除实例的比例上限
           StatIntervalMs:               5000, // 统计的时间窗口长度
           Threshold:                    0.4, // 针对不同的策略的阈值
       },
    })
  4. 创建客户端实例时嵌入我们提供的adapter。注意adapter可能在不同的微服务框架中有不同的名字,比如在go-micro框架中被称为Wrapper。总之,我们将会针对1~2个微服务框架提供NewClientWrapper函数,用于给客户端对象添加Wrapper。由于adapter中已经封装好了离群检测和摘除的相关逻辑,所以用户不必关注adapter实现的更多细节,旨在最小化代码迁移的成本。

    Sentinel现有的adapter大多是将resourceName设置为method名,显然离群摘除并不能直接使用这样的埋点,但是我们又不能修改这个resourceName,否则会导致用户必须修改现有规则中的resourceName。因此我们拟采取的方案是在现有的adapter中新增一个专用于离群摘除的埋点,这个埋点的resourceName会映射到具体的服务,比如服务名称。

对比离群摘除和熔断

为了帮助用户在合适的场景选择熔断或者离群摘除,下面对比了二者的联系和区别。

联系

  1. 熔断和离群摘除都是通过尽早阻断不健康的调用链,避免局部不稳定因素导致整个分布式系统的雪崩。
  2. 熔断和离群摘除做出决策都依赖于历史调用的结果统计,此外熔断的许多规则和策略可以借鉴到离群摘除中。
  3. 熔断和离群摘除通常都在客户端配置且都针对OutBound的流量进行控制。

区别

  1. 规则配置不同:
    • 熔断:通常基于错误率、响应时间或异常流量等指标,当超过预设阈值时触发熔断。
    • 离群摘除:虽然大部分熔断中用到的指标在离群摘除中仍然会用到,但是针对离群摘除也有其特有的规则,比如摘除实例比例上限等。
  2. 影响范围不同 (重要!重要!重要!)
    • 熔断:通常作用于整个服务,一旦发生熔断,会导致整个服务的不可用。假如服务A调用服务B,当服务B触发熔断后,服务A对服务B的所有调用都会返回异常。即使服务B可能还存在少量可用节点,服务A对服务B的调用仍旧会失败。因此熔断是一种粗粒度的控制手段。
    • 离群摘除:它的作用对象是具体服务实例。通过摘除不健康的服务节点,尽量保证业务的连续性,通常情况下不会导致整个服务的不可用。同样是服务A调用服务B的示例,当服务B出现某个节点异常时,服务A会从负载均衡集中摘除这个异常节点,这只会影响出现异常的服务实例,而不会像熔断那样造成整个服务的不可用。因此离群摘除是一种细粒度的控制手段。
  3. 使用场景不同:
    • 熔断:如果你的历史调用结果以服务为粒度进行统计,当超过预设阈值时触发阻断整个服务,那么应该使用熔断。
    • 离群摘除:如果你的历史调用结果以节点为粒度进行统计,当超过预设阈值时仅仅阻断该异常节点而其他节点正常调用,那么应该使用离群摘除。

离群摘除方案设计

定义资源

在Sentinel中,使用Entry函数将业务逻辑封装起来,这一过程被称作“埋点”。每个埋点都关联一个资源名称,它标识了触发该资源的调用或访问。埋点API定义在api包中,如下所示:

func Entry(resource string, opts ...Option) (*base.SentinelEntry, *base.BlockError)

具体API可以参考Sentinel的官方文档,特别地,离群摘除方案要求流量类型TrafficType必须设置为Outbound,因为离群摘除配置总是在客户端进行,Inbound代表入口流量,而Outbound代表出口流量。此外,resourceName必须映射到具体的服务,而不是映射到具体的节点或者方法。由于Entry函数一般由Sentinel提供的adapter调用,用户不会直接使用埋点函数,所以用户不必修改现有规则中的resourceName,我们的adapter会自动处理好这个映射过程。

如果服务调用被拒绝,Entry 函数将返回非空的BlockError,表示调用被Sentinel限流。BlockError提供了限流的原因和触发规则等详细信息,帮助开发者进行记录和处理。但是比较特别的是,离群摘除方案不会使用也不应该使用BlockError(保证BlockError为空),因为离群摘除的初衷仅仅是排除被调用服务中的离群节点,而不是阻止对整个服务的调用。

定义错误

一般而言,离群摘除需要区分网络错误和业务错误。事实上在Envoy中,错误检测类型被分为externally originated errors 和 locally originated errors,但是为了便于理解,我们在后文的陈述中将二者改名为业务错误和网络错误。

网络错误包括连接超时、TCP重置、ICMP错误、端口不可用等,我们将会在“想做的更多事情”中支持网络错误的识别,初期的设计我们将只会考虑业务错误。业务错误对于HTTP服务而言是指错误代码为5XX的响应,对于RPC服务而言可能有专属的字段标记Internal Server Error。值得注意的是,业务错误一定是在Client成功连接到服务节点后才由服务节点生成的。

定义规则

我们需要精心设计埋点资源的规则,以便为离群摘除算法的实现提供必要的信息。在规则配置上,我们将其分为两大类:一类是基于Sentinel熔断器 的现有基础规则,另一类则是专为离群摘除设计的扩展规则。在前文中,我们已经对比了熔断和离群摘除的联系和区别。进一步地,我们可以将服务的离群摘除功能理解为对各个具体节点的熔断机制的组合应用。当然完整的离群摘除实现还需要我们引入一些额外的扩展功能,例如每个节点对应熔断器的动态管理和垃圾回收等。

为了提升代码的复用率并减少重复劳动,我们计划利用现有的熔断器代码来实现离群摘除的基础规则部分。这样,当服务中的某个节点满足熔断条件时,我们可以通过熔断器提供的TryPass接口判断节点是否需要被摘除,而具体的摘除策略和机制则由离群摘除定义的扩展规则来实现,比如对规则“摘除比例上限”而言,它保证摘除的异常节点不能超过预设的比例上限,即达到阈值后,不再摘除异常节点。下面我们将分别介绍离群摘除的基础规则和扩展规则。

基础规则

离群摘除的基础规则主要由节点熔断器实现,其主要目的是评估某个服务节点是否存在异常。在评估过程中,我们会综合考虑多种可能影响服务稳定性的因素。基础规则参考了Sentinel现有的熔断器模块,下面将重点介绍一些关键的熔断器规则,用以全面评估服务节点是否出现离群现象。

扩展规则

除了基础规则外,我们还设计了离群摘除的扩展规则。在第一阶段,我们通过每个节点的熔断器已经评估了各个节点是否存在异常,进入第二阶段,我们将利用扩展规则来有效地摘除那些被识别为异常的节点。下面将详细介绍专为离群摘除而设计的扩展规则,它们有助于更精确地控制离群摘除过程,确保服务的稳定性和效率,从而更好地适应多样化的业务场景。

RecoveryIntervalMaxRecoveryAttempts的值设置对恢复检测的时间间隔都有影响。如果二者设置得过大,则会导致恢复检测的间隔时间变得过长。这意味着即使节点在检测间隔的早期阶段已经恢复,仍然需要等待间隔时间结束后才能进行下一次健康检查。由于节点已经恢复但未能及时收到业务调用请求,导致了节点资源被闲置。相反,如果二者被设置得过小,则会导致频繁的恢复检测,这会显著增加客户端的负载,甚至可能干扰到正常的业务流程。因此需要根据实际的业务需求合理设置RecoveryIntervalMaxRecoveryAttempts的值。

摘除算法

服务的离群摘除其实是其众多节点的熔断的组合,因此在实现离群摘除功能时,可以复用 Sentinel 现有的熔断器代码。我们将在 Sentinel 内部维护具体节点的状态转移,从而为每个服务节点独立进行离群的判定和摘除。为此我们定义了一组map类型的全局变量,用于维护服务资源对应的熔断和离群摘除功能的规则和状态,下面是对每个全局变量的详细介绍:

// resource name ---> node count
var nodeCount = make(map[string]int)
// resource name ---> outlier ejection rule
var outlierRules = make(map[string][]*Rule)
// resource name ---> circuitbreaker rule
var breakerRules = make(map[string][]*circuitbreaker.Rule)
// resource name ---> nodeID ---> circuitbreaker rule
var nodeBreakers = make(map[string]map[string][]circuitbreaker.CircuitBreaker)

在定义了上述数据结构后,我们开始正式介绍离群摘除算法的实现。我们将在SentinelEntry结构体中新增表示离群节点集合的新字段,摘除算法的主要目标正是基于当前记录的状态填充这个新字段。这样微服务框架的Filter就可以通过调用entry.Context().FilterNodes() 获取这个集合的内容,然后从负载均衡集中过滤掉这些异常节点即可。

对于客户端的每次调用,将进入Sentinel的Entry函数,并在内部遍历所有已注册的 RuleCheckSlot,同时调用Slot的Check方法。当然其中也包含了离群摘除注册的 Slot,所以摘除算法应该实现在该Slot的Check方法中。摘除算法依赖于熔断器规则和离群摘除的扩展规则,算法流程分为两个主要步骤:首先通过每个节点的熔断器评估该节点是否存在异常;其次通过扩展规则来有效摘除那些被识别为异常的节点。下面的代码简单实现了摘除算法,其中在Slot的Check方法中调用了checkAllNodes函数。checkAllNodes函数的作用是:遍历并评估该资源名称的所有节点的异常情况,返回需要过滤的节点ID列表。下面是checkAllNodes函数按步骤的解释:

func (s *Slot) Check(ctx *base.EntryContext) *base.TokenResult {
    result := ctx.RuleCheckResult
    filterNodes := checkAllNodes(ctx)
    if len(filterNodes) != 0 {
        result.SetFilterNodes(filterNodes)
    }
    return result
}

func checkAllNodes(ctx *base.EntryContext) (nodeRes []string) {
    resource := ctx.Resource.Name()
    nodeBreaks := getNodeBreakersOfResource(resource)
    outlierRules := getOutlierRulesOfResource(resource)
    nodeCount := getNodeCountOfResource(resource)
    for nodeID, breakers := range nodeBreaks {
        for index, breaker := range breakers {
            if breaker.TryPass(ctx) {
                continue
            }
            rule := outlierRules[index]
            if len(nodeRes) < int(float64(nodeCount)*rule.MaxEjectionPercent) {
                nodeRes = append(nodeRes, nodeID)
            }
        }
    }
    return nodeRes
}

需要注意的是在上面的代码中,对于“通过扩展规则来有效摘除那些被识别为异常的节点”这一步骤,我们只使用了MaxEjectionPercent这个扩展规则。然而,单一的规则可能无法满足所有复杂场景的需求,因此在后续的代码开发和功能迭代中,我们计划引入更多的扩展规则来丰富我们的离群摘除策略。

恢复检测

离群摘除功能需要具备两项关键能力:一是能够迅速识别并摘除那些异常的服务节点,二是能够在节点恢复正常后自动将其重新纳入负载均衡池,从而最大化服务的可用性。本小节我们将重点关注对异常节点的恢复检测。根据异常节点恢复检测的时机,我们设计的恢复检测机制分为主动模式和被动模式两部分。

Adapter设计

我们计划在变量 entry(也就是Entry函数的返回值)中携带一个节点集合的字段,这些节点是需要在服务调用前被摘除的。接下来,在针对具体框架实现的 adapter 中,我们会从负载均衡的候选列表中排除这些实例。然后由框架自身来处理具体的服务调用,此时框架可能会从负载均衡列表中选择一个存活节点并正式发起服务调用。在调用结束后,我们会利用 TraceError 方法来记录错误信息,并新增TraceCallee函数来记录被调用的节点ID。最后在entry.Exit()函数中更新被调用节点的状态信息。

许多开源的微服务框架都允许用户自定义 Middleware(中间件)或 Wrapper(包装器)。这为离群摘除的实现提供了机会,使其能够在服务调用之前过滤异常节点,并在服务调用之后更新被调用节点的状态。我们已经基于 go-micro 框架实现了原型,下面这段代码展示了其中的客户端包装器(clientWrapper)的 Call 方法实现,当然这种方法同样适用于其他的微服务框架。以下是对adapter中离群摘除相关部分代码的详细说明:

  1. entry, blockErr := sentinelApi.Entry(:尝试进入 Sentinel 的流量控制入口。我们会在返回值entry中携带需要被过滤的实例集合。
  2. if blockErr != nil {:处理资源被阻塞的错误。如果该服务名称下仅配置了离群摘除规则,那么不会出现服务调用被阻止的情况,即 blockErr == nil。在离群摘除的场景中,blockErr 将始终为 nil,因为我们的目的是识别并摘除表现不佳的节点,而不是阻止服务调用。
  3. defer entry.Exit():通过defer确保 Sentinel 的流量控制出口一定会被执行到。在这个退出过程中,我们嵌入了离群摘除的StatSlot实现,目前暂定名称为outlier.DefaultMetricStatSlot。在其OnCompleted(ctx *EntryContext)方法中,我们将会实现节点状态的更新机制,这不仅包括对新发现的服务节点添加熔断器对象,还需要对现有熔断器对象的状态进行更新。此外,长时间未更新状态的熔断器对象将被回收,以避免发生内存泄漏。
  4. opt1 := client.WithSelectOption(selector.WithFilter(...)):定义一个Filter选项用于服务调用前过滤异常节点。具体来说,我们首先通过entry.Context().FilterNodes()获取待摘除的节点集合,然后从负载均衡的候选列表中排除这些节点。
  5. opt2 := client.WithCallWrapper(func(f1 client.CallFunc) client.CallFunc {...}):定义一个调用包装器选项用于服务调用之后记录结果信息。具体来说,首先我们调用用户的f1函数,当这个服务调用返回后,我们会通过TraceError 方法来记录可能的错误,以及通过TraceCallee方法来记录被调用节点的 ID。这两点信息对于entry.Exit()阶段的节点状态更新而言是必要的。
  6. return c.Client.Call(ctx, req, rsp, opts...):最后调用go-micro定义的客户端的 Call 方法,用于发起实际的服务调用。传入的调用选项CallOption包括了在之前代码中定义的 SelectOptionCallWrapper,这样我们就在 Call 方法内部完整地嵌入了离群检测和摘除的逻辑。
func (c *clientWrapper) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
    resourceName := req.Method()
    options := evaluateOptions(c.Opts)
    if options.clientResourceExtract != nil {
        resourceName = options.clientResourceExtract(ctx, req)
    }

    entry, blockErr := sentinelApi.Entry(
        resourceName,
        sentinelApi.WithResourceType(base.ResTypeRPC),
        sentinelApi.WithTrafficType(base.Outbound),
    )

    if blockErr != nil {
        if options.clientBlockFallback != nil {
            return options.clientBlockFallback(ctx, req, blockErr)
        }
        return blockErr
    }
    defer entry.Exit() // 此处实现节点状态的更新机制

    // 定义一个Filter选项用于服务调用前过滤异常节点
    opt1 := client.WithSelectOption(selector.WithFilter(
        func(old []*registry.Service) []*registry.Service {
            nodes := entry.Context().FilterNodes()
            nodesMap := make(map[string]struct{})
            for _, node := range nodes {
                nodesMap[node] = struct{}{}
            }

            for _, service := range old {
                nodesCopy := slices.Clone(service.Nodes)
                service.Nodes = make([]*registry.Node, 0)
                for _, ep := range nodesCopy {
                    if _, ok := nodesMap[ep.Id]; !ok {
                        service.Nodes = append(service.Nodes, ep)
                    }
                }
            }
            return old
        },
    ))

    // 定义一个调用包装器选项用于服务调用之后记录结果信息
    opt2 := client.WithCallWrapper(func(f1 client.CallFunc) client.CallFunc {
        return func(ctx context.Context, node *registry.Node, req client.Request, rsp interface{}, opts client.CallOptions) error {
            err := f1(ctx, node, req, rsp, opts)
            sentinelApi.TraceCallee(entry, node.Id)
            if err != nil {
                sentinelApi.TraceError(entry, err)
            }
            return err
        }
    })
    opts = append(opts, opt1, opt2)
    return c.Client.Call(ctx, req, rsp, opts...)
}

想做的更多事情

开源框架适配

我们计划开发一系列适配器,以实现离群摘除方案与特定微服务框架的无缝集成。我们会参考pkg/adapters目录下现有的众多适配器的实现。目前我们规划适配的开源框架包括:

  1. gRPC-go:我们将利用NewUnaryClientInterceptor函数实现离群摘除功能,该函数将完全封装离群摘除的相关操作,提供给用户一个即插即用的解决方案。
  2. go-micro:我们将通过NewClientWrapper函数暴露离群摘除能力,使得用户能够轻松集成到现有的服务逻辑中。

我们会尽可能考虑到适配器实现的通用性,这具体表现在两个方面:

  1. 我们会将离群摘除的核心逻辑完全封装到上述暴露出的接口中,主要包括:在发起服务调用前的过滤负载均衡集,以及用服务调用的结果更新节点状态。
  2. 为了提高暴露出的接口的灵活性,我们可能会引入一些形如WithXXX的设置函数,例如WithClientBlockFallback允许用户设置客户端请求被阻塞时的回调函数。通过这一系列设置函数满足不同用户在特定场景下的需求。

最后对于每种适配器,我们将会提供示例代码(Example)以及相应的测试程序(xxx_test.go),确保用户能够理解并验证适配器的功能。

动态配置加载

我们计划实现离群摘除规则的动态加载功能,允许用户通过配置中心灵活地调整离群摘除的规则,无需静态修改配置文件,无需重新部署服务,这一特性旨在提升系统灵活性和可维护性。

目前Sentinel已经提供了一些动态数据源接口,使用户能够从多种配置中心动态加载和更新规则,如etcd、consul、nacos、apollo等。Sentinel的动态数据源接口的相关代码可以在pkg/datasource/ext/datasource目录下找到。这一功能对于实现实时的离群摘除功能至关重要。

我们的主要工作是实现针对离群摘除规则的更新器,并与Sentinel提供的动态数据源接口进行适配。如下所示,Sentinel的DefaultPropertyHandler结构体封装了属性的转换器和更新器,详见ext/datasource/property.go文件。

type DefaultPropertyHandler struct {
    lastUpdateProperty interface{}

    converter PropertyConverter
    updater   PropertyUpdater
}

DefaultPropertyHandler 会检查当前属性是否与上次更新的属性一致。如果不一致,其中PropertyConverter会将消息转换为特定的属性,然后PropertyUpdater会将特定的属性更新到下游。由于每个 DefaultPropertyHandler 实例用于处理一种属性类型,所以我们应该实现一个适用于离群摘除规则的更新器,该函数预期的签名为:func OutlierRulesUpdater(data interface{}) error

网络错误识别

目前无论是网络错误(如连接超时)还是业务错误(如HTTP返回5XX错误),我们都笼统地将其视为“一次失败”。但是从用户的角度来看,更加细粒度的错误区分有利于服务的可维护性。错误区分的设计可以参考Envoy的离群摘除机制 Detection types 小节。因此我们计划在未来的版本中引入错误类型的区分,本阶段的任务可以分为两部分,一是能够识别错误类型,二是根据错误的类型进行不同的处理。

为了能够区分错误类型,未来我们将在Sentinel中引入网络错误的识别机制。因为业务错误独特于HTTP协议或者RPC调用,比较容易区分,所以关键在于网络错误的识别。具体来说,我们需要从现有的笼统的错误返回信息中挖掘关于网络连接本身的信息,这要么需要微服务框架提供更多的支持,要么需要Sentinel主动进行检测和区分,最终使得Sentinel能够捕捉到网络错误(例如超时、重置等)。

当我们能够区分业务错误和网络错误后,我们需要将二者通过单独的计数器进行跟踪并分开处理。值得注意的是,无论是网络连接问题还是服务返回的错误,二者都会被计入到离群检测的总错误数中。但是针对不同的错误类型,离群检测也可以有不同的处理方式,从而实现更加细粒度的错误记录和状态更新。比如设置不同的最大错误请求次数,针对业务错误阈值设置为10次,针对网络错误阈值设置为3次。