MicroService - x893675/note GitHub Wiki

微服务学习&开发指南(golang向)

微服务相关的一些概念的个人理解以及golang微服务开发的例子

文中的代码例子可访问https://github.com/x893675/hal9000master分支及gokit分支查看

云原生

cloud-native的关键词就是微服务

原生云cloud-native应用的定义是:

  • 应用系统应该与底层物理基础设施解耦。 应用程序应该与操作系统等基础设施分离,不应该依赖Linux或Windows等底层平台,或依赖某个云平台。即应用从开始就设计为运行在云中,无论私有云或公有云;
  • 应用必须能满足扩展性需求 垂直扩展(向上和向下)或水平扩展(跨节点服务器)。

SOA面向服务架构

SOA是一种思想,是一个架构理念,强调服务共享和重用

微服务也指一种种松耦合的、有一定的边界上下文的面向服务架构

SOA是一种面向服务的单体架构,SOA是一种思想,它是一个架构理念,强调服务共享和重用

例1 (来自真实世界) :你去餐馆订餐,订单首先进入到柜台,然后在厨房进行食物准备,最后服务员提供食物。因此,为了实现一个餐厅订购服务,需要三个逻辑部门/服务协同工作(计帐,厨房和服务员)。

例2 (软件世界) :你去亚马逊订购了一本书,有不同的服务,如支付网关,库存系统,货运系统等共同完成一本书的订购。

所有的服务是自包含的,合乎逻辑。他们就像黑盒子。我们不需要了解业务服务的内部工作细节。对于外部世界,它只是一个能够使用消息交互的黑盒子。例如在“支付网关”业务服务获得消息“检查信贷”后会给出输出:这个客户的信贷有或没有。对于“订单系统”,“支付网关”的服务是一个黑盒子。

SOA服务特点:

  • SOA组件是松耦合的。当我们说松耦合,这意味着每一个服务是自包含单独存在的逻辑。

  • SOA服务是黑匣子。在SOA中,服务隐藏有内在的复杂性。他们只使用交互消息,服务接受和发送消息。通过虚拟化一个服务为黑盒子,服务变得更松散的耦合。

  • SOA服务应该是自定义: SOA服务应该能够自己定义。

  • SOA服务维持在一个列表中: 中心化的服务注册及发现

  • SOA服务可以编排和链接实现一个特定功能: SOA服务可以使用了即插即用的方式。例如,“业务流程”中有两个服务“安全服务”和“订单处理服务” 。从它的业务流程可以实现两种类型:一,可以先检查用户,然后处理订单,或反之亦然。使用SOA可以松散耦合的方式管理服务之间的工作流。

微服务架构

使用SOA时逐渐发现服务的解耦性必须高于服务的复用共享性,因此催生了微服务架构。

微服务是指开发一个单个 小型的但有业务功能的服务,每个服务都有自己的处理和轻量通讯机制,可以部署在单个或多个服务器上。

微服务也指一种种松耦合的、有一定的边界上下文的面向服务架构。也就是说,如果每个服务都要同时修改,那么它们就不是微服务,因为它们紧耦合在一起;

微服务优点

  • 每个微服务都很小,这样能聚焦一个指定的业务功能或业务需求。
  • 微服务能够被小团队单独开发,这个小团队是2到5人的开发人员组成。
  • 微服务是松耦合的,是有功能意义的服务,无论是在开发阶段或部署阶段都是独立的。
  • 微服务能使用不同的语言开发。
  • 微服务允许容易且灵活的方式集成自动部署,通过持续集成工具,如Jenkins, drone等 。
  • 一个团队的新成员能够更快投入生产。
  • 微服务易于被一个开发人员理解,修改和维护,这样小团队能够更关注自己的工作成果。无需通过合作才能体现价值。
  • 微服务只是业务逻辑的代码,不会和HTML,CSS 或其他界面组件混合。
  • 微服务能够即时被要求扩展。
  • 微服务能部署中低端配置的服务器上。
  • 易于和第三方集成。
  • 每个微服务都有自己的存储能力,可以有自己的数据库。也可以有统一数据库。

微服务缺点

  • 微服务架构可能带来过多的操作。
  • 需要DevOps技巧 (http://en.wikipedia.org/wiki/DevOps).
  • 可能双倍的努力。
  • 分布式系统可能复杂难以管理。
  • 因为分布部署跟踪问题难。
  • 当服务数量增加,管理复杂性增加

golang微服务开发

因golang的语言特性,比较适用于开发web服务。在微服务的框架选择上也没有多大的空间,基本都是grpc/http打天下。

开发框架主要有:

  • 原生grpc生态
  • go-micro:封装了grpc,http等的开发框架,功能完善,开发容易,附带服务治理功能
  • go-kit:微服务开发工具集,传输层支持grpc/http

go-micro

go-micro各种介绍的文章很多,这里就不再多赘述。

go-micro使用的例子可以参考个人写的一个小例子hal9000,其中使用k8s作为注册中心会在后文提到。

go-kit

go kit相关的文章和例子较少,这里多总结一下

gokit是一系列的工具的集合,能够帮助你快速的构建健壮的,可靠的,可维护的微服务。但是个人感觉没有go-micro方便,代码重复性太高,例子太少不利于理解,但是其代码结构分层对提高自身有启发性。

go-kit使用的例子可以参考hal9000 gokit分支

gokit的架构

gokit不同于传统的MVC的框架,它只是一系列工具的组合,他有着自己的层次结构,主要有三层,分别是transport,endpoint和service层。

  • transport层:这是一个抽象的层级,对应真实世界中的http/grpc/thrift等,通过gokit你可以在同一个微服务中同时支持http和grpc。

  • endpoint层:endpoint层对应于controller中的action,主要是实现逻辑的地方,如果你要同时支持http和grpc,那么你将需要创建两个方法同时路由到同一个endpoint。

  • service层:service是具体的业务逻辑实现的层级,在这里,你应该使用接口,并且通过实现这些接口来构建具体的业务逻辑。一个service通常聚合了多个endpoints,在service层,你应该使用clean architecture或者六边形模型,也就是说你的service层不需要知道enpoint以及transport层的具体实现,也不需要关心具体的http头部或者grpc的错误状态码。

  • middleware:middleware实现了装饰器模式,通过middleware你可以包装你的service或者endpoint,通常你需要构建一个middleware链来实现如日志,rate limit,负载均衡和分布式追踪。

缺点

  • 太复杂:添加api的开销高,且大多数代码是重复的。要添加一个api,需要做:
    • 声明一个interface,并定义相关的方法并实现这个interface
    • 实现endpoint的工厂方法
    • 实现transport方法
    • 实现每个endpoint的入参和出参的编码解码
    • 把endpoint添加到server
    • 把endpoint添加到client
  • 代码难理解
    • 虽然业务层,端点层,传输层分层清晰,有很好的抽象性,但是代码难以阅读,interface漫天飞

微服务与k8s及服务网格的集成

几乎所有的微服务框架都附带了服务治理,服务注册发现等功能,这部分功能与k8s/istio有很高的重复度,且大部分情况下不兼容。

k8s的service是一个四层代理,可以在微服务中用作服务注册和发现。istio则补全了k8s缺失的服务治理功能。

所以常见的微服务向k8s迁移有两种做法:

  • 微服务整个迁移,不需要对微服务做改动,放弃k8s的service等功能,单纯用做容器编排工具。
  • 将服务注册&发现及治理部分托管到k8s+istio,需要对微服务做改动,放弃微服务框架的这部分功能。

接下来以微服务的trace改造说明详细的改造步骤

微服务使用istio注意事项

  • istio通过对每个pod加入一个envoy的sidecar容器接管服务的进出流量,如果服务不是使用k8s作为注册中心,而是使用consul等注册中心,则istio不能显示服务间的调用关系及流量走向,如下图所示,使用的是consul作为注册中心的一个例子:

    而使用k8s作为注册中心,则可以清晰的的到服务间的调用关系和流量走向

  • istio目前能识别的是http1.1,http2.0以及grpc的流量,在定义k8s的service时,需要指明name,istio才能对特定的流量进行区别,例如如下service定义:

    apiVersion: v1
    kind: Service
    metadata:
      name: api
      labels:
        app: api
    spec:
      ports:
        - port: 8080
          targetPort: 8080
          name: http
      selector:
        app: api
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: auth-srv
      labels:
        app: auth
    spec:
      ports:
        - port: 8080
          targetPort: 8080
          name: grpc
      selector:
        app: auth
    

微服务对接istio调用链

分布式追踪

分布式追踪中的主要概念:

  • Trace: 一次完整的分布式调用跟踪链路
  • Span: 跨服务的一次调用;多个Span组合成一次Trace追踪记录

一个完整的调用链跟踪系统,包括调用链埋点,调用链数据收集,调用链数据存储和处理,调用链数据检索(除了提供检索的 APIServer,一般还要包含一个非常酷炫的调用链前端)等若干重要组件。istio现在默认使用的是jaeger作为trace系统,可以选择使用jaeger和zipkin的trace格式。

istio-trace

istio官方的介绍为:

Istio makes it easy to create a network of deployed services with load balancing, service-to-service authentication, monitoring, and more, without any changes in service code.

istio在使用时,不对代码做任何处理即可进行服务治理,但是实际使用过程中,不修改服务代码,istio的调用链总是断开的。

在 Istio 中,所有的治理逻辑的执行体都是和业务容器一起部署的 Envoy 这个 Sidecar,不管是负载均衡、熔断、流量路由还是安全、可观察性的数据生成都是在 Envoy 上。Sidecar 拦截了所有的流入和流出业务程序的流量,根据收到的规则执行执行各种动作。实际使用中一般是基于 K8S 提供的 InitContainer 机制,用于在 Pod 中执行一些初始化任务. InitContainer 中执行了一段 Iptables 的脚本。正是通过这些 Iptables 规则拦截 pod 中流量,并发送到 Envoy 上。Envoy 拦截到 Inbound 和 Outbound 的流量会分别作不同操作,执行上面配置的操作,另外再把请求往下发,对于 Outbound 就是根据服务发现找到对应的目标服务后端上;对于 Inbound 流量则直接发到本地的服务实例上。

Envoy的埋点规则为:

  • Inbound 流量:对于经过 Sidecar 流入应用程序的流量,如果经过 Sidecar 时 Header 中没有任何跟踪相关的信息,则会在创建一个根 Span,TraceId 就是这个 SpanId,然后再将请求传递给业务容器的服务;如果请求中包含 Trace 相关的信息,则 Sidecar 从中提取 Trace 的上下文信息并发给应用程序。
  • Outbound 流量:对于经过 Sidecar 流出的流量,如果经过 Sidecar 时 Header 中没有任何跟踪相关的信息,则会创建根 Span,并将该跟 Span 相关上下文信息放在请求头中传递给下一个调用的服务;当存在 Trace 信息时,Sidecar 从 Header 中提取 Span 相关信息,并基于这个 Span 创建子 Span,并将新的 Span 信息加在请求头中传递。

根据这个规则,对于一个api->A-这个简单调用,我们有如下分析:

  • 当一个请求进入api时,该请求头中没有任何trace相关的信息,对于这个inbound流量,istio会创建一个根span,并向请求头注入span信息。
  • 当api向A创建并发送rpc或http请求时,这个请求对于api的envoy来说时outbound流量,如果请求头中没有trace信息,会创建根span信息填入请求头
  • 这种情况下,在istio的jaeger页面上我们可以看到两段断裂的trace记录

结论埋点逻辑是在 Sidecar 代理中完成,应用程序不用处理复杂的埋点逻辑,但应用程序需要配合在请求头上传递生成的 Trace 相关信息

istio使用jaeger作为trace系统,格式为zipkin format。在请求头中有如下headers:

  • x-request-id
  • x-b3-traceid
  • x-b3-spanid
  • x-b3-parentspanid
  • x-b3-sampled
  • x-b3-flags
  • x-ot-span-context

注意: 在http请求中,比如使用gin框架时,这些header中的key应是首字母大写的,例如:X-Request-Id

代码示例分析

完成代码详见hal9000

以下代码段使用的是gin作为http框架,AuthSvc是rpc客户端,使用go-micro框架

import(
	"github.com/uber/jaeger-client-go"
	ot "github.com/opentracing/opentracing-go"
	"github.com/micro/go-micro/metadata"
	"github.com/gin-gonic/gin"
)


func (a *LoginController) Login(c *gin.Context) {
	//从http头中获得根span,使用istio时,该根span由envoy注入,记为root span
	inBoundSpanCtx, err := ot.GlobalTracer().Extract(ot.HTTPHeaders, ot.HTTPHeadersCarrier(c.Request.Header))
	//由根span创建一个子span,改span为span2
	span := ot.StartSpan("controller.(*LoginController).Login", 
		ot.ChildOf(inBoundSpanCtx),
		ot.Tags{
			"kind": "function",
		})
	//将span2与当前context绑定
	ctx := ot.ContextWithSpan(context.Background(), span)
	//在testtrace中再创建一个子span3
	testTrace(ctx)

    //从当前context中得到rpc调用的metadata,因为当前调用入口为http调用,所以ok永远为false
	md, ok := metadata.FromContext(ctx)
	if ok{
		fmt.Println("metadata from context is ok")
		for k, v := range md{
			fmt.Println(k,v)
		}
	}else{
		fmt.Println("metadata from context is not ok")
		md = make(map[string]string)
        //从span2的spancontext中获取trace信息,因为istio使用的是jaeger,所以将opentracing的接口进行类型断言转换为jaeger的spancontext,将span2的trance信息填入metadata
		if sc, ok := span.Context().(jaeger.SpanContext); ok {
			md["x-request-id"] = c.GetHeader("X-Request-Id")
			md["x-b3-traceid"] = sc.TraceID().String()
			md["x-b3-spanid"] = sc.SpanID().String()
			md["x-b3-sampled"] = c.GetHeader("X-B3-Sampled")
		}else{
			md["x-request-id"] = c.GetHeader("X-Request-Id")
			md["x-b3-traceid"] = c.GetHeader("X-B3-Traceid")
			md["x-b3-spanid"] = c.GetHeader("X-B3-Spanid")
			md["x-b3-sampled"] = c.GetHeader("X-B3-Sampled")
		}
        //从创建好的metadata中创建一个新的context
		ctx = metadata.NewContext(ctx, md)
	}

	var item schema.LoginParam
	if err := ginplus.ParseJSON(c, &item); err != nil {
		ginplus.ResError(c, err)
		return
	}
    //发送rpc请求时,使用新创建的携带了rpc metadata的context,该请求经过envoy时,envoy看到该outbound流量中的trace信息,会创建一个子span,传递给下一个服务,标记该span为span4
	response, err := a.AuthSvc.Verify(ctx, &auth.LoginRequest{
		Username: item.UserName,
		Password: item.Password,
	})

	if err != nil {
		ginplus.ResError(c, err)
		return
	}
    //结束span2
	span.Finish()
	ginplus.ResSuccess(c, response)
}

func testTrace(ctx context.Context){
    //传入的ctx已经与span2绑定,再创建一个子span,标记为span3
	span, _ := ot.StartSpanFromContext(ctx,
		"testTrace",
		ot.Tags{
		string(ext.SpanKind): "function",
	})
	fmt.Println("in test Trace function...")
	//span结束上报jaeger
    span.Finish()
}

由上图的注释分析得到下列span关系:

root span --> span2 -- span3
                    -- span4

在istio中就把之前分裂的两个trace记录合并为一个了。

结论:

  • 使用istio时,我们只需要对服务间调用的header信息进行透传
  • 如果想把服务内的调用关系与istio生成的trace合并,只需以istio生成的span作为父span,生成子span即可
  • 透传header的代码大都一致,可以做成一个通用的函数调用,减少服务代码的修改