在Go微服务博客系列的这一部分,我们将探讨如何使用Netflix Hystrix的Go实现和go-resilience重试包,使用断路器模式使我们的服务间通信更具弹性。
已完成的源代码可以从GitHub复制:
> git clone https://github.com/callistaenterprise/goblog.git
> git checkout P11
考虑以下虚构的系统场景,其中一些微服务处理传入的请求::
图1 - 系统景观
如果最右边的服务“服务Y”失败会发生什么?假设它将接受传入的请求,但只是让它们等待,或许底层的数据存储没有响应。消费者服务(服务N和服务A)的等待请求最终会超时,但如果你的系统每秒处理数十或数百个请求,则会导致线程池填满,内存使用率急剧上升,最终消费者(那些打电话给服务1的人)会很恼火地等待他们的回应。这甚至可以通过调用链一路返回到入口点服务,有效地使整个场景陷入停顿。
图2 - 级联失败
虽然正确实施的健康检查最终会通过容器协调器中的机制触发服务重新启动失败的服务,但这可能需要几分钟的时间。与此同时,重负载应用程序将遭受级联失败,除非我们实际已经实施了模式来处理这种情况。这是断路器模式出现的地方。
图3 - 断路器
在这里,我们看到服务A和服务Y之间逻辑上存在断路器(实际断路器总是在消费者服务中实施)。断路器的概念来自于电力领域。托马斯·爱迪生于1879年就提出了专利申请。断路器设计为在检测到故障时打开,确保级联的副作用,例如你的房子被烧毁或微服务崩溃不会发生。Hystrix断路器的工作原理是:
图4 - 断路器状态
当电路闭合时,Hystrix有两个关键要点:
如果给定的生产者服务宕机,断路器确保我们既可以优雅地处理问题,又可以将应用程序的其余部分从级联故障中保存下来。但是,在微服务环境中,我们很少只有一个给定服务的单个实例。如果您有许多实例,其中可能只有一个出现问题,那么为什么要将第一次尝试视为断路器内部的故障呢?这就是回收器的来源::
在我们的上下文中,在Docker Swarm模式环境中使用Go微服务,如果我们假设给定的生产者服务有3个实例,那么我们知道Swarm负载均衡器会自动循环访问给定服务的请求。因此,与其在断路器内部失败,为什么不使用一种机制来自动执行可配置的重试次数,包括某种备份?
图5 - 回收器
也许有点简化,序列图应该能够很好地解释关键的概念:
在这篇博文中,我们将使用go-resilience的retries包。
在这篇博客文章和我们稍后将要实现的示例代码中,我们将向accountservice添加断路器,用于对quot- service和一个名为imageservice的新服务的传出调用。我们还将安装运行Netflix Hystrix监控仪表板和Netflix Turbine Hystrix流聚合器的服务。稍后再谈这两个。
图6 - 场景概述
终于到了去编程的时候了!在这一部分,我们推出了一个全新的基础服务——imageservice。但是,我们不会花费任何宝贵的博客空间来描述它。它只返回一个给定的“acountId”的URL以及服务器的IP地址。它为场景提供了更多的复杂性,适合展示如何在一个服务中使用多个命名断路器。
让我们深入了解“accountservice”和/goblog/accountservice/service/handlers.go文件。从GetAccount 函数的代码中,我们希望使用go-hystrix和go-resilience/retrier调用底层报价服务和新的imageservice。这是报价服务的起点:
func getQuote() (model.Quote, error) {
body, err := cb.CallUsingCircuitBreaker("quotes-service", "http://quotes-service:8080/api/quote?strength=4", "GET")
// Code handling response or err below, omitted for clarity
...
}
我在我们的/common/circuitbreaker/hystrix.go文件中添加了cb.CallUsingCircuitBreaker 函数。它有点过于简单了,但基本上是包装了go-hystrix和重试库。出于可读性原因,我故意让代码更加冗长和不紧凑。
func CallUsingCircuitBreaker(breakerName string, url string, method string) ([]byte, error) {
output := make(chan []byte, 1) // Declare the channel where the hystrix goroutine will put success responses.
errors := hystrix.Go(breakerName, // Pass the name of the circuit breaker as first parameter.
// 2nd parameter, the inlined func to run inside the breaker.
func() error {
// Create the request. Omitted err handling for brevity
req, _ := http.NewRequest(method, url, nil)
// For hystrix, forward the err from the retrier. It's nil if successful.
return callWithRetries(req, output)
},
// 3rd parameter, the fallback func. In this case, we just do a bit of logging and return the error.
func(err error) error {
logrus.Errorf("In fallback function for breaker %v, error: %v", breakerName, err.Error())
circuit, _, _ := hystrix.GetCircuit(breakerName)
logrus.Errorf("Circuit state is: %v", circuit.IsOpen())
return err
})
// Response and error handling. If the call was successful, the output channel gets the response. Otherwise,
// the errors channel gives us the error.
select {
case out := <-output:
logrus.Debugf("Call in breaker %v successful", breakerName)
return out, nil
case err := <-errors:
return nil, err
}
}
如上所示,go-hystrix允许我们给断路器命名,我们也可以给出名称的细粒度配置。请注意,hystrix.Go函数将在一个新的goroutine中执行实际工作,在这个goroutine中,结果稍后会通过未缓冲(例如阻塞)的输出通道传递到选择代码段,该通道将有效地阻塞,直到输出或错误通道接收到消息为止。
接下来,使用go-resilience retrrie包的callWithRetries(...) 函数:
func callWithRetries(req *http.Request, output chan []byte) error {
// Create a retrier with constant backoff, RETRIES number of attempts (3) with a 100ms sleep between retries.
r := retrier.New(retrier.ConstantBackoff(RETRIES, 100 * time.Millisecond), nil)
// This counter is just for getting some logging for showcasing, remove in production code.
attempt := 0
// Retrier works similar to hystrix, we pass the actual work (doing the HTTP request) in a func.
err := r.Run(func() error {
attempt++
// Do HTTP request and handle response. If successful, pass resp.Body over output channel,
// otherwise, do a bit of error logging and return the err.
resp, err := Client.Do(req)
if err == nil && resp.StatusCode < 299 {
responseBody, err := ioutil.ReadAll(resp.Body)
if err == nil {
output <- responseBody
return nil
}
return err
} else if err == nil {
err = fmt.Errorf("Status was %v", resp.StatusCode)
}
logrus.Errorf("Retrier failed, attempt %v", attempt)
return err
})
return err
}
我在/goblog/common/circuitbreaker/hystrix_test.go文件中创建了三个单元测试,并运行CallUsingCircuitBreaker()函数。我们不会遍历所有的测试代码,一个例子就足够了。在这个测试中,我们使用gock来模拟对三个传出HTTP请求的响应,其中两个失败,最后一个成功:
func TestCallUsingResilienceLastSucceeds(t *testing.T) {
defer gock.Off()
buildGockMatcherTimes(500, 2) // First two requests respond with 500 Server Error
body := []byte("Some response")
buildGockMatcherWithBody(200, string(body)) // Next (3rd) request respond with 200 OK
hystrix.Flush() // Reset circuit breaker state
Convey("Given a Call request", t, func() {
Convey("When", func() {
// Call single time (will become three requests given that we retry thrice)
bytes, err := CallUsingCircuitBreaker("TEST", "http://quotes-service", "GET")
Convey("Then", func() {
// Assert no error and expected response
So(err, ShouldBeNil)
So(bytes, ShouldNotBeNil)
So(string(bytes), ShouldEqual, string(body))
})
})
})
}
上面测试的控制台输出如下所示:
ERRO[2017-09-03T10:26:28.106] Retrier failed, attempt 1
ERRO[2017-09-03T10:26:28.208] Retrier failed, attempt 2
DEBU[2017-09-03T10:26:28.414] Call in breaker TEST successful
其他测试断言,如果所有重试都失败,那么Hystrix回退函数c将运行,而另一个测试则确保在足够数量的请求失败时,Hhystrix断路器被打开。
Hystrix断路器可以通过多种方式进行配置。下面是一个简单的例子,我们指定了应该打开电路的失败请求数和重试超时:
hystrix.ConfigureCommand("quotes-service", hystrix.CommandConfig{
SleepWindow: 5000,
RequestVolumeThreshold: 10,
})
有关详细信息,请参阅文档。我的/common/circuitbreaker/hystrix.go “库”有一些代码可以自动尝试使用此命名约定从配置服务器中获取配置值:
hystrix.command.[circuit name].[config property] = [value]
例如:(在accountservice-test.yml中)
hystrix.command.quotes-service.SleepWindow: 5000
在这一部分的git的分支,还有更新的微服务代码和./copyall.sh,它建立并部署新的imageservice。这个真的没什么新鲜的。所以我们来看看断路器的运行情况。
在这种情况下,我们将运行一个小负载测试,默认情况下每秒将运行10个请求到/ accounts / {accountId}端点。
> go run *.go -zuul=false
(不要介意-zuul属性,这是博客系列的后面部分。)
假设我们分别有两个imageService和quotes-service实例。在所有服务运行正常的情况下,一些示例响应可能如下所示:
{"name":"Person_6","servedBy":"10.255.0.19","quote":{"quote":"To be or not to be","ipAddress":"10.0.0.22"},"imageUrl":"http://imageservice:7777/file/cake.jpg"}
{"name":"Person_23","servedBy":"10.255.0.21","quote":{"quote":"You, too, Brutus?","ipAddress":"10.0.0.25"},"imageUrl":"http://imageservice:7777/file/cake.jpg"}
如果我们关闭quotes-service:
> docker service scale quotes-service=0
我们几乎马上就会看到(由于连接被拒绝)回退函数如何进入并返回fallbackQuote:
{name":"Person_23","servedBy":"10.255.0.19","quote":{"quote":"May the source be with you, always.","ipAddress":"circuit-breaker"},"imageUrl":"http://imageservice:7777/file/cake.jpg"}
更有趣的是,当报价服务开始缓慢响应时,看看整个应用程序是如何反应的。在引用服务中有一个小的“功能”,可以在调用报价服务时指定散列强度。
http://quotes-service:8080/api/quote?strength=4
这样的请求通常在大约10毫秒内完成。通过将强度查询参数更改为13,quotes-service将使用大量的CPU,并且需要稍微少于一秒才能完成。这是一个很好的案例,可以看到我们的断路器在系统处于负载状态时如何反应,并且可能会导致CPU不足。让我们使用Gatling来分析两种情况:一种是我们禁用了断路器,另一种是断路器处于活动状态。
没有断路器,只使用标准的http.Get(url字符串):
第一个请求需要略少于一秒,但随后延迟增加,每个请求15-20 秒。我们两个报价服务实例(都使用100%CPU)的峰值吞吐量实际上不超过约3 req / s,因为它们完全缺乏CPU资源(老实说,它们都运行在我笔记本上的同一个群集节点上,在所有运行的微服务上共享两个CPU内核)。
断路器,超时设置为5000毫秒,即当有足够的请求等待超过5000毫秒时,电路将打开并返回报警。(注意最右边4-5秒左右的小条,这是电路处于“半开放”状态时的请求,以及电路打开前的一些早期请求)。
在该图中,我们看到测试中途的响应时间分布。在标记的数据点,断路器肯定是开放的,95%的第三百分位是10ms,而99%的第三百分位是超过4秒。换句话说,大约95%的请求在10ms内处理,但是一小部分(可能是半开的重试)在超时之前最多使用5秒。
在前15秒左右,绿/黄部分,我们看到或多或少的所有请求都是线性增加的延迟,接近5000毫秒阈值。这种行为与预期的一样,类似于我们在没有断路器的情况下运行。即请求可以成功处理,但需要很长时间。然后越来越多的延迟使断路器跳闸,我们立即看到大部分请求的响应时间如何回落到几毫秒而不是约5秒。如上所述,当处于“半开”状态时,断路器每隔一段时间发出请求。两个报价服务实例可以处理这些“半开放”请求中的几个,但是电路将几乎立即再次打开,因为引号服务 在延迟再次变得过高并且断路器重新跳闸之前,实例不能服务超过几个请求。
我们在这里看到关于断路器的两件巧妙的事情:
关于Hystrix的一个很好的事情是,有一个名为Hystrix Dashboard的配套Web应用程序,它可以提供微服务器内断路器当前正在进行的操作的图形表示。
它的工作原理是生成每秒更新一次的每个配置的断路器的状态和统计信息。然而,Hystrix控制面板一次只能读取一个这样的数据流,因此Netflix Turbine存在一种软件,它可以收集景观中所有断路器的数据流,并将这些数据聚合到仪表板可以消耗的一个数据流中:
图7 - 服务 - > Turbine - > Hystrix仪表板关系
在图7中,注意仪表盘猬请求的/turbine.stream从涡轮机服务器,和涡轮反过来请求/hystrix.stream从一些微服务的。凭借Turbine收集来自我们账户服务的断路器指标,仪表板输出可能如下所示:
图8 - Hystrix仪表板
Hystrix仪表板的GUI绝对不是最容易掌握的。在上面,我们看到帐户服务中的两个断路器以及它们在上述负载测试运行中的状态。对于每个断路器,我们可以看到断路器状态,请求次数,平均等待时间,每个断路器名称连接的主机数量和错误百分比。其中之一。下面还有一个线程池部分,但我不确定它们在根统计生产者是go-hystrix库而不是启用hystrix的Spring Boot应用程序时能够正常工作。毕竟 - 在使用标准goroutines时,我们并没有在Go中使用线程池的概念。
以下是运行上述部分负载测试时账户服务内部的“报价服务”断路器的简短视频:( 点击图片开始视频)
总而言之,Turbine和Hystrix仪表板提供了一个相当不错的监控功能,可以非常容易地实时查明不健康的服务或意外延迟。务必确保您的服务间通话在断路器内执行。
将Netflix Turbine&Hystrix仪表板与非Spring微服务和/或基于容器协调器的服务发现结合使用有一个问题。原因是Turbine需要知道在哪里找到那些/hystrix.stream端点,例如http://10.0.0.13:8181/hystrix.stream。在不断变化的微服务环境中,服务的扩展和缩减等等,必须存在确保Turbine尝试连接哪些 URL以消耗Hystrix数据流的机制。
默认情况下,Turbine依赖Netflix Eureka,微服务正在向Eureka注册。然后,Turbine可以在内部查询Eureka以获得可能的服务IP连接。
在我们的上下文中,我们运行在Docker Swarm模式上,并依靠群集模式中的内置服务抽象Docker为我们提供。我们如何将我们的服务IP加入Turbine?
幸运的是,Turbine支持插入自定义发现机制。除了协调者的服务发现机制之外,我想除了加倍使用尤里卡之外,还有两种选择。我认为这是第7部分中一个非常糟糕的主意。
该解决方案使用AMQP消息总线(RabbitMQ)和“发现”通道。当我们的微型服务有断路器启动时,他们找出他们自己的IP地址,然后通过我们的定制涡轮插件可以读取并转换成Turbine所理解的代理的代理发送消息。
图9 - 使用消息传递的Hystrix流发现。
在帐户服务启动时运行的注册码:
func publishDiscoveryToken(amqpClient messaging.IMessagingClient) {
// Get hold of our IP adress (reads it from /etc/hosts) and build a discovery token.
ip, _ := util.ResolveIpFromHostsFile()
token := DiscoveryToken{
State: "UP",
Address: ip,
}
bytes, _ := json.Marshal(token)
// Enter an eternal loop in a new goroutine that sends the UP token every
// 30 seconds to the "discovery" channel.
go func() {
for {
amqpClient.PublishOnQueue(bytes, "discovery")
time.Sleep(time.Second * 30)
}
}()
}
我的小断路器库的完整源代码可以在这里找到,它包装了go-hystrix和go-resilience 。
另一种选择是让自定义的Turbine插件使用Docker Remote API来获取容器及其IP地址,然后将其转换为Turbine可以使用的内容。这也应该起作用,但也有一些缺点,例如将插件绑定到特定容器协调器以及在Docker群模式管理器节点上运行Turbine。
我编写的Turbine插件的源代码和一些基本文档可以在我的个人GitHub页面上找到。由于它是基于Java的,因此我不会花费宝贵的博客空间在此上下文中详细描述它。
您还可以使用我在hub.docker.com上放置的预先构建的容器图像。只需启动Docker群集服务即可。
/ goblog / support / monitor-dashboard中存在Hystrix仪表板的可执行jar文件和Dockerfile 。定制涡轮机是从我上面链接的容器图像中最容易使用的。
我已经更新了我的shell脚本以启动自定义Turbine和Hystrix仪表板。在springcloud.sh中:
# Hystrix Dashboard
docker build -t someprefix/hystrix support/monitor-dashboard
docker service rm hystrix
docker service create --constraint node.role==manager --replicas 1 -p 7979:7979 --name hystrix --network my_network --update-delay 10s --with-registry-auth --update-parallelism 1 someprefix/hystrix
# Turbine
docker service rm turbine
docker service create --constraint node.role==manager --replicas 1 -p 8282:8282 --name turbine --network my_network --update-delay 10s --with-registry-auth --update-parallelism 1 eriklupander/turbine
另外,帐户服务 Dockerfile现在公开端口8181,因此可以从群集内读取Hystrix流。您不应将8181映射到docker service create命令中的公共端口。
我不知道涡轮机是否有轻微的错误或者是什么问题,但是我倾向于为Hystrix仪表板从Turbine接收一个流:
在博客系列的第11部分中,我们研究了断路器和弹性以及这些机制如何用于构建更容错和弹性的系统。
在博客系列的下一部分,我们将介绍两个新概念:Zuul EDGE服务器和使用Zipkin和Opentracing的分布式追踪。