| // 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) |
| } |
| } |