为什么您不能忽视分布式跟踪以实现可观测性

导航至

可观测性是一个热门话题,但很多人并不知道它真正的含义。如今,每个人都在谈论监控与可观测性的区别,我有机会体验了我认为是这个运动背后的主要概念。

首先,监控很复杂。仪表板无法扩展,因为它们通常在故障发生之后才会揭示你需要的信息,而且在某一点上,寻找图表中的峰值成为一种令人眼花缭乱的练习。而这并不是监控——它只是理解某事物不工作的一种“不太聪明”的方法。换句话说,监控只是冰山一角,而坚实的基础则是你对系统的了解。

然而,如今分布式系统过于复杂,难以理解。一个请求可能会在多个服务和应用程序之间来回传递,其中一些甚至不是您公司的。处理请求的每一个参与者都可能会失败,当这种情况发生时,您需要一种方式来回答基本问题:“刚才发生了什么?”可观察性通过一系列工具、方法和思维方式,使您和您的团队能够了解系统。

特别是,我认为分布式跟踪是您工具箱中必须拥有的可观察性工具之一。在这里,我将分享我得出这一结论的经历。

我目前在InfluxData公司担任SRE,主要负责我们的SaaS产品InfluxDB Cloud。几个月前,经过一段疯狂的增长期后,我们得知我们的自研调度器(当时的情况)不可持续,并且它没有给我们足够的信心来了解事物是否处于正常工作状态。幸运的是,我们非常了解代码,因为我们从头开始编写了它,因为我们没有使用标准的配置管理工具或像Kubernetes这样的调度器——我们编写了自己的小型调度器来解决我们的特定用例。它被设置在AWS上每个拥有InfluxDB、Chronograf、Kapacitor和其他附加组件的客户的独立环境中的守护程序上。

我们决定从最重要的最复杂的流程开始重构应用程序。

  • 集群创建:为新客户创建子网、安全组和实例的流程;
  • 集群终止:当客户停止为服务付费时使用;
  • 添加附加组件:启动Grafana、Kapacitor或Chronograf等附加组件。

为了做到这一点,我们使用了一种称为反应式规划的样式。基本上,您创建一个计划(例如集群创建),然后将计划分解为不同的步骤

  • 创建安全组;
  • 配置入口/出口权限;
  • 创建实例;
  • 等待实例运行;
  • 创建负载均衡器。

有一个调度器会取这个计划,并按顺序执行每个步骤,最重要的是,它会再次执行整个计划。当计划第二次执行时,它应该没有步骤返回,因为这意味着所有步骤都应该已经执行,这表明计划已经解决。在这个上下文中,这很好,因为它迫使您再次确认一切是否正常。例如,计划检查AWS上是否创建了安全组,如果已创建,则不会返回该步骤。这使得资源配置坚如磐石。

另一个良好的副作用是,所有执行日志都在同一个地方,即在调度器内。这意味着只有一个地方可以查看以了解正在发生的事情。调度器看起来是这样的

func (s *Scheduler) Execute(ctx context.Context, p Plan) error {
	for {
                       // Create the plan.
		steps := p.Create(ctx)
		if len(steps) == 0 {
			break
		}
		err := s.react(ctx, steps)
		if err != nil {
			return err
		}
	}
	return nil
}

The react function is recursion because steps can return new steps.

func (s *Scheduler) react(ctx context.Context, steps []Procedure) error {
	for _, step := range steps {
		span, _ := opentracing.StartSpanFromContext(ctx, step.Identifier())
		step.WithSpan(span)

		logger := s.logger
		f := []zapcore.Field{zap.String("step", step.Identifier())}
		zipkinSpan, ok := span.Context().(zipkin.SpanContext)
		if ok == true && zipkinSpan.TraceID.Empty() == false {
			f = append(f, zap.String("trace_id", zipkinSpan.TraceID.ToHex()))
		}
		logger = s.logger.With(f...)
		step.WithLogger(logger)

		innerSteps, err := step.Do(ctx)
		if err != nil {
                             ….
		}
		span.Finish()
		if len(innerSteps) > 0 {
			if err := s.react(ctx, innerSteps); err != nil {
				return err
			}
		}
	}
	return nil
}

在除Do()函数之外的每个步骤中,执行逻辑有两个函数:WithLoggerWithSpan。我添加此函数仅用于可观察性目的。如您所见,我正在使用opentracing对我的计划进行度量。

只需几行代码,我就能配置日志记录器始终暴露 trace_id,这样我就可以轻松地按请求查询日志。在步骤内部,我可以通过 span 来轻松访问,并基于 span 内发生的情况获得更多上下文。例如,我们使用 etcd。在 etcd 中保存信息的步骤包含键和值。当我查看跟踪时,我就能理解记录在执行计划过程中的变化。

此外,我们还有一个前端,它会为与后端的每次交互记录 trace_id

Nov 15 19:04:45  PATCH https://what.net/v1/clusters/idg trace_id:d572232a8fed45fa 422

我之所以能从日志中获取它,是因为后端将 trace_id 作为头部返回。这是一种查找日志或直接从特定请求中查找跟踪的简单方法,如果你能教会用户将其附加,例如,在支持票据中。

然而,几天前我们遇到了一个问题。一些集群(比例很低)在创建过程中失败,但 AWS 没有报告任何错误(顺便说一下,我们也跟踪所有 AWS 请求)。所有资源都创建好了,但 EC2 没有注册到目标组。

创建集群是一个复杂的流程,因为与 AWS 有超过 40 次交互,完成所有步骤可能需要超过 10 分钟。但查看跟踪,很容易理解错误发生在哪里。这是跟踪的一部分快照,该部分负责将 EC2 注册到正在运行的集群的负载均衡器。

每个步骤都有一个名称,我正在查看的是 register_cluster_node_to_lb。正如你所看到的,它调用一次 AWS.EC2 来获取所有要附加的实例 ID,并两次调用 AWS.ELBv2 服务:一次获取正确的目标组,一次注册实例。

将这个跟踪与失败的跟踪进行比较,很容易看出在第二个屏幕中只有一个请求到 AWS.ELBv2。所以问题就在这里!它最终变成了一个处理不当的错误。

这个特定的应用程序负载不大,但有关键的流程,其中故障排除能力很重要。对计划执行的可见性是一个功能,而不是一个选项。这就是为什么我们跟踪所有 AWS 请求;例如,甚至包括请求和响应。

可观察性不仅在生产中重要,在开发中也很重要,因为它教会你你的程序是如何工作的。这就是为什么我也在开发中使用这个设置。可观察性是提高你开发功能速度和质量的工具。这很好,因为它使得可观察性很容易改进和保持更新。相反,监控只在生产中重要,你通常不会在开发周期中关心触发警报。

我们目前在生产中运行这个系统,这让我对我们的支持团队在请求我帮助他们调试客户问题时所需的信心。这特别有用,因为几秒钟内我就能直观地了解失败发生的位置,因为反应性计划突出显示需要重构、测试或优化的相关代码。

我认为分布式跟踪是开发周期中的关键,因为现代应用程序的架构与旧的应用程序非常不同——不是因为现代应用程序更小或更微,而是因为它们与外部数据库、第三方服务等有很多集成点。断路器和重试策略使得调试变得复杂得多,如果这些失败不经常发生,处理这些失败可能不可预测或非常昂贵。考虑到这一点,对于某些失败,拥有一种快速了解新问题的方法比试图预测和避免所有可能发生的失败要好,因为这是不可能的。