Go微服务 - 第七部分 - 服务发现和负载均衡

第七部分: Go微服务 - 服务发现和负载均衡

本部分处理一个健全的微服务架构的两个基本部分 - 服务发现和负载均衡 - 以及在2017年, 它们如何促进重要的非功能性需求的水平扩展。git

简介

负载均衡是很出名的概念了,但我认为服务发现须要更深刻的理解, 我先从一个问题开始。github

若是服务A要和服务B通话,可是殊不知道到哪里找服务B如何处理?

换句话说, 若是咱们服务B在任意数量的集群节点上运行了10个实例, 则有人须要跟踪这10个实例。golang

所以,当服务A须要与服务B通讯时,必须为服务A提供至少一个适当的IP地址或主机名(客户端负载均衡), 或者服务A必须可以委托地址解析和路由到第三方给一个已知的服务B的逻辑名(服务端负载均衡). 在微服务领域不断变化的上下文中,这两种方式都须要出现服务发现。在最简单的形式中,服务发现只是为一个或多个服务注册运行实例。docker

若是这对你来讲听起来像DNS服务, 它确实如此。区别在于服务发现用于集群内部,让微服务互相能找到对方,而DNS通常是更加静态的、适用于外部路由,所以外部方能够请求路由到你的服务。此外,DNS服务和DNS协议一般不适合处理具备不断变化微服务环境的拓扑结构,容器和节点来来每每,客户端一般也不遵循TTL值、失败监测等等。apache

大多数微服务框架为服务发现提供一个或多个选项。 默认状况下,Spring Cloud/Netflix OSS使用Netflix Eureka(支持Consul, etcd和ZooKeeper), 服务使用已知的Eureka实例来注册本身,而后间歇性的发送心跳来确保Eureka实例知道它们依然活跃着。Consul提供了一个包含DNS集成的丰富的特征集的选项已经变得愈来愈流行。 其余流行的选项是分布式和可复制key-value存储的使用, 例如etcd中服务能够注册本身。Apache ZooKeeper也将会意识到这样需求的一群人。json

本文,咱们主要处理Docker Swarm提供的一些机制(Docker in swarm mode),并展现咱们在第五部分探索的服务抽象,以及它实际上如何为咱们提供服务发现和服务端负载均衡的。另外,咱们也会看看咱们单元测试中使用gock模拟HTTP请求输出的模拟, 由于咱们再作服务间通讯。api

注意: 当咱们引用Docker Swarm的时候,我指的是以swarm mode运行Docker 1.12以上版本。"Docker Swarm"在Docker 1.12以后再也不做为一个独立的概念存在了。

两种类型的负载均衡

在微服务领域,一般会区分上面提到的两种类型的负载均衡:缓存

  • 客户端负载均衡.
  • 服务端负载均衡.

客户端负载均衡
由客户端查询发现服务来获取它们要调用服务的实际地址信息(IP, 主机名, 端口号), 找到以后,它们可使用一种负载均衡策略(好比轮询或随机)来选择一个服务。此外,为了避免必要让每一个即将到来的调用都查询发现服务,每一个客户端一般都保持一份端点的本地缓存,这些端点必须与来自发现服务的主信息保持合理同步。 Spring Cloud中客户端负载均衡的一个例子是Netflix Ribbon。相似的东西在etcd支持的go-kit生态中也存在。客户端负载均衡的一些优势是具备弹性、分散性以及没有中心瓶颈,由于每一个服务消费者都本身保持有生产端的注册。 缺点就是具备较高的内部服务复杂性,以及本地注册可能会包含过期条目的风险。安全

clipboard.png

服务端负载均衡
这个模型中,客户端依赖负载均衡,提供服务逻辑名来查询它要调用服务的合适实例。这种操做模式一般称为代理, 由于它既充当负载均衡器又充当反向代理。我认为它的主要优势就是简单。 负载均衡器和服务发现机制通常都内置于你的容器编排器中,你无需关心安装和管理这些组件。另外,客户端(e.g. 咱们的服务)不须要知道服务注册 - 负载均衡器为咱们负责这些。 依赖负载均衡器来路由全部呼叫可能下降弹性,而且负载均衡器在理论上来讲可能成为性能的瓶颈。服务器

clipboard.png

客户端负载均衡和服务端负载均衡的图很是类似,区别在于LB的位置。

注意:当咱们使用swarm模式的Docker的服务抽象时, 例如上面的服务端的生产服务注册实际上对做为开发者的你来讲是彻底透明的。也就是说,咱们的生产服务甚至不会意识到它们在操做服务端负载均衡的上下文(或者甚至在容器编排的上下文中). Swarm模式的Docker负责咱们所有的注册、心跳、取消注册。

在blog系列的第2部分中,咱们一直在使用的例子域中, 咱们可能想要请求accountservice,让它从quotes-service获取当前的随机报价。 在本文中,咱们将集中使用Docker Swarm的服务发现和负载均衡机制。若是你对如何集成基于Go语言的微服务和Eureka感兴趣, 能够参考我2016年的一篇博客。我还编写了一个简单的自用的集成Go应用和Eureka客户端类库,它包含有基本的生命周期管理。

消费服务发现信息

假设你想构建一个定制的监控应用程序,并须要查询每一个部署服务的每一个实例的/health端点(路由)。你的监控程序如何知道须要请求的ip和端口呢? 你须要掌握实际的服务发现细节。若是你使用的是Docker Swarm来做为服务发现和负载均衡的提供者,而且须要这些IP, 你如何才能掌握Docker Swarm为咱们保存的每一个实例的IP地址呢? 对于客户端解决,例如Eureka, 你只须要使用它的API来消费就能够了。然而,在依赖编排器的服务发现机制的状况中,这可能不那么简单了。我认为须要追求一个主要选择, 以及一些次要选择来考虑更具体的用例。

Docker远程API

首先,我建议使用Docker的远程API - 例如使用来自服务内的Docker API来查询Swarm Manager的服务和实例信息。毕竟,你正在使用容器编排器的内置服务发现机制,那也是你应该查询的源头。对于可移植性,这是一个问题, 你能够老是为你选择的编排器选择一个适配器。 可是,应该说明的是,使用编排器的API也有一些注意事项 - 它将你的解决方案和特定容器API紧密的联系在一块儿, 你必须确保你的应用程序能够和Docker Manager进行对话, 例如,它们会意识到它们正在运行的一些上下文,使用Docker远程API的确有些增长了服务复杂度。

替代方案(ALTERNATIVES)

  • 使用另一个单独的服务发现机制 - 即运行Netflix Eureka, Consul或相似的东西,并确保除了Docker Swarm模式的机制外,在这些服务发现机制中也能够发现可注册/取消注册的微服务。而后咱们只须要使用发现服务的注册/查询/心跳等API便可。我不喜欢这个选项,由于它引入了更多复杂的东西到服务中,当Swarm模式的Docker能够或多或少透明的为咱们处理这些里边的大部分的事情。我几乎认为这是一种饭模式,若是除非你必需要这么作,不然仍是不要这样了。
  • 应用特定的发现令牌 - 在这种方式中,服务想要广播它们的存在,能够周期性的在一个消息话题上post一个带有IP, 服务名等等的发现令牌。消费者须要了解实例以及它们的IP, 能够订阅这个话题(Topic), 并保持它本身的服务实例注册即时更新。当咱们在稍后的文章中看不使用Eureka的Netflix Turbine, 咱们就会使用这个机制来向一个定制的Turbine发现插件提供信息。这种方式有点不一样,由于它们不须要充分利用完整的服务注册表 - 毕竟,在这个特定的用例中,咱们只关心特定的一组服务。

源代码

请放心的切出本部分的代码: https://github.com/callistaen...

扩展和负载均衡

咱们继续本部分,看看如何扩展咱们的accountservice, 让它们运行到多个实例中,而且看咱们是否能让Docker Swarm自动为咱们将请求负载均衡。

为了想要知道具体什么实例真正的为咱们提供服务,咱们须要给Account添加一个字段, 咱们可使用生产服务实例的IP地址填充它。打开/accountservice/model/account.go文件。

type Account struct {
    Id string `json:"id"`
    Name string  `json:"name"`
    
    // NEW
    ServedBy string `json:"servedBy"`
}

而后在提供account服务的GetAccount方法中为account添加ServedBy属性。

func GetAccount(w http.ResponseWriter, r *http.Request) {
    // Read the 'accountId' path parameter from the mux map
    var accountId = mux.Vars(r)["accountId"]

    // Read the account struct BoltDB
    account, err := DBClient.QueryAccount(accountId)

    account.ServedBy = getIP()      // NEW, add this line
    ...
}

// ADD THIS FUNC
func getIP() string {
    addrs, err := net.InterfaceAddrs()
    if err != nil {
        return "error"
    }
    for _, address := range addrs {
        // check the address type and if it is not a loopback the display it
        if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
            if ipnet.IP.To4() != nil {
                return ipnet.IP.String()
            }
        }
    }
    panic("Unable to determine local IP address (non loopback). Exiting.")
}

咱们使用getIP()获取机器IP,而后填充给ServedBy。在真正的项目中,getIP函数应该放在具体的工具包中,这样每一个微服务须要获取非回送IP地址(non-loopback ip address)的时候均可以使用它。

而后使用copyall.sh从新构建并部署accountservice服务。

./copyall.sh

等待几秒钟,而后输入下面命令:

> docker service ls
ID            NAME             REPLICAS  IMAGE
yim6dgzaimpg  accountservice   1/1       someprefix/accountservice

使用curl访问以下:

> curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.255.0.5"}

很好,咱们已经看到响应中包含有容器的IP地址了。下面咱们对这个服务进行扩展。

> docker service scale accountservice=3
accountservice scaled to 3

等几秒钟,而后再执行docker service ls进行查看,获得下面的内容:

> docker service ls
ID            NAME             REPLICAS  IMAGE
yim6dgzaimpg  accountservice   3/3       someprefix/accountservice

上面表示accountservice被复制了3份。而后再进行curl屡次请求account, 看看咱们是否每次都获得不同的ip地址呢。

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.0.0.22"}

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.255.0.5"}

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.0.0.18"}

curl $ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.0.0.22"}

在10.0.0.22处理完当前请求以前,咱们能够看到4次调用分别在三个实例以内循环。这种使用Docker Swarm服务抽象的容器编排提供的负载均衡是很是有吸引力的,由于它把基于负载均衡(例如Netflix Ribbon)的客户端的复杂性去掉了, 而且咱们能够负载均衡而无需依赖服务发现机制来为咱们提供能调用的IP地址列表。此外,从Docker Swarm 1.3不会路由任何流量到那些没有报告它们本身是健康的节点上, 前提是实现了健康检查。这就很是重要,当你须要将规模变大或变小的时候,特别是你的服务很是复杂的时候,可能须要超过几百毫秒来启动咱们当前须要的accountservice。

FOOTPRINT AND PERFORMANCE WHEN SCALING

有趣的是,若是咱们将accountservice实例从1个扩展为4个的时候如何影响延迟和CPU/内存使用的。当Swarm模式的负载均衡器轮询咱们请求的时候是否是有实质性的开销?

docker service scale accountservice=4

等待几秒,让全部事情就绪。

在负载测试时CPU和内存使用状况

使用每秒1000个请求来运行Gatling测试。

CONTAINER                                    CPU %               MEM USAGE / LIMIT       
accountservice.3.y8j1imkor57nficq6a2xf5gkc   12.69%              9.336 MiB / 1.955 GiB 
accountservice.2.3p8adb2i87918ax3age8ah1qp   11.18%              9.414 MiB / 1.955 GiB 
accountservice.4.gzglenb06bmb0wew9hdme4z7t   13.32%              9.488 MiB / 1.955 GiB 
accountservice.1.y3yojmtxcvva3wa1q9nrh9asb   11.17%              31.26 MiB / 1.955 GiB

很是好,咱们四个实例几乎彻底享有相同的工做负载, 咱们看到另外3个新实例内存保持在10M之内, 鉴于这样的状况,每一个实例应该不须要服务超过250个请求/s。

性能

首先,Gatling引用一个实例:

clipboard.png

而后,Gatling引用4个实例:

clipboard.png

区别不是很大 - 可是不该该啊 - 全部四个服务实例毕竟都运行在一样的虚拟主机Docker Swarm节点, 而且共用相同的底层硬件(例如个人笔记本)。若是咱们给Swarm添加更多可视化实例,它们能够利用未使用主机OS的资源, 那么咱们会看到更大的延迟减小,由于它将被分离到不一样的逻辑CPU等上来处理负载。然而,咱们看到性能的稍微增长,平均大概百分之95/99。咱们能够彻底得出一个结论, 在这个特定的场景中,Swarm模式负载均衡对性能没有什么负面影响。

带出Quote服务

还记得咱们在第5部分部署的Java实现的quote服务吗? 让咱们将它也扩展多个,而后从accountservice里边调用它,使用quotes-service名。 添加这个的目的是展现服务发现和负载均衡有多透明, 咱们惟一须要作的就是要知道咱们要调用服务的逻辑服务名。

咱们将编辑/goblog/accountservice/model/account.go文件,所以咱们的响应会包含一个quote。

type Account struct {
    Id string `json:"id"`
    Name string  `json:"name"`
    ServedBy string `json:"servedBy"`
    Quote Quote `json:"quote"`         // NEW
}

// NEW struct
type Quote struct {
    Text string `json:"quote"`
    ServedBy string `json:"ipAddress"`
    Language string `json:"language"`
}

注意,上面咱们使用json tag来未来自quotes-service输出的字段映射到咱们字节结构体的quote字段,它包含有quote, ipAddress和servedBy字段。

继续编辑/goblog/accountservice/service/handler.go。咱们将田间一个简单的getQuote函数,执行一个HTTP调用,请求http://quotes-service:8080/api/quote, 这个请求会返回一个quote值,而后咱们用它来产生新的结构体Quote。 咱们在GetAccount()方法中调用它。

首先,咱们处理下链接: Keep-Alive问题,它会致使负载均衡问题,除非咱们明确的恰当配置Go语言的client。在handlers.go中,在GetAccount函数上面添加以下代码:

var client = &http.Client{}

func init() {
    var transport http.RoundTripper = &http.Transport{
        DisableKeepAlives: true,
    }
    client.Transport = transport
}

init函数会确保任何有client实例发出的HTTP请求都具备适当的报头, 确保基于负载均衡的Docker Swarm都能正常工做。接下来,在GetAccount函数中,添加一个包级别的getQuote函数。

func getQuote() (model.Quote, error) {
    req, _ := http.NewRequest("GET", "http://quotes-service:8080/api/quote?strength=4", nil)
    resp, err := client.Do(req)

    if err == nil && resp.StatusCode == 200 {
        quote := model.Quote{}
        bytes, _ := ioutil.ReadAll(resp.Body)
        json.Unmarshal(bytes, &quote)
        return quote, nil
    } else {
        return model.Quote{}, fmt.Errorf("Some error")
    }
}

没有什么特别的。 参数strength=4是quotes-service API特有的,能够用于使它能或多或少的消耗CPU。使用这个请求还有一些问题,咱们返回了一个通常化的error。

咱们将在GetAccount函数中调用新的getQuote函数, 若是没有发生错误的话,将它的返回值的Quote属性赋给Account实例。

// Read the account struct BoltDB
account, err := DBClient.QueryAccount(accountId)
account.ServedBy = getIP()

// NEW call the quotes-service
quote, err := getQuote()
if err == nil {
    account.Quote = quote
}
全部的错误检查是我在Go语言中最不喜欢的事情之一,虽然它能产生很安全的代码,也能够更清楚的表达代码的意图。

不产生HTTP请求的单元测试

若是咱们运行/accountservice/service/handlers_test.go的单元测试, 它就会失败。 test下面的GetAccount函数如今会尝试发起一个HTTP请求来获取著名的引言, 可是既然没有quote-service运营在特定的URL(我猜测它不能解决任何事), 测试就不能经过。

咱们能够有两种可选策略用在这, 给定单元测试一个上下文。

  • 将getQuote函数提取为一个接口,提供一种真实实现和一种模拟实现, 就像咱们在第四部分,为Bolt客户端那样作的同样。
  • 利用HTTP特定的模拟框架来拦截咱们将要发出的请求,并返回一个提早肯定的响应。

内置httptest包能够为咱们开启一个嵌入的HTTP服务器, 能够用于单元测试,可是我更喜欢第三方gock框架,它更加简洁也便于使用。

func init() {
    gock.InterceptClient(client)
}

上面咱们添加了一个init函数。这样能够确保咱们的http client实例会被gock劫走。

gock DSL为指望发出的HTTP请求和响应提供了细粒度的控制。 在下面的示例中,咱们使用New(), Get()和MatchParam()来告诉gock指望http://quotes-service:8080/api/quote?strength=4 GET请求并响应HTTP 200, 并硬编码响应body。

在TestGetAccount函数上面添加以下代码:

func TestGetAccount(t *testing.T) {
        defer gock.Off()
        gock.New("http://quotes-service:8080").
                Get("/api/quote").
                MatchParam("strength", "4").
                Reply(200).
                BodyString(`{"quote":"May the source be with you. Always.","ipAddress":"10.0.0.5:8080","language":"en"}`)

defer gock.Off()确保在当前测试完成后关闭HTTP的劫获, 既然gock.New()会返回http劫获, 这样可能会让后续测试失败。

下面让咱们断言指望返回的quote。 在TestGetAccount测试最里边的Convey块中添加新的断言:

Convey("Then the response should be a 200", func() {
    So(resp.Code, ShouldEqual, 200)

    account := model.Account{}
    json.Unmarshal(resp.Body.Bytes(), &account)
    So(account.Id, ShouldEqual, "123")
    So(account.Name, ShouldEqual, "Person_123")
    
    // NEW!
    So(account.Quote.Text, ShouldEqual, "May the source be with you. Always.")
})

运行测试

> go test ./...
?       github.com/callistaenterprise/goblog/accountservice    [no test files]
?       github.com/callistaenterprise/goblog/accountservice/dbclient    [no test files]
?       github.com/callistaenterprise/goblog/accountservice/model    [no test files]
ok      github.com/callistaenterprise/goblog/accountservice/service    0.011s

部署并在Swarm上运行

一样咱们使用copyall.sh脚原本从新构建和部署。 而后经过curl调用account路由:

> curl $ManagerIP:6767/accounts/10000
  {"id":"10000","name":"Person_0","servedBy":"10.255.0.8","quote":
      {"quote":"You, too, Brutus?","ipAddress":"461caa3cef02/10.0.0.5:8080","language":"en"}
  }

而后将quotes-service扩展成两个实例。

> docker service scale quotes-service=2

等待一段时间,大概15-30秒,由于Spring Boot的服务没有Go语言的服务启动快。 而后再使用curl调用几回, 结果可能以下所示:

{"id":"10000","name":"Person_0","servedBy":"10.255.0.15","quote":{"quote":"To be or not to be","ipAddress":"768e4b0794f6/10.0.0.8:8080","language":"en"}}
{"id":"10000","name":"Person_0","servedBy":"10.255.0.16","quote":{"quote":"Bring out the gimp.","ipAddress":"461caa3cef02/10.0.0.5:8080","language":"en"}}
{"id":"10000","name":"Person_0","servedBy":"10.0.0.9","quote":{"quote":"You, too, Brutus?","ipAddress":"768e4b0794f6/10.0.0.8:8080","language":"en"}}

咱们能够看到servedBy很好的在accountservice实例中循环。 咱们也能够看到quote的ipAddress字段也有两个不一样的IP. 若是咱们已经禁用了keep-alive行为的话, 咱们可能看到一样的accountservice服务保持一样的quotes-service来提供服务。

总结

在本节内容中,咱们接触到了微服务上下文中的服务发现和负载均衡的概念, 以及实现了调用其余服务,只须要提供服务逻辑服务名便可。

在第8节中,咱们转向另一个可自由扩展的微服务中最重要的概念, 集中配置。

参考链接

相关文章
相关标签/搜索