OpenTelemetry

OpenTelemetry 是一个开源项目,旨在通过统一的工具集、API和SDK,简化在多样化的技术栈中集成可观测性功能,确保一致地收集、处理及导出应用性能数据。

OTel Profiling 技术发展介绍

铖朴,义泊

OTel Profiling 简介

OTel 社区从 2022 年在 KubeCon + CloudNativeCon Europe 2022 in Valencia 大会开始关注 Profiling 领域的建设,在那次的会议上成立了 Profiling SIG,这个兴趣小组成为了后续推动 OTel 在 Profiling 方面建设发展的主要推动力量。该 SIG 的主要活跃贡献者目前主要来自于 Grafana Labs、Elastic、Red Hat 以及 DataDog 等。

Profiling SIG 资料

Profiling SIG Meeting Note

Profiling SIG Slack讨论小组

OTel Profiling 演进介绍

下图展示了OTel Profiling发展过程中的一些关键节点:

2024年以来,OTel Profiling 发展很快,完成了包括新版 Data model 设计、Data model 合入 OTel社区Spec、基于 Spec 开发对应实现等工作。接下来,结合OTel社区的Spec发起流程,介绍上述相关时间节点细节:

OpenTelemetry 项目对一种新数据类型的支持过程分为以下步骤:

编写并向社区提交 OpenTelemetry Enhancement Proposal(OTEP)。OpenTelemetry OTEP 旨在进行跨领域的变更(即跨语言和实现适用),并且引入新行为、更改所需行为或以其他方式修改需求。基于 OTEP 编写对应的 Specification(Spec)。Spec 规范描述了所有 OpenTelemetry 实现的跨语言要求和期望。基于 OTEP 实现各种语言相关 Profiling 数据采集和传输处理能力。

1. 发起 OTEP 与社区响应

由 Grafana 等公司起草的 Profile 领域数据模型提案经过 3 个多月的讨论和 review,已经在 3 月被社区通过进行了合并 #237#239。这套标准定义的数据格式跟 pprof 比较接近。详细细节见相关PR。

目前社区对这个领域很看重,2024年3月,在官网通过Blog文章文档形式,介绍和宣布 Profilers 将作为OTel社区继 Traces/Metrics/Logs 后的重要组成部分。

2. 将 OTEP 集成到规范中

在完成 OTEP 的提交和社区合并以后,下一步需要在社区的 Spec 中添加相关 OTEP。目前这块 OTel-Profiles 小组是选择先确定提交 OpenTelemetry Protocol (OTLP) Specification #534,其作为 Spec 中的一部分,旨在描述数据在采集测、中间节点(例如收集器和后端)之间的编码、传输和传递机制。OTel-Profiles 小组希望先确定协议内容后,在 OTel-Collector 中进行基于相关数据传输协议标准的数据收集实现,以跑通整体流程。相关更多细节可以参见 Profiling SIG Meeting Notes

3. 基于 OTEP 进行数据采集/收集测的实现

目前社区目前社区在相关部分的实现包括:

Splunk 已经针对 .Net 探针捐献了一个版本的实现,详情见#3196,相关代码已合并。

Elastic 向社区捐献一个基于 eBPF 实现的 Profiling 探针实现,详细讨论内容见#1918,该实现opentelemetry-ebpf-profiler已于6月份被社区接受,成为OTel社区第一个相关实现。

OTel Data Model 介绍

OTel Data Model是在Google pprof基础上设计而来,因此,本部分将介绍 pprof 相关基础内容,然后介绍OTel Data Model 相关 pprof 有哪些差异。

pprof简介

pprof 是 Go 语言内置的性能剖析工具,可以帮助分析 Go 程序的 CPU、内存、阻塞、和锁等维度的性能。

接下来,用一个例子来说明 pprof 的使用。比如下面的代码会对10万个数字进行排序,运行时间大概是接近4秒。

package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
start := time.Now().UnixMilli()
arr := prepare(1000 * 100)
doWork(arr)
fmt.Println(time.Now().UnixMilli() - start)
}
func prepare(n int) []int {
arr := make([]int, n)
for i := 1; i < n; i++ {
arr[i] = rand.Intn(1000)
}
return arr
}
func doWork(arr []int) {
size := len(arr)
if size == 0 {
return
}
for i := 0; i < size; i++ {
for j := 0; j < size; j++ {
if arr[i] < arr[j] {
tmp := arr[i]
arr[i] = arr[j]
arr[j] = tmp
}
}
}
}

如果想对代码进行优化,就可以使用pprof进行CPU热点剖析。程序运行结束后,会生成一个pprof格式的性能剖析文件/tmp/cpu.pprof。

package main
import (
"fmt"
"math/rand"
"os"
"runtime/pprof"
"time"
)
func main() {
f1, err := os.Create("/tmp/cpu.pprof")
if err != nil {
fmt.Println(err)
}
pprof.StartCPUProfile(f1)
start := time.Now().UnixMilli()
arr := prepare(1024 * 200)
doWork(arr)
fmt.Println(time.Now().UnixMilli() - start)
defer func() {
pprof.StopCPUProfile()
f1.Close()
}()
}
func prepare(n int) []int {
arr := make([]int, n)
for i := 1; i < n; i++ {
arr[i] = rand.Intn(1000)
}
return arr
}
func doWork(arr []int) {
size := len(arr)
if size == 0 {
return
}
for i := 0; i < size; i++ {
for j := 0; j < size; j++ {
if arr[i] < arr[j] {
tmp := arr[i]
arr[i] = arr[j]
arr[j] = tmp
}
}
}
}

可以用命令行对这个文件进行分析,找到占用CPU的热点方法。从下图可以看到,main.doWork方法占用了2800毫秒,也就是说大多数时间都消耗在这个方法上。

Terminal window
go tool pprof /tmp/cpu.pprof
File: ___go_build_golang_test
Type: cpu
Time: May 30, 2024 at 7:32pm (CST)
Duration: 3.94s, Total samples = 3.14s (79.78%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 3140ms, 100% of 3140ms total
flat flat% sum% cum cum%
2800ms 89.17% 89.17% 3140ms 100% main.doWork (inline)
340ms 10.83% 100% 340ms 10.83% runtime.asyncPreempt
0 0% 100% 3140ms 100% main.main
0 0% 100% 3140ms 100% runtime.main

可以进一步分析,找出占用CPU的代码行。从下图可以看到,有1.67秒的时间在执行第42行,有990毫秒在执行第43行。结合代码,很容易看出,问题在于使用了冒泡排序,时间复杂度是N的平方。

Terminal window
(pprof) list main.doWork
Total: 3.14s
ROUTINE ======================== main.doWork in /Users/yanglong/Documents/Private/code/go/golang-test/main.go
2.80s 3.14s (flat, cum) 100% of Total
. . 35:func doWork(arr []int) {
. . 36: size := len(arr)
. . 37: if size == 0 {
. . 38: return
. . 39: }
. . 40:
. . 41: for i := 0; i < size; i++ {
1.67s 1.86s 42: for j := 0; j < size; j++ {
990ms 1.10s 43: if arr[i] < arr[j] {
. . 44: tmp := arr[i]
140ms 180ms 45: arr[i] = arr[j]
. . 46: arr[j] = tmp
. . 47: }
. . 48: }
. . 49: }
. . 50:}

只需要更换更高效的排序算法,就应该会大大提升性能,运行时间从4秒缩短为了15毫秒左右。

package main
import (
"fmt"
"math/rand"
"os"
"runtime/pprof"
"sort"
"time"
)
func main() {
f1, err := os.Create("/tmp/cpu.pprof")
if err != nil {
fmt.Println(err)
}
pprof.StartCPUProfile(f1)
start := time.Now().UnixMilli()
arr := prepare(1024 * 100)
doWork(arr)
fmt.Println(time.Now().UnixMilli() - start)
defer func() {
pprof.StopCPUProfile()
f1.Close()
}()
}
func prepare(n int) []int {
arr := make([]int, n)
for i := 1; i < n; i++ {
arr[i] = rand.Intn(1000)
}
return arr
}
func doWork(arr []int) {
sort.Slice(arr, func(i, j int) bool {
return arr[i] < arr[j]
})
}

实际上,pprof 的 CPU 热点启动后,Go 会每隔10毫秒记录一次协程的方法栈,最终写入到 pprof 格式的文件中。下面对 pprof 格式做一些说明:

Profile

代表整个 pprof 文件,由多个 Sample 组成。

Sample

代表的是一次方法栈的快照。所谓方法栈,就是多个存在调用关系的方法,比如a() -> b() -> c()。

Label

在 Tracing 和 Profling 关联的场景,方法栈还有附加信息,比如当前的 TraceId 和 SpanId 等,这些信息则存储在 Label 中,结构有点像一个 map。

Location

pprof 在采样时,会记录c函数中正在执行的指令的地址,也对应的记录 b 和 a 函数的指令的地址。把上述的函数指令地址叫做 Location。

Mapping

实际上,不同的函数可能存在于不同的动态库中,为此也需要将动态库的文件名,动态库在内存中的地址范围等信息记录下来,把这些信息叫做 Mapping。

需要说明的是,由于编译器会对函数进行编译优化,比如内联等,一个 Location 可能表示多个函数及行号,所以 Location 和 Line 是1对多的关系。

也可以将 pprof 文件转换成火焰图,阅读起来会更加直观。

如果想对 pprof 有更多了解,可以阅读文章做更详尽了解。

pprof 和 OTel Format 对比

两种格式的核心功能都是记录 Sample(也就是方法栈),以及和 Sample 相关的附加信息。

在上图中,左边是 pprof,右边是 OTel Format。通过对比可以看到,OTel Format 中保留了几乎所有 pprof的内容,也就是说 OTel Format 可以无损的转换为 pprof,但反过来则不行,因为 OTel Format 中增加了pprof 所没有的一些结构(上图中标记为1的部分)。

除此以为,OTel Format 对 pprof 也有一些优化

Label 的存储更加优化,可以节省更多空间,图中标记为2的部分

Location 的存储更加优化,可以节省更多空间,图中标记为3的部分

直接在每个样本中增加了和 Trace 关联的字段,以及时间字段,虽然 pprof 也能实现,但 OTel 的格式更加简单直接,图中标记为4的部分

还有一些其它的扩展点,比如可以将 JFR 文件的二进制数据直接保存到 OTel 的格式中,等等


observability.cn Authors 2024 | Documentation Distributed under CC-BY-4.0
Copyright © 2017-2024, Alibaba. All rights reserved. Alibaba has registered trademarks and uses trademarks.
浙ICP备2021005855号-32