From 33e4b4f20e8831a4249ca8b78b5cf2d2f974ef08 Mon Sep 17 00:00:00 2001 From: zhengchenyu Date: Sat, 18 Jul 2026 22:50:53 +0800 Subject: [PATCH 1/5] [SPARK-58203][K8S] Support configuring spark-managed UI service to work with spark.ui.port=0 Signed-off-by: zhengchenyu --- .../org/apache/spark/deploy/k8s/Config.scala | 30 +++++ .../apache/spark/deploy/k8s/Constants.scala | 4 + .../spark/deploy/k8s/KubernetesConf.scala | 13 +++ .../features/DriverUIServiceFeatureStep.scala | 108 ++++++++++++++++++ .../k8s/submit/KubernetesDriverBuilder.scala | 1 + .../k8s/K8sDriverUIServicePatcher.scala | 94 +++++++++++++++ .../KubernetesClusterSchedulerBackend.scala | 22 ++++ .../DriverUIServiceFeatureStepSuite.scala | 97 ++++++++++++++++ 8 files changed, 369 insertions(+) create mode 100644 resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala create mode 100644 resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/K8sDriverUIServicePatcher.scala create mode 100644 resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStepSuite.scala diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala index 00a02f3cba6f6..a8b9977fe34b2 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala @@ -100,6 +100,36 @@ private[spark] object Config extends Logging { .booleanConf .createWithDefault(false) + val KUBERNETES_DRIVER_UI_SERVICE_ENABLED = + ConfigBuilder("spark.kubernetes.driver.ui.service.enabled") + .doc("If true, Spark will create a dedicated Kubernetes Service for the Spark driver " + + "Web UI (separate from the headless driver service). When enabled, after the driver " + + "Web UI starts, Spark will patch the Service's targetPort to match the actual bound " + + "UI port, which allows using `spark.ui.port=0` (random port). Requires the driver's " + + "ServiceAccount to have `patch services` permission.") + .version("4.3.0") + .booleanConf + .createWithDefault(false) + + val KUBERNETES_DRIVER_UI_SERVICE_TYPE = + ConfigBuilder("spark.kubernetes.driver.ui.service.type") + .doc("K8s Service type for the dedicated Spark driver Web UI Service " + + s"(only applies when ${KUBERNETES_DRIVER_UI_SERVICE_ENABLED.key}=true). " + + "Supported values: ClusterIP, NodePort, LoadBalancer.") + .version("4.3.0") + .stringConf + .checkValues(Set("ClusterIP", "NodePort", "LoadBalancer")) + .createWithDefault("ClusterIP") + + val KUBERNETES_DRIVER_UI_SERVICE_NAME = + ConfigBuilder("spark.kubernetes.driver.ui.service.name") + .doc("Optional override for the dedicated Spark driver Web UI Service name " + + s"(only applies when ${KUBERNETES_DRIVER_UI_SERVICE_ENABLED.key}=true). " + + "If unset, Spark derives the name as `-ui-svc`.") + .version("4.3.0") + .stringConf + .createOptional + val KUBERNETES_DRIVER_OWN_PVC = ConfigBuilder("spark.kubernetes.driver.ownPersistentVolumeClaim") .doc("If true, driver pod becomes the owner of on-demand persistent volume claims " + diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala index 4ffd617c4e4ce..29b5fecbecdd5 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Constants.scala @@ -108,6 +108,10 @@ object Constants { val DEFAULT_PVC_ACCESS_MODE = "ReadWriteOncePod" val NON_JVM_MEMORY_OVERHEAD_FACTOR = 0.4d val CONNECT_GRPC_BINDING_PORT = "spark.connect.grpc.binding.port" + + // Suffix for the dedicated Spark Web UI Service. + val DRIVER_UI_SVC_POSTFIX = "-ui-svc" + val EXIT_EXCEPTION_ANNOTATION = "spark.exit-exception" val POD_DELETION_COST = "controller.kubernetes.io/pod-deletion-cost" diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala index 9070d6ce35ff6..228172bfd6b50 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/KubernetesConf.scala @@ -117,6 +117,19 @@ class KubernetesDriverConf( } } + lazy val driverUIServiceName: String = { + val preferredServiceName = s"$resourceNamePrefix$DRIVER_UI_SVC_POSTFIX" + if (preferredServiceName.length <= MAX_SERVICE_NAME_LENGTH) { + preferredServiceName + } else { + val randomServiceId = KubernetesUtils.uniqueID(clock) + val shorterServiceName = s"spark-$randomServiceId$DRIVER_UI_SVC_POSTFIX" + logWarning(s"Preferred UI service name '$preferredServiceName' exceeds Kubernetes DNS " + + s"label limit ($MAX_SERVICE_NAME_LENGTH); using '$shorterServiceName' instead.") + shorterServiceName + } + } + override val resourceNamePrefix: String = { val custom = if (Utils.isTesting) get(KUBERNETES_DRIVER_POD_NAME_PREFIX) else None custom.getOrElse(KubernetesConf.getResourceNamePrefix(appName)) diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala new file mode 100644 index 0000000000000..c99004f57aeca --- /dev/null +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala @@ -0,0 +1,108 @@ +/* + * 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 org.apache.spark.deploy.k8s.features + +import scala.jdk.CollectionConverters._ + +import io.fabric8.kubernetes.api.model.{HasMetadata, ServiceBuilder} + +import org.apache.spark.deploy.k8s.{KubernetesDriverConf, SparkPod} +import org.apache.spark.deploy.k8s.Config.{ + KUBERNETES_DRIVER_UI_SERVICE_ENABLED, + KUBERNETES_DRIVER_UI_SERVICE_NAME, + KUBERNETES_DRIVER_UI_SERVICE_TYPE +} +import org.apache.spark.deploy.k8s.Constants._ +import org.apache.spark.internal.{config, Logging} + +/** + * Optionally provisions a dedicated Kubernetes Service exposing only the Spark driver's Web UI + * port. + * + * At creation time, the Service's `targetPort` is set to the configured `spark.ui.port` + * (a placeholder when the user has requested a random port). Once the driver's Jetty server + * has bound, `K8sDriverUIServicePatcher` updates the Service's `targetPort` to reflect the + * actual bound port. + */ +private[spark] class DriverUIServiceFeatureStep(kubernetesConf: KubernetesDriverConf) + extends KubernetesFeatureConfigStep with Logging { + import DriverUIServiceFeatureStep._ + + private val enabled = kubernetesConf.get(KUBERNETES_DRIVER_UI_SERVICE_ENABLED) + private lazy val serviceType = kubernetesConf.get(KUBERNETES_DRIVER_UI_SERVICE_TYPE) + private lazy val configuredUIPort = kubernetesConf.get(config.UI.UI_PORT) + + /** + * Port value used when building the Service. When the user has requested a random UI port + * (`spark.ui.port=0`), the actual port is only known after the driver's Jetty server binds, + * so we substitute the default UI port (typically 4040) purely as a placeholder to satisfy + * Kubernetes' Service port validation (must be > 0). After the driver JVM starts, + * [[org.apache.spark.scheduler.cluster.k8s.K8sDriverUIServicePatcher]] updates the Service's + * `targetPort` to the real bound port. + */ + private lazy val servicePort: Int = if (configuredUIPort == 0) { + config.UI.UI_PORT.defaultValue.get + } else { + configuredUIPort + } + + private lazy val serviceName: String = kubernetesConf.get(KUBERNETES_DRIVER_UI_SERVICE_NAME) + .getOrElse(kubernetesConf.driverUIServiceName) + + override def configurePod(pod: SparkPod): SparkPod = pod + + override def getAdditionalPodSystemProperties(): Map[String, String] = { + if (enabled) { + Map(KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL -> serviceName) + } else { + Map.empty + } + } + + override def getAdditionalKubernetesResources(): Seq[HasMetadata] = { + if (!enabled) return Seq.empty + + val uiService = new ServiceBuilder() + .withNewMetadata() + .withName(serviceName) + .addToAnnotations(kubernetesConf.serviceAnnotations.asJava) + .addToLabels(SPARK_APP_ID_LABEL, kubernetesConf.appId) + .addToLabels(kubernetesConf.serviceLabels.asJava) + .endMetadata() + .withNewSpec() + .withType(serviceType) + .withSelector(kubernetesConf.labels.asJava) + .addNewPort() + .withName(UI_PORT_NAME) + .withPort(servicePort) + .withNewTargetPort(servicePort) + .endPort() + .endSpec() + .build() + Seq(uiService) + } +} + +private[spark] object DriverUIServiceFeatureStep { + /** + * Internal spark conf key used to pass the UI service name from this feature step to the + * driver runtime (SparkContext) so `K8sDriverUIServicePatcher` can look up the Service to + * patch. + */ + val KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL = + "spark.kubernetes.driver.ui.service.name.internal" +} diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala index 8af7d243a5a40..3e285d1bcd08a 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesDriverBuilder.scala @@ -77,6 +77,7 @@ class KubernetesDriverBuilder { new BasicDriverFeatureStep(conf), new DriverKubernetesCredentialsFeatureStep(conf), new DriverServiceFeatureStep(conf), + new DriverUIServiceFeatureStep(conf), new NetworkPolicyFeatureStep(conf), new MountSecretsFeatureStep(conf), new EnvSecretsFeatureStep(conf), diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/K8sDriverUIServicePatcher.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/K8sDriverUIServicePatcher.scala new file mode 100644 index 0000000000000..c2c78e3ad46b0 --- /dev/null +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/K8sDriverUIServicePatcher.scala @@ -0,0 +1,94 @@ +/* + * 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 org.apache.spark.scheduler.cluster.k8s + +import scala.jdk.CollectionConverters._ + +import io.fabric8.kubernetes.api.model.{IntOrString, ServiceBuilder} +import io.fabric8.kubernetes.client.KubernetesClient + +import org.apache.spark.deploy.k8s.Constants.UI_PORT_NAME +import org.apache.spark.internal.Logging + +/** + * Driver-side utility that updates the `targetPort` of the dedicated Spark UI Kubernetes Service + * (created by [[org.apache.spark.deploy.k8s.features.DriverUIServiceFeatureStep]]) to the actual + * port the driver's Jetty server bound to. + */ +private[k8s] object K8sDriverUIServicePatcher extends Logging { + + /** + * Patch the targetPort of the given Service's `spark-ui` port entry to `actualPort`, if the + * current value differs. + * + * @param client Kubernetes client to use (typically the one already held by the backend). + * @param namespace Namespace where the Service lives. + * @param serviceName Name of the UI Service (from + * `spark.kubernetes.driver.ui.service.name.internal`). + * @param actualPort The actual port the driver's Jetty server bound to + * (typically `SparkUI.boundPort`). + */ + def patchTargetPort( + client: KubernetesClient, + namespace: String, + serviceName: String, + actualPort: Int): Unit = { + try { + val service = client.services() + .inNamespace(namespace) + .withName(serviceName) + .get() + + if (service == null) { + logWarning(s"UI service '$serviceName' not found in namespace '$namespace'; " + + "skipping targetPort patch.") + return + } + + val currentTargetPort = service.getSpec.getPorts.asScala + .find(_.getName == UI_PORT_NAME) + .map(_.getTargetPort.getIntVal.toInt) + + currentTargetPort match { + case Some(existing) if existing == actualPort => + logInfo(s"UI service '$serviceName' targetPort already matches actual UI port " + + s"($actualPort); no patch needed.") + + case _ => + val updated = new ServiceBuilder(service) + .editSpec() + .editMatchingPort(portBuilder => portBuilder.build().getName == UI_PORT_NAME) + .withTargetPort(new IntOrString(actualPort)) + .endPort() + .endSpec() + .build() + + client.services() + .inNamespace(namespace) + .withName(serviceName) + .patch(updated) + + logInfo(s"Patched UI service '$serviceName' targetPort " + + s"${currentTargetPort.map(_.toString).getOrElse("")} -> $actualPort") + } + } catch { + case e: Exception => + logError(s"Failed to patch UI service '$serviceName' targetPort to $actualPort", e) + throw e + } + } +} diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala index 0784b82a85de2..5c079f3e8d43b 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala @@ -31,6 +31,7 @@ import org.apache.spark.SparkContext import org.apache.spark.deploy.k8s.{KubernetesConf, KubernetesUtils} import org.apache.spark.deploy.k8s.Config._ import org.apache.spark.deploy.k8s.Constants._ +import org.apache.spark.deploy.k8s.features.DriverUIServiceFeatureStep import org.apache.spark.deploy.k8s.submit.KubernetesClientUtils import org.apache.spark.deploy.security.HadoopDelegationTokenManager import org.apache.spark.internal.LogKeys.{COUNT, TOTAL} @@ -120,6 +121,27 @@ private[spark] class KubernetesClusterSchedulerBackend( if (!conf.get(KUBERNETES_EXECUTOR_DISABLE_CONFIGMAP)) { setUpExecutorConfigMap(podAllocator.driverPod) } + maybePatchDriverUIServiceTargetPort() + } + + /** + * Patch the dedicated UI Service's `targetPort` to match the actual bound port of the driver's + * Jetty server. Only applies when the UI service feature is enabled. Requires `patch services` + * RBAC on the driver's ServiceAccount. + */ + private def maybePatchDriverUIServiceTargetPort(): Unit = { + if (!conf.get(KUBERNETES_DRIVER_UI_SERVICE_ENABLED)) return + for { + svcName <- conf.getOption( + DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL) + actualPort <- sc.ui.map(_.boundPort) + } { + K8sDriverUIServicePatcher.patchTargetPort( + kubernetesClient, + conf.get(KUBERNETES_NAMESPACE), + svcName, + actualPort) + } } override def stop(): Unit = { diff --git a/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStepSuite.scala b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStepSuite.scala new file mode 100644 index 0000000000000..bf85653abaa0c --- /dev/null +++ b/resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStepSuite.scala @@ -0,0 +1,97 @@ +/* + * 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 org.apache.spark.deploy.k8s.features + +import scala.jdk.CollectionConverters._ + +import io.fabric8.kubernetes.api.model.Service + +import org.apache.spark.{SparkConf, SparkFunSuite} +import org.apache.spark.deploy.k8s.Config._ +import org.apache.spark.deploy.k8s.Constants._ +import org.apache.spark.deploy.k8s.KubernetesTestConf +import org.apache.spark.internal.config.UI._ + +class DriverUIServiceFeatureStepSuite extends SparkFunSuite { + + test("SPARK-58203: no additional resource when the feature is disabled") { + val sparkConf = new SparkConf(false).set(UI_PORT, 4080) + val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf) + val step = new DriverUIServiceFeatureStep(kconf) + + assert(step.getAdditionalKubernetesResources().isEmpty) + assert(!step.getAdditionalPodSystemProperties() + .contains(DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL)) + } + + test("SPARK-58203: dedicated UI service is created when enabled") { + val sparkConf = new SparkConf(false) + .set(UI_PORT, 4080) + .set(KUBERNETES_DRIVER_UI_SERVICE_ENABLED, true) + val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf) + val step = new DriverUIServiceFeatureStep(kconf) + + val resources = step.getAdditionalKubernetesResources() + assert(resources.size === 1) + + val uiSvc = resources.head.asInstanceOf[Service] + assert(uiSvc.getMetadata.getName === + s"${kconf.resourceNamePrefix}${DRIVER_UI_SVC_POSTFIX}") + assert(uiSvc.getSpec.getType === "ClusterIP") + assert(uiSvc.getSpec.getSelector.asScala === kconf.labels) + assert(uiSvc.getSpec.getPorts.size === 1) + assert(uiSvc.getSpec.getPorts.get(0).getName === UI_PORT_NAME) + assert(uiSvc.getSpec.getPorts.get(0).getPort.intValue() === 4080) + assert(uiSvc.getSpec.getPorts.get(0).getTargetPort.getIntVal === 4080) + + assert(step.getAdditionalPodSystemProperties() + .get(DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL) + === Some(s"${kconf.resourceNamePrefix}${DRIVER_UI_SVC_POSTFIX}")) + } + + test("SPARK-58203: UI service honors explicit name and type overrides") { + val sparkConf = new SparkConf(false) + .set(UI_PORT, 4080) + .set(KUBERNETES_DRIVER_UI_SERVICE_ENABLED, true) + .set(KUBERNETES_DRIVER_UI_SERVICE_TYPE, "NodePort") + .set(KUBERNETES_DRIVER_UI_SERVICE_NAME, "my-app-ui-svc") + val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf) + val step = new DriverUIServiceFeatureStep(kconf) + + val uiSvc = step.getAdditionalKubernetesResources().head.asInstanceOf[Service] + assert(uiSvc.getMetadata.getName === "my-app-ui-svc") + assert(uiSvc.getSpec.getType === "NodePort") + + assert(step.getAdditionalPodSystemProperties() + .get(DriverUIServiceFeatureStep.KUBERNETES_DRIVER_UI_SERVICE_NAME_INTERNAL) + === Some("my-app-ui-svc")) + } + + test("SPARK-58203: spark.ui.port=0 substitutes the default UI port as Service placeholder") { + val sparkConf = new SparkConf(false) + .set(UI_PORT, 0) + .set(KUBERNETES_DRIVER_UI_SERVICE_ENABLED, true) + val kconf = KubernetesTestConf.createDriverConf(sparkConf = sparkConf) + val step = new DriverUIServiceFeatureStep(kconf) + + val uiSvc = step.getAdditionalKubernetesResources().head.asInstanceOf[Service] + val expectedPlaceholder = UI_PORT.defaultValue.get + assert(uiSvc.getSpec.getPorts.size === 1) + assert(uiSvc.getSpec.getPorts.get(0).getPort.intValue() === expectedPlaceholder) + assert(uiSvc.getSpec.getPorts.get(0).getTargetPort.getIntVal === expectedPlaceholder) + } +} From 03c85737ea375d4eb3db3f17e4c9ecad9bdfb826 Mon Sep 17 00:00:00 2001 From: zhengchenyu Date: Mon, 20 Jul 2026 11:27:33 +0800 Subject: [PATCH 2/5] update document --- .../src/main/scala/org/apache/spark/deploy/k8s/Config.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala index a8b9977fe34b2..892b3cc7a6387 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala @@ -106,7 +106,7 @@ private[spark] object Config extends Logging { "Web UI (separate from the headless driver service). When enabled, after the driver " + "Web UI starts, Spark will patch the Service's targetPort to match the actual bound " + "UI port, which allows using `spark.ui.port=0` (random port). Requires the driver's " + - "ServiceAccount to have `patch services` permission.") + "ServiceAccount to have `get` and `patch` verbs on `services`.") .version("4.3.0") .booleanConf .createWithDefault(false) From e9497be15df10c121a9f1d8db5a09ea9b56f77ec Mon Sep 17 00:00:00 2001 From: zhengchenyu Date: Thu, 23 Jul 2026 09:14:47 +0800 Subject: [PATCH 3/5] update document --- docs/running-on-kubernetes.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/running-on-kubernetes.md b/docs/running-on-kubernetes.md index 23bc44c46751f..1934ed82691a3 100644 --- a/docs/running-on-kubernetes.md +++ b/docs/running-on-kubernetes.md @@ -1594,6 +1594,38 @@ See the [configuration page](configuration.html) for information on Spark config 4.3.0 + + spark.kubernetes.driver.ui.service.enabled + false + + If true, Spark will create a dedicated Kubernetes Service for the Spark driver Web UI. + When enabled, after the driver Web UI starts, Spark will patch the Service's + targetPort to match the actual bound UI port, which allows using + spark.ui.port=0 (random port). Requires the driver's ServiceAccount to have + get and patch verbs on services. + + 4.3.0 + + + spark.kubernetes.driver.ui.service.type + ClusterIP + + K8s Service type for the dedicated Spark driver Web UI Service (only applies when + spark.kubernetes.driver.ui.service.enabled=true). Supported values are + ClusterIP, NodePort, and LoadBalancer. + + 4.3.0 + + + spark.kubernetes.driver.ui.service.name + (none) + + Optional override for the dedicated Spark driver Web UI Service name (only applies when + spark.kubernetes.driver.ui.service.enabled=true). If unset, Spark derives the + name as <resourceNamePrefix>-ui-svc. + + 4.3.0 + spark.kubernetes.securityContext.allowPrivilegeEscalation false From 659cd57fed03b4e1d098aa8144e814a9fb703b62 Mon Sep 17 00:00:00 2001 From: zhengchenyu Date: Thu, 23 Jul 2026 09:51:32 +0800 Subject: [PATCH 4/5] remove space --- docs/running-on-kubernetes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/running-on-kubernetes.md b/docs/running-on-kubernetes.md index 1934ed82691a3..48dc195755629 100644 --- a/docs/running-on-kubernetes.md +++ b/docs/running-on-kubernetes.md @@ -1599,7 +1599,7 @@ See the [configuration page](configuration.html) for information on Spark config false If true, Spark will create a dedicated Kubernetes Service for the Spark driver Web UI. - When enabled, after the driver Web UI starts, Spark will patch the Service's + When enabled, after the driver Web UI starts, Spark will patch the Service's targetPort to match the actual bound UI port, which allows using spark.ui.port=0 (random port). Requires the driver's ServiceAccount to have get and patch verbs on services. From 7cd1b9647603ac655b7e925647f8bcaa2ee011cd Mon Sep 17 00:00:00 2001 From: zhengchenyu Date: Thu, 23 Jul 2026 14:03:27 +0800 Subject: [PATCH 5/5] reuse driver service's ipfamily --- docs/running-on-kubernetes.md | 2 ++ .../main/scala/org/apache/spark/deploy/k8s/Config.scala | 6 ++++-- .../deploy/k8s/features/DriverUIServiceFeatureStep.scala | 9 +++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/running-on-kubernetes.md b/docs/running-on-kubernetes.md index 48dc195755629..b5f0858fa9835 100644 --- a/docs/running-on-kubernetes.md +++ b/docs/running-on-kubernetes.md @@ -1571,6 +1571,7 @@ See the [configuration page](configuration.html) for information on Spark config K8s IP Family Policy for Driver Service. Valid values are SingleStack, PreferDualStack, and RequireDualStack. + The driver UI Service reuses this setting to keep the same IP family. 3.4.0 @@ -1580,6 +1581,7 @@ See the [configuration page](configuration.html) for information on Spark config A list of IP families for K8s Driver Service. Valid values are IPv4 and IPv6. + The driver UI Service reuses this setting to keep the same IP family. 3.4.0 diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala index 892b3cc7a6387..b190ca4a3de79 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala @@ -74,7 +74,8 @@ private[spark] object Config extends Logging { val KUBERNETES_DRIVER_SERVICE_IP_FAMILY_POLICY = ConfigBuilder("spark.kubernetes.driver.service.ipFamilyPolicy") - .doc("K8s IP Family Policy for Driver Service") + .doc("K8s IP Family Policy for Driver Service. The driver UI Service reuses this " + + "setting to keep the same IP family.") .version("3.4.0") .stringConf .checkValues(Set("SingleStack", "PreferDualStack", "RequireDualStack")) @@ -82,7 +83,8 @@ private[spark] object Config extends Logging { val KUBERNETES_DRIVER_SERVICE_IP_FAMILIES = ConfigBuilder("spark.kubernetes.driver.service.ipFamilies") - .doc("A list of IP families for K8s Driver Service") + .doc("A list of IP families for K8s Driver Service. The driver UI Service reuses this " + + "setting to keep the same IP family.") .version("3.4.0") .stringConf .checkValues(Set("IPv4", "IPv6", "IPv4,IPv6", "IPv6,IPv4")) diff --git a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala index c99004f57aeca..212103356b419 100644 --- a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala +++ b/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/DriverUIServiceFeatureStep.scala @@ -22,6 +22,8 @@ import io.fabric8.kubernetes.api.model.{HasMetadata, ServiceBuilder} import org.apache.spark.deploy.k8s.{KubernetesDriverConf, SparkPod} import org.apache.spark.deploy.k8s.Config.{ + KUBERNETES_DRIVER_SERVICE_IP_FAMILIES, + KUBERNETES_DRIVER_SERVICE_IP_FAMILY_POLICY, KUBERNETES_DRIVER_UI_SERVICE_ENABLED, KUBERNETES_DRIVER_UI_SERVICE_NAME, KUBERNETES_DRIVER_UI_SERVICE_TYPE @@ -63,6 +65,11 @@ private[spark] class DriverUIServiceFeatureStep(kubernetesConf: KubernetesDriver private lazy val serviceName: String = kubernetesConf.get(KUBERNETES_DRIVER_UI_SERVICE_NAME) .getOrElse(kubernetesConf.driverUIServiceName) + // The UI Service reuses the driver Service IP family settings to keep the same IP family. + private lazy val ipFamilyPolicy = kubernetesConf.get(KUBERNETES_DRIVER_SERVICE_IP_FAMILY_POLICY) + private lazy val ipFamilies = + kubernetesConf.get(KUBERNETES_DRIVER_SERVICE_IP_FAMILIES).split(",").toList.asJava + override def configurePod(pod: SparkPod): SparkPod = pod override def getAdditionalPodSystemProperties(): Map[String, String] = { @@ -85,6 +92,8 @@ private[spark] class DriverUIServiceFeatureStep(kubernetesConf: KubernetesDriver .endMetadata() .withNewSpec() .withType(serviceType) + .withIpFamilyPolicy(ipFamilyPolicy) + .withIpFamilies(ipFamilies) .withSelector(kubernetesConf.labels.asJava) .addNewPort() .withName(UI_PORT_NAME)