原文链接:https://medium.com/opentracing/take-opentracing-for-a-hotrod-ride-f6e3141f7941
OpenTracing是一个新的开放标准,适用于应用和开源软件包的分布式链路追踪和监控。本文将借助一个demo带你探索OpenTracing的特性及功能,一步步向你展示如何在实践中应用OpenTracing概念来监控基于微服务的体系结构并进行性能问题的根因分析;本文还强调了OpenTracing的关键特性:厂商无关,平台无关,OpenTracing允许自由的选用其他开源框架(例如RPC框架)实现分布式跟踪。在Demo源码中的RPC调用中并没有显式的使用OpenTracing API,因为开源社区的一些免费工具可以自动帮我们完成。
介绍本Demo程序及其特性
借助Jaeger UI理解Demo程序的架构和数据流
对比普通服务日志和OpenTracing中的日志
在调用图中识别延迟和错误的来源
通过“baggage” 在链路中传递调用链顶端的信息,实现调用链路耗时的统计
无需任何其他工具即可获取RPC远端的指标
查看有关如何实现上述功能的代码示例。其中大部分功能都无需繁琐的手动操作,因为我们使用的开源框架已经集成了OpenTracing
下面我们将会使用Jaeger,一个开源的分布式追踪系统,来采集并查看分析应用的行为。让我们使用all-in-one的Docker镜像来启动Jaeger后端服务:
$ docker run -d --name jaeger \
-e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
-p 5775:5775/udp \
-p 6831:6831/udp \
-p 6832:6832/udp \
-p 5778:5778 \
-p 16686:16686 \
-p 14268:14268 \
-p 14250:14250 \
-p 9411:9411 \
jaegertracing/all-in-one:1.18
容器启动后,我们可以就在浏览器访问Jaeger UI了:http://127.0.0.1:16686/。
容器里的Jaeger后端使用的是内存存储引擎,首次启动时是空的,所以现在我们在Jaeger UI上暂时还不能查看具体的trace信息,让我们启动demo程序生成一些trace信息吧。
HotROD是一个网约车应用demo程序,让我们下载并启动它(请参考最新的README指导):
git clone [email protected]:jaegertracing/jaeger.git jaeger
cd jaeger/examples/hotrod
go run ./main.go all
注:从Jaeger项目的1.3版本开始,引入了HotROD 程序的Docker镜像(请参阅README),以快速启动并测试该程序,但在本文中我们会修改一些源代码,所以我们使用编译源码启动。
这里的all
命令通知程序在一个二进制文件中启动所有的微服务。我们可以在标准输出中打印的日志中看到这些微服务已经启动并监听着不同的端口:
2017–05–03T23:53:19.693–0400 INFO cmd/all.go:31 Starting all services
2017–05–03T23:53:19.696–0400 INFO log/logger.go:43 Starting {“service”: “route”, “address”: “http://127.0.0.1:8083"}
2017–05–03T23:53:19.696–0400 INFO log/logger.go:43 Starting {“service”: “frontend”, “address”: “http://127.0.0.1:8080"}
2017–05–03T23:53:19.697–0400 INFO log/logger.go:43 Starting {“service”: “customer”, “address”: “http://127.0.0.1:8081"}
2017–05–03T23:53:19.697–0400 INFO log/logger.go:43 TChannel listening {“service”: “driver”, “hostPort”: “127.0.0.1:8082”}
让我们打开服务的主入口:http://127.0.0.1:8080/
界面中有四个按钮分别代表一位乘客,点击其中一个即可为该乘客发起约车请求,请求到达后端,经过计算后端会返回接单司机的车牌号及预计到达时间。
界面上会显示一些调试信息:
web client id: 9323
,它是由JavaScript UI随机生成的session ID,如果刷新页面,会生成一个不同的session IDreq: 9323–1
代表请求ID,由Session ID和一个序列号组成,会被发送至后端latency: 782ms
是有JavaScript UI测量的请求响应时间以上这些附加信息不会影响应用程序的行为,但对我们深入了解背后的机制很有帮助。
现在我们已经清楚了整个应用的功能,我们可能想知道它的架构情况。毕竟,也许我们在日志中看到的所有那些服务只是用于展示,而整个应用程序仅仅是一个JavaScript前端。在不向开发人员索要设计文档的情况下,Jaeger可以通过观察服务之间的交互来自动构建架构图,这听起来不是很好吗?这正是Jaeger的能力!我们执行约车的请求已经提供了足够的数据,让我们转到“依赖关系”页面,然后点击DAG标签:
事实证明,HotROD的单个二进制文件实际上正在运行四个微服务,并且很显然,还有两个存储服务。其实,这两个存储接点并不是真实存在的,它们是由应用的内部组件模拟的,但是前四个微服务的确是真实存在的,我们在前面已经看到了这四个微服务监听地址的日志。frontend
服务由Javascript UI组成,并且负责向其他三个服务发起RPC调用。上图也表明了一次约车请求,各个服务间的调用次数,比如,route
服务被调用了10次,还有对redis
的14次调用。
我们已经了解到HotROD应用由四个微服务构成,那么请求在各服务间究竟是怎样流转的呢?是时候查看以下真正的追踪信息了。我们回到Jaeger UI的搜索页面,在Find Traces
标题下,有个Services
的下拉菜单包含我们刚才看到的那些微服务。我们知道frontend
服务是我们的根服务,所以我们这里我们选择frontend
并点击Find Traces
。
Jaeger系统已经发现了一条追踪信息和一些相关的元信息,比如这条追踪链上涉及服务的名字、各服务向Jaeger上报的span
的总数。如上图标题栏所示,整个调用链路的顶层根入口是HTTP GET /dispatch
,在右侧我们可以看出整个调用的耗时为774.85ms
;这个数据比我们之前在HotROD UI
上看到的小一点点,这是因为HotROD UI
上是由JavaScript测量的,7.15ms
的误差是由http连接建立及http传输的耗时引起的。下面让我们点击并查看这条追踪信息:
上面的时间线展示了一条包含多个嵌套span
的追踪信息的典型视图,这里的sapn
表示某服务内的一个工作单元。顶层span
有时也称为root span
,代表从Javascript UI
到frontend
的HTTP请求,期间,frontend
又调用了customer
,后者又调用了MySQL。span
的宽度与操作的耗时成正比,这里的操作可能是内部的计算或者下游的调用。
从上图中,我们可以清晰地看出应用服务如何处理一个请求:
frontend
服务收到一条外部的HTTP GET请求,请求路径为/dispatch
frontend
服务向customer
服务发起一条HTTP GET请求,请求路径为/customer
customer
服务调用MySQL执行了一条SQL SELECT语句,处理结果被返回至frontend
服务driver
服务发起了RPC调用(Driver::findNearest
),在没有深挖这条trace信息前,我们还看不出使用的是哪一个RPC框架,但是我们可以猜测不是HTTP(实际上是TChannel)driver
服务向redis
发起了一系列的请求,其中一些请求被使用粉色背景高亮,表明是失败frontend
向route
服务发起一条HTTP GET请求,请求路径为/route
frontend
向外部调用者(浏览器)返回处理结果如果我们点击上图时间线中的任一span
,它会展开并显示详细信息,包括span tags
、process tags
和logs
。让我们点击一个失败Redis请求的span:
我们可以看到tags信息中有一条error=true
,所以这个span被高亮;在Logs下还可以看到一些错误信息的原始声明——“redis timeout”,我们也可以看到driver
服务尝试从Redis获取的driver_id。
现在我们已经对整个应用的功能有了充分的了解,除了底层的运作机制,比如,为什么frontend
服务要调用customer
服务的/customer
接口?当然,我们可以查看源代码,但是我们这次想从应用监控的角度去分析,可取的一个方向是查看这个应用输出的日志。
从这些繁杂的日志中找出应用的执行逻辑是非常困难的,而且通常我们查看日志时只想查看某一次调用的日志,想象一下大量并发的请求进入系统,在这种情况下日志凌乱到几乎没用。所以唱我们另辟蹊跷,看一下追踪系统收集的日志,点击root span
展开详情并展开它的日志:
上图只展示了本次请求期间的日志,我们称之为情景化日志,因为它们是在特定请求甚至请求中中特定范围的上下文中捕获的。我们在前面看到过对Redis调用超时的一次失败的请求,在普通的标准输出日志中,这条错误早就被其他的日志淹没了,但在追踪系统中,它与相关的服务和span
完全隔离。情景化日志使我们能够深入分析应用程序的行为,而不必担心程序其他部分或其他并发请求的日志。
让我们多展开一些span
:
在customer span我们可以看到一个http.url的tag,它表示请求/customer路径上的有一个customer=123的参数;在mysql span,可以看到一个sql.query的tag,表明执行的具体语句。
那么,数据应该放到span tag和span log呢?OpenTracing API并没有硬性规定;一个通用的原则是:对于那些适用于整个span的信息应该记录在tag;对于有时间戳的事件应该记录在log。
OpenTracing技术参数定义了一些语义术语约定,这些约定规定了常见场景的一些共识tag名和log字段。鼓励使用这些名字,以保证上报的数据被追踪系统更好地定义,并能够在在不同的追踪系统后端方便的移植。
目前为止我们还没有讨论过HotROD应用的性能,回到请求的时间线分布视图,我们可以轻易地得出以下结论:
customer
服务的调用是这次外部请求的主要瓶颈,因为在获取到乘客的信息之前,我们不能进行其他工作,后续的分派司机的任务都需要乘客的信息driver
服务获取距离给定乘客最近的10名司机,然后依次从Redis获取各司机的信息,在redis GetDriver
span可以看到route
服务的调用不是串行的,但也不全是并行的;可以看出大多数时候是3个请求并行起执行,每当有一个请求结束了,就有新的请求开始接入,这正是使用固定大小执行池的典型现象当我们同时向后台发起大量请求会发生什么呢?让我们回到HotROD UI 并快速重复点击其中的一个按钮:
正如我们所见,请求并发度越高,服务端的响应时间越长。让我们看一下最慢的一次请求的追踪信息。我们可以按照响应时间排序快速找到最慢的请求。
让我们查看这个耗时长达1.8秒的追踪信息,并和我们之前查看过的一个耗时700ms的信息作对比。
最明显的差别是mysql span比之前的请求时长大幅增加,从305ms增加到了1.37s,让我们展开这个span并尝试分析原因。
在日志中我们看到获取锁就被阻塞了超过1秒,这是一个明显的性能瓶颈;但是在深入分析之前,我们先来看一下以前的日志记录,该记录显然是在被锁锁定之前发出的,它告诉我们已经有多少其他请求排队等待该锁,甚至还提供了这些请求的标识。不难想象一个Lock可以实现跟踪被阻塞goroutine的数量,但是它在哪里获得请求的身份呢?如果我们展开customer服务的先前的span,我们可以看到通过HTTP请求传递给它的唯一数据是customer ID 392。实际上,如果我们检查trace中的每个span,都找不到将该请求ID(5038-3)作为参数传递的任何远程调用。
日志中并发请求ID的这种“魔术式”出现,是基于OpenTracing被称为“baggage”的功能实现的。分布式追踪之所以能起作用,是因为通过使用OpenTracing API的跟踪工具,一些元数据可以在整个调用链中跨越线程和进程边界传播。一个元信息的例子是span ID,还有一个就是baggage——一个嵌入在每个进程间请求中的通用键值存储。HotROD的Javascript UI在向后端发起请求之前,将session ID和request ID存储在baggage中,这个baggage可用于处理请求的每一个服务,而无需将该信息作为请求参数显式传递。这是一项非常强大的技术,可用于在整个体系结构中的单个请求的上下文中传播各种有用的信息(例如安全令牌),需更改每个服务即可感知它们正在传播的内容。Pivot Tracing 项目显示了使用baggage进行动态监控的有趣示例。
在我们的示例中,知道阻塞在请求队列中的请求的身份,使我们能够找到这些请求的追踪信息并进行分析。在实际的生产系统中,这可以让我们更容易地发现那些阻塞其他请求的耗时的请求。稍后,我们将看到另一个使用baggage的示例。
既然我们已经知道mysql调用卡在争抢锁上,我们可以轻易的修复它。如我们之前所说,程序并没有真正使用MySQL而是模拟的,这里的锁代表一个数据库连接被多个goroutine共享。源码在:examples/hotrod/services/customer/database.go
// simulate misconfigured connection pool that only gives
// one connection at a time
d.lock.Lock(ctx)
defer d.lock.Unlock()
// simulate db query delay
delay.Sleep(config.MySQLGetDelay, config.MySQLGetDelayStdDev)
注意这里我们把ctx
作为第一个参数传给锁对象,context.Context
是Golang中用于在整个应用程序中传递请求上下文数据的标准方法。上下文中包含OpenTracing span,该span允许锁对象对其进行检查并从baggage中检索JavaScript的请求ID。
让我们注释掉lock语句,来模拟我们已修复代码并具有足够容量的sql连接池,以使我们的并发请求不必争抢连接;并且我们还将config.MySQLGetDelay(在services / config / config.go
文件中)的延迟从300ms减少到100ms,然后重新启动HotROD应用并重复该实验。
随着向系统添加更多请求,延迟仍然会增加,但不再像以前的单个mysql瓶颈那样急剧增加。让我们再看一次耗时较长的trace。
不出所料,无论负载如何增加,mysql span都保持在100ms左右;driver span并没有被展开,但它与之前的耗时相同;有趣的变化是对route服务的调用,它占用了总请求时间的50%以上。之前,我们看到这些请求一次并行执行三个,但是现在几乎每个请求都是在串行执行。显然,goroutine在争用一些有限的资源;另外我们可以看到frontend服务的span之间存在缝隙,意味着瓶颈并不在route服务,而在frontend服务对route服务的调用上。让我们看下源码,services/frontend/best_eta.go
,函数 getRoutes()
:
// getRoutes calls Route service for each (customer, driver) pair
func (eta *bestETA) getRoutes(
ctx context.Context,
customer *customer.Customer,
drivers []driver.Driver,
) []routeResult {
results := make([]routeResult, 0, len(drivers))
wg := sync.WaitGroup{}
routesLock := sync.Mutex{}
for _, dd := range drivers {
wg.Add(1)
driver := dd // capture loop var
// Use worker pool to (potentially) execute
// requests in parallel
eta.pool.Execute(func() {
route, err := eta.route.FindRoute(ctx, driver.Location, customer.Location)
routesLock.Lock()
results = append(results, routeResult{
driver: driver.DriverID,
route: route,
err: err,
})
routesLock.Unlock()
wg.Done()
})
}
wg.Wait()
return results
}
这个函数接受一个customer记录(附带乘客地址)和一个Drivers列表(附带他们当前的位置),并为每位司机计算预计到达时间,它使用执行池为每一位司机调用route服务;因此,只要池中有足够的执行者,我们就应该能够并行运行所有计算。执行序池的大小被定义在services/config/config.go
:
RouteWorkerPoolSize = 3
默认值3解释了为什么我们在检查的第一条trace中最多看到三个并行请求,让我们把大小修改为100(gorotine开销很低),重启HotROD并重新测试:
现在我们必须非常快地单击按钮,因为请求将在不到半秒的时间内返回。
正如预期的那样,从frontend到route服务的调用现在全部并行完成,从而最大程度地减少了总体请求延迟。
我们将driver服务的最终优化留给读者练习。
译者注:文中提到的默认参数的修改,在下最新的代码中,需要修改对应命令行参数的默认值:https://github.com/jaegertracing/jaeger/blob/master/examples/hotrod/cmd/root.go#L101-L112
这听起来可能很枯燥,不过当想要提高一个大型应用的执行效率,如提高硬件的利用率(磁盘、CPU等),通常需要测量资源使用情况,并使用一些高层的业务参数来表示;例如:route服务执行的最短路径计算是一个相对昂贵的操作(可能占用大量CPU),如果可以计算出每位乘客花费多少CPU时间,那就太好了。然而,route
服务和查询乘客信息的调用中间间隔了好几层调用,并且它无需了解乘客的全部信息就可以计算两点之间的最短路线,所以给route
服务传一个customer ID
作为参数并不是一个好的API设计;其实这正是baggage的用武之地,在trace的上下文中,我们知道系统正在为哪个乘客执行请求,并且我们可以使用baggage在整个体系结构中透明地传递该信息,而无需更改所有服务以显式地接收它。而且,如果我们想通过其他一些参数(例如来自Javascript UI的session ID)执行CPU使用信息汇总,也无需更改应用程序就可以做到这一点。
为了演示baggage的用法,route
服务包含从baggage中获取乘客和session ID信息,并计算每位乘客及每个会话的CPU计算时长的代码。在services / route / server.go
文件中,我们可以看到以下代码:
func computeRoute(
ctx context.Context,
pickup, dropoff string,
) *Route {
start := time.Now()
defer func() {
updateCalcStats(ctx, time.Since(start))
}()
// actual calculation
}
如前面所说,我们不会传递任何customer ID/session ID,因为可以通过上下文从baggage中检索到它们。 updateCalcStats
函数使用Go的标准库中的expvar包来累加和显示结果。
var routeCalcByCustomer = expvar.NewMap(
"route.calc.by.customer.sec",
)
var routeCalcBySession = expvar.NewMap(
"route.calc.by.session.sec",
)
var stats = []struct {
expvar *expvar.Map
baggage string
}{
{routeCalcByCustomer, "customer"},
{routeCalcBySession, "session"},
}
func updateCalcStats(ctx context.Context, delay time.Duration) {
span := opentracing.SpanFromContext(ctx)
if span == nil {
return
}
delaySec := float64(delay/time.Millisecond) / 1000.0
for _, s := range stats {
key := span.BaggageItem(s.baggage)
if key != "" {
s.expvar.AddFloat(key, delaySec)
}
}
}
如果我们浏览到http://127.0.0.1:8083/debug/vars,我们可以看到route.calc.by.*的记录,分别代表每位乘客和一些UI会话所花费的CPU计算时间(以秒为单位)的明细。
route.calc.by.customer.sec: {
Amazing Coffee Roasters: 1.479,
Japanese Deserts: 2.019,
Rachel's Floral Designs: 5.938,
Trom Chocolatier: 2.542
},
route.calc.by.session.sec: {
0861: 9.448,
6108: 2.530
},
这个问题可能需要单独再写一篇文章来展开,但值得一提的是,HotROD源码中本身并没有很多OpenTracing相关的代码。例如,driver
服务中暴露了一个TChannel服务器:
// NewServer creates a new driver.Server
func NewServer(
hostPort string,
tracer opentracing.Tracer,
metricsFactory metrics.Factory,
logger log.Factory,
) *Server {
channelOpts := &tchannel.ChannelOptions{
Tracer: tracer,
}
ch, err := tchannel.NewChannel("driver", channelOpts)
if err != nil {
logger.Bg().Fatal("Cannot create TChannel", zap.Error(err))
}
server := thrift.NewServer(ch)
和OpenTracing相关的唯一一行代码是向Options传递了一个tracer,实现接口的函数中也没有对OpenTracing的任何引用(services / driver / server.go):
// FindNearest implements Thrift interface TChanDriver
func (s *Server) FindNearest(
ctx thrift.Context,
location string,
) ([]*driver.DriverLocation, error) {
s.logger.For(ctx).Info("Searching for nearby drivers",
zap.String("location", location))
driverIDs := s.redis.FindDriverIDs(ctx, location)
这是因为TChannel(一个开源框架)与OpenTracing(另一个开源框架)直接集成好了,当我们新建一个应用时,只需实例化一个具体的tracer并把它传给框架就行了。
我们可以看到HTTP接口中也没有对OpenTracing的任何引用。实际上,显式使用OpenTracing的唯一位置是在模拟Redis和MySQL的函数中,因为实际上我们没有在任何框架上发出任何RPC请求。
我们在一开始就说过,使用OpenTracing的应用程序可以避免被限制在特定的追踪系统后端中。我们将其作为练习,让读者尝试从HotROD收集追踪信息到另一个追踪系统中,例如Lightstep或Appdash。应用程序中唯一需要更改的地方是pkg/tracing/init.go
中的Init函数:
// Init creates a new instance of Jaeger tracer.
func Init(
serviceName string,
metricsFactory metrics.Factory,
logger log.Factory,
) opentracing.Tracer {
cfg := config.Configuration{
Sampler: &config.SamplerConfig{
Type: "const",
Param: 1,
},
Reporter: &config.ReporterConfig{
LogSpans: false,
BufferFlushInterval: 1 * time.Second,
},
}
tracer, _, err := cfg.New(
serviceName,
)
if err != nil {
logger.Bg().Fatal(
"cannot initialize Jaeger Tracer",
zap.Error(err),
)
}
return tracer
}
我们看到追踪信息中包含来自应用程序的情景化日志。他们是如何到达追踪后端的呢?是否需要直接在OpenTracing Spans上手动调用log方法?
在上面的FindNearest
函数中,我们看到了log语句的示例:
s.logger.For(ctx).Info(
"Searching for nearby drivers",
zap.String("location", location),
)
表达式zap.String(k,v)
是称为Zap(https://github.com/uber-go/zap)的Go快速日志记录库支持的结构化日志记录API,因此这里没有关于OpenTracing的内容。HotROD封装了Zap的logging方法,对外提供两个通用的方法:
For(context.Context)
为请求范围内的日志返回一个情景化的日志记录器
Bg()
返回一个后台记录器,用于记录不属于请求范围的事件,例如应用程序启动顺序。
访问情景化日志记录器总是需要一个Context对象。有了上下文对象后,不仅可以将所有常规日志方法委派给常规日志记录器,还可以将其转换为Span上对日志记录方法的调用。可以在pkg/log/spanlogger.go
中找到源代码,例如:
func (sl spanLogger) Info(msg string, fields ...zapcore.Field) {
sl.logToSpan("info", msg, fields...)
sl.logger.Info(msg, fields...)
}
我们已经了解链路追踪如何通过在各自的追踪span上下文中传递日志,且这种传递对应用程序代码几乎零浸入,这种特性在日志聚合系统中起到了重要的作用。那监控微服务,采集有关RPC端点的指标的这一关键环节又是怎样的呢?事实证明,OpenTracing API是RPC端点检测工具的超集。RPC的最常见度量指标有:请求计数,错误计数和请求延迟的分布。 OpenTracing工具已经捕获了所有这些信号,并且可以在不使用其他工具的情况下上报出来。
用于Go的Jaeger追踪器具有为RPC调用上报指标的选项,该选项已在HotROD demo中启用。如果我们回到expvar端点http://127.0.0.1:8083/debug/vars,我们可以看到为应用程序中所有端点收集的各种指标。
比如,我们可以看出对/customer
这个url有24次成功请求,错误请求数为0;expvar包提供的/debug/vars
端点是一种较为原始的显示RPC指标的方法,下图为Uber内部仪表板的一个示例。所有这些指标都是由OpenTracing工具和Jaeger追踪器上报的。
在本文中,我们回顾了以下OpenTracing功能的实际示例:
演示的源代码以及Jaeger后端位于GitHub:https://github.com/uber/jaeger。如果本教程中存在任何错误,请提issue。