Skip to content

Commit 0435263

Browse files
authored
Add Python Support (#31)
* Add Python Support * Update docs
1 parent c76ec13 commit 0435263

File tree

11 files changed

+261
-11
lines changed

11 files changed

+261
-11
lines changed

.github/workflows/release.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ jobs:
3636
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
3737
repository: verizondigital/kubectl-flame
3838
tags: ${{ steps.vars.outputs.tag }}-bpf
39+
- name: Build Python Docker Image
40+
uses: docker/build-push-action@v1
41+
with:
42+
dockerfile: 'agent/docker/python/Dockerfile'
43+
username: ${{ secrets.DOCKER_HUB_USER }}
44+
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
45+
repository: verizondigital/kubectl-flame
46+
tags: ${{ steps.vars.outputs.tag }}-python
3947
- name: Setup Go
4048
uses: actions/setup-go@v1
4149
with:

.krew.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ spec:
99
description: |
1010
Generate CPU flame graphs without restarting pods and with low overhead.
1111
caveats: |
12-
Currently, only Java is supported.
12+
Currently supported languages: Go, Java (any JVM based language) and Python
1313
platforms:
1414
- {{addURIAndSha "https://github.com/VerizonMedia/kubectl-flame/releases/download/{{ .TagName }}/kubectl-flame_{{ .TagName }}_darwin_x86_64.tar.gz" .TagName | indent 6 }}
1515
bin: kubectl-flame

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Running `kubectlf-flame` does **not** require any modification to existing pods.
1515
- [License](#license)
1616

1717
## Requirements
18-
* Supported languages: Go, Java (any JVM based language)
18+
* Supported languages: Go, Java (any JVM based language) and Python
1919
* Kubernetes cluster that use Docker as the container runtime (tested on GKE, EKS and AKS)
2020

2121
## Usage
@@ -63,6 +63,7 @@ See the release page for the full list of pre-built assets.
6363
Under the hood `kubectl-flame` use [async-profiler](https://github.com/jvm-profiling-tools/async-profiler) in order to generate flame graphs for Java applications.
6464
Interaction with the target JVM is done via a shared `/tmp` folder.
6565
Golang support is based on [ebpf profiling](https://en.wikipedia.org/wiki/Berkeley_Packet_Filter).
66+
Python support is based on [py-spy](https://github.com/benfred/py-spy).
6667

6768
## Contribute
6869
Please refer to [the contributing.md file](Contributing.md) for information about how to get involved. We welcome issues, questions, and pull requests.

agent/docker/python/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM golang:1.14-buster as agentbuild
2+
WORKDIR /go/src/github.com/VerizonMedia/kubectl-flame
3+
ADD . /go/src/github.com/VerizonMedia/kubectl-flame
4+
RUN go get -d -v ./...
5+
RUN cd agent && go build -o /go/bin/agent
6+
7+
FROM alpine AS pyspybuild
8+
RUN apk add python3
9+
RUN echo 'manylinux1_compatible = True' > /usr/lib/python3.8/site-packages/_manylinux.py
10+
RUN pip3 install py-spy==0.4.0.dev1
11+
12+
FROM alpine
13+
RUN mkdir /app
14+
COPY --from=agentbuild /go/bin/agent /app/agent
15+
COPY --from=pyspybuild /usr/bin/py-spy /app/py-spy
16+
17+
CMD [ "/app/agent" ]

agent/profiler/python.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package profiler
2+
3+
import (
4+
"bytes"
5+
"github.com/VerizonMedia/kubectl-flame/agent/details"
6+
"github.com/VerizonMedia/kubectl-flame/agent/utils"
7+
"os/exec"
8+
"strconv"
9+
)
10+
11+
const (
12+
pySpyLocation = "/app/py-spy"
13+
pythonOutputFileName = "/tmp/python.svg"
14+
)
15+
16+
type PythonProfiler struct{}
17+
18+
func (p *PythonProfiler) SetUp(job *details.ProfilingJob) error {
19+
return nil
20+
}
21+
22+
func (p *PythonProfiler) Invoke(job *details.ProfilingJob) error {
23+
pid, err := utils.FindRootProcessId(job)
24+
if err != nil {
25+
return err
26+
}
27+
28+
duration := strconv.Itoa(int(job.Duration.Seconds()))
29+
cmd := exec.Command(pySpyLocation, "record", "-p", pid, "-o", pythonOutputFileName, "-d", duration, "-s", "-t")
30+
var out bytes.Buffer
31+
var stderr bytes.Buffer
32+
cmd.Stdout = &out
33+
cmd.Stderr = &stderr
34+
err = cmd.Run()
35+
if err != nil {
36+
return err
37+
}
38+
39+
return utils.PublishFlameGraph(pythonOutputFileName)
40+
}

agent/profiler/root.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ type FlameGraphProfiler interface {
1212
}
1313

1414
var (
15-
jvm = JvmProfiler{}
16-
bpf = BpfProfiler{}
15+
jvm = JvmProfiler{}
16+
bpf = BpfProfiler{}
17+
python = PythonProfiler{}
1718
)
1819

1920
func ForLanguage(lang api.ProgrammingLanguage) (FlameGraphProfiler, error) {
@@ -22,6 +23,8 @@ func ForLanguage(lang api.ProgrammingLanguage) (FlameGraphProfiler, error) {
2223
return &jvm, nil
2324
case api.Go:
2425
return &bpf, nil
26+
case api.Python:
27+
return &python, nil
2528
default:
2629
return nil, fmt.Errorf("could not find profiler for language %s", lang)
2730
}

agent/utils/process.go

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package utils
22

33
import (
4+
"bufio"
45
"errors"
56
"github.com/VerizonMedia/kubectl-flame/agent/details"
67
"github.com/VerizonMedia/kubectl-flame/api"
@@ -13,7 +14,8 @@ import (
1314

1415
var (
1516
defaultProcessNames = map[api.ProgrammingLanguage]string{
16-
api.Java: "java",
17+
api.Java: "java",
18+
api.Python: "python",
1719
}
1820
)
1921

@@ -97,3 +99,98 @@ func FindProcessId(job *details.ProfilingJob) (string, error) {
9799

98100
return "", errors.New("could not find any process")
99101
}
102+
103+
func FindRootProcessId(job *details.ProfilingJob) (string, error) {
104+
name := getProcessName(job)
105+
procsAndParents := make(map[string]string)
106+
proc, err := os.Open("/proc")
107+
if err != nil {
108+
return "", err
109+
}
110+
111+
defer proc.Close()
112+
113+
for {
114+
dirs, err := proc.Readdir(15)
115+
if err == io.EOF {
116+
break
117+
}
118+
if err != nil {
119+
return "", err
120+
}
121+
122+
for _, di := range dirs {
123+
if !di.IsDir() {
124+
continue
125+
}
126+
127+
dname := di.Name()
128+
if dname[0] < '0' || dname[0] > '9' {
129+
continue
130+
}
131+
132+
mi, err := mountinfo.GetMountInfo(path.Join("/proc", dname, "mountinfo"))
133+
if err != nil {
134+
continue
135+
}
136+
137+
for _, m := range mi {
138+
root := m.Root
139+
if strings.Contains(root, job.PodUID) &&
140+
strings.Contains(root, job.ContainerName) {
141+
142+
exeName, err := os.Readlink(path.Join("/proc", dname, "exe"))
143+
if err != nil {
144+
continue
145+
}
146+
147+
ppid, err := getProcessPPID(dname)
148+
if err != nil {
149+
return "", err
150+
}
151+
152+
if name != "" {
153+
// search by process name
154+
if strings.Contains(exeName, name) {
155+
procsAndParents[dname] = ppid
156+
}
157+
} else {
158+
procsAndParents[dname] = ppid
159+
}
160+
}
161+
}
162+
}
163+
}
164+
165+
return findRootProcess(procsAndParents)
166+
}
167+
168+
func getProcessPPID(pid string) (string, error) {
169+
ppidKey := "PPid"
170+
statusFile, err := os.Open(path.Join("/proc", pid, "status"))
171+
if err != nil {
172+
return "", err
173+
}
174+
175+
defer statusFile.Close()
176+
scanner := bufio.NewScanner(statusFile)
177+
for scanner.Scan() {
178+
text := scanner.Text()
179+
if strings.Contains(text, ppidKey) {
180+
return strings.Fields(text)[1], nil
181+
}
182+
}
183+
184+
return "", errors.New("unable to get process ppid")
185+
}
186+
187+
func findRootProcess(procsAndParents map[string]string) (string, error) {
188+
for process, ppid := range procsAndParents {
189+
if _, ok := procsAndParents[ppid]; !ok {
190+
// Found process with ppid that is not in the same programming language - this is the root
191+
return process, nil
192+
}
193+
}
194+
195+
return "", errors.New("could not find root process")
196+
}

api/langs.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ package api
33
type ProgrammingLanguage string
44

55
const (
6-
Java ProgrammingLanguage = "java"
7-
Go ProgrammingLanguage = "go"
6+
Java ProgrammingLanguage = "java"
7+
Go ProgrammingLanguage = "go"
8+
Python ProgrammingLanguage = "python"
89
)
910

1011
var (
11-
supportedLangs = []ProgrammingLanguage{Java, Go}
12+
supportedLangs = []ProgrammingLanguage{Java, Go, Python}
1213
)
1314

1415
func AvailableLanguages() []ProgrammingLanguage {

cli/cmd/data/target.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type TargetDetails struct {
1818
Alpine bool
1919
DryRun bool
2020
Image string
21-
DockerPath string
21+
DockerPath string
2222
Language api.ProgrammingLanguage
2323
Pgrep string
2424
}

cli/cmd/kubernetes/job/python.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package job
2+
3+
import (
4+
"fmt"
5+
"github.com/VerizonMedia/kubectl-flame/cli/cmd/data"
6+
"github.com/VerizonMedia/kubectl-flame/cli/cmd/version"
7+
batchv1 "k8s.io/api/batch/v1"
8+
apiv1 "k8s.io/api/core/v1"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/util/uuid"
11+
)
12+
13+
type pythonCreator struct{}
14+
15+
func (p *pythonCreator) create(targetPod *apiv1.Pod, targetDetails *data.TargetDetails) (string, *batchv1.Job) {
16+
id := string(uuid.NewUUID())
17+
var imageName string
18+
if targetDetails.Image != "" {
19+
imageName = targetDetails.Image
20+
} else {
21+
imageName = fmt.Sprintf("%s:%s-python", baseImageName, version.GetCurrent())
22+
}
23+
24+
commonMeta := metav1.ObjectMeta{
25+
Name: fmt.Sprintf("kubectl-flame-%s", id),
26+
Namespace: targetDetails.Namespace,
27+
Labels: map[string]string{
28+
"kubectl-flame/id": id,
29+
},
30+
Annotations: map[string]string{
31+
"sidecar.istio.io/inject": "false",
32+
},
33+
}
34+
35+
job := &batchv1.Job{
36+
TypeMeta: metav1.TypeMeta{
37+
Kind: "Job",
38+
APIVersion: "batch/v1",
39+
},
40+
ObjectMeta: commonMeta,
41+
Spec: batchv1.JobSpec{
42+
Parallelism: int32Ptr(1),
43+
Completions: int32Ptr(1),
44+
TTLSecondsAfterFinished: int32Ptr(5),
45+
Template: apiv1.PodTemplateSpec{
46+
ObjectMeta: commonMeta,
47+
Spec: apiv1.PodSpec{
48+
HostPID: true,
49+
InitContainers: nil,
50+
Containers: []apiv1.Container{
51+
{
52+
ImagePullPolicy: apiv1.PullAlways,
53+
Name: ContainerName,
54+
Image: imageName,
55+
Command: []string{"/app/agent"},
56+
Args: []string{id,
57+
string(targetPod.UID),
58+
targetDetails.ContainerName,
59+
targetDetails.ContainerId,
60+
targetDetails.Duration.String(),
61+
string(targetDetails.Language),
62+
targetDetails.Pgrep,
63+
},
64+
SecurityContext: &apiv1.SecurityContext{
65+
Privileged: boolPtr(true),
66+
Capabilities: &apiv1.Capabilities{
67+
Add: []apiv1.Capability{"SYS_PTRACE"},
68+
},
69+
},
70+
},
71+
},
72+
RestartPolicy: "Never",
73+
NodeName: targetPod.Spec.NodeName,
74+
},
75+
},
76+
},
77+
}
78+
79+
return id, job
80+
}

cli/cmd/kubernetes/job/root.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ const (
1616
)
1717

1818
var (
19-
jvm = jvmCreator{}
20-
bpf = bpfCreator{}
19+
jvm = jvmCreator{}
20+
bpf = bpfCreator{}
21+
python = pythonCreator{}
2122
)
2223

2324
type creator interface {
@@ -30,6 +31,8 @@ func Create(targetPod *apiv1.Pod, targetDetails *data.TargetDetails) (string, *b
3031
return jvm.create(targetPod, targetDetails)
3132
case api.Go:
3233
return bpf.create(targetPod, targetDetails)
34+
case api.Python:
35+
return python.create(targetPod, targetDetails)
3336
}
3437

3538
// Should not happen

0 commit comments

Comments
 (0)