在3月初, 我们就考虑在Kubernetes集群中增加性能采样分析工具(主要针对Python, 尤其是uwsgi应用), 在调研了几种性能分析方案之后, 选择py-spy用于uwsgi应用的性能分析, 这篇博客介绍具体的实现流程以及一些调试策略, 附录部分介绍了一下我学习到的内容.

Profile工具

我们集群主要是针对Python应用的采样以及性能分析, 我们原有的未在kubernetes环境中的方案是pyflame, 但是它已经不维护了. 组里的其他同事提出了用py-spy, 还在比较活跃的开发中, 而且功能比pyflame更多, 安装更为简单, 就转而使用py-spy了

系统实现方案

直接运行

它的top功能我比较喜欢, 可以马上确认当前堆栈信息.

在它的README中, 也介绍了如何在docker容器中运行py-spy, 需要增加PTRACE权限, 另外一点就是也需要读取hostPID, 如果只能看到容器中自己的pid, 是无法取样的.

kubernetes环境中运行

参考github中的方案

下面是pyflame的实现方案: https://github.com/monsterxx03/kube-pyflame/blob/master/kubectl-pyflame

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
nodeName=$(kubectl get pod ${POD} -n ${NAMESPACE} -o jsonpath='{.spec.nodeName}')
kubectl create -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: ${DEBUG_POD}
namespace: ${NAMESPACE}
spec:
terminationGracePeriodSeconds: 0
hostPID: true
containers:
- command: ['sh', '-c', 'while true; do sleep 10; done;']
image: ${IMAGE}
imagePullPolicy: IfNotPresent
name: pyflame
securityContext:
privileged: true
nodeSelector:
kubernetes.io/hostname: ${nodeName}
EOF
kubectl wait --for=condition=Ready pod/${DEBUG_POD} -n ${NAMESPACE} --timeout=120s
kubectl exec ${DEBUG_POD} -n ${NAMESPACE} -- bash -c "pyflame -p ${pid} -s $SECONDS -r $RATE | flamegraph.pl > /tmp/pyflame.svg"

这是一种最为简单的实现方式, 需要注意的有两点, 这里的hostPID: true以及securityContext: privileged: true, 这就是给定了特殊权限.

我们的实现方式

根据上面的pyflame方法, 我们使用py-spy也差不多, 和上面配置很像, 但有一些地方是不同的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
---
apiVersion: batch/v1
kind: Job
metadata:
name: profile-task-22-1618198229
namespace: default
spec:
ttlSecondsAfterFinished: 259200
backoffLimit: 3
template:
metadata:
labels:
app_id: "11"
spec:
nodeSelector:
kubernetes.io/hostname: "minikube"
restartPolicy: Never
hostPID: true
containers:
- name: profile-task--22
image: <IMAGE>
args:
- profile_task
env:
- name: params
value: "{\"rate\": 100, \"seconds\": 60, \"exclude_idle\": true, \"multi_threads\": true, \"app_name\": \"arya-python3\", \"version_id\": 2255}"
securityContext:
capabilities:
add:
- SYS_PTRACE

不同点有以下几个方面:

  1. 我们使用了job, 而不使用pod, 我主要是为了规范代码, 另外有一点, job可以有这个参数ttlSecondsAfterFinished
  2. namespace我这里默认用来default, 而不是应用所在的namespace, 主要原因是: 前面的文章中, 我介绍了: 基于Kubernetes的PaaS平台提供dashboard支持的一种方案. 因此我们给用户开放了自己的应用所在namespace的操作权限, 也就是可以exec进入容器中, 那么就会有一个问题了, 因为这个采样容器是具有hostPID权限的, 如果普通用户可以进入到 这个特权容器中, 那么它就可以kill其他程序了, 这种操作是不能被允许的. 因此, 这一类特权容器需要统一放到用户无法访问的namespace中.
  3. nodeSelector中给定了需要运行采样任务的机器, 这个机器由PaaS随机进行选择, 不需要关心自己的应用是在哪台机器中
  4. 环境变量中, 保存了采样参数, 该任务运行启动后, 可以自动开始采样操作, 采样结束后, 上传结果, 用户只需要点几下按钮就可以得到数据

采样容器中代码细节

这个程序有几个工作:

  1. 读取环境变量中的配置信息,
  2. 确认要取样的进程id,
  3. 取样
  4. 上报火焰图

我贴下主要的代码, 错误处理已经忽略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def _run_profile_task():
params = json.loads(os.environ['params'])
app_name = params['app_name']
version_id = params['version_id']

# 查询对应的进程信息, 这里搜索uwsgi worker名称
# 因为我们设置了uwsgi启动参数, 会增加进程名前缀 procname-prefix: arya-python3-2255_
proc_name = '%s-%s_uWSGI worker' % (app_name, version_id)
for proc in psutil.process_iter():
cmdline = proc.cmdline()
if len(cmdline) > 0:
cmd = " ".join(cmdline)
if cmd.startswith(proc_name):
target_proc = proc
break
output_file = os.path.join(
'var', 'run',
'profile-{}-{}-{}'.format(app_name, version_id, int(time.time()))
)
proc_args = [
'py-spy', 'record', '--subprocesses',
'--duration', '{}'.format(params['seconds']),
'--rate', '{}'.format(params['rate']),
'--pid', '{}'.format(target_proc.pid),
'--output', output_file,
]
if params.get('multi_threads'):
pass
if params.get('exclude_idle', True) is False:
proc_args.append('--idle')

# 运行采样进程
_ret, _out, _err = run_with_timeout(proc_args, get_timeout())

# 上报...

程序安全性考量

安全性问题主要就是namespace的限制策略, 已经在上面的对比实现中给出了具体的原因以及方案.

总结

这篇文章主要想介绍下我们在kubernetes环境中根据PaaS平台情况增加的性能分析工具, 目前的情况止步于Python应用, 不过未来也很容易扩展到其他语言的程序. 另外需要注意的一点就是特权容器的处理, 尽量少给权限, 而且也不要让用户看到.

附1: Job调试策略

在pyflame的实现方案中, 有一行这样的配置, 学习一下, 简直就是job任务以及pod容器的调试神器.

1
- command: ['sh', '-c', 'while true; do sleep 10; done;']

附2: kubectl flame 工具学习

在我们的功能上线后, 我也看到有这么一个取样工具, 它是基于kubectl的插件开发方式提供的. 如果只是需要一个简单的取样工具, 那么没必要开发一整套系统, 可以尝试用用kubectl的这个插件

Introducing Kubectl Flame: Effortless Profiling on Kubernetes

里面有这么一句话: Profiling is a non-trivial task.(性能分析是一项非同小可的任务.)

1
kubectl flame mypod -t 1m -f /tmp/flamegraph.svg

我顺便也读了读代码 https://github.com/VerizonMedia/kubectl-flame 代码库不长, 我简单介绍下它的实现吧.

代码实现

cli/cmd/kubernetes/root.go入口代码在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
cmd := &cobra.Command{
Use: "flame [pod-name]",
DisableFlagsInUseLine: true,
Short: "Profile running applications by generating flame graphs.",
Long: flameLong,
Example: fmt.Sprintf(flameExamples, "kubectl"),
PersistentPreRun: func(c *cobra.Command, args []string) {
c.SetOutput(streams.ErrOut)
},
Run: func(cmd *cobra.Command, args []string) {
// 检查是否是支持的语言以及事件
validateFlags(chosenLang, chosenEvent, &targetDetails, &jobDetails);

targetDetails.PodName = args[0]
if len(args) > 1 {
targetDetails.ContainerName = args[1]
}

cfg := &data.FlameConfig{
TargetConfig: &targetDetails,
JobConfig: &jobDetails,
ConfigFlags: options.configFlags,
}

Flame(cfg)
},
}

Flame函数就是具体的采样函数了, 我把错误处理全部省略了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func Flame(cfg *data.FlameConfig) {
ns, err := kubernetes.Connect(cfg.ConfigFlags)
p := NewPrinter(cfg.TargetConfig.DryRun)

cfg.TargetConfig.Namespace = ns
ctx := context.Background()

// 处理要监听的pod的信息
p.Print("Verifying target pod ... ")
pod, err := kubernetes.GetPodDetails(cfg.TargetConfig.PodName, cfg.TargetConfig.Namespace, ctx)
containerName, err := validatePod(pod, cfg.TargetConfig)

containerId, err := kubernetes.GetContainerId(containerName, pod)
p.PrintSuccess()

cfg.TargetConfig.ContainerName = containerName
cfg.TargetConfig.ContainerId = containerId

p.Print("Launching profiler ... ")
// 启动profile任务
profileId, job, err := kubernetes.LaunchFlameJob(pod, cfg, ctx)
if cfg.TargetConfig.DryRun {
return
}

cfg.TargetConfig.Id = profileId
profilerPod, err := kubernetes.WaitForPodStart(cfg.TargetConfig, ctx)
p.PrintSuccess()
apiHandler := &handler.ApiEventsHandler{
Job: job,
Target: cfg.TargetConfig,
}
done, err := kubernetes.GetLogsFromPod(profilerPod, apiHandler, ctx)
<-done
}

后面的代码就不贴了, 就是为了启动一个Job. 直接快进到具体的profile代码

1
2
3
4
5
6
7
// agent/profiler/python.go
cmd := exec.Command(pySpyLocation, "record", "-p", pid, "-o", pythonOutputFileName, "-d", duration, "-s", "-t")
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
// 讲道理, 这个参数比较简单

不同语言的应用应该如何采样分析

也是读代码库学到的…

语言 取样工具
Java async-profiler
Python py-spy
Golang bcc-profiler
Ruby rbspy