[Fix][RayJob SidecarMode] Prevent premature job termination during transient head node spikes (#4399)

* [Fix][1/N] defer sidecar submitter failure until dashboard check

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Feature][SidecarMode] Add SidecarSubmitterRestart feature gate for per-container restart

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Feature][SidecarMode] Add SidecarSubmitterRestart feature gate unit test

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Feature][SidecarMode] Add K8s version check if SidecarSubmitterRestart feature gate is enabled at operator startup

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Chore] better explainations

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Chore] remove incorrect nil check

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix] SidecarMode should not fail job based on container exit status

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* Trigger CI

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix][SidecarMode] add e2e tests

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix][SidecarMode] clarify RestartPolicyRules exit code rationale

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix][Sidecar Mode] Use ParseGeneric + GitVersion, and add patch version for more robust k8s version comparison

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* Apply suggestions

Co-authored-by: Jia-Wei Jiang <36886416+JiangJiaWei1103@users.noreply.github.com>
Signed-off-by: JustinYeh <justinyeh1995@gmail.com>

* [Fix] use ContainerRestartPolicyOnFailure to express the intention better; update the comment to explain the intention better

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix] bump version check to v1.35 and update sidecar mode test to check onFailure restart policy

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix] check the existence of the feature gate instead of relying on the derivate result

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* improve the why for using K8s v1.35

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix] Revert checkSidecarContainerStatus removal; scope PR to K8s v1.35+ use case

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix] use correct health command constant and argument order in the feature gate test.

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix] remove leftover changes

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* [Fix] prevent panicing due to failing to fetch head pod

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* add warning about the risk of having kubelet version skew

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* document ray v2.54.0+ is needed

Co-authored-by: Jun-Hao Wan <ken89@kimo.com>
Signed-off-by: JustinYeh <justinyeh1995@gmail.com>

* add max restart count annotaion, envVar, and default max restart count

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* do not fail the job if the submitter container exited successfully

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* fix BasePythonHealthCommand argument

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* trigger ci

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* address suggestion part 1

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* k8s 1.35 version

Signed-off-by: Future-Outlier <eric901201@gmail.com>

* add override

Signed-off-by: Future-Outlier <eric901201@gmail.com>

* update

Signed-off-by: Future-Outlier <eric901201@gmail.com>

* update

Signed-off-by: Future-Outlier <eric901201@gmail.com>

* upd for helm and kustomization

Signed-off-by: Future-Outlier <eric901201@gmail.com>

* update

Signed-off-by: Future-Outlier <eric901201@gmail.com>

* fix typo

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* address comment, assert no resubmission

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* bump kind version to the latest version which supports v1.35.0 by default

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* incremental upgrade use kind-config-buildkite.yml (v1.35)

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

* replace hardcoded value to use rayJob.Spec.SubmitterConfig.BackoffLimit

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>

---------

Signed-off-by: justinyeh1995 <justinyeh1995@gmail.com>
Signed-off-by: JustinYeh <justinyeh1995@gmail.com>
Signed-off-by: Future-Outlier <eric901201@gmail.com>
Co-authored-by: Jia-Wei Jiang <36886416+JiangJiaWei1103@users.noreply.github.com>
Co-authored-by: Jun-Hao Wan <ken89@kimo.com>
Co-authored-by: Future-Outlier <eric901201@gmail.com>
This commit is contained in:
JustinYeh
2026-06-01 21:20:44 -07:00
committed by GitHub
co-authored by Jia-Wei Jiang Jun-Hao Wan Future-Outlier
parent ff0e074694
commit a284b829f8
16 changed files with 448 additions and 28 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ export PATH=$PATH:/usr/local/go/bin
export DOCKER_API_VERSION=1.43
# Install kind
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.31.0/kind-linux-amd64
chmod +x ./kind
mv ./kind /usr/local/bin/kind
+1 -1
View File
@@ -83,7 +83,7 @@
image: golang:1.26-bookworm
commands:
- source .buildkite/setup-env.sh
- kind create cluster --wait 900s --config ./ci/kind-config-buildkite-1-29.yml
- kind create cluster --wait 900s --config ./ci/kind-config-buildkite.yml
- kubectl config set clusters.kind-kind.server https://docker:6443
# Install MetalLB for LoadBalancer IPs on Kind
@@ -20,3 +20,5 @@ featureGates:
enabled: true
- name: RayServiceIncrementalUpgrade
enabled: true
- name: SidecarSubmitterRestart
enabled: true
+1 -1
View File
@@ -9,7 +9,7 @@ networking:
# https://blog.scottlowe.org/2019/07/30/adding-a-name-to-kubernetes-api-server-certificate/
nodes:
- role: control-plane
image: kindest/node:v1.29.0
image: kindest/node:v1.35.0
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
+1 -1
View File
@@ -593,7 +593,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `backoffLimit` _integer_ | BackoffLimit of the submitter k8s job. | | |
| `backoffLimit` _integer_ | BackoffLimit of the submitter. In K8sJobMode, this is the K8s Job backoffLimit.<br />In SidecarMode with SidecarSubmitterRestart enabled, this is the maximum container restart count. | | |
#### UpscalingMode
+2 -1
View File
@@ -187,7 +187,8 @@ const (
)
type SubmitterConfig struct {
// BackoffLimit of the submitter k8s job.
// BackoffLimit of the submitter. In K8sJobMode, this is the K8s Job backoffLimit.
// In SidecarMode with SidecarSubmitterRestart enabled, this is the maximum container restart count.
// +optional
BackoffLimit *int32 `json:"backoffLimit,omitempty"`
}
@@ -9,4 +9,4 @@ spec:
containers:
- name: kuberay-operator
args:
- --feature-gates=RayClusterStatusConditions=true,RayJobDeletionPolicy=true,RayMultiHostIndexing=true,RayCronJob=true,RayServiceIncrementalUpgrade=true
- --feature-gates=RayClusterStatusConditions=true,RayJobDeletionPolicy=true,RayMultiHostIndexing=true,RayCronJob=true,RayServiceIncrementalUpgrade=true,SidecarSubmitterRestart=true
+9 -5
View File
@@ -14,6 +14,7 @@ import (
rayv1 "github.com/ray-project/kuberay/ray-operator/apis/ray/v1"
"github.com/ray-project/kuberay/ray-operator/controllers/ray/utils"
"github.com/ray-project/kuberay/ray-operator/pkg/features"
pkgutils "github.com/ray-project/kuberay/ray-operator/pkg/utils"
)
@@ -150,10 +151,13 @@ func BuildJobSubmitCommand(rayJobInstance *rayv1.RayJob, submissionMode rayv1.Jo
}
cmd = append(cmd, waitLoop...)
// In Sidecar mode, we only support RayJob level retry, which means that the submitter retry won't happen,
// In Sidecar mode without SidecarSubmitterRestart feature gate enabled, we only support RayJob level retry, which means that the submitter retry won't happen,
// so we won't have to check if the job has been submitted.
if submissionMode == rayv1.K8sJobMode {
// Only check job status in K8s mode to handle duplicated submission gracefully
// In K8sJobMode (submitter Job may retry) or Sidecar mode with SidecarSubmitterRestart feature gate enabled (submitter container may restart on failure).
// we check job status before submitting to handle duplicated submission gracefully.
needsStatusCheck := submissionMode == rayv1.K8sJobMode || (submissionMode == rayv1.SidecarMode && features.Enabled(features.SidecarSubmitterRestart))
if needsStatusCheck {
cmd = append(cmd, "if", "!")
cmd = append(cmd, jobStatusCommand...)
cmd = append(cmd, ";", "then")
@@ -161,7 +165,7 @@ func BuildJobSubmitCommand(rayJobInstance *rayv1.RayJob, submissionMode rayv1.Jo
cmd = append(cmd, jobSubmitCommand...)
if submissionMode == rayv1.K8sJobMode {
if needsStatusCheck {
cmd = append(cmd, "--no-wait")
}
@@ -199,7 +203,7 @@ func BuildJobSubmitCommand(rayJobInstance *rayv1.RayJob, submissionMode rayv1.Jo
// "--" is used to separate the entrypoint from the Ray Job CLI command and its arguments.
cmd = append(cmd, "--", entrypoint, ";")
if submissionMode == rayv1.K8sJobMode {
if needsStatusCheck {
cmd = append(cmd, "fi", ";")
cmd = append(cmd, jobFollowCommand...)
}
@@ -12,6 +12,7 @@ import (
rayv1 "github.com/ray-project/kuberay/ray-operator/apis/ray/v1"
"github.com/ray-project/kuberay/ray-operator/controllers/ray/utils"
"github.com/ray-project/kuberay/ray-operator/pkg/features"
)
func rayJobTemplate() *rayv1.RayJob {
@@ -213,6 +214,54 @@ func TestBuildJobSubmitCommandWithK8sJobModeHealthWaitLoop(t *testing.T) {
assert.NotContains(t, command[1], "wget")
}
func TestBuildJobSubmitCommandWithSidecarModeAndFeatureGate(t *testing.T) {
// Enable the SidecarSubmitterRestart feature gate for this test
features.SetFeatureGateDuringTest(t, features.SidecarSubmitterRestart, true)
testRayJob := rayJobTemplate()
testRayJob.Spec.RayClusterSpec.HeadGroupSpec.Template.Spec.Containers = []corev1.Container{
{
Ports: []corev1.ContainerPort{
{
Name: utils.DashboardPortName,
ContainerPort: utils.DefaultDashboardPort,
},
},
},
}
// With SidecarSubmitterRestart enabled, the command should include:
// - status check (if ! ray job status ...)
// - --no-wait flag
// - job logs follow at the end
healthURL := fmt.Sprintf("http://localhost:%d/%s", utils.DefaultDashboardPort, utils.RayDashboardGCSHealthPath)
expected := []string{
"until",
fmt.Sprintf(
utils.BasePythonHealthCommand,
healthURL,
utils.RayDashboardGCSHealthCheckTimeoutSeconds,
),
">/dev/null", "2>&1", ";",
"do", "echo", strconv.Quote("Waiting for Ray Dashboard GCS to become healthy at http://127.0.0.1:8265 ..."), ";", "sleep", "2", ";", "done", ";",
"if", "!", "ray", "job", "status", "--address", "http://127.0.0.1:8265", "testJobId", ">/dev/null", "2>&1", ";", "then",
"ray", "job", "submit", "--address", "http://127.0.0.1:8265", "--no-wait",
"--runtime-env-json", strconv.Quote(`{"test":"test"}`),
"--metadata-json", strconv.Quote(`{"testKey":"testValue"}`),
"--submission-id", "testJobId",
"--entrypoint-num-cpus", "1.000000",
"--entrypoint-num-gpus", "0.500000",
"--entrypoint-resources", strconv.Quote(`{"Custom_1": 1, "Custom_2": 5.5}`),
"--",
"echo no quote 'single quote' \"double quote\"",
";", "fi", ";",
"ray", "job", "logs", "--address", "http://127.0.0.1:8265", "--follow", "testJobId",
}
command, err := BuildJobSubmitCommand(testRayJob, rayv1.SidecarMode)
require.NoError(t, err)
assert.Equal(t, expected, command)
}
func TestBuildJobSubmitCommandWithK8sJobModeAndYAML(t *testing.T) {
rayJobWithYAML := &rayv1.RayJob{
Spec: rayv1.RayJobSpec{
@@ -588,6 +588,19 @@ func getSubmitterContainer(rayJobInstance *rayv1.RayJob, rayClusterInstance *ray
return corev1.Container{}, err
}
// When SidecarSubmitterRestart feature gate is enabled, configure per-container restart rules.
// This requires Kubernetes 1.35+ with ContainerRestartRules feature gate enabled.
if features.Enabled(features.SidecarSubmitterRestart) {
// OnFailure restarts only the submitter container (not all containers in the pod) on non-zero exit.
// The non-zero exit can come from `ray job submit --no-wait` or `ray job logs --follow`.
// The key case is `ray job logs --follow` exiting non-zero on a transient
// WebSocket closure even when the Ray job is still running.
// On restart, the submitter checks ray job status first.
// Since the job is still running, the submitter simply reattaches to the log stream.
// See BuildJobSubmitCommand in ray-operator/controllers/ray/common/job.go for more details.
submitterContainer.RestartPolicy = ptr.To(corev1.ContainerRestartPolicyOnFailure)
}
return submitterContainer, nil
}
@@ -992,8 +1005,8 @@ func (r *RayJobReconciler) constructRayClusterForRayJob(rayJobInstance *rayv1.Ra
rayCluster.Spec.HeadGroupSpec.Template.Spec.Containers, sidecar)
// In K8sJobMode, the submitter Job relies on the K8s Job backoffLimit API to restart if it fails.
// This mainly handles WebSocket connection failures caused by transient network issues.
// In SidecarMode, however, the submitter container shares the same network namespace as the Ray dashboard,
// so restarts are no longer needed.
// In SidecarMode, the pod-level RestartPolicy is set to Never.
// The submitter container may override this with per-container restart rules when the SidecarSubmitterRestart feature gate is enabled.
rayCluster.Spec.HeadGroupSpec.Template.Spec.RestartPolicy = corev1.RestartPolicyNever
}
@@ -1052,20 +1065,44 @@ func (r *RayJobReconciler) checkSubmitterAndUpdateStatusIfNeeded(ctx context.Con
return
}
shouldUpdate, submitterContainerStatus = checkSidecarContainerStatus(headPod)
if shouldUpdate {
logger.Info("The submitter sidecar container has failed. Attempting to transition the status to `Failed`.",
"Submitter sidecar container", submitterContainerStatus.Name, "Reason", submitterContainerStatus.State.Terminated.Reason, "Message", submitterContainerStatus.State.Terminated.Message)
rayJob.Status.JobDeploymentStatus = rayv1.JobDeploymentStatusFailed
// The submitter sidecar container needs to wait for the user code to finish and retrieve its logs.
// Therefore, a failed Submitter sidecar container indicates that the submission itself has failed or the user code has thrown an error.
// If the failure is due to user code, the JobStatus and Job message will be updated accordingly from the previous reconciliation.
if rayJob.Status.JobStatus == rayv1.JobStatusFailed {
rayJob.Status.Reason = rayv1.AppFailed
} else {
rayJob.Status.Reason = rayv1.SubmissionFailed
rayJob.Status.Message = fmt.Sprintf("Ray head pod container %s terminated with exit code %d: %s",
submitterContainerStatus.Name, submitterContainerStatus.State.Terminated.ExitCode, submitterContainerStatus.State.Terminated.Reason)
// Only check exit code when the feature gate is disabled.
// When SidecarSubmitterRestart is enabled, the container restarts on non-zero exit,
// so a terminated container is transient — not a permanent failure.
if !features.Enabled(features.SidecarSubmitterRestart) {
shouldUpdate, submitterContainerStatus = checkSidecarContainerStatus(headPod)
if shouldUpdate {
logger.Info("The submitter sidecar container has failed. Attempting to transition the status to `Failed`.",
"Submitter sidecar container", submitterContainerStatus.Name, "Reason", submitterContainerStatus.State.Terminated.Reason, "Message", submitterContainerStatus.State.Terminated.Message)
rayJob.Status.JobDeploymentStatus = rayv1.JobDeploymentStatusFailed
// The submitter sidecar container needs to wait for the user code to finish and retrieve its logs.
// Therefore, a failed Submitter sidecar container indicates that the submission itself has failed or the user code has thrown an error.
// If the failure is due to user code, the JobStatus and Job message will be updated accordingly from the previous reconciliation.
if rayJob.Status.JobStatus == rayv1.JobStatusFailed {
rayJob.Status.Reason = rayv1.AppFailed
} else {
rayJob.Status.Reason = rayv1.SubmissionFailed
rayJob.Status.Message = fmt.Sprintf("Ray head pod container %s terminated with exit code %d: %s",
submitterContainerStatus.Name, submitterContainerStatus.State.Terminated.ExitCode, submitterContainerStatus.State.Terminated.Reason)
}
}
} else {
submitterBackoffLimit := int32(2)
if rayJob.Spec.SubmitterConfig != nil && rayJob.Spec.SubmitterConfig.BackoffLimit != nil {
submitterBackoffLimit = *rayJob.Spec.SubmitterConfig.BackoffLimit
}
shouldUpdate, submitterContainerStatus = checkIsRestartCountExceeded(headPod, submitterBackoffLimit)
if shouldUpdate {
logger.Info("The submitter sidecar container has exceeded the max restart count. Attempting to transition the status to `Failed`.",
"Submitter sidecar container", submitterContainerStatus.Name,
"RestartCount", submitterContainerStatus.RestartCount)
rayJob.Status.JobDeploymentStatus = rayv1.JobDeploymentStatusFailed
if rayJob.Status.JobStatus == rayv1.JobStatusFailed {
rayJob.Status.Reason = rayv1.AppFailed
} else {
rayJob.Status.Reason = rayv1.SubmissionFailed
rayJob.Status.Message = fmt.Sprintf("Ray head pod submitter container %s terminated after exceeding the maximum restart count",
submitterContainerStatus.Name)
}
}
}
@@ -1149,6 +1186,24 @@ func checkSidecarContainerStatus(headPod *corev1.Pod) (bool, *corev1.ContainerSt
return false, nil
}
func checkIsRestartCountExceeded(headPod *corev1.Pod, backoffLimit int32) (bool, *corev1.ContainerStatus) {
for _, containerStatus := range headPod.Status.ContainerStatuses {
if containerStatus.Name == utils.SubmitterContainerName {
// Only check when the container has been terminated at least once.
// When the submitter container fails in a CrashLoopBackOff fashion, LastTerminationState.Terminated is populated
if containerStatus.LastTerminationState.Terminated != nil {
// If the container exited successfully, we do not fail the job.
if containerStatus.State.Terminated != nil && containerStatus.State.Terminated.ExitCode == 0 {
break
}
return containerStatus.RestartCount >= backoffLimit, &containerStatus
}
break
}
}
return false, nil
}
func checkActiveDeadlineAndUpdateStatusIfNeeded(ctx context.Context, rayJob *rayv1.RayJob) bool {
logger := ctrl.LoggerFrom(ctx)
if rayJob.Spec.ActiveDeadlineSeconds == nil || time.Now().Before(rayJob.Status.StartTime.Add(time.Duration(*rayJob.Spec.ActiveDeadlineSeconds)*time.Second)) {
@@ -30,6 +30,7 @@ import (
"github.com/ray-project/kuberay/ray-operator/controllers/ray/metrics/mocks"
utils "github.com/ray-project/kuberay/ray-operator/controllers/ray/utils"
"github.com/ray-project/kuberay/ray-operator/pkg/client/clientset/versioned/scheme"
"github.com/ray-project/kuberay/ray-operator/pkg/features"
)
// fakeBatchScheduler implements schedulerinterface.BatchScheduler for testing.
@@ -224,6 +225,118 @@ func TestGetSubmitterTemplate(t *testing.T) {
assert.Equal(t, "test-job-id", envVar.Value)
}
func TestGetSubmitterContainerWithFeatureGate(t *testing.T) {
// Enable the SidecarSubmitterRestart feature gate for this test
features.SetFeatureGateDuringTest(t, features.SidecarSubmitterRestart, true)
rayJobInstance := &rayv1.RayJob{
Spec: rayv1.RayJobSpec{
Entrypoint: "echo test",
SubmissionMode: rayv1.SidecarMode,
RayClusterSpec: &rayv1.RayClusterSpec{
HeadGroupSpec: rayv1.HeadGroupSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "rayproject/ray:test",
Ports: []corev1.ContainerPort{
{
Name: utils.DashboardPortName,
ContainerPort: utils.DefaultDashboardPort,
},
},
},
},
},
},
},
},
},
Status: rayv1.RayJobStatus{
DashboardURL: "http://127.0.0.1:8265",
JobId: "test-job-id",
},
}
rayClusterInstance := &rayv1.RayCluster{
Spec: *rayJobInstance.Spec.RayClusterSpec,
}
container, err := getSubmitterContainer(rayJobInstance, rayClusterInstance)
require.NoError(t, err)
// Verify restart policy is set to OnFailure for the submitter container
require.NotNil(t, container.RestartPolicy)
assert.Equal(t, corev1.ContainerRestartPolicyOnFailure, *container.RestartPolicy)
}
func TestCheckIsRestartCountExceeded(t *testing.T) {
tests := []struct {
name string
restartCount int32
existCode int32
hasTermState bool
hasLastTermState bool
expectedShouldUpdate bool
}{
{
name: "restart count below limit of 2",
restartCount: 1,
existCode: 1,
hasTermState: true,
hasLastTermState: true,
expectedShouldUpdate: false,
},
{
name: "restart count at limit of 2",
restartCount: 2,
existCode: 1,
hasTermState: true,
hasLastTermState: true,
expectedShouldUpdate: true,
},
{
name: "successful exit should not trigger failure",
restartCount: 3,
existCode: 0,
hasTermState: true,
hasLastTermState: true,
expectedShouldUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
containerStatus := corev1.ContainerStatus{
Name: utils.SubmitterContainerName,
RestartCount: tt.restartCount,
}
if tt.hasTermState {
containerStatus.State = corev1.ContainerState{
Terminated: &corev1.ContainerStateTerminated{ExitCode: tt.existCode},
}
}
if tt.hasLastTermState {
containerStatus.LastTerminationState = corev1.ContainerState{
Terminated: &corev1.ContainerStateTerminated{ExitCode: 1},
}
}
headPod := &corev1.Pod{
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{containerStatus},
},
}
shouldUpdate, _ := checkIsRestartCountExceeded(headPod, 2)
assert.Equal(t, tt.expectedShouldUpdate, shouldUpdate)
})
}
}
func TestUpdateStatusToSuspendingIfNeeded(t *testing.T) {
newScheme := runtime.NewScheme()
_ = rayv1.AddToScheme(newScheme)
@@ -23,6 +23,9 @@ import (
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/json"
"k8s.io/apimachinery/pkg/util/rand"
utilversion "k8s.io/apimachinery/pkg/util/version"
"k8s.io/apimachinery/pkg/version"
"k8s.io/client-go/discovery"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -885,6 +888,39 @@ func GetWeightsFromHTTPRoute(httpRoute *gwv1.HTTPRoute, rayServiceInstance *rayv
return
}
// GetKubernetesVersion returns the API server version
func GetKubernetesVersion() (*version.Info, error) {
config, err := ctrl.GetConfig()
if err != nil {
return nil, err
}
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, err
}
serverVersion, err := discoveryClient.ServerVersion()
if err != nil {
return nil, err
}
return serverVersion, nil
}
// IsK8sVersionAtLeast checks the API server version is at least v{major}.{minor}.{patch}
func IsK8sVersionAtLeast(serverVersion *version.Info, major, minor, patch int) (bool, error) {
requiredVersionString := fmt.Sprintf("%d.%d.%d", major, minor, patch)
currentVersion, err := utilversion.ParseGeneric(serverVersion.GitVersion)
if err != nil {
return false, err
}
requiredVersion, err := utilversion.ParseGeneric(requiredVersionString)
if err != nil {
return false, err
}
return currentVersion.AtLeast(requiredVersion), nil
}
func GetContainerCommand(additionalOptions []string) []string {
bashOptions := []string{"c"}
bashOptions = append(bashOptions, additionalOptions...)
+24
View File
@@ -194,6 +194,30 @@ func main() {
utilruntime.Must(gwv1.Install(scheme))
}
// Although ContainerRestartPolicy is introduced in Kubernetes v1.34 as an alpha feature, which requires
// manually enabling the ContainerRestartRules feature gate, it becomes beta in v1.35.
// We require v1.35+ to avoid asking users to opt in to a separate K8s feature gate.
// NOTE: this checks the API server version only. Please make sure worker nodes are also v1.35+,
// as kubelets are allowed to be up to 3 minor versions older per the Kubernetes version skew policy.
// If ContainerRestartRules is not enabled on a kubelet, the per-container restart policy on the
// submitter container will be ignored. In this scenario, if the submitter container exit on failure,
// a 30-second timeout will be applied by the operator and could incorrectly mark the RayJob as Failed
// even if the Ray job is still running.
if features.Enabled(features.SidecarSubmitterRestart) {
serverVersion, err := utils.GetKubernetesVersion()
if err != nil {
exitOnError(err, "SidecarSubmitterRestart feature gate enabled but unable to detect K8s version. Feature requires K8s 1.35+.")
}
isAtLeast, err := utils.IsK8sVersionAtLeast(serverVersion, 1, 35, 0)
if err != nil {
exitOnError(err, "Failed to compare K8s version.")
}
if !isAtLeast {
exitOnError(fmt.Errorf("current version %s is below 1.35", serverVersion.GitVersion),
"SidecarSubmitterRestart feature gate requires K8s 1.35+")
}
}
// Manager options
options := ctrl.Options{
Cache: cache.Options{
@@ -5,7 +5,8 @@ package v1
// SubmitterConfigApplyConfiguration represents a declarative configuration of the SubmitterConfig type for use
// with apply.
type SubmitterConfigApplyConfiguration struct {
// BackoffLimit of the submitter k8s job.
// BackoffLimit of the submitter. In K8sJobMode, this is the K8s Job backoffLimit.
// In SidecarMode with SidecarSubmitterRestart enabled, this is the maximum container restart count.
BackoffLimit *int32 `json:"backoffLimit,omitempty"`
}
+9
View File
@@ -47,6 +47,14 @@ const (
//
// Enables RayCronJob controller for scheduled RayJob execution.
RayCronJob featuregate.Feature = "RayCronJob"
// owner: @justinyeh1995
// rep: N/A
// alpha: v1.7
//
// Enables per-container restart policy for SidecarMode submitter to handle transient failures.
// Requires Kubernetes v1.35+ since it supports ContainerRestartPolicy by default starting in v1.35 and Ray v2.54.0+.
SidecarSubmitterRestart featuregate.Feature = "SidecarSubmitterRestart"
)
func init() {
@@ -59,6 +67,7 @@ var defaultFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
RayMultiHostIndexing: {Default: true, PreRelease: featuregate.Beta},
RayServiceIncrementalUpgrade: {Default: false, PreRelease: featuregate.Alpha},
RayCronJob: {Default: false, PreRelease: featuregate.Alpha},
SidecarSubmitterRestart: {Default: false, PreRelease: featuregate.Alpha},
}
// SetFeatureGateDuringTest is a helper method to override feature gates in tests.
@@ -1,6 +1,8 @@
package e2erayjob
import (
"os/exec"
"strings"
"testing"
"time"
@@ -301,4 +303,128 @@ env_vars:
LogWithTimestamp(test.T(), "RayJob %s/%s completed successfully with auth token", rayJob.Namespace, rayJob.Name)
})
test.T().Run("RayJob Sidecar Mode should restart submitter container on non-zero exit", func(t *testing.T) {
// Check if the SidecarSubmitterRestart feature gate is enabled by inspecting the operator's args directly, rather than inferring from the container spec.
// The k8s version >= 1.35 is already verified at operator startup (see main.go), so we only need to check the feature gate here.
var sidecarSubmitterRestartEnabled bool
// Search all namespaces since the operator may be deployed in any namespace
operatorDeploymentList, err := test.Client().Core().AppsV1().Deployments("").List(t.Context(), metav1.ListOptions{
LabelSelector: "app.kubernetes.io/component=kuberay-operator",
})
g.Expect(err).NotTo(HaveOccurred())
// Assuming there should only be one kuberay-operator in the cluster
g.Expect(operatorDeploymentList.Items).To(HaveLen(1), "expected exactly one kuberay-operator deployment during the test")
for _, container := range operatorDeploymentList.Items[0].Spec.Template.Spec.Containers {
if container.Name != "kuberay-operator" {
continue
}
for _, arg := range container.Args {
if strings.Contains(arg, "SidecarSubmitterRestart=true") {
sidecarSubmitterRestartEnabled = true
break
}
}
if sidecarSubmitterRestartEnabled {
break
}
}
if !sidecarSubmitterRestartEnabled {
t.Skip("SidecarSubmitterRestart feature gate is not active. SidecarSubmitterRestart is not set to true")
}
rayJobAC := rayv1ac.RayJob("submitter-container-should-restart", namespace.Name).
WithSpec(rayv1ac.RayJobSpec().
WithSubmissionMode(rayv1.SidecarMode).
WithRayClusterSpec(NewRayClusterSpec()).
WithEntrypoint("python -c \"import time; time.sleep(60)\"").
WithShutdownAfterJobFinishes(true))
rayJob, err := test.Client().Ray().RayV1().RayJobs(namespace.Name).Apply(test.Ctx(), rayJobAC, TestApplyOptions)
g.Expect(err).NotTo(HaveOccurred())
LogWithTimestamp(test.T(), "Created RayJob %s/%s successfully", rayJob.Namespace, rayJob.Name)
// Wait until the RayJob to become Running
LogWithTimestamp(test.T(), "Waiting for RayJob %s/%s to be 'Running'", rayJob.Namespace, rayJob.Name)
g.Eventually(RayJob(test, rayJob.Namespace, rayJob.Name), TestTimeoutMedium).
Should(WithTransform(RayJobStatus, Equal(rayv1.JobStatusRunning)))
// Get RayCluster name
rayJob, err = GetRayJob(test, rayJob.Namespace, rayJob.Name)
g.Expect(err).NotTo(HaveOccurred())
rayClusterName := rayJob.Status.RayClusterName
// Get RayCluster
rayCluster, err := GetRayCluster(test, namespace.Name, rayClusterName)
g.Expect(err).NotTo(HaveOccurred())
// Get headPod
headPod, err := GetHeadPod(test, rayCluster)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(headPod).NotTo(BeNil())
// Get Submitter Container id
var containerID string
for _, containerStatus := range headPod.Status.ContainerStatuses {
if containerStatus.Name == utils.SubmitterContainerName {
containerID = strings.TrimPrefix(containerStatus.ContainerID, "containerd://")
break
}
}
g.Expect(containerID).ToNot(BeEmpty(), "Submitter's container ID should not be empty")
LogWithTimestamp(test.T(), "Found submitter container ID: %s", containerID)
// Record job id before restart
initialJobID := rayJob.Status.JobId
// stop the container with docker exec
kindNodeName := headPod.Spec.NodeName
cmd := exec.CommandContext(test.Ctx(), "docker", "exec", kindNodeName, "crictl", "stop", containerID)
err = cmd.Run()
g.Expect(err).NotTo(HaveOccurred())
// Verify RayJob is still running
g.Expect(GetRayJob(test, rayJob.Namespace, rayJob.Name)).
To(WithTransform(RayJobStatus, Equal(rayv1.JobStatusRunning)))
// Verify the restart count should > 0
g.Eventually(func() int32 {
headPod, err = GetHeadPod(test, rayCluster)
if err != nil || headPod == nil {
return 0
}
for _, cs := range headPod.Status.ContainerStatuses {
if cs.Name == utils.SubmitterContainerName {
return cs.RestartCount
}
}
return 0
}, TestTimeoutMedium, 2*time.Second).Should(BeNumerically(">", 0))
// Verify RayJob is still running
rayJob, err = GetRayJob(test, rayJob.Namespace, rayJob.Name)
g.Expect(err).NotTo(HaveOccurred())
LogWithTimestamp(test.T(), "RayJob status after stopping submitter: jobStatus=%s deploymentStatus=%s", rayJob.Status.JobStatus, rayJob.Status.JobDeploymentStatus)
// Verify there is no duplicate submission by checking the job id with initialJobID
g.Consistently(func() string {
rayjob, err := GetRayJob(test, rayJob.Namespace, rayJob.Name)
g.Expect(err).NotTo(HaveOccurred())
return rayjob.Status.JobId
}, TestTimeoutShort, 2*time.Second).Should(Equal(initialJobID))
LogWithTimestamp(test.T(), "RayJob JobID remains %s, there is no duplicate submission", initialJobID)
// Verify RayJob does not transition to Failed
g.Consistently(RayJob(test, rayJob.Namespace, rayJob.Name), TestTimeoutShort, 2*time.Second).
Should(WithTransform(RayJobDeploymentStatus, Not(Equal(rayv1.JobDeploymentStatusFailed))))
// Verify RayJob eventually completed.
g.Eventually(RayJob(test, rayJob.Namespace, rayJob.Name), TestTimeoutLong).
Should(WithTransform(RayJobDeploymentStatus, Equal(rayv1.JobDeploymentStatusComplete)))
LogWithTimestamp(test.T(), "RayJob %s/%s completed successfully", rayJob.Namespace, rayJob.Name)
})
}