Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/running-on-kubernetes.md
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,7 @@ See the [configuration page](configuration.html) for information on Spark config
<td>
K8s IP Family Policy for Driver Service. Valid values are
<code>SingleStack</code>, <code>PreferDualStack</code>, and <code>RequireDualStack</code>.
The driver UI Service reuses this setting to keep the same IP family.
</td>
<td>3.4.0</td>
</tr>
Expand All @@ -1580,6 +1581,7 @@ See the [configuration page](configuration.html) for information on Spark config
<td>
A list of IP families for K8s Driver Service. Valid values are
<code>IPv4</code> and <code>IPv6</code>.
The driver UI Service reuses this setting to keep the same IP family.
</td>
<td>3.4.0</td>
</tr>
Expand All @@ -1594,6 +1596,38 @@ See the [configuration page](configuration.html) for information on Spark config
</td>
<td>4.3.0</td>
</tr>
<tr>
<td><code>spark.kubernetes.driver.ui.service.enabled</code></td>
<td><code>false</code></td>
<td>
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
<code>targetPort</code> to match the actual bound UI port, which allows using
<code>spark.ui.port=0</code> (random port). Requires the driver's ServiceAccount to have
<code>get</code> and <code>patch</code> verbs on <code>services</code>.
</td>
<td>4.3.0</td>
</tr>
<tr>
<td><code>spark.kubernetes.driver.ui.service.type</code></td>
<td><code>ClusterIP</code></td>
<td>
K8s Service type for the dedicated Spark driver Web UI Service (only applies when
<code>spark.kubernetes.driver.ui.service.enabled=true</code>). Supported values are
<code>ClusterIP</code>, <code>NodePort</code>, and <code>LoadBalancer</code>.
</td>
<td>4.3.0</td>
</tr>
<tr>
<td><code>spark.kubernetes.driver.ui.service.name</code></td>
<td>(none)</td>
<td>
Optional override for the dedicated Spark driver Web UI Service name (only applies when
<code>spark.kubernetes.driver.ui.service.enabled=true</code>). If unset, Spark derives the
name as <code>&lt;resourceNamePrefix&gt;-ui-svc</code>.
</td>
<td>4.3.0</td>
</tr>
<tr>
<td><code>spark.kubernetes.securityContext.allowPrivilegeEscalation</code></td>
<td><code>false</code></td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,17 @@ 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"))
.createWithDefault("SingleStack")

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"))
Expand All @@ -100,6 +102,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 `get` and `patch` verbs on `services`.")
.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 `<resourceNamePrefix>-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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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_SERVICE_IP_FAMILIES,
KUBERNETES_DRIVER_SERVICE_IP_FAMILY_POLICY,
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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Handle port zero in the existing driver resources too

This placeholder only fixes the new dedicated Service. With spark.ui.port=0, BasicDriverFeatureStep still emits containerPort=0, and DriverServiceFeatureStep still emits port=0 / targetPort=0 for the mandatory headless Service. Kubernetes requires these numeric ports to be in 1..65535, so the driver Pod/resources are rejected before this runtime patch can run. Please omit or substitute both existing UI port declarations and cover the complete KubernetesDriverBuilder output in a test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sunchao In fact, Because I think the internal service do not need to expose ports. So I use spark.kubernetes.executor.useDriverPodIP=true and add org.apache.spark.deploy.k8s.features.DriverServiceFeatureStep to spark.kubernetes.driver.pod.excludedFeatureSteps.

If the driver service's port is configured to 0, then this is indeed necessary. However, it's unclear whether it's appropriate to do this in this PR, since this is a PR for adding a UI service. I'll add it to this PR if you think it's suitable.

config.UI.UI_PORT.defaultValue.get
} else {
configuredUIPort
}

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] = {
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the configured IP family on the UI Service

This Service does not set ipFamilyPolicy or ipFamilies, even though the existing driver Service honors spark.kubernetes.driver.service.ipFamilyPolicy / .ipFamilies. On a dual-stack cluster, Kubernetes defaults an unspecified Service to SingleStack on the first Service CIDR, so applications configured for IPv6 or RequireDualStack get a compatible RPC Service but an incompatible UI Service. Please apply the driver Service family settings here (or add equivalent UI-service settings) and cover the dual-stack case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sunchao I have updated the code. UI service will reuse Driver Service's ip family to keep the same IP family.

.withIpFamilyPolicy(ipFamilyPolicy)
.withIpFamilies(ipFamilies)
.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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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("<unset>")} -> $actualPort")
}
} catch {
case e: Exception =>
logError(s"Failed to patch UI service '$serviceName' targetPort to $actualPort", e)
throw e
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -120,6 +121,27 @@ private[spark] class KubernetesClusterSchedulerBackend(
if (!conf.get(KUBERNETES_EXECUTOR_DISABLE_CONFIGMAP)) {
setUpExecutorConfigMap(podAllocator.driverPod)
}
maybePatchDriverUIServiceTargetPort()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Patch the Service in driver-only mode too

spark.kubernetes.driver.master=local[*] is a supported driver-only mode, but KubernetesClusterManager returns LocalSchedulerBackend for it, so this method never runs even though DriverUIServiceFeatureStep still creates the Service. After the zero-port manifest issue is fixed, the UI binds randomly while targetPort remains at the placeholder; a nonzero bind collision produces the same stale target. Please move this hook to a driver lifecycle shared by both backends, or explicitly reject/disable the feature in driver-only mode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local mode generally doesn't require exposing the UI port, so there's no need to patch the port; simply keep spark.kubernetes.driver.ui.service.enabled set to false.

}

/**
* 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 = {
Expand Down
Loading