介绍
Async Profiler 是一款开源的 Java 性能分析工具,基于 HotSpot API,可以收集程序运行中的堆栈和内存分配等信息。Async Profiler 可以跟踪以下类型的事件:
- CPU 周期
- Java 堆的分配
- 硬件和软件性能计数器,如缓存未命中、分支未命中、页面错误、上下文切换等;
- 满足的锁定尝试,包括Java对象监视器和可重入锁;
此文重点不在于讨论 Async Proiler 功能,在实际项目中,引入 Async Proiler 工具可能是基于以下的考虑:
- 支持 arm 和 amd 架构
- 对系统性能影响比较小
- 能够支持较旧的 Java 版本(对于版本高于
8u262+的建议直接使用tracing agent的profiling方案)
Async Proiler 提供的火焰图是我们分析CPU性能和堆内存分配最有用的工具,再 Kubernetes 环境下,如何收集 Async Proiler 产生的数据,并借助第三方工具实时进行分析是本文涉及的范围。
总体方案设计
说明:
- 此方案需要在 Docker 容器中运行 Async Profiler 工具,本质上违反了 Docker 容器只运行一个进程的规范,所以并不推荐在生产环境使用此方案,可作为研发和测试环境排查性能问题。
- 总体方案需要通过 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 任务列表
-
任务运行信息
-
火焰图
关于作者
Harlon,大厂多年监控系统开发经验,目前专注于云原生可观测性领域,欢迎交流~
Github:github.com/Harlonxl/Ob…