亚马逊AWS官方博客

借助 Pyroscope 对 EKS 容器服务进行 Continuous Profiling 诊断应用性能

Continuous Profiling 的现状

在可观测领域,Trace、Log、Metrics 作为“三大支柱”,帮助工程师更方便的洞察应用的内部问题。然而,对于开发人员而言,经常还需要深入应用程序,找出造成瓶颈的根本原因。在可观测的“三大支柱”中,往往通过日志来收集这些信息。不幸的是,这种方法往往非常耗时,又缺乏足够的细节帮助开发人员定位应用的性能问题。

一种比较有效的方法是使用 Profiling 技术,Profiling 是一种分析程序复杂性的动态方法,旨在运行时收集应用系统信息来研究系统运行状况,定位性能热点,例如 CPU 利用率或函数调用的频率和持续时间。通过分析可以准确定位应用程序的哪些部分消耗了最多的资源或者时间,从而优化整体系统性能。Profiling 技术的使用一般有以下几种形式:

  • 系统工具:如 linux 的 strace/perf、Solaris 的 DTrace,这类工具的使用需要很强的 C 和操作系统基础,往往需要能够理解 OS 级别的系统调用;
  • Languages Native:通过编程语言 Profiling 库方式提供,例如 golang 的 net/http/pprof、runtime/pprof,需要工程师将这些 package 引入到程序中,并通过专门的工具进行查看分析。AWS CodeGuru Profiler 也提供 Java/Python 语言的 agent,为 Java 和 Python 应用提供 Profiling;
  • 使用 eBPF:eBPF Profiling 是使用 Infrastructure 的思路来解决应用的观测问题,eBPF 是当前 Linux 内核非常热门的技术,使用 eBPF profiling 可以不需要对代码进行修改,就能够从内核获取整个系统的堆栈跟踪(eBPF 的用途远远不止 Profiling)。

需要注意的是,在使用 Golang/Java/C/C++等编译型语言时,eBPF profiler 能够获得与非 eBPF profiler 非常相似的信息。但对于 Python 等解释型语言,运行时的堆栈跟踪无法从内核轻松访问,Languages Native 在这种场景下有更好的效果。鉴于 Languages Native 和 eBPF 的优劣势,一般商业产品会同时提供这两种方式的接入。

另一方面,原始的 Profiling 信息往往难以阅读理解,为解决这个问题,“Systems Performance: Enterprise and the Cloud”(中文翻译“性能之巅”)的作者 Brendan Gregg 发明了 FlameGraph(火焰图),通过分层的方式对 Profiling 中的应用堆栈跟踪、持续时长进行可视化,以便直观且快速准确地识别执行频率最高、最耗费资源的代码路径。主流的 Profiling 工具几乎都使用 FlameGraph 的方式做可视化。火焰图的解读可以参考:性能调优利器:火焰图

仅仅有 Profiling 往往是不够的,在现代应用的场景中,不可变基础设施被大量使用,以 Kubernetes 为例,往往故障发生后应用已经崩溃,Liveness Probe 检查失败,紧随着 Pod 被销毁,新的应用 Pod 将取代被销毁的 Pod 提供服务,如果没有及时的进行Profiling,应用堆栈调用信息将随着 Pod 生命周期终止而丢失。另外,针对内存溢出 OOM 这样的问题,往往需要对照不同时间的 Profiling 数据,才能发现问题。Continuous Profiling 为 Profiling 增加了时间维度,通过了解程序 Profiling 信息随时间的变化,帮助定位、调试和修复与性能相关的问题。

如何在 EKS 里使用 Pyroscope

Pyroscope 架构简介

Pyroscope 是一家提供开源 Continuous Profiling 服务的公司,2023 年 3 月被 Grafana 收购,并将 Grafana 自己的 Phlare 融合到 Pyroscope。与 Trace 可观测支柱的技术实现类似,Pyroscope 也同时支持 SDK-Instrumentation 和 Auto-Instrumentation 生产 Profiling 数据,采用 Push 和 Pull 结合的方式采集数据,并做存储和展示。本文以 Pyroscope 为例,介绍演示如何使用 Pyroscope 工具实现现代化应用的 Continuous Profiling,洞察现代应用的性能。

Pyroscope 的部署分两部分,Pyroscope Server 端和 Client 端:

  • Server 部分主要对 Client 上报上来的数据做收集、处理、存储、展示,并对外提供 API 接口,可以使用 Grafana 对 Pyroscope Profiling 数据以火焰图的方式展示。
  • Client 端是个 Grafana Agent,Agent 可以使用 eBPF 技术采集 Profiling 后再 push 到 Server 端,或使用 Agent pull 的方式直接从应用采集 Profiling 数据。除了使用 Agent,用户也可以使用 SDK 将生成的 Profiling 数据直接 push 到 server 端。

在 EKS 中安装 Pyroscope

要在 EKS 中使用 Pyroscope,需要先安装 Pyroscope 服务。安装步骤如下:

1、准备 S3 桶用于 Pyroscope 的持久化

Pyroscope 支持使用 S3 对数据的持久化,Pyroscope 使用 Thanos’ object store client,由于文档未说明是否支持 IRSA 鉴权,这里可以为 Pyroscope 创建单独的 IAM User 用于访问 S3 桶,建议保管好该用户的 AK/SK,避免泄漏,同时为 Pyroscope 使用的 S3 桶设置精细的 IAM Policy。注意替换以下模版的<YOUR-S3-BUCKET>。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Pyroscope",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:DeleteObject",
                "s3:GetObjectTagging",
                "s3:PutObjectTagging"
            ],
            "Resource": [
                "arn:aws:s3:::<YOUR-S3-BUCKET>/*",
                "arn:aws:s3:::<YOUR-S3-BUCKET>"
            ]
        }
    ]
}

2、使用以下命令为 Pyroscope 创建 kubernetes namespace

kubectl create namespace pyroscope

3、安装 Pyroscope helm repo

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

4、下载 Pyroscope 的模版,调整部署参数。Pyroscope 支持分布式部署,提供单独 minio 作为后端的持久化对象存储,分布式部署模式下不支持在 helm 的模版里修改使用 S3 作为长期持久化层,因此这里使用单体架构的部署方式。

Pyroscope server 是个 statefulset 有状态应用,通过配置 replicaCount 调整 Pyroscope server 服务实例数量。

pyroscope:
  replicaCount: 3

如果在生产中运行,建议修改 Pyroscope server Pod 使用资源的 limit 和 request。

  resources:
    {}

默认情况下,Pyroscope ingester 模块接受到 Profiling 数据后,会将近期的数据保留在内存里,达到阀值或超过 3 小时,Pyroscope 将数据持久化到块存储,由于可以使用 S3 来分层,pv 不需要设置太大。

  persistence:
    enabled: True
    accessModes:
      - ReadWriteOnce
    size: 10Gi
    annotations: {}

在配置了对象存储的情况下,完整的数据块将被 upload 到 S3上进行持久化。可以借助 S3 Intelligent-Tiering 降低这部分的数据持久化成本。注意根据自己的环境调整以下 S3 配置的参数。

  config: |
    storage:
      backend: s3
      s3:
        region: <YOUR-S3-REGION>
        endpoint: s3.<YOUR-S3-REGION>.amazonaws.com
        bucket_name: <YOUR-S3-BUCKET>
        access_key_id: <YOUR-ACCESS-KEY>
        secret_access_key: <YOUR-SECRET-KEY>

默认情况下 Pyroscope 搭建了 minio 对象存储用于长期数据持久化,通过以下设置关闭 minio 服务,直接使用 S3:

minio:
  enabled: false

5、修改完部署配置后,执行 helm install 安装 Pyroscope server 端

helm -n pyroscope install pyroscope grafana/pyroscope --values pyroscope-values.yaml

命令输出大致如下:

NAME: pyroscope
LAST DEPLOYED: Tue Sep  5 09:10:31 2023
NAMESPACE: pyroscope
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
Thanks for deploying Grafana Pyroscope.

# Pyroscope UI & Grafana

Pyroscope database comes with a built-in UI, to access it from your localhost you can use:

```
kubectl --namespace pyroscope port-forward svc/pyroscope 4040:4040
```

You can also use Grafana to explore Pyroscope data.
For that, you'll need to add the Pyroscope data source to your Grafana instance and configure the query URL accordingly.
See https://grafana.com/docs/grafana/latest/datasources/grafana-pyroscope/ for more details.

The in-cluster query URL for the data source in Grafana is:

```
http://pyroscope.pyroscope.svc.cluster.local.:4040
```

# Collecting profiles.


The Grafana Agent has been installed to scrape and discover pprof profiles endpoint via pod annotations.

As an example, to start collecting memory and cpu profile using the 8080 port, add the following annotations to your workload:

```
profiles.grafana.com/memory.scrape: "true"
profiles.grafana.com/memory.port: "8080"
profiles.grafana.com/cpu.scrape: "true"
profiles.grafana.com/cpu.port: "8080"
```


To learn more supported annotations, read our guide https://grafana.com/docs/pyroscope/next/deploy-kubernetes/#optional-scrape-your-own-workloads-profiles

There are various ways to collect profiles from your application depending on your needs.
Follow our guide to setup profiling data collection for your workload:

https://grafana.com/docs/pyroscope/next/configure-client/

注意输出的信息 http://pyroscope.pyroscope.svc.cluster.local.:4040,需要用于配制 Grafana Datasource。

6、Pyroscope 使用 Grafana 做数据的查询和展示,默认情况 Grafana 不支持火焰图,需要在 helm 安装时使用以下的参数开启该 feature。

helm upgrade -n pyroscope --install grafana grafana/grafana \
  --set image.repository=grafana/grafana \
  --set image.tag=main \
  --set env.GF_FEATURE_TOGGLES_ENABLE=flameGraph \
  --set env.GF_AUTH_ANONYMOUS_ENABLED=true \
  --set env.GF_AUTH_ANONYMOUS_ORG_ROLE=Admin \
  --set env.GF_DIAGNOSTICS_PROFILING_ENABLED=true \
  --set env.GF_DIAGNOSTICS_PROFILING_ADDR=0.0.0.0 \
  --set env.GF_DIAGNOSTICS_PROFILING_PORT=6060 \
  --set-string 'podAnnotations.pyroscope\.grafana\.com/scrape=true' \
  --set-string 'podAnnotations.pyroscope\.grafana\.com/port=6060'

7、安装完 Grafana

安装 Grafana 会生成登录的 secret 信息,默认账户为 admin,密码需要使用以下命令获取:

kubectl get secret --namespace pyroscope grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo

使用以下命令执行 port-forward(亦可以通过 ingress 或 loadbalancer 的形式使用负载均衡器进行服务暴露),将 Grafana UI 映射到 localhost 进行访问。

export POD_NAME=$(kubectl get pods --namespace pyroscope -l "app.kubernetes.io/name=grafana,app.kubernetes.io/instance=grafana" -o jsonpath="{.items[0].metadata.name}")
kubectl --namespace pyroscope port-forward $POD_NAME 3000

这里将 port-forward 到本地 3000 端口,在浏览器中使用 localhost:3000 访问 Grafana,使用 admin 用户名和获取的 secret 登录,在 Datasource 中选择 Grafana Pyroscope 类型。

在 URL 中填写 http://pyroscope.pyroscope.svc.cluster.local.:4040,保存该数据源。

在 Grafana explorer 中,选择 Pyroscope 数据源,使用以下的标签过滤 Profiling 数据,执行 Run query,可以看到 Grafana 展示 pyroscope-0 这个 Pod 的火焰图信息。

到这里为止,Pyroserver server 搭建完成,且只能访问 Pyroserver server 自身的 Profiling 火焰图信息。如果要展示应用的 Profiling 信息,还需要在代码引入 SDK 进行开发或在节点上安装 eBPF agent,再将生成的 Profiling 信息 push 到 Pyroserver server。

使用 Pyroscope eBPF agent 自动 Profiling

1、创建 agent 配置

Pyroscope eBPF agent 以 daemonset 的方式部署在每一个 EKS 节点上,使用 Kubernetes 的服务发现机制获取 Pod 列表,通过配置规则对采集的数据执行 relabel。这里参考 Grafana agent 编写了个配置,注意根据自己环境设置 endpoint 的 URL,将配置保存为pyroscope-ebpf-values.yaml。

agent:
  mode: 'flow'
  configMap:
    create: true
    content: |
      discovery.kubernetes "all_pods" {
        selectors {
          field = "spec.nodeName=" + env("HOSTNAME")
          role = "pod"
        }
        role = "pod"
      }
      discovery.relabel "local_pods" {
        targets = discovery.kubernetes.all_pods.targets
        rule {
          action = "replace"
          replacement = "${1}/${2}"
          separator = "/"
          source_labels = ["__meta_kubernetes_namespace", "__meta_kubernetes_pod_container_name"]
          target_label = "service_name"
        }
      }
      pyroscope.ebpf "instance" {
        forward_to = [ pyroscope.write.endpoint.receiver ]
        targets = discovery.kubernetes.local_pods.targets
      }
      pyroscope.write "endpoint" {
        endpoint {
          url = "http://pyroscope.pyroscope.svc.cluster.local:4040"
        }
      }

  securityContext:
    privileged: true
    runAsGroup: 0
    runAsUser: 0

controller:
  hostPID: true

也可以根据需要和 relabel 的规则,参考该配置手册进行配置修改:https://grafana.com/docs/pyroscope/latest/configure-client/grafana-agent/ebpf/

2、安装 Pyroscope eBPF agent

helm install -n pyroscope pyroscope-ebpf grafana/grafana-agent -f pyroscope-ebpf-values.yaml

3、在 Grafana 上查询 eBPF 生成的应用火焰图

由于我的环境中已经部署了 istio,agent 在采集后对 profiling 数据标签做了转换,可以直接使用以下标签进行查询:

可以看到 istio 的 jaeger 程序的火焰图信息:

可以看到 eBPF 无需对 jaeger 进行代码修改,就能持续的采集 jaeger 程序的 profiling 信息,并在 Grafana 上进行火焰图的展示。

使用 SDK 对应用 Profiling

除了使用 eBPF 对应用持续 profiling,Pyroscope 也支持使用 SDK 的方式 profiling。

这是一段 Pyroscope 提供的 go 的示例代码,该程序使用 goroutine 持续运行 fastFunction 和 slowFunction 两个函数,在函数里调用 work,分别循环 800000000 和 200000000 次,生成 profiling 信息并附加上 label 方便查询,生成的 profiling 数据传输给 PYROSCOPE_ENDPOINT 进行存储和查询展示。除了 golang,Pyroscope 也为其他开发语言提供了相应的 SDK。

package main

import (
    "context"
    "fmt"
    "os"
    "runtime"
    "runtime/pprof"
    "sync"

    "github.com/grafana/pyroscope-go"
)

//go:noinline
func work(n int) {
    // revive:disable:empty-block this is fine because this is a example app, not real production code
    for i := 0; i < n; i++ {
    }
    fmt.Printf("work\n")
    // revive:enable:empty-block
}

var m sync.Mutex

func fastFunction(c context.Context, wg *sync.WaitGroup) {
    m.Lock()
    defer m.Unlock()

    pyroscope.TagWrapper(c, pyroscope.Labels("function", "fast"), func(c context.Context) {
        work(200000000)
    })
    wg.Done()
}

func slowFunction(c context.Context, wg *sync.WaitGroup) {
    m.Lock()
    defer m.Unlock()

    // standard pprof.Do wrappers work as well
    pprof.Do(c, pprof.Labels("function", "slow"), func(c context.Context) {
        work(800000000)
    })
    wg.Done()
}

func main() {
    runtime.SetMutexProfileFraction(5)
    runtime.SetBlockProfileRate(5)
    pyroscope.Start(pyroscope.Config{
        ApplicationName:   os.Getenv("SERVICE_NAME"),
        ServerAddress:     os.Getenv("PYROSCOPE_ENDPOINT"),
        Logger:            pyroscope.StandardLogger,
        AuthToken:         os.Getenv("PYROSCOPE_AUTH_TOKEN"),
        TenantID:          os.Getenv("PYROSCOPE_TENANT_ID"),
        BasicAuthUser:     os.Getenv("PYROSCOPE_BASIC_AUTH_USER"),
        BasicAuthPassword: os.Getenv("PYROSCOPE_BASIC_AUTH_PASSWORD"),
        ProfileTypes: []pyroscope.ProfileType{
            pyroscope.ProfileCPU,
            pyroscope.ProfileInuseObjects,
            pyroscope.ProfileAllocObjects,
            pyroscope.ProfileInuseSpace,
            pyroscope.ProfileAllocSpace,
            pyroscope.ProfileGoroutines,
            pyroscope.ProfileMutexCount,
            pyroscope.ProfileMutexDuration,
            pyroscope.ProfileBlockCount,
            pyroscope.ProfileBlockDuration,
        },
        HTTPHeaders: map[string]string{"X-Extra-Header": "extra-header-value"},
    })

    pyroscope.TagWrapper(context.Background(), pyroscope.Labels("foo", "bar"), func(c context.Context) {
        for {
            wg := sync.WaitGroup{}
            wg.Add(2)
            go fastFunction(c, &wg)
            go slowFunction(c, &wg)
            wg.Wait()
        }
    })
}

使用以下 Dockerfile 将代码构建成镜像,保存在 ECR 里:

FROM golang:alpine AS build-env
RUN apk update && apk add ca-certificates
WORKDIR /usr/src/app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags '-extldflags "-static"'
FROM scratch
COPY --from=build-env /usr/src/app/pyroscope-demo /pyroscope-demo
COPY --from=build-env /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/pyroscope-demo"]

使用以下的配置将应用 pyroscope-demo 应用部署到 EKS 集群,使用环境变量设置 Pyroscope server 的 endpoint:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pyroscope-demo
  labels:
    app: pyroscope-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pyroscope-demo
  template:
    metadata:
      labels:
        app: pyroscope-demo
    spec:
      containers:
      - name: pyroscope-demo
        image: "<AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-1.amazonaws.com/pyroscope-demo:latest"
        env:
        - name: PYROSCOPE_ENDPOINT
          value: "http://pyroscope.pyroscope.svc.cluster.local:4040"
        - name: SERVICE_NAME
          value: "Pyroscope-demo"

运行成功后,在 Grafana explorer 里查询 pyroscope-demo 应用的 profiling 信息,可以看到在近 5 分钟内,该应用的运行情况。火焰图是个很直观展示 profiling 的图表,上下层代表调用关系,横向 bar 的宽窄代表占用资源的多少。可以看到在 slowFunction 里 main.work 函数的运行时间为 3.98 分钟,而 fastFunction 为 59.9s,与两个函数执行的循环次数几乎匹配。

通过该例子,可以很直观的对应用运行消耗的资源进行分析,寻找性能问题产生的原因,从而对问题进行排查,优化应用性能。

总结

简而言之,Continuous Profiling 是现代化应用内部性能分析的未来,结合可观测性的另外三个支柱,基于 eBPF 无代码侵入的 Profiling 帮助客户更简单、更大规模、更持续地从基础设施、应用以及涉及的中间件等,进行应用性能的分析调试,帮助客户在关键业务场景结合日志,指标,追踪进行快速的问题定位,并对应用持续优化改进。

参考资料

https://www.cncf.io/blog/2022/05/31/what-is-continuous-profiling/

https://www.brendangregg.com/flamegraphs.html

https://github.com/brendangregg/FlameGraph

https://www.infoq.cn/article/a8kmnxdhbwmzxzsytlga

https://github.com/grafana/pyroscope

https://grafana.com/docs/pyroscope/latest/configure-client/language-sdks/

https://opentelemetry.io/community/roadmap/

本篇作者

林旭芳

AWS 解决方案架构师,主要负责 AWS 云技术和解决方案的推广工作,在 Container、主机、存储、容灾等方向有丰富实践经验。

李俊杰

亚马逊云解决方案架构师,负责云计算方案的咨询与架构设计,同时致力于容器方面研究和推广。在加入亚马逊云科技之前曾在金融行业IT部门负责传统金融系统的现代化改造,对传统应用的改造,容器化具有丰富经验。