Kubernetes 容器引入 Async Profiler 性能分析工具

2,584 阅读3分钟

介绍

Async Profiler 是一款开源的 Java 性能分析工具,基于 HotSpot API,可以收集程序运行中的堆栈和内存分配等信息。Async Profiler 可以跟踪以下类型的事件:

  • CPU 周期
  • Java 堆的分配
  • 硬件和软件性能计数器,如缓存未命中、分支未命中、页面错误、上下文切换等;
  • 满足的锁定尝试,包括Java对象监视器和可重入锁;

此文重点不在于讨论 Async Proiler 功能,在实际项目中,引入 Async Proiler 工具可能是基于以下的考虑:

  • 支持 arm 和 amd 架构
  • 对系统性能影响比较小
  • 能够支持较旧的 Java 版本(对于版本高于 8u262+ 的建议直接使用 tracing agentprofiling 方案)

Async Proiler 提供的火焰图是我们分析CPU性能和堆内存分配最有用的工具,再 Kubernetes 环境下,如何收集 Async Proiler 产生的数据,并借助第三方工具实时进行分析是本文涉及的范围。

总体方案设计

说明:

  1. 此方案需要在 Docker 容器中运行 Async Profiler 工具,本质上违反了 Docker 容器只运行一个进程的规范,所以并不推荐在生产环境使用此方案,可作为研发和测试环境排查性能问题。
  2. 总体方案需要通过 datakit agent 来收集 Async Profiler 产生的数据,数据通过观测云的应用性能观测中的 Profile 进行数据展示。

Docker 镜像引入 Async Profiler

打包 JDK 镜像

为了测试 Async Profiler 对于低版本的 JDK 的支持,先打包了一个 1.8.0_251 的镜像库,本文所有涉及的所有物料包见文末 github 地址。

  • DockerfileJDK
FROM centos:7

MAINTAINER Harlon

ADD jdk-8u251-linux-x64.tar.gz /usr/local/
ENV JAVA_HOME /usr/local/jdk1.8.0_251
ENV CLASSPATH $JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
ENV PATH $PATH:$JAVA_HOME/bin

镜像打包命令如下:

docker build -f DockerfileJDK -t jdk:8u251 .

打包 async-profile 镜像

按照实际需要修改对应的 jar 包,start.sh 会启动 java 程序并运行 collect.sh 定时收集 profile 数据,collect.sh 通过运行 async-profile 工具收集 profile 数据并上传至观测云,同时支持以下环境变量来控制收集行为:

  • PROFILING_INTERVAL:设置采集周期,默认为60s

  • PROFILING_ENABLED:设置采集开关,为0时不采集

  • DATAKIT_URL: DataKit url 地址,默认为 datakit-service.datakit:9529

  • APP_ENV: 当前应用环境,如 dev | prod | test 等,默认为 dev

  • APP_VERSION: 当前应用版本,默认为0.0.0

  • HOST_NAME: 主机名称

  • SERVICE_NAME: 服务名称,默认为 jps 命令所显示的服务名称

  • PROFILING_DURATION: 采样持续时间,单位为秒,默认为10s

  • PROFILING_EVENT: 采集的事件,如 cpu,alloc,lock 等,默认只采集 cpu

  • PROCESS_ID: 采集的 java 进程 ID, 多个 ID 以逗号分割,如 98789,33432,默认为 jps 名称所有进程

  • DockerfileAsyncProfile

FROM jdk:8u251

RUN /bin/cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' >/etc/timezone

ENV jar profilingtest-1.0.jar
ENV workdir /data/app/
ENV profiler /usr/local/async-profiler/

# Async Profiler
RUN mkdir ${profiler}
COPY collect.sh ${profiler}
RUN chmod +x ${profiler}/collect.sh
COPY async-profiler-2.8.3-linux-x64.tar.gz ${profiler}/async-profiler.tar.gz
RUN cd ${profiler} && tar -xvzf ${profiler}/async-profiler.tar.gz && \
      mv ${profiler}/async-profiler-2.8.3-linux-x64/* ${profiler} && \
      rm -r  ${profiler}/async-profiler-2.8.3-linux-x64 && \
      rm -f ${profiler}/async-profiler.tar.gz

RUN mkdir -p ${workdir}
COPY ${jar} ${workdir}
COPY start.sh ${workdir}
RUN chmod +x ${workdir}start.sh

WORKDIR ${workdir}
CMD ["bash", "-ec", "${workdir}start.sh"]
  • start.sh
#!/bin/bash

java ${JAVA_OPTS} -jar profilingtest-1.0.jar ${PARAMS} 2>&1 > /dev/null &

profiling_interval=60
if [ -n "$PROFILING_INTERVAL" ]; then
    profiling_interval=$PROFILING_INTERVAL
fi

while [[ true ]]; do
    cd /usr/local/async-profiler && /bin/bash collect.sh >> ./profiling_cron.log 2>&1
    sleep $profiling_interval
done
  • collect.sh
set -e

LIBRARY_VERSION=2.8.3

if [ -n "$PROFILING_ENABLED" ]; then
   if [ $PROFILING_ENABLED -eq 0 ]; then
    exit 0
   fi
fi

# 允许上传至 DataKit 的 jfr 文件大小 (6 M),请勿修改
MAX_JFR_FILE_SIZE=6000000

# DataKit 服务地址
datakit_url=http://datakit-service.datakit:9529
if [ -n "$DATAKIT_URL" ]; then
    datakit_url=$DATAKIT_URL
fi

# 上传 profiling 数据的完整地址
datakit_profiling_url=$datakit_url/profiling/v1/input

# 应用的环境
app_env=dev
if [ -n "$APP_ENV" ]; then
    app_env=$APP_ENV
fi

# 应用的版本
app_version=0.0.0
if [ -n "$APP_VERSION" ]; then
    app_version=$APP_VERSION
fi

# 主机名称
host_name=$(hostname)
if [ -n "$HOST_NAME" ]; then
    host_name=$HOST_NAME
fi

# 服务名称
service_name=
if [ -n "$SERVICE_NAME" ]; then
  service_name=$SERVICE_NAME
fi

# profiling duration, in seconds
profiling_duration=10
if [ -n "$PROFILING_DURATION" ]; then
    profiling_duration=$PROFILING_DURATION
fi

# profiling event
profiling_event=cpu
if [ -n "$PROFILING_EVENT" ]; then
    profiling_event=$PROFILING_EVENT
fi

# 采集的 java 应用进程 ID,此处可以自定义需要采集的 java 进程,比如可以根据进程名称过滤
java_process_ids=$(jps -q -J-XX:+PerfDisableSharedMem)
if [ -n "$PROCESS_ID" ]; then
    java_process_ids=`echo $PROCESS_ID | tr "," " "`
fi

if [[ $java_process_ids == "" ]]; then
    printf "Warning: no java program found, exit now\n"
    exit 1
fi

is_valid_process_id() {
    if [ -n "$1" ]; then
        if [[ $1 =~ ^[0-9]+$ ]]; then
            return 1
        fi
    fi
    return 0
}

profile_collect() {
    # disable -e
    set +e

    process_id=$1
    is_valid_process_id $process_id
    if [[ $? == 0 ]]; then
        printf "Warning: invalid process_id: $process_id, ignore"
        return 1
    fi

    uuid=
    jfr_file=$runtime_dir/profiler_$uuid.jfr
    event_json_file=$runtime_dir/event_$uuid.json

    process_name=$(jps | grep $process_id | awk '{print $2}')


    start_time=$(date +%FT%T.%N%:z)
    ./profiler.sh -d $profiling_duration --fdtransfer -e $profiling_event -o jfr -f $jfr_file $process_id
    end_time=$(date +%FT%T.%N%:z)

  if [ ! -f $jfr_file ]; then
    printf "Warning: generating profiling file failed for %s, pid %d\n" $process_name $process_id
    return
  else
    printf "generate profiling file successfully for %s, pid %d\n" $process_name $process_id
  fi

  jfr_zip_file=$jfr_file
  if hash zip 2>/dev/null; then
      jfr_zip_file=$jfr_file.zip
      zip -q $jfr_zip_file $jfr_file
  fi

    zip_file_size=`ls -la $jfr_zip_file | awk '{print $5}'`

  if [ -z "$service_name" ]; then
    service_name=$process_name
  fi

    if [ $zip_file_size -gt $MAX_JFR_FILE_SIZE ]; then
        printf "Warning: the size of the jfr file generated is bigger than $MAX_JFR_FILE_SIZE bytes, now is $zip_file_size bytes\n"
    else
        cat >$event_json_file <<END
{
    "tags_profiler": "library_version:$LIBRARY_VERSION,library_type:async_profiler,process_id:$process_id,process_name:$process_name,service:$service_name,host:$host_name,env:$app_env,version:$app_version",
    "start": "$start_time",
    "end": "$end_time",
    "family": "java",
    "format": "jfr"
}
END
        res=$(curl $datakit_profiling_url \
            -F "main=@$jfr_zip_file;filename=main.jfr" \
            -F "event=@$event_json_file;filename=event.json;type=application/json"  )

        if [[ $res != *ProfileID* ]]; then
            printf "Warning: send profile file to datakit failed\n"
            printf "$res"
        else
            printf "Info: send profile file to datakit successfully\n"
            rm -rf $event_json_file $jfr_file $jfr_zip_file
        fi
    fi

    set -e
}

runtime_dir=runtime
if [ ! -d $runtime_dir ]; then
  mkdir $runtime_dir
fi

# 并行采集 profiling 数据
for process_id in $java_process_ids; do
  printf "profiling process %d\n" $process_id
  profile_collect $process_id > $runtime_dir/$process_id.log 2>&1 &
done

# 等待所有任务结束
wait

# 输出任务执行日志
for process_id in $java_process_ids; do
  log_file=$runtime_dir/$process_id.log
  if [ -f $log_file ]; then
    echo
    cat $log_file
    rm $log_file
  fi
done

镜像打包命令如下:

docker build -f DockerfileAsyncProfile -t profilingtest:v0.1 .

Kubernetes 进行部署

安装 datakit

注册观测云(www.guance.com)后,通过「集成」-「Datakit」-「Kubernetes」或「Kubernetes(Helm)」方式安装 datakit,这里采用 Daemonset 的方式进行安装,datakit 默认会采集 kubernetes 集群的主机、容器、标准输出的日志和事件等信息,可通过修改 env 的方式进行控制,具体可参考观测云文档

开启 Profile 采集配置

修改 datakit.yaml,注入 configmap 配置.

apiVersion: v1
kind: ConfigMap
metadata:
  name: datakit-conf
  namespace: datakit
data:
    profile.conf: |-
        [inputs.profile]
            endpoints = ["/profiling/v1/input"]

datakit.yaml 添加 mounthpath 配置。

- mountPath: /usr/local/datakit/conf.d/db/profile.conf
  name: datakit-conf
  subPath: profile.conf
  readOnly: true

修改完成后,重新安装 datakit。

kubectl apply -f datakit.yaml

部署应用

Kubernetes 部署 yaml 文件如下所示,可通过 env 环境变量来控制采集行为。

  • profilingtest.yaml
apiVersion: v1
kind: Service
metadata:
  name: profilingtest-service
  namespace: profiling
  labels:
    app: profilingtest-service
spec:
  selector:
    app: profilingtest-service
  ports:
    - protocol: TCP
      port: 9999
      targetPort: 9999
      nodePort: 30001
  type: NodePort
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: profilingtest-service
  namespace: profiling
  labels:
    app: profilingtest-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: profilingtest-service
  template:
    metadata:
      labels:
        app: profilingtest-service
    spec:
      containers:
        - name: profilingtest-service
          image: profilingtest:v0.1
          imagePullPolicy: IfNotPresent
          env:
            - name: SERVICE_NAME
              value: "profilingtest"
          ports:
            - containerPort: 9999
              protocol: TCP
      restartPolicy: Always

通过 kubectl 进行部署:

kubectl apply -f profilingtest.yaml

效果展示

  • Profile 任务列表

  • 任务运行信息 image.png

  • 火焰图

关于作者

Harlon,大厂多年监控系统开发经验,目前专注于云原生可观测性领域,欢迎交流~

Github:github.com/Harlonxl/Ob…