Skip to content

Commit 4cd9268

Browse files
a7icursoragent
andcommitted
fix(controller): cancel in-flight spark-submit on controller shutdown
When the controller leader receives SIGTERM during spark-submit, the reconcile context is cancelled immediately and the JVM subprocess may keep running until the process exits or the kubelet sends SIGKILL. That can create a driver pod the controller never records, contributing to duplicate submissions on restart. - Add SparkSubmitter.runSparkSubmit using exec.CommandContext; send SIGTERM on cancellation and wait up to ShutdownGracePeriod before force-killing. - Reuse --driver-pod-creation-grace-period for ShutdownGracePeriod and Manager.GracefulShutdownTimeout (no separate graceful-shutdown flag). - Helm: controller pod terminationGracePeriodSeconds default 60. - Add runSparkSubmit unit tests and chart deployment tests. Refs #2788 Signed-off-by: Amir Alavi <amiralavi7@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2a6b222 commit 4cd9268

7 files changed

Lines changed: 163 additions & 10 deletions

File tree

charts/spark-operator-chart/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ See [helm uninstall](https://helm.sh/docs/helm/helm_uninstall) for command docum
102102
| controller.workers | int | `10` | Reconcile concurrency, higher values might increase memory usage. |
103103
| controller.logLevel | string | `"info"` | Configure the verbosity of logging, can be one of `debug`, `info`, `error`. |
104104
| controller.logEncoder | string | `"console"` | Configure the encoder of logging, can be one of `console` or `json`. |
105-
| controller.driverPodCreationGracePeriod | string | `"10s"` | Grace period after a successful spark-submit when driver pod not found errors will be retried. Useful if the driver pod can take some time to be created. |
105+
| controller.driverPodCreationGracePeriod | string | `"10s"` | Grace period after a successful spark-submit when driver pod not found errors will be retried. Also caps how long an in-flight spark-submit is given to exit after SIGTERM during controller shutdown (reconcile context is cancelled immediately on SIGTERM; this window is for reaping the JVM subprocess). Set lower than `terminationGracePeriodSeconds` so the manager exits before the kubelet sends SIGKILL. |
106+
| controller.terminationGracePeriodSeconds | int | `60` | Termination grace period for the controller pod. The kubelet sends SIGKILL when this elapses. Must be set higher than `controller.driverPodCreationGracePeriod` so the manager has time to interrupt and reap in-flight `spark-submit` subprocesses before the process is forcibly killed. |
106107
| controller.maxTrackedExecutorPerApp | int | `1000` | Specifies the maximum number of Executor pods that can be tracked by the controller per SparkApplication. |
107108
| controller.kubeAPIQPS | int | `20` | Maximum QPS to the API server from the controller client. |
108109
| controller.kubeAPIBurst | int | `30` | Maximum burst for throttle from the controller client. |

charts/spark-operator-chart/templates/controller/deployment.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ spec:
212212
{{- end }}
213213
serviceAccountName: {{ include "spark-operator.controller.serviceAccountName" . }}
214214
automountServiceAccountToken: {{ .Values.controller.serviceAccount.automountServiceAccountToken }}
215+
{{- with .Values.controller.terminationGracePeriodSeconds }}
216+
terminationGracePeriodSeconds: {{ . }}
217+
{{- end }}
215218
{{- with .Values.controller.podSecurityContext }}
216219
securityContext:
217220
{{- toYaml . | nindent 8 }}

charts/spark-operator-chart/tests/controller/deployment_test.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,21 @@ tests:
844844
path: spec.template.spec.containers[?(@.name=="spark-operator-controller")].args
845845
content: --max-tracked-executor-per-app=123
846846

847+
- it: Should set `terminationGracePeriodSeconds` on the pod by default
848+
asserts:
849+
- equal:
850+
path: spec.template.spec.terminationGracePeriodSeconds
851+
value: 60
852+
853+
- it: Should override `terminationGracePeriodSeconds` if `controller.terminationGracePeriodSeconds` is set
854+
set:
855+
controller:
856+
terminationGracePeriodSeconds: 240
857+
asserts:
858+
- equal:
859+
path: spec.template.spec.terminationGracePeriodSeconds
860+
value: 240
861+
847862

848863
- it: Should add leader election parameters if `controller.leaderElection.leaseDuration`, `controller.leaderElection.renewDeadline` and `controller.leaderElection.retryPeriod` are set.
849864
set:

charts/spark-operator-chart/values.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,19 @@ controller:
107107
# -- Configure the encoder of logging, can be one of `console` or `json`.
108108
logEncoder: console
109109

110-
# -- Grace period after a successful spark-submit when driver pod not found errors will be retried. Useful if the driver pod can take some time to be created.
110+
# -- Grace period after a successful spark-submit when driver pod not found errors will be retried.
111+
# Also caps how long an in-flight spark-submit is given to exit after SIGTERM during controller
112+
# shutdown (reconcile context is cancelled immediately on SIGTERM; this window is for reaping the
113+
# JVM subprocess). Set lower than `terminationGracePeriodSeconds` so the manager exits before the
114+
# kubelet sends SIGKILL.
111115
driverPodCreationGracePeriod: 10s
112116

117+
# -- Termination grace period for the controller pod. The kubelet sends SIGKILL when this
118+
# elapses. Must be set higher than `controller.driverPodCreationGracePeriod` so the manager
119+
# has time to interrupt and reap in-flight `spark-submit` subprocesses before the process is
120+
# forcibly killed.
121+
terminationGracePeriodSeconds: 60
122+
113123
# -- Specifies the maximum number of Executor pods that can be tracked by the controller per SparkApplication.
114124
maxTrackedExecutorPerApp: 1000
115125

cmd/operator/controller/start.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ func NewStartCommand() *cobra.Command {
222222
command.Flags().DurationVar(&leaderElectionRenewDeadline, "leader-election-renew-deadline", 10*time.Second, "Leader election renew deadline.")
223223
command.Flags().DurationVar(&leaderElectionRetryPeriod, "leader-election-retry-period", 2*time.Second, "Leader election retry period.")
224224

225-
command.Flags().DurationVar(&driverPodCreationGracePeriod, "driver-pod-creation-grace-period", 10*time.Second, "Grace period after a successful spark-submit when driver pod not found errors will be retried. Useful if the driver pod can take some time to be created.")
225+
command.Flags().DurationVar(&driverPodCreationGracePeriod, "driver-pod-creation-grace-period", 10*time.Second, "Grace period after a successful spark-submit when driver pod not found errors will be retried, and maximum time to wait for an in-flight spark-submit to exit after SIGTERM during controller shutdown. Useful if driver pod creation can take some time. Should be set lower than the pod's terminationGracePeriodSeconds.")
226226

227227
command.Flags().Float32Var(&kubeAPIQPS, "kube-api-qps", 20, "Maximum QPS to the API server from the controller client.")
228228
command.Flags().IntVar(&kubeAPIBurst, "kube-api-burst", 30, "Maximum burst for throttle from the controller client.")
@@ -312,6 +312,7 @@ func start() {
312312
LeaseDuration: &leaderElectionLeaseDuration,
313313
RenewDeadline: &leaderElectionRenewDeadline,
314314
RetryPeriod: &leaderElectionRetryPeriod,
315+
GracefulShutdownTimeout: &driverPodCreationGracePeriod,
315316
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
316317
// when the Manager ends. This requires the binary to immediately end when the
317318
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
@@ -539,7 +540,9 @@ func newSparkConnectReconcilerOptions() sparkconnect.Options {
539540

540541
func newSparkSubmitter(ctx context.Context) (sparkapplication.SparkApplicationSubmitter, error) {
541542
if !features.Enabled(features.RestSubmitter) {
542-
return &sparkapplication.SparkSubmitter{}, nil
543+
return &sparkapplication.SparkSubmitter{
544+
ShutdownGracePeriod: driverPodCreationGracePeriod,
545+
}, nil
543546
}
544547

545548
var tlsCfg *sparkapplication.TLSConfig

internal/controller/sparkapplication/submission.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import (
2525
"reflect"
2626
"slices"
2727
"strings"
28+
"syscall"
29+
"time"
2830

2931
corev1 "k8s.io/api/core/v1"
3032
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -44,37 +46,53 @@ type SparkApplicationSubmitter interface {
4446

4547
// SparkSubmitter submits a SparkApplication by calling spark-submit.
4648
type SparkSubmitter struct {
49+
// ShutdownGracePeriod caps how long runSparkSubmit waits for spark-submit to exit
50+
// after SIGTERM when the reconcile context is cancelled (e.g. controller shutdown).
51+
ShutdownGracePeriod time.Duration
4752
}
4853

4954
// SparkSubmitter implements SparkApplicationSubmitter interface.
5055
// This interface is highly experimental and may go under significant changes or removed in the future.
5156
var _ SparkApplicationSubmitter = &SparkSubmitter{}
5257

5358
// Submit implements SparkApplicationSubmitter interface.
54-
func (*SparkSubmitter) Submit(ctx context.Context, app *v1beta2.SparkApplication) error {
59+
func (s *SparkSubmitter) Submit(ctx context.Context, app *v1beta2.SparkApplication) error {
5560
logger := log.FromContext(ctx)
5661

5762
args, err := buildSparkSubmitArgs(app, false)
5863
if err != nil {
5964
return fmt.Errorf("failed to build spark-submit arguments: %v", err)
6065
}
6166

62-
// Try submitting the application by running spark-submit.
67+
// Try submitting the application by running spark-submit. The reconcile context is
68+
// threaded into spark-submit so that controller shutdown (or any other context
69+
// cancellation) signals the child process to exit. Cancellation can still arrive
70+
// after spark-submit has already created the driver pod (e.g., between the create
71+
// call and process exit), so this reduces, rather than eliminates, the chance of
72+
// losing a status update for a freshly submitted SparkApplication.
6373
logger.Info("Running spark-submit", "arguments", args)
64-
if err := runSparkSubmit(args); err != nil {
74+
if err := s.runSparkSubmit(ctx, args); err != nil {
6575
return fmt.Errorf("failed to run spark-submit: %v", err)
6676
}
6777

6878
return nil
6979
}
7080

71-
func runSparkSubmit(args []string) error {
81+
func (s *SparkSubmitter) runSparkSubmit(ctx context.Context, args []string) error {
7282
sparkHome, present := os.LookupEnv(common.EnvSparkHome)
73-
if !present {
83+
if !present || sparkHome == "" {
7484
return fmt.Errorf("env %s is not specified", common.EnvSparkHome)
7585
}
7686
command := filepath.Join(sparkHome, "bin", "spark-submit")
77-
cmd := exec.Command(command, args...)
87+
cmd := exec.CommandContext(ctx, command, args...)
88+
// exec.CommandContext defaults to SIGKILL on cancellation. Override so we send
89+
// SIGTERM first, giving spark-submit and the underlying JVM a chance to shut
90+
// down gracefully (release the API server connection, flush logs). WaitDelay
91+
// caps how long we wait after the signal before the process is force-killed.
92+
cmd.Cancel = func() error {
93+
return cmd.Process.Signal(syscall.SIGTERM)
94+
}
95+
cmd.WaitDelay = s.ShutdownGracePeriod
7896
_, err := cmd.Output()
7997
if err != nil {
8098
var errorMsg string

internal/controller/sparkapplication/submission_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@ limitations under the License.
1717
package sparkapplication
1818

1919
import (
20+
"context"
2021
"fmt"
2122
"os"
23+
"path/filepath"
24+
"runtime"
2225
"slices"
2326
"strings"
2427
"testing"
28+
"time"
2529

2630
"github.com/google/uuid"
2731
"github.com/stretchr/testify/assert"
32+
"github.com/stretchr/testify/require"
2833
corev1 "k8s.io/api/core/v1"
2934
"k8s.io/apimachinery/pkg/api/resource"
3035
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -37,6 +42,104 @@ import (
3742
"github.com/kubeflow/spark-operator/v2/pkg/util"
3843
)
3944

45+
// writeFakeSparkHome creates a temporary SPARK_HOME directory containing a
46+
// `bin/spark-submit` script with the given contents and sets SPARK_HOME for the test.
47+
func writeFakeSparkHome(t *testing.T, script string) {
48+
t.Helper()
49+
if runtime.GOOS == "windows" {
50+
t.Skip("fake spark-submit shell script is not supported on Windows")
51+
}
52+
root := t.TempDir()
53+
binDir := filepath.Join(root, "bin")
54+
require.NoError(t, os.MkdirAll(binDir, 0o755))
55+
sparkSubmit := filepath.Join(binDir, "spark-submit")
56+
require.NoError(t, os.WriteFile(sparkSubmit, []byte(script), 0o755))
57+
t.Setenv(common.EnvSparkHome, root)
58+
}
59+
60+
func TestRunSparkSubmit(t *testing.T) {
61+
submitter := &SparkSubmitter{ShutdownGracePeriod: 10 * time.Second}
62+
63+
t.Run("returns error when SPARK_HOME is not set", func(t *testing.T) {
64+
t.Setenv(common.EnvSparkHome, "")
65+
66+
err := submitter.runSparkSubmit(context.Background(), nil)
67+
require.Error(t, err)
68+
assert.Contains(t, err.Error(), common.EnvSparkHome)
69+
})
70+
71+
t.Run("returns nil on successful spark-submit", func(t *testing.T) {
72+
writeFakeSparkHome(t, "#!/bin/sh\nexit 0\n")
73+
74+
assert.NoError(t, submitter.runSparkSubmit(context.Background(), []string{"--foo", "bar"}))
75+
})
76+
77+
t.Run("surfaces stderr when spark-submit fails", func(t *testing.T) {
78+
writeFakeSparkHome(t, "#!/bin/sh\necho boom 1>&2\nexit 1\n")
79+
80+
err := submitter.runSparkSubmit(context.Background(), nil)
81+
require.Error(t, err)
82+
assert.Contains(t, err.Error(), "boom")
83+
})
84+
85+
t.Run("returns driver pod already exists error", func(t *testing.T) {
86+
script := fmt.Sprintf("#!/bin/sh\necho %q 1>&2\nexit 1\n", "got "+common.ErrorCodePodAlreadyExists+" from apiserver")
87+
writeFakeSparkHome(t, script)
88+
89+
err := submitter.runSparkSubmit(context.Background(), nil)
90+
require.Error(t, err)
91+
assert.Contains(t, err.Error(), "driver pod already exist")
92+
})
93+
94+
t.Run("context cancellation triggers SIGTERM rather than SIGKILL", func(t *testing.T) {
95+
if _, err := os.Stat("/bin/bash"); err != nil {
96+
t.Skip("/bin/bash is required for this test")
97+
}
98+
// The script traps SIGTERM, emits a marker to stderr, and exits non-zero.
99+
// A foreground busy-loop is used so the trap fires between sleeps and so
100+
// no background process keeps stderr open after the script exits (which
101+
// would delay Wait by WaitDelay). We require bash (not /bin/sh) because
102+
// some POSIX shells (notably bash 3.2 invoked as sh on macOS) do not
103+
// deliver trapped signals during builtins like `wait`. If runSparkSubmit
104+
// defaulted to SIGKILL (the os/exec default for context-cancelled
105+
// commands), the trap would never run and stderr would be empty.
106+
// The script writes a "ready" marker once the trap is registered so the
107+
// test can wait until SIGTERM will be delivered after the handler is in
108+
// place, avoiding a race on slow/loaded test machines.
109+
readyMarker := filepath.Join(t.TempDir(), "ready")
110+
script := fmt.Sprintf(`#!/bin/bash
111+
trap 'echo SIGTERM_RECEIVED 1>&2; exit 42' TERM
112+
touch %q
113+
while true; do
114+
sleep 0.1
115+
done
116+
`, readyMarker)
117+
writeFakeSparkHome(t, script)
118+
119+
ctx, cancel := context.WithCancel(context.Background())
120+
// Cancel only after the script has installed the trap.
121+
go func() {
122+
deadline := time.Now().Add(5 * time.Second)
123+
for time.Now().Before(deadline) {
124+
if _, statErr := os.Stat(readyMarker); statErr == nil {
125+
break
126+
}
127+
time.Sleep(20 * time.Millisecond)
128+
}
129+
cancel()
130+
}()
131+
132+
start := time.Now()
133+
err := submitter.runSparkSubmit(ctx, nil)
134+
elapsed := time.Since(start)
135+
136+
require.Error(t, err)
137+
assert.Less(t, elapsed, 8*time.Second, "runSparkSubmit should return promptly after SIGTERM")
138+
assert.Contains(t, err.Error(), "SIGTERM_RECEIVED",
139+
"spark-submit child should receive SIGTERM, not SIGKILL")
140+
})
141+
}
142+
40143
func TestExecutorConfOption(t *testing.T) {
41144
tests := []struct {
42145
name string

0 commit comments

Comments
 (0)