blob: ff88fc9e64d001198810eb3da9f7d6f46fc157a0 [file]
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to You under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/apache/beam/sdks/v2/go/container/tools"
)
type profilerConfigKeyType struct{}
var (
profilerConfigKey profilerConfigKeyType
profilerMu sync.Mutex
cleanupCallbacks []func(ctx context.Context, logger *tools.Logger)
)
// registerCleanupCallback registers a function to be executed synchronously during container shutdown.
// This allows individual profiling agents to perform the final iteration of profile post processing.
func registerCleanupCallback(cb func(ctx context.Context, logger *tools.Logger)) {
cleanupCallbacks = append(cleanupCallbacks, cb)
}
// ProfilerConfig holds all pre-computed profiling parameters.
type ProfilerConfig struct {
Enabled bool
Agent string
ExtraArgs []string
ExtraEnvVars []string
Location string
TempLocation string
BaseTempDir string
StopSentinelPath string
GcsDestPath string
UploadIntervalSec int
StopAfterSec int
StopAfterCrash bool
PostprocessIntervalSec int
GcloudAvailable bool
}
// setupProfilerConfig parses PipelineOptions and stores a resolved ProfilerConfig in the context.
func setupProfilerConfig(ctx context.Context, logger *tools.Logger, po *tools.PipelineOptions) context.Context {
agent, err := po.GetString("profiler_agent")
if err != nil || agent == "" {
return ctx
}
baseTempDir, err := po.GetString("profile_temp_location")
if err != nil || baseTempDir == "" {
baseTempDir = filepath.Join(*semiPersistDir, "profiles")
}
jobId, err := po.GetString("jobId")
if err != nil || jobId == "" {
jobId = os.Getenv("JOB_ID")
}
if jobId == "" {
jobId = os.Getenv("JOB_NAME")
}
if jobId == "" {
jobId = "BEAM_JOB"
}
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "default-worker"
}
tempLocation := filepath.Join(baseTempDir, jobId, hostname)
sentinelPath := filepath.Join(tempLocation, fmt.Sprintf(".profiler_disengaged_%s_%s", jobId, hostname))
var gcsDestPath string
gcloudAvailable := false
profileLocation, err := po.GetString("profile_location")
if err != nil || profileLocation == "" {
logger.Printf(ctx, "profile_location not specified, profiles will only be stored locally.")
} else if strings.HasPrefix(profileLocation, "gs://") {
gcsDestPath = strings.TrimSuffix(profileLocation, "/")
if _, err := exec.LookPath("gcloud"); err == nil {
gcloudAvailable = true
} else {
logger.Errorf(ctx, "gcloud is not available, profiles will not be uploaded.")
}
}
profilerExtraArgs, err := po.GetStringSlice("profiler_extra_args")
if err != nil {
profilerExtraArgs = []string{}
}
profilerExtraEnvVars, err := po.GetStringSlice("profiler_extra_env_vars")
if err != nil {
profilerExtraEnvVars = []string{}
}
profileUploadIntervalSec, err := po.GetInt("profile_upload_interval_sec")
if err != nil {
profileUploadIntervalSec = 300
logger.Printf(ctx, "Using default profile_upload_interval_sec: %v", profileUploadIntervalSec)
}
profilerStopAfterSec, err := po.GetInt("profiler_stop_after_sec")
if err != nil {
profilerStopAfterSec = 0
}
profilerStopAfterCrash, err := po.GetBool("profiler_stop_after_crash")
if err != nil {
profilerStopAfterCrash = false
}
profilePostprocessIntervalSec, err := po.GetInt("profile_postprocess_interval_sec")
if err != nil {
profilePostprocessIntervalSec = 600
logger.Printf(ctx, "Using default profile_postprocess_interval_sec: %v", profilePostprocessIntervalSec)
}
config := &ProfilerConfig{
Enabled: true,
Agent: agent,
ExtraArgs: profilerExtraArgs,
ExtraEnvVars: profilerExtraEnvVars,
Location: profileLocation,
BaseTempDir: baseTempDir,
TempLocation: tempLocation,
StopSentinelPath: sentinelPath,
GcsDestPath: gcsDestPath,
UploadIntervalSec: profileUploadIntervalSec,
StopAfterSec: profilerStopAfterSec,
StopAfterCrash: profilerStopAfterCrash,
PostprocessIntervalSec: profilePostprocessIntervalSec,
GcloudAvailable: gcloudAvailable,
}
return context.WithValue(ctx, profilerConfigKey, config)
}
// getProfilerConfig extracts the ProfilerConfig from the context.
func getProfilerConfig(ctx context.Context) *ProfilerConfig {
if cfg, ok := ctx.Value(profilerConfigKey).(*ProfilerConfig); ok {
return cfg
}
return nil
}
// startProfilerBackgroundTasks initializes profiling locations and runs background tasks (GCS sync, post-processing loops) if profiling is enabled.
func startProfilerBackgroundTasks(ctx context.Context, logger *tools.Logger) {
pcfg := getProfilerConfig(ctx)
if pcfg == nil {
return
}
logger.Printf(ctx, "Worker will be configured with profiler agent enabled.")
logger.Printf(ctx, "ProfilerAgent: %v", pcfg.Agent)
logger.Printf(ctx, "ProfilerExtraArgs: %v", pcfg.ExtraArgs)
logger.Printf(ctx, "ProfilerExtraEnvVars: %v", pcfg.ExtraEnvVars)
logger.Printf(ctx, "ProfileLocation: %v", pcfg.Location)
logger.Printf(ctx, "ProfileTempLocation: %v", pcfg.BaseTempDir)
logger.Printf(ctx, "ProfileUploadIntervalSec: %v", pcfg.UploadIntervalSec)
logger.Printf(ctx, "ProfilerStopAfterSec: %v", pcfg.StopAfterSec)
logger.Printf(ctx, "ProfilerStopAfterCrash: %v", pcfg.StopAfterCrash)
logger.Printf(ctx, "ProfilePostprocessIntervalSec: %v", pcfg.PostprocessIntervalSec)
if err := os.MkdirAll(pcfg.TempLocation, 0755); err != nil {
logger.Warnf(ctx, "Failed to create ProfileTempLocation: %v", err)
}
if pcfg.GcsDestPath != "" && pcfg.GcloudAvailable {
if pcfg.UploadIntervalSec > 0 {
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(pcfg.UploadIntervalSec) * time.Second):
// TODO(tvalentyn): Consider a periodic cleanup as well to save local disk space.
syncProfilesToGCS(ctx, logger, pcfg.BaseTempDir, pcfg.GcsDestPath)
}
}
}()
}
}
if pcfg.PostprocessIntervalSec > 0 {
if pcfg.Agent == "memray" {
go postProcessProfilesLoop(ctx, logger, pcfg)
registerCleanupCallback(func(ctx context.Context, logger *tools.Logger) {
runPostProcessingSweep(ctx, logger, pcfg.TempLocation, pcfg.PostprocessIntervalSec)
})
}
if pcfg.Agent == "coredump" {
go monitorCoredumpsLoop(ctx, logger, pcfg)
registerCleanupCallback(func(ctx context.Context, logger *tools.Logger) {
processNewCoredumps(ctx, logger, pcfg)
})
}
}
}
// maybeWithProfiler builds the execution arguments and environment variables if profiling is enabled and active.
func maybeWithProfiler(
ctx context.Context,
logger *tools.Logger,
workerId string,
currentProg string,
currentArgs []string,
currentEnv map[string]string,
) (string, []string, map[string]string, bool) {
pcfg := getProfilerConfig(ctx)
if pcfg == nil {
return currentProg, currentArgs, currentEnv, false
}
if _, err := os.Stat(pcfg.StopSentinelPath); err == nil {
return currentProg, currentArgs, currentEnv, false
}
prog := currentProg
var args []string
// Copy env
env := make(map[string]string)
for k, v := range currentEnv {
env[k] = v
}
if pcfg.Agent == "memray" {
timeSuffix := time.Now().Format("20060102150405")
memrayFile := filepath.Join(pcfg.TempLocation, fmt.Sprintf("memray-%s-%s.bin", workerId, timeSuffix))
args = []string{"-m", "memray", "run"}
args = append(args, pcfg.ExtraArgs...)
args = append(args, "-o", memrayFile, "-m", sdkHarnessEntrypoint)
} else if pcfg.Agent == "tcmalloc" {
tcmallocHeapPath := filepath.Join(pcfg.TempLocation, fmt.Sprintf("tcmalloc-%s", workerId))
existingPreload := os.Getenv("LD_PRELOAD")
if existingPreload != "" {
env["LD_PRELOAD"] = existingPreload + ":libtcmalloc.so.4"
} else {
env["LD_PRELOAD"] = "libtcmalloc.so.4"
}
env["HEAPPROFILE"] = tcmallocHeapPath
args = currentArgs
} else if pcfg.Agent == "coredump" {
// No wrapping of the executable is needed for coredump analysis.
args = currentArgs
} else {
prog = pcfg.Agent
args = append(append([]string{}, pcfg.ExtraArgs...), currentProg)
args = append(args, currentArgs...)
}
for _, envVar := range pcfg.ExtraEnvVars {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
} else {
logger.Errorf(ctx, "Failed to parse profiler extra environment variable: %v. Expected format KEY=VALUE", envVar)
}
}
return prog, args, env, true
}
// stopProfiling creates a dummy file at StopSentinelPath to signal that profiling should stop.
func stopProfiling(ctx context.Context) error {
pcfg := getProfilerConfig(ctx)
if pcfg == nil {
return nil
}
f, err := os.Create(pcfg.StopSentinelPath)
if err == nil {
f.Close()
}
return err
}
// isProfilerDisengaged checks if the stop sentinel file exists.
func isProfilerDisengaged(pcfg *ProfilerConfig) bool {
if _, err := os.Stat(pcfg.StopSentinelPath); err == nil {
return true
}
return false
}
// syncProfilesToGCS uploads newly created local memory profiles to the designated GCS target path using gcloud storage.
func syncProfilesToGCS(ctx context.Context, logger *tools.Logger, localDir, gcsDest string) {
entries, err := os.ReadDir(localDir)
if err != nil || len(entries) == 0 {
return
}
logger.Printf(ctx, "Syncing profiles from %s to %s", localDir, gcsDest)
cmd := exec.CommandContext(ctx, "gcloud", "storage", "rsync", "-r", localDir, gcsDest)
if err := cmd.Run(); err != nil {
logger.Warnf(ctx, "Failed to sync profiles to GCS: %v", err)
} else {
logger.Printf(ctx, "Successfully synced profiles to GCS.")
}
}
// postProcessProfilesLoop runs a background loop that periodically triggers profile post-processing if enabled.
func postProcessProfilesLoop(ctx context.Context, logger *tools.Logger, pcfg *ProfilerConfig) {
for {
runPostProcessingSweep(ctx, logger, pcfg.TempLocation, pcfg.PostprocessIntervalSec)
if isProfilerDisengaged(pcfg) {
return
}
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(pcfg.PostprocessIntervalSec) * time.Second):
// Block until the sleep completes before starting the next sweep
}
}
}
// runPostProcessingSweep scans the profiles directory and launches sequential postprocessing for newly updated profiles.
func runPostProcessingSweep(ctx context.Context, logger *tools.Logger, profilesDir string, intervalSec int) {
profilerMu.Lock()
defer profilerMu.Unlock()
files, err := os.ReadDir(profilesDir)
if err != nil {
return
}
for _, file := range files {
name := file.Name()
if !strings.HasSuffix(name, ".bin") || strings.HasPrefix(name, ".") {
continue
}
binPath := filepath.Join(profilesDir, name)
binInfo, err := os.Stat(binPath)
if err != nil || binInfo.Size() == 0 {
continue
}
peakHtml := strings.TrimSuffix(binPath, ".bin") + ".html"
leaksHtml := strings.TrimSuffix(binPath, ".bin") + "_leaks.html"
filename := filepath.Base(binPath)
peakReportStale := needsProcessing(binInfo, peakHtml)
leakReportStale := needsProcessing(binInfo, leaksHtml)
if peakReportStale || leakReportStale {
binSizeMb := float64(binInfo.Size()) / (1024 * 1024)
logger.Printf(ctx, "Post-processing profile %s of size %.2f MB", filename, binSizeMb)
}
// 1. Peak Flamegraph
if peakReportStale {
tmpPath := peakHtml + ".tmp"
cmd1 := exec.CommandContext(ctx, "python", "-m", "memray", "flamegraph", "-f", "-o", tmpPath, binPath)
if err := cmd1.Run(); err != nil {
logger.Warnf(ctx, "Failed to generate peak flamegraph for %s: %v", filename, err)
} else {
if err := os.Rename(tmpPath, peakHtml); err != nil {
logger.Warnf(ctx, "Failed to rename peak flamegraph for %s: %v", filename, err)
} else {
logger.Printf(ctx, "Successfully updated peak flamegraph for %s", filename)
_ = os.Chtimes(peakHtml, binInfo.ModTime(), binInfo.ModTime())
}
}
}
// 2. Leaks Flamegraph
if leakReportStale {
tmpPath := leaksHtml + ".tmp"
cmd2 := exec.CommandContext(ctx, "python", "-m", "memray", "flamegraph", "-f", "--leaks", "-o", tmpPath, binPath)
if err := cmd2.Run(); err != nil {
logger.Warnf(ctx, "Failed to generate leaks flamegraph for %s: %v", filename, err)
} else {
if err := os.Rename(tmpPath, leaksHtml); err != nil {
logger.Warnf(ctx, "Failed to rename leaks flamegraph for %s: %v", filename, err)
} else {
logger.Printf(ctx, "Successfully updated leaks flamegraph for %s", filename)
_ = os.Chtimes(leaksHtml, binInfo.ModTime(), binInfo.ModTime())
}
}
}
}
}
func needsProcessing(binInfo os.FileInfo, path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return true
}
if err != nil {
return true
}
// Don't regenerate when there were no updates to the profile.
return binInfo.ModTime().After(info.ModTime())
}
func monitorCoredumpsLoop(ctx context.Context, logger *tools.Logger, pcfg *ProfilerConfig) {
if pcfg.PostprocessIntervalSec <= 0 {
return
}
interval := time.Duration(pcfg.PostprocessIntervalSec) * time.Second
logger.Printf(ctx, "Monitoring core dumps every %v", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
processNewCoredumps(ctx, logger, pcfg)
if isProfilerDisengaged(pcfg) {
return
}
}
}
}
func processNewCoredumps(ctx context.Context, logger *tools.Logger, pcfg *ProfilerConfig) {
profilerMu.Lock()
defer profilerMu.Unlock()
// We expect the runner runtime environment to set the core pattern
// to /tmp/beam_coredump.%e.%p or similar. To do that, we pass
// the --experiment=core_pattern pipeline option, which can be interpreted by a runner.
coreDir := "/tmp"
files, err := os.ReadDir(coreDir)
if err != nil {
return
}
prefix := "beam_coredump."
for _, file := range files {
if file.IsDir() {
continue
}
name := file.Name()
if !strings.HasPrefix(name, prefix) {
continue
}
corePath := filepath.Join(coreDir, name)
var info os.FileInfo
var err error
for {
info, err = os.Stat(corePath)
if err != nil || time.Since(info.ModTime()) >= 2*time.Second {
break
}
// Wait for the core file to finish being written.
time.Sleep(500 * time.Millisecond)
}
if err != nil {
continue
}
logger.Printf(ctx, "Found core dump file: %s (%d bytes)", name, info.Size())
// Find python executable. Since the worker might be running in a venv,
// we look for "python" in the PATH.
pythonProg := "python"
if path, err := exec.LookPath("python"); err == nil {
pythonProg = path
}
timeSuffix := info.ModTime().Format("20060102150405")
newName := fmt.Sprintf("%s-%s", name, timeSuffix)
destTxtPath := filepath.Join(pcfg.TempLocation, fmt.Sprintf("%s.txt", newName))
// Delete the core file after up to 2 attempts to process it.
shouldDelete := time.Since(info.ModTime()) > time.Duration(pcfg.PostprocessIntervalSec)*time.Second
pystackPath, pystackErr := exec.LookPath("pystack")
gdbPath, gdbErr := exec.LookPath("gdb")
if pystackErr != nil && gdbErr != nil {
logger.Warnf(ctx, "Core dump analysis enabled but no analysis tools found. Please install pystack (recommended) or/and gdb into the runtime environment.")
}
if pystackErr == nil {
args := []string{"core"}
if len(pcfg.ExtraArgs) > 0 {
args = append(args, pcfg.ExtraArgs...)
} else {
args = append(args, "--native-last")
}
args = append(args, corePath, pythonProg)
logger.Printf(ctx, "Running pystack %s", strings.Join(args, " "))
cmd := exec.CommandContext(ctx, pystackPath, args...)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warnf(ctx, "pystack failed on %s: %v. Output:\n%s", name, err, string(output))
} else {
if err := os.WriteFile(destTxtPath, output, 0644); err != nil {
logger.Warnf(ctx, "Failed to write pystack output to %s: %v", destTxtPath, err)
}
pystackSummary := createPystackSummary(string(output))
logger.Errorf(ctx, "Full pystack coredump analysis saved to %s.txt\nExcerpt:\n%s", newName, pystackSummary)
shouldDelete = true
}
}
if gdbErr == nil {
gdbArgs := []string{
"-batch",
"-ex", "set pagination off",
"-ex", "set trace-commands on",
"-ex", "info sharedlibrary",
"-ex", "info proc mappings",
"-ex", "info threads",
"-ex", "thread",
"-ex", "print $_siginfo",
"-ex", "info registers",
"-ex", "x/10i $pc",
"-ex", "x/16gx $rsp",
"-ex", "bt full",
"-ex", "thread apply all bt full",
pythonProg,
corePath,
}
logger.Printf(ctx, "Running gdb on %s using %s", name, pythonProg)
gdbCmd := exec.CommandContext(ctx, gdbPath, gdbArgs...)
gdbOutput, err := gdbCmd.CombinedOutput()
destGdbPath := filepath.Join(pcfg.TempLocation, fmt.Sprintf("%s.gdb.txt", newName))
if err != nil {
logger.Warnf(ctx, "gdb failed on %s: %v. Output:\n%s", name, err, string(gdbOutput))
} else {
if err := os.WriteFile(destGdbPath, gdbOutput, 0644); err != nil {
logger.Warnf(ctx, "Failed to write gdb output to %s: %v", destGdbPath, err)
}
logger.Errorf(ctx, "Full GDB coredump analysis saved to %s.gdb.txt", newName)
shouldDelete = true
}
}
if shouldDelete {
if err := os.Remove(corePath); err != nil {
logger.Warnf(ctx, "Failed to delete core dump %s: %v", corePath, err)
}
}
}
}
func extractGILThread(output string) string {
lines := strings.Split(output, "\n")
var result []string
recording := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(line, "Has the GIL") {
recording = true
}
if recording {
result = append(result, line)
if trimmed == "" {
break
}
}
}
if len(result) == 0 {
return ""
}
return strings.Join(result, "\n")
}
func firstNLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) <= n {
return s
}
return strings.Join(lines[:n], "\n")
}
func createPystackSummary(output string) string {
gilThreadTrace := extractGILThread(output)
if gilThreadTrace != "" {
return gilThreadTrace
}
return firstNLines(output, 100)
}
// cleanUpProfiler checks for and uploads any final profiler artifacts before container exit.
func cleanUpProfiler(ctx context.Context, logger *tools.Logger) {
pcfg := getProfilerConfig(ctx)
if pcfg == nil || !pcfg.Enabled {
return
}
logger.Printf(ctx, "Running final profiler cleanup sweep and GCS sync...")
// Execute all registered agent-specific cleanups
for _, cb := range cleanupCallbacks {
cb(ctx, logger)
}
if pcfg.GcsDestPath != "" && pcfg.GcloudAvailable {
syncProfilesToGCS(ctx, logger, pcfg.BaseTempDir, pcfg.GcsDestPath)
}
}