dongjoon-hyun commented on code in PR #59756:
URL: https://github.com/apache/spark/pull/59756#discussion_r4426960796
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -518,86 +540,220 @@ class ExecutorPodsAllocator(
applicationId: String,
resourceProfileId: Int,
pvcsInUse: Set[String]): Unit = {
- // Check reusable PVCs for this executor allocation batch
- val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
- for ( _ <- 0 until numExecutorsToAllocate) {
- if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
- logInfo(
- log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
- return
+ if (podAllocationParallelEnabled) {
+ if (stopped) return
+ requestNewExecutorsParallel(numExecutorsToAllocate, applicationId,
+ resourceProfileId, pvcsInUse)
+ } else {
+ // Check reusable PVCs for this executor allocation batch
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ for ( _ <- 0 until numExecutorsToAllocate) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
+ logInfo(
+ log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
+ return
+ }
+ val (newExecutorId, podWithAttachedContainer, resources) =
+ buildExecutorPod(applicationId, resourceProfileId, reusablePVCs)
+ val optCreatedExecutorPod = try {
+ Some(kubernetesClient
+ .pods()
+ .inNamespace(namespace)
+ .resource(podWithAttachedContainer)
+ .create())
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to create executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ None
+ }
+ optCreatedExecutorPod.foreach { createdExecutorPod =>
+ try {
+ addOwnerReference(createdExecutorPod, resources)
+ resources
+ .filter(_.getKind == "PersistentVolumeClaim")
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty)
{
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME,
pvc.getMetadata.getName)} with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+ kubernetesClient
+ .persistentVolumeClaims()
+ .inNamespace(namespace)
+ .resource(pvc)
+ .create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
+ logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to add owner reference or create PVC for
executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ kubernetesClient.pods()
+ .inNamespace(namespace)
+ .resource(createdExecutorPod)
+ .delete()
+ throw e
+ }
+ }
}
- val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
- if (newExecutorId >= podAllocationMaximum) {
- throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ }
+
+ // Build on the allocation thread: feature steps and PVC reuse mutate shared
state.
+ private def buildExecutorPod(
+ applicationId: String,
+ resourceProfileId: Int,
+ reusablePVCs: mutable.Buffer[PersistentVolumeClaim]): (Long, Pod,
Seq[HasMetadata]) = {
+ val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
+ if (newExecutorId >= podAllocationMaximum) {
+ throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ val executorConf = KubernetesConf.createExecutorConf(
+ conf,
+ newExecutorId.toString,
+ applicationId,
+ driverPod,
+ resourceProfileId,
+ Option(secMgr.getSecretKey()))
+ val resolvedExecutorSpec = executorBuilder.buildFromFeatures(executorConf,
+ kubernetesClient, rpIdToResourceProfile(resourceProfileId))
+ val executorPod = resolvedExecutorSpec.pod
+ val podWithAttachedContainer = new PodBuilder(executorPod.pod)
+ .editOrNewSpec()
+ .addToContainers(executorPod.container)
+ .endSpec()
+ .build()
+ val resources = replacePVCsIfNeeded(
+ podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
+ (newExecutorId.toLong, podWithAttachedContainer, resources)
+ }
+
+ // Only the Kubernetes calls run on worker threads; allocator maps are
updated by the caller.
+ private def createExecutorPod(pod: Pod, resources: Seq[HasMetadata]):
Boolean = {
+ if (stopped) return false
+ val createdExecutorPod =
+ kubernetesClient.pods().inNamespace(namespace).resource(pod).create()
+ var succeeded = false
+ var failed = true
+ try {
+ if (!stopped) {
+ addOwnerReference(createdExecutorPod, resources)
+ resources.iterator.filter(_.getKind ==
"PersistentVolumeClaim").takeWhile(_ => !stopped)
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+
kubernetesClient.persistentVolumeClaims().inNamespace(namespace).resource(pvc).create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ succeeded = !stopped
}
- val executorConf = KubernetesConf.createExecutorConf(
- conf,
- newExecutorId.toString,
- applicationId,
- driverPod,
- resourceProfileId,
- Option(secMgr.getSecretKey()))
- val resolvedExecutorSpec =
executorBuilder.buildFromFeatures(executorConf,
- kubernetesClient, rpIdToResourceProfile(resourceProfileId))
- val executorPod = resolvedExecutorSpec.pod
- val podWithAttachedContainer = new PodBuilder(executorPod.pod)
- .editOrNewSpec()
- .addToContainers(executorPod.container)
- .endSpec()
- .build()
- val resources = replacePVCsIfNeeded(
- podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
- val optCreatedExecutorPod = try {
- Some(kubernetesClient
- .pods()
- .inNamespace(namespace)
- .resource(podWithAttachedContainer)
- .create())
- } catch {
- case NonFatal(e) =>
- // Register failure with global tracker if lifecycle manager is
available
- val failureCount = registerPodCreationFailure()
- logError(log"Failed to create executor pod
${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
- log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
- None
+ failed = false
+ succeeded
+ } finally {
+ // Clean up failed creations and, unless pods should be retained,
requests finishing at stop.
+ if (failed || (!succeeded && shouldDeleteExecutors)) {
+ Utils.tryLogNonFatalError {
+
kubernetesClient.pods().inNamespace(namespace).resource(createdExecutorPod).delete()
+ }
}
- optCreatedExecutorPod.foreach { createdExecutorPod =>
+ }
+ }
+
+ private def requestNewExecutorsParallel(
+ numExecutorsToAllocate: Int,
+ applicationId: String,
+ resourceProfileId: Int,
+ pvcsInUse: Set[String]): Unit = {
+ val batchStartTime = clock.getTimeMillis()
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ val podSpecs = mutable.ArrayBuffer.empty[(Long, Pod, Seq[HasMetadata])]
+ var reservedPVCs = PVC_COUNTER.get().toLong
+ var canAllocate = true
+ // Build the complete batch before submitting it so a feature-step failure
cannot leave
+ // already-submitted pods untracked. Reserve PVC capacity without counting
it as created.
+ while (podSpecs.size < numExecutorsToAllocate && canAllocate && !stopped) {
Review Comment:
**6. [Low] Building the batch up front misses a recovery-mode switch during
the batch**
`setRecoveryMode()` updates `conf`, and `KubernetesConf.createExecutorConf`
clones it at build time (L622). Serial builds each pod right before its POST
(L556-L557), so pods after the switch start as single-task executors
(`BasicExecutorFeatureStep.scala:170`). Here the build takes milliseconds and
the POSTs come later: with a 3s webhook and concurrency 5, a batch of 10 takes
about 6s, and none of its pods see a switch made during that time.
A task at risk of OOM can then run again on a full-core executor and use up
more of `spark.task.maxFailures`. The effect is limited to one round. This
depends on timing and I did not reproduce it. If this is an accepted tradeoff,
a note next to the comment at L688 would help.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -518,86 +540,220 @@ class ExecutorPodsAllocator(
applicationId: String,
resourceProfileId: Int,
pvcsInUse: Set[String]): Unit = {
- // Check reusable PVCs for this executor allocation batch
- val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
- for ( _ <- 0 until numExecutorsToAllocate) {
- if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
- logInfo(
- log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
- return
+ if (podAllocationParallelEnabled) {
+ if (stopped) return
+ requestNewExecutorsParallel(numExecutorsToAllocate, applicationId,
+ resourceProfileId, pvcsInUse)
+ } else {
+ // Check reusable PVCs for this executor allocation batch
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ for ( _ <- 0 until numExecutorsToAllocate) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
+ logInfo(
+ log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
+ return
+ }
+ val (newExecutorId, podWithAttachedContainer, resources) =
+ buildExecutorPod(applicationId, resourceProfileId, reusablePVCs)
+ val optCreatedExecutorPod = try {
+ Some(kubernetesClient
+ .pods()
+ .inNamespace(namespace)
+ .resource(podWithAttachedContainer)
+ .create())
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to create executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ None
+ }
+ optCreatedExecutorPod.foreach { createdExecutorPod =>
+ try {
+ addOwnerReference(createdExecutorPod, resources)
+ resources
+ .filter(_.getKind == "PersistentVolumeClaim")
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty)
{
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME,
pvc.getMetadata.getName)} with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+ kubernetesClient
+ .persistentVolumeClaims()
+ .inNamespace(namespace)
+ .resource(pvc)
+ .create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
+ logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to add owner reference or create PVC for
executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ kubernetesClient.pods()
+ .inNamespace(namespace)
+ .resource(createdExecutorPod)
+ .delete()
+ throw e
+ }
+ }
}
- val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
- if (newExecutorId >= podAllocationMaximum) {
- throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ }
+
+ // Build on the allocation thread: feature steps and PVC reuse mutate shared
state.
+ private def buildExecutorPod(
+ applicationId: String,
+ resourceProfileId: Int,
+ reusablePVCs: mutable.Buffer[PersistentVolumeClaim]): (Long, Pod,
Seq[HasMetadata]) = {
+ val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
+ if (newExecutorId >= podAllocationMaximum) {
+ throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ val executorConf = KubernetesConf.createExecutorConf(
+ conf,
+ newExecutorId.toString,
+ applicationId,
+ driverPod,
+ resourceProfileId,
+ Option(secMgr.getSecretKey()))
+ val resolvedExecutorSpec = executorBuilder.buildFromFeatures(executorConf,
+ kubernetesClient, rpIdToResourceProfile(resourceProfileId))
+ val executorPod = resolvedExecutorSpec.pod
+ val podWithAttachedContainer = new PodBuilder(executorPod.pod)
+ .editOrNewSpec()
+ .addToContainers(executorPod.container)
+ .endSpec()
+ .build()
+ val resources = replacePVCsIfNeeded(
+ podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
+ (newExecutorId.toLong, podWithAttachedContainer, resources)
+ }
+
+ // Only the Kubernetes calls run on worker threads; allocator maps are
updated by the caller.
+ private def createExecutorPod(pod: Pod, resources: Seq[HasMetadata]):
Boolean = {
+ if (stopped) return false
+ val createdExecutorPod =
+ kubernetesClient.pods().inNamespace(namespace).resource(pod).create()
+ var succeeded = false
+ var failed = true
+ try {
+ if (!stopped) {
+ addOwnerReference(createdExecutorPod, resources)
+ resources.iterator.filter(_.getKind ==
"PersistentVolumeClaim").takeWhile(_ => !stopped)
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+
kubernetesClient.persistentVolumeClaims().inNamespace(namespace).resource(pvc).create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ succeeded = !stopped
}
- val executorConf = KubernetesConf.createExecutorConf(
- conf,
- newExecutorId.toString,
- applicationId,
- driverPod,
- resourceProfileId,
- Option(secMgr.getSecretKey()))
- val resolvedExecutorSpec =
executorBuilder.buildFromFeatures(executorConf,
- kubernetesClient, rpIdToResourceProfile(resourceProfileId))
- val executorPod = resolvedExecutorSpec.pod
- val podWithAttachedContainer = new PodBuilder(executorPod.pod)
- .editOrNewSpec()
- .addToContainers(executorPod.container)
- .endSpec()
- .build()
- val resources = replacePVCsIfNeeded(
- podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
- val optCreatedExecutorPod = try {
- Some(kubernetesClient
- .pods()
- .inNamespace(namespace)
- .resource(podWithAttachedContainer)
- .create())
- } catch {
- case NonFatal(e) =>
- // Register failure with global tracker if lifecycle manager is
available
- val failureCount = registerPodCreationFailure()
- logError(log"Failed to create executor pod
${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
- log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
- None
+ failed = false
+ succeeded
+ } finally {
+ // Clean up failed creations and, unless pods should be retained,
requests finishing at stop.
+ if (failed || (!succeeded && shouldDeleteExecutors)) {
+ Utils.tryLogNonFatalError {
+
kubernetesClient.pods().inNamespace(namespace).resource(createdExecutorPod).delete()
+ }
}
- optCreatedExecutorPod.foreach { createdExecutorPod =>
+ }
+ }
+
+ private def requestNewExecutorsParallel(
+ numExecutorsToAllocate: Int,
+ applicationId: String,
+ resourceProfileId: Int,
+ pvcsInUse: Set[String]): Unit = {
+ val batchStartTime = clock.getTimeMillis()
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ val podSpecs = mutable.ArrayBuffer.empty[(Long, Pod, Seq[HasMetadata])]
+ var reservedPVCs = PVC_COUNTER.get().toLong
+ var canAllocate = true
+ // Build the complete batch before submitting it so a feature-step failure
cannot leave
+ // already-submitted pods untracked. Reserve PVC capacity without counting
it as created.
+ while (podSpecs.size < numExecutorsToAllocate && canAllocate && !stopped) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <= reservedPVCs) {
+ canAllocate = false
+ } else {
+ val spec = buildExecutorPod(applicationId, resourceProfileId,
reusablePVCs)
Review Comment:
**1. [High] A batch that reaches `spark.kubernetes.allocation.maximum` is
discarded, including the pods below the limit**
`buildExecutorPod` throws at the limit (L619), and this loop runs before
anything is submitted, so the specs built so far in this batch are lost. Their
IDs stay consumed, so every later round throws on its first build.
With `spark.executor.instances=10`, `spark.kubernetes.allocation.maximum=10`
and the default batch size of 20:
- Serial creates 9 pods and then hits the limit.
- Parallel builds IDs 1 to 9, throws on ID 10 and creates nothing.
`registerPodCreationFailure` is never called, so the application does not fail
either. It waits with no executors, and the only trace is the WARN at
`ExecutorPodsSnapshotsStoreImpl.scala:148`.
A limit above the batch size loses the last batch in the same way:
`maximum=100` with `instances=100` gives 80 pods instead of 99.
`Parallel allocation respects the maximum executor pod ID`
(`ExecutorPodsAllocatorSuite.scala:1434`) pins this with `verify(podResource,
never()).create()`, and neither the docs nor the PR description mention the
difference. Could the limit end this loop instead, so the specs already built
are submitted and awaited, and the exception is raised after the batch? A
feature-step failure can still discard the batch, as the comment at L688
intends.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -518,86 +540,220 @@ class ExecutorPodsAllocator(
applicationId: String,
resourceProfileId: Int,
pvcsInUse: Set[String]): Unit = {
- // Check reusable PVCs for this executor allocation batch
- val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
- for ( _ <- 0 until numExecutorsToAllocate) {
- if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
- logInfo(
- log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
- return
+ if (podAllocationParallelEnabled) {
+ if (stopped) return
+ requestNewExecutorsParallel(numExecutorsToAllocate, applicationId,
+ resourceProfileId, pvcsInUse)
+ } else {
+ // Check reusable PVCs for this executor allocation batch
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ for ( _ <- 0 until numExecutorsToAllocate) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
+ logInfo(
+ log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
+ return
+ }
+ val (newExecutorId, podWithAttachedContainer, resources) =
+ buildExecutorPod(applicationId, resourceProfileId, reusablePVCs)
+ val optCreatedExecutorPod = try {
+ Some(kubernetesClient
+ .pods()
+ .inNamespace(namespace)
+ .resource(podWithAttachedContainer)
+ .create())
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to create executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ None
+ }
+ optCreatedExecutorPod.foreach { createdExecutorPod =>
+ try {
+ addOwnerReference(createdExecutorPod, resources)
+ resources
+ .filter(_.getKind == "PersistentVolumeClaim")
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty)
{
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME,
pvc.getMetadata.getName)} with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+ kubernetesClient
+ .persistentVolumeClaims()
+ .inNamespace(namespace)
+ .resource(pvc)
+ .create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
+ logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to add owner reference or create PVC for
executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ kubernetesClient.pods()
+ .inNamespace(namespace)
+ .resource(createdExecutorPod)
+ .delete()
+ throw e
+ }
+ }
}
- val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
- if (newExecutorId >= podAllocationMaximum) {
- throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ }
+
+ // Build on the allocation thread: feature steps and PVC reuse mutate shared
state.
+ private def buildExecutorPod(
+ applicationId: String,
+ resourceProfileId: Int,
+ reusablePVCs: mutable.Buffer[PersistentVolumeClaim]): (Long, Pod,
Seq[HasMetadata]) = {
+ val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
+ if (newExecutorId >= podAllocationMaximum) {
+ throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ val executorConf = KubernetesConf.createExecutorConf(
+ conf,
+ newExecutorId.toString,
+ applicationId,
+ driverPod,
+ resourceProfileId,
+ Option(secMgr.getSecretKey()))
+ val resolvedExecutorSpec = executorBuilder.buildFromFeatures(executorConf,
+ kubernetesClient, rpIdToResourceProfile(resourceProfileId))
+ val executorPod = resolvedExecutorSpec.pod
+ val podWithAttachedContainer = new PodBuilder(executorPod.pod)
+ .editOrNewSpec()
+ .addToContainers(executorPod.container)
+ .endSpec()
+ .build()
+ val resources = replacePVCsIfNeeded(
+ podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
+ (newExecutorId.toLong, podWithAttachedContainer, resources)
+ }
+
+ // Only the Kubernetes calls run on worker threads; allocator maps are
updated by the caller.
+ private def createExecutorPod(pod: Pod, resources: Seq[HasMetadata]):
Boolean = {
+ if (stopped) return false
+ val createdExecutorPod =
+ kubernetesClient.pods().inNamespace(namespace).resource(pod).create()
+ var succeeded = false
+ var failed = true
+ try {
+ if (!stopped) {
Review Comment:
**4. [Low] With pod retention, a stop during the Pod POST can leave a pod
whose PVC was never created**
The conditions are `spark.kubernetes.executor.deleteOnTermination=false`,
`spark.kubernetes.driver.ownPersistentVolumeClaim=false` and on-demand PVCs,
with `stopped` set while L646 is in flight. This check then skips the PVC
creation, and `failed` and `succeeded` are both false, so L670 keeps the pod.
The backend does not delete it either
(`KubernetesClusterSchedulerBackend.scala:187`).
The pod already references the claim
(`MountVolumesFeatureStep.scala:118-122`), so it stays Pending and holds quota
until its owner reference to the driver pod collects it. Without
`spark.kubernetes.driver.pod.name`, someone has to delete it. With several
PVCs, the `takeWhile` at L652 leaves the same state with only some of the
claims.
A PVC creation failure in this method deletes the pod regardless of
retention. Could a pod whose PVCs were not all created get the same treatment?
`Stopping drains API responses` (`ExecutorPodsAllocatorSuite.scala:1535`)
covers a spec without a PVC and a PVC POST in flight, but not this case. With
the default `ownPersistentVolumeClaim=true` the backend deletes the PVCs by
label anyway, so this is limited to the non-default setting.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala:
##########
@@ -645,6 +645,41 @@ private[spark] object Config extends Logging {
.checkValue(value => value > 0, "Allocation maximum should be a positive
integer")
.createWithDefault(Int.MaxValue)
+ val KUBERNETES_ALLOCATION_PARALLEL_ENABLED =
+ ConfigBuilder("spark.kubernetes.allocation.batch.parallel.enabled")
+ .doc("When true, executor pods are created in parallel using a thread
pool sized by " +
+ "spark.kubernetes.allocation.batch.concurrency. This can speed up
executor allocation " +
+ "when Kubernetes API latency is high. When false, pods are created
serially. " +
+ "Only applies when spark.kubernetes.allocation.pods.allocator is
direct.")
+ .version("4.4.0")
+ .booleanConf
+ .createWithDefault(false)
+
+ val KUBERNETES_ALLOCATION_BATCH_CONCURRENCY =
+ ConfigBuilder("spark.kubernetes.allocation.batch.concurrency")
+ .doc("Number of concurrent threads used to create executor pods in each
allocation batch. " +
+ "Only takes effect when spark.kubernetes.allocation.pods.allocator is
direct and " +
+ "spark.kubernetes.allocation.batch.parallel.enabled is true. " +
+ "The number of pods requested is still limited by
spark.kubernetes.allocation.batch.size " +
+ "and spark.kubernetes.allocation.maxPendingPods.")
+ .version("4.4.0")
+ .intConf
+ .checkValue(value => value > 0,
+ "Allocation batch concurrency should be a positive integer")
+ .createWithDefault(5)
+
+ val KUBERNETES_ALLOCATION_SHUTDOWN_TIMEOUT =
+ ConfigBuilder("spark.kubernetes.allocation.shutdownTimeout")
+ .doc("Maximum time to wait for in-flight parallel executor pod creation
during shutdown. " +
+ "Queued requests are cancelled. After this timeout, application
cleanup proceeds and " +
+ "late API responses may not be cleaned up by the allocator. This wait
is separate from " +
+ "the snapshot subscriber shutdown timeout and only applies to parallel
allocation " +
+ "when spark.kubernetes.allocation.pods.allocator is direct.")
+ .version("4.4.0")
+ .timeConf(TimeUnit.MILLISECONDS)
+ .checkValue(value => value >= 0, "Allocation shutdown timeout must not
be negative")
+ .createWithDefaultString("20s")
Review Comment:
**2. [Medium] The default timeouts add up to more than the 30s shutdown hook
budget**
Spark's shutdown hooks share `hadoop.service.shutdown.timeout` (30s by
default), and the doc of
`spark.kubernetes.executorSnapshotsSubscribersShutdownGracePeriod` (L974-L979)
asks for its 20s to stay shorter than that, so the rest of the shutdown is not
blocked. This new 20s wait runs in front of it. The docs in this PR say the two
"should fit within the overall application shutdown budget", but the defaults
do not fit on this path:
1. `ExecutorPodsLifecycleManager` calls `sys.exit(11)` on a subscriber
thread (`ExecutorPodsLifecycleManager.scala:95`). That thread never returns, so
`snapshotsStore.stop()` always uses its full 20s.
2. A create is still in flight, and `stopAllocation` waits for more than 10s
first. For example, a webhook with `failurePolicy: Fail` that keeps timing out
holds a POST that long, because the client retries on 5xx.
3. At 30s Hadoop cancels the hook with an interrupt. `ThreadUtils.shutdown`
rethrows the `InterruptedException`, `Utils.tryLogNonFatalError` does not catch
it, and the whole stop unwinds from
`KubernetesClusterSchedulerBackend.scala:156`.
That skips deleting the Service, PVCs, pods and ConfigMap, and closing the
client. The rest of `SparkContext.stop` is skipped too, including
`listenerBus.stop` and `_eventLogger.stop`, so the event log stays
`.inprogress`. Before this PR the same path had about 10s left after the grace
period.
A normal stop does not reach 40s, because the snapshot store wait is short
there. Lowering the default alone would give up the late-create cleanup this
wait exists for, so the two waits probably need a shared deadline or a single
budget (see item 12).
##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackendSuite.scala:
##########
@@ -235,6 +235,48 @@ class KubernetesClusterSchedulerBackendSuite extends
SparkFunSuite with BeforeAn
verify(kubernetesClient).close()
}
+ Seq(false, true).foreach { deleteExecutors =>
+ test(s"Stop allocation before closing the client:
deleteExecutors=$deleteExecutors") {
+ val conf = sparkConf.clone.set(KUBERNETES_DELETE_EXECUTORS,
deleteExecutors)
+ when(sc.conf).thenReturn(conf)
+ val backend = new KubernetesClusterSchedulerBackend(taskScheduler, sc,
kubernetesClient,
+ schedulerExecutorService, eventQueue, podAllocator, lifecycleManager,
+ watchEvents, pollEvents)
+ backend.stop()
+ val order = inOrder(podAllocator, eventQueue, kubernetesClient)
+ order.verify(podAllocator).stopAllocation()
+ order.verify(eventQueue).stop()
+ if (deleteExecutors) {
+ order.verify(podAllocator).stop(TEST_SPARK_APP_ID)
+ } else {
+ verify(podAllocator, never()).stop(any())
+ }
+ order.verify(kubernetesClient).close()
+ }
+ }
+
+ test("Interrupted allocation shutdown still cleans resources and restores
interruption") {
+ doAnswer((_: org.mockito.invocation.InvocationOnMock) => {
+ Thread.currentThread().interrupt()
+ // Model an interrupted awaitTermination, which clears the flag before
throwing.
+ assert(Thread.interrupted())
+ throw new InterruptedException("allocation shutdown interrupted")
+ }).when(podAllocator).stopAllocation()
+ doAnswer((_: org.mockito.invocation.InvocationOnMock) => {
+ assert(!Thread.currentThread().isInterrupted)
Review Comment:
**9. [Low] This assertion cannot fail the test**
`eventQueue.stop()` runs inside `Utils.tryLogNonFatalError`
(`KubernetesClusterSchedulerBackend.scala:155-157`), so a `TestFailedException`
from this Answer is only logged. If the catch block restored the flag right
away, `verify(eventQueue).stop()` would still pass because Mockito records the
call before running the Answer, and so would the `assert` at L274. That is the
regression this test is meant to catch. Recording the value and asserting after
`stop()` would make it effective:
```scala
var interruptedDuringCleanup = false
doAnswer((_: org.mockito.invocation.InvocationOnMock) => {
interruptedDuringCleanup = Thread.currentThread().isInterrupted
null
}).when(eventQueue).stop()
...
schedulerBackendUnderTest.stop()
assert(!interruptedDuringCleanup)
```
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -654,7 +810,34 @@ class ExecutorPodsAllocator(
}
}
+ override def stopAllocation(): Unit = synchronized {
+ if (!podAllocationParallelEnabled || stopped) return
+ stopped = true
+ podCreationThreadPool.foreach { pool =>
+ // Do not interrupt an in-flight create: Fabric8 stops waiting for the
response on interrupt,
+ // but the API server may still create the resource. Let workers observe
the result and clean
+ // up before the backend deletes PVCs and closes the client. Queued
tasks need not run.
+ pool.shutdown()
+ val queuedTasks = new ArrayList[Runnable]()
+ pool.getQueue.drainTo(queuedTasks)
+ queuedTasks.asScala.foreach {
+ case future: Future[_] => future.cancel(false)
+ case _ =>
+ }
+ // A worker may observe a nonempty queue after shutdown, then block in
take() after
+ // drainTo empties it. Signal idle workers again without interrupting
active API calls.
+ pool.shutdown()
+ val timeout = podAllocationShutdownTimeout
+ if (!pool.awaitTermination(timeout, TimeUnit.MILLISECONDS)) {
Review Comment:
**8. [Nit] `shutdownTimeout=0` can warn about late responses when nothing is
in flight**
The docs say zero skips the wait. `pool.shutdown()` only signals idle
workers, which exit asynchronously, so `awaitTermination(0)` returns false
unless the pool has already terminated. After any batch the fixed pool has idle
workers, so a stop with no active request can still log "late API responses may
leave resources behind". Adding `pool.getActiveCount > 0` to this condition
would keep the warning for the real case. The `0s` case of `Allocation shutdown
uses its own timeout and is idempotent`
(`ExecutorPodsAllocatorSuite.scala:1669`) does not look at this log.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/AbstractPodsAllocator.scala:
##########
@@ -87,6 +87,16 @@ abstract class AbstractPodsAllocator {
@Since("3.3.0")
def start(applicationId: String, schedulerBackend:
KubernetesClusterSchedulerBackend): Unit
+ /**
+ * Stop allocating executors before application resource cleanup, including
when pods are
Review Comment:
**10. [Nit] The new Scaladoc does not hold for the default mode**
`ExecutorPodsAllocator.stopAllocation` returns immediately unless parallel
allocation is enabled (`ExecutorPodsAllocator.scala:814`), and `Disabled
parallel allocation ignores concurrency and the new stop hook`
(`ExecutorPodsAllocatorSuite.scala:1135`) pins that a serial allocator still
creates pods after this call. The default implementation here is also empty, so
the Deployment and StatefulSet allocators do nothing. Could this describe it as
an optional hook called before cleanup, and say that the direct allocator acts
on it only in parallel mode?
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -518,86 +540,220 @@ class ExecutorPodsAllocator(
applicationId: String,
resourceProfileId: Int,
pvcsInUse: Set[String]): Unit = {
- // Check reusable PVCs for this executor allocation batch
- val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
- for ( _ <- 0 until numExecutorsToAllocate) {
- if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
- logInfo(
- log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
- return
+ if (podAllocationParallelEnabled) {
+ if (stopped) return
+ requestNewExecutorsParallel(numExecutorsToAllocate, applicationId,
+ resourceProfileId, pvcsInUse)
+ } else {
+ // Check reusable PVCs for this executor allocation batch
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ for ( _ <- 0 until numExecutorsToAllocate) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
+ logInfo(
+ log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
+ return
+ }
+ val (newExecutorId, podWithAttachedContainer, resources) =
+ buildExecutorPod(applicationId, resourceProfileId, reusablePVCs)
+ val optCreatedExecutorPod = try {
+ Some(kubernetesClient
+ .pods()
+ .inNamespace(namespace)
+ .resource(podWithAttachedContainer)
+ .create())
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to create executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ None
+ }
+ optCreatedExecutorPod.foreach { createdExecutorPod =>
+ try {
+ addOwnerReference(createdExecutorPod, resources)
+ resources
+ .filter(_.getKind == "PersistentVolumeClaim")
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty)
{
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME,
pvc.getMetadata.getName)} with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+ kubernetesClient
+ .persistentVolumeClaims()
+ .inNamespace(namespace)
+ .resource(pvc)
+ .create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
+ logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to add owner reference or create PVC for
executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ kubernetesClient.pods()
+ .inNamespace(namespace)
+ .resource(createdExecutorPod)
+ .delete()
+ throw e
+ }
+ }
}
- val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
- if (newExecutorId >= podAllocationMaximum) {
- throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ }
+
+ // Build on the allocation thread: feature steps and PVC reuse mutate shared
state.
+ private def buildExecutorPod(
+ applicationId: String,
+ resourceProfileId: Int,
+ reusablePVCs: mutable.Buffer[PersistentVolumeClaim]): (Long, Pod,
Seq[HasMetadata]) = {
+ val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
+ if (newExecutorId >= podAllocationMaximum) {
+ throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ val executorConf = KubernetesConf.createExecutorConf(
+ conf,
+ newExecutorId.toString,
+ applicationId,
+ driverPod,
+ resourceProfileId,
+ Option(secMgr.getSecretKey()))
+ val resolvedExecutorSpec = executorBuilder.buildFromFeatures(executorConf,
+ kubernetesClient, rpIdToResourceProfile(resourceProfileId))
+ val executorPod = resolvedExecutorSpec.pod
+ val podWithAttachedContainer = new PodBuilder(executorPod.pod)
+ .editOrNewSpec()
+ .addToContainers(executorPod.container)
+ .endSpec()
+ .build()
+ val resources = replacePVCsIfNeeded(
+ podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
+ (newExecutorId.toLong, podWithAttachedContainer, resources)
+ }
+
+ // Only the Kubernetes calls run on worker threads; allocator maps are
updated by the caller.
+ private def createExecutorPod(pod: Pod, resources: Seq[HasMetadata]):
Boolean = {
+ if (stopped) return false
+ val createdExecutorPod =
+ kubernetesClient.pods().inNamespace(namespace).resource(pod).create()
+ var succeeded = false
+ var failed = true
+ try {
+ if (!stopped) {
+ addOwnerReference(createdExecutorPod, resources)
+ resources.iterator.filter(_.getKind ==
"PersistentVolumeClaim").takeWhile(_ => !stopped)
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+
kubernetesClient.persistentVolumeClaims().inNamespace(namespace).resource(pvc).create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ succeeded = !stopped
}
- val executorConf = KubernetesConf.createExecutorConf(
- conf,
- newExecutorId.toString,
- applicationId,
- driverPod,
- resourceProfileId,
- Option(secMgr.getSecretKey()))
- val resolvedExecutorSpec =
executorBuilder.buildFromFeatures(executorConf,
- kubernetesClient, rpIdToResourceProfile(resourceProfileId))
- val executorPod = resolvedExecutorSpec.pod
- val podWithAttachedContainer = new PodBuilder(executorPod.pod)
- .editOrNewSpec()
- .addToContainers(executorPod.container)
- .endSpec()
- .build()
- val resources = replacePVCsIfNeeded(
- podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
- val optCreatedExecutorPod = try {
- Some(kubernetesClient
- .pods()
- .inNamespace(namespace)
- .resource(podWithAttachedContainer)
- .create())
- } catch {
- case NonFatal(e) =>
- // Register failure with global tracker if lifecycle manager is
available
- val failureCount = registerPodCreationFailure()
- logError(log"Failed to create executor pod
${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
- log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
- None
+ failed = false
+ succeeded
+ } finally {
+ // Clean up failed creations and, unless pods should be retained,
requests finishing at stop.
+ if (failed || (!succeeded && shouldDeleteExecutors)) {
+ Utils.tryLogNonFatalError {
+
kubernetesClient.pods().inNamespace(namespace).resource(createdExecutorPod).delete()
+ }
}
- optCreatedExecutorPod.foreach { createdExecutorPod =>
+ }
+ }
+
+ private def requestNewExecutorsParallel(
+ numExecutorsToAllocate: Int,
+ applicationId: String,
+ resourceProfileId: Int,
+ pvcsInUse: Set[String]): Unit = {
+ val batchStartTime = clock.getTimeMillis()
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ val podSpecs = mutable.ArrayBuffer.empty[(Long, Pod, Seq[HasMetadata])]
+ var reservedPVCs = PVC_COUNTER.get().toLong
+ var canAllocate = true
+ // Build the complete batch before submitting it so a feature-step failure
cannot leave
+ // already-submitted pods untracked. Reserve PVC capacity without counting
it as created.
+ while (podSpecs.size < numExecutorsToAllocate && canAllocate && !stopped) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <= reservedPVCs) {
+ canAllocate = false
+ } else {
+ val spec = buildExecutorPod(applicationId, resourceProfileId,
reusablePVCs)
+ // As in serial allocation, admit the whole executor before counting
its PVCs. One
+ // executor may need multiple PVCs, so a strict per-PVC cap could
prevent any progress.
+ podSpecs += spec
+ reservedPVCs += spec._3.count(_.getKind == "PersistentVolumeClaim")
+ }
+ }
+ if (!canAllocate) {
+ logInfo(log"Wait to reuse PVCs: ${MDC(LogKeys.COUNT, PVC_COUNTER.get())}
created, " +
+ log"${MDC(LogKeys.TOTAL, reservedPVCs)} including this batch.")
+ }
+
+ val podBuildTotalMs = clock.getTimeMillis() - batchStartTime
+ val futures = podSpecs.iterator.takeWhile(_ => !stopped).flatMap {
+ case (executorId, pod, resources) =>
try {
- addOwnerReference(createdExecutorPod, resources)
- resources
- .filter(_.getKind == "PersistentVolumeClaim")
- .foreach { resource =>
- if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
- addOwnerReference(driverPod.get, Seq(resource))
- }
- val pvc = resource.asInstanceOf[PersistentVolumeClaim]
- logInfo(log"Trying to create PersistentVolumeClaim " +
- log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
- log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
- kubernetesClient
- .persistentVolumeClaims()
- .inNamespace(namespace)
- .resource(pvc)
- .create()
- PVC_COUNTER.incrementAndGet()
+ val future = podCreationThreadPool.get.submit(new
Callable[Option[Long]] {
+ override def call(): Option[Long] = {
+ if (createExecutorPod(pod, resources))
Some(clock.getTimeMillis()) else None
+ }
+ })
+ Some((executorId, future))
+ } catch {
+ case _: RejectedExecutionException if stopped => None
+ }
+ }.toVector
+
+ var succeeded = 0
+ futures.foreach { case (executorId, future) =>
+ if (!stopped) {
+ try {
+ awaitPodCreation(future).foreach { timestamp =>
+ if (!stopped) {
+ newlyCreatedExecutors(executorId) = (resourceProfileId,
timestamp)
+ succeeded += 1
}
- newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
- logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ }
} catch {
case NonFatal(e) =>
- // Register failure with global tracker if lifecycle manager is
available
val failureCount = registerPodCreationFailure()
Review Comment:
**3. [Medium] A persistent PVC failure now costs a whole batch of API calls
and failures per round**
The serial path ends the round on a PVC failure (`throw e` at L606). Here
each future records its own failure and the batch continues, so a PVC POST that
keeps failing (for example, on an exhausted quota) is multiplied by the batch
size. Each attempt is a Pod POST, a PVC POST and a Pod DELETE. With the default
batch size of 20:
| | API writes per round | Failures per round | Rounds until `sys.exit(11)`
with `instances=50` |
|---|---|---|---|
| Serial | 3 | 1 | 101 |
| Parallel | 60 | 20 | 6 |
The limit in that example is `max(3, 2 * 50)` and rounds are about 1s apart,
so the application exits after about 6s instead of about 105s. The extra writes
arrive while the API server is already rejecting requests.
Pod POST failures do not multiply this way, because serial also continues
the batch on them (L571). The `failPVC` case of `Parallel allocation reserves
PVC capacity and retries failures` (`ExecutorPodsAllocatorSuite.scala:1377`)
runs with `maxPVCs=1`, so it tries one pod per round and does not show this.
Could a PVC failure stop the rest of the batch, the way `stopped` does, and
rethrow after the batch as serial does?
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/KubernetesClusterSchedulerBackend.scala:
##########
@@ -132,6 +132,26 @@ private[spark] class KubernetesClusterSchedulerBackend(
super.stop()
}
+ var interrupted = false
+ try {
+ try {
+ Utils.tryLogNonFatalError {
+ podAllocator.stopAllocation()
Review Comment:
**5. [Low] Subscribers keep running after the driver endpoint is gone**
`super.stop()` has already removed the driver endpoint, and polling stops
only in `snapshotsStore.stop()`, which now runs after this wait. While a create
is in flight, the lifecycle subscriber keeps ticking for up to 20s more:
- Each new terminal executor goes to
`driverEndpoint.send(RemoveExecutor(...))`, which throws `Could not find
CoarseGrainedScheduler` and logs a WARN through
`ExecutorPodsSnapshotsStoreImpl.scala:148`.
- If a `PodFailed` in this window passes the failure limit, `sys.exit(11)`
runs. An application that finished its work then exits with code 11. The hook's
`SparkContext.stop` returns at once because a stop is already in progress, so
the JVM ends with `stop()` still waiting here and the Kubernetes cleanup is
skipped.
The second case should be rare, since executors stopped on request end as
`PodSucceeded`. Signaling first and waiting after `snapshotsStore.stop()` would
close this window (see item 12).
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -654,7 +810,34 @@ class ExecutorPodsAllocator(
}
}
+ override def stopAllocation(): Unit = synchronized {
+ if (!podAllocationParallelEnabled || stopped) return
+ stopped = true
+ podCreationThreadPool.foreach { pool =>
+ // Do not interrupt an in-flight create: Fabric8 stops waiting for the
response on interrupt,
+ // but the API server may still create the resource. Let workers observe
the result and clean
+ // up before the backend deletes PVCs and closes the client. Queued
tasks need not run.
+ pool.shutdown()
+ val queuedTasks = new ArrayList[Runnable]()
+ pool.getQueue.drainTo(queuedTasks)
+ queuedTasks.asScala.foreach {
+ case future: Future[_] => future.cancel(false)
+ case _ =>
+ }
+ // A worker may observe a nonempty queue after shutdown, then block in
take() after
+ // drainTo empties it. Signal idle workers again without interrupting
active API calls.
+ pool.shutdown()
Review Comment:
**11. [Low] `drainTo`, `cancel` and the second `shutdown()` are not needed**
`stopped` is set at L815, before `pool.shutdown()`, and a queued task
returns at `if (stopped) return false` (L644) without reaching the API. With
`shutdown()` alone, workers take the queued tasks through the normal `getTask`
path, finish them at once, and the pool terminates by itself. Queued requests
still do not reach the API, active requests are still not interrupted, and the
timeout and idempotence stay the same. I did not find a case that needs the
drain.
The race in [this
thread](https://github.com/apache/spark/pull/59756#discussion_r4396982374)
exists only because `drainTo` goes around `getTask`. Removing the drain also
removes these 9 lines, the 60-line test that writes a private final field
through reflection (`ExecutorPodsAllocatorSuite.scala:1607-1666`), and the path
in item 7. The docs and `Config.scala:674` would then say that queued requests
are skipped instead of cancelled.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -518,86 +540,220 @@ class ExecutorPodsAllocator(
applicationId: String,
resourceProfileId: Int,
pvcsInUse: Set[String]): Unit = {
- // Check reusable PVCs for this executor allocation batch
- val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
- for ( _ <- 0 until numExecutorsToAllocate) {
- if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
- logInfo(
- log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
- return
+ if (podAllocationParallelEnabled) {
+ if (stopped) return
+ requestNewExecutorsParallel(numExecutorsToAllocate, applicationId,
+ resourceProfileId, pvcsInUse)
+ } else {
+ // Check reusable PVCs for this executor allocation batch
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ for ( _ <- 0 until numExecutorsToAllocate) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
+ logInfo(
+ log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
+ return
+ }
+ val (newExecutorId, podWithAttachedContainer, resources) =
+ buildExecutorPod(applicationId, resourceProfileId, reusablePVCs)
+ val optCreatedExecutorPod = try {
+ Some(kubernetesClient
+ .pods()
+ .inNamespace(namespace)
+ .resource(podWithAttachedContainer)
+ .create())
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to create executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ None
+ }
+ optCreatedExecutorPod.foreach { createdExecutorPod =>
+ try {
+ addOwnerReference(createdExecutorPod, resources)
+ resources
+ .filter(_.getKind == "PersistentVolumeClaim")
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty)
{
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME,
pvc.getMetadata.getName)} with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+ kubernetesClient
+ .persistentVolumeClaims()
+ .inNamespace(namespace)
+ .resource(pvc)
+ .create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
+ logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to add owner reference or create PVC for
executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ kubernetesClient.pods()
+ .inNamespace(namespace)
+ .resource(createdExecutorPod)
+ .delete()
+ throw e
+ }
+ }
}
- val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
- if (newExecutorId >= podAllocationMaximum) {
- throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ }
+
+ // Build on the allocation thread: feature steps and PVC reuse mutate shared
state.
+ private def buildExecutorPod(
+ applicationId: String,
+ resourceProfileId: Int,
+ reusablePVCs: mutable.Buffer[PersistentVolumeClaim]): (Long, Pod,
Seq[HasMetadata]) = {
+ val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
+ if (newExecutorId >= podAllocationMaximum) {
+ throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ val executorConf = KubernetesConf.createExecutorConf(
+ conf,
+ newExecutorId.toString,
+ applicationId,
+ driverPod,
+ resourceProfileId,
+ Option(secMgr.getSecretKey()))
+ val resolvedExecutorSpec = executorBuilder.buildFromFeatures(executorConf,
+ kubernetesClient, rpIdToResourceProfile(resourceProfileId))
+ val executorPod = resolvedExecutorSpec.pod
+ val podWithAttachedContainer = new PodBuilder(executorPod.pod)
+ .editOrNewSpec()
+ .addToContainers(executorPod.container)
+ .endSpec()
+ .build()
+ val resources = replacePVCsIfNeeded(
+ podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
+ (newExecutorId.toLong, podWithAttachedContainer, resources)
+ }
+
+ // Only the Kubernetes calls run on worker threads; allocator maps are
updated by the caller.
+ private def createExecutorPod(pod: Pod, resources: Seq[HasMetadata]):
Boolean = {
+ if (stopped) return false
+ val createdExecutorPod =
+ kubernetesClient.pods().inNamespace(namespace).resource(pod).create()
+ var succeeded = false
+ var failed = true
+ try {
+ if (!stopped) {
+ addOwnerReference(createdExecutorPod, resources)
+ resources.iterator.filter(_.getKind ==
"PersistentVolumeClaim").takeWhile(_ => !stopped)
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+
kubernetesClient.persistentVolumeClaims().inNamespace(namespace).resource(pvc).create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ succeeded = !stopped
}
- val executorConf = KubernetesConf.createExecutorConf(
- conf,
- newExecutorId.toString,
- applicationId,
- driverPod,
- resourceProfileId,
- Option(secMgr.getSecretKey()))
- val resolvedExecutorSpec =
executorBuilder.buildFromFeatures(executorConf,
- kubernetesClient, rpIdToResourceProfile(resourceProfileId))
- val executorPod = resolvedExecutorSpec.pod
- val podWithAttachedContainer = new PodBuilder(executorPod.pod)
- .editOrNewSpec()
- .addToContainers(executorPod.container)
- .endSpec()
- .build()
- val resources = replacePVCsIfNeeded(
- podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
- val optCreatedExecutorPod = try {
- Some(kubernetesClient
- .pods()
- .inNamespace(namespace)
- .resource(podWithAttachedContainer)
- .create())
- } catch {
- case NonFatal(e) =>
- // Register failure with global tracker if lifecycle manager is
available
- val failureCount = registerPodCreationFailure()
- logError(log"Failed to create executor pod
${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
- log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
- None
+ failed = false
+ succeeded
+ } finally {
+ // Clean up failed creations and, unless pods should be retained,
requests finishing at stop.
+ if (failed || (!succeeded && shouldDeleteExecutors)) {
+ Utils.tryLogNonFatalError {
+
kubernetesClient.pods().inNamespace(namespace).resource(createdExecutorPod).delete()
+ }
}
- optCreatedExecutorPod.foreach { createdExecutorPod =>
+ }
+ }
+
+ private def requestNewExecutorsParallel(
+ numExecutorsToAllocate: Int,
+ applicationId: String,
+ resourceProfileId: Int,
+ pvcsInUse: Set[String]): Unit = {
+ val batchStartTime = clock.getTimeMillis()
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ val podSpecs = mutable.ArrayBuffer.empty[(Long, Pod, Seq[HasMetadata])]
+ var reservedPVCs = PVC_COUNTER.get().toLong
+ var canAllocate = true
+ // Build the complete batch before submitting it so a feature-step failure
cannot leave
+ // already-submitted pods untracked. Reserve PVC capacity without counting
it as created.
+ while (podSpecs.size < numExecutorsToAllocate && canAllocate && !stopped) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <= reservedPVCs) {
+ canAllocate = false
+ } else {
+ val spec = buildExecutorPod(applicationId, resourceProfileId,
reusablePVCs)
+ // As in serial allocation, admit the whole executor before counting
its PVCs. One
+ // executor may need multiple PVCs, so a strict per-PVC cap could
prevent any progress.
+ podSpecs += spec
+ reservedPVCs += spec._3.count(_.getKind == "PersistentVolumeClaim")
+ }
+ }
+ if (!canAllocate) {
+ logInfo(log"Wait to reuse PVCs: ${MDC(LogKeys.COUNT, PVC_COUNTER.get())}
created, " +
+ log"${MDC(LogKeys.TOTAL, reservedPVCs)} including this batch.")
+ }
+
+ val podBuildTotalMs = clock.getTimeMillis() - batchStartTime
+ val futures = podSpecs.iterator.takeWhile(_ => !stopped).flatMap {
+ case (executorId, pod, resources) =>
try {
- addOwnerReference(createdExecutorPod, resources)
- resources
- .filter(_.getKind == "PersistentVolumeClaim")
- .foreach { resource =>
- if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
- addOwnerReference(driverPod.get, Seq(resource))
- }
- val pvc = resource.asInstanceOf[PersistentVolumeClaim]
- logInfo(log"Trying to create PersistentVolumeClaim " +
- log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
- log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
- kubernetesClient
- .persistentVolumeClaims()
- .inNamespace(namespace)
- .resource(pvc)
- .create()
- PVC_COUNTER.incrementAndGet()
+ val future = podCreationThreadPool.get.submit(new
Callable[Option[Long]] {
+ override def call(): Option[Long] = {
+ if (createExecutorPod(pod, resources))
Some(clock.getTimeMillis()) else None
+ }
+ })
+ Some((executorId, future))
+ } catch {
+ case _: RejectedExecutionException if stopped => None
+ }
+ }.toVector
+
+ var succeeded = 0
+ futures.foreach { case (executorId, future) =>
+ if (!stopped) {
+ try {
+ awaitPodCreation(future).foreach { timestamp =>
+ if (!stopped) {
+ newlyCreatedExecutors(executorId) = (resourceProfileId,
timestamp)
+ succeeded += 1
}
- newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
- logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ }
} catch {
case NonFatal(e) =>
Review Comment:
**7. [Low] A cancelled queued request can be counted as a pod creation
failure**
If the caller is waiting on a future that `stopAllocation` cancels (L824),
the `CancellationException` comes back wrapped in a `SparkException`
(`ThreadUtils.scala:399-401`). L753 does not match it and this catch has no
`stopped` check, so a pod whose request was never sent gets an ERROR log and
one registered failure.
It can happen at most once per stop and the window is narrow (the future
must still be queued while the caller waits on it), so the real cost is a
misleading ERROR. A `stopped` check here would fix it, and removing the drain
(item 11) removes this path.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/ExecutorPodsAllocator.scala:
##########
@@ -518,86 +540,220 @@ class ExecutorPodsAllocator(
applicationId: String,
resourceProfileId: Int,
pvcsInUse: Set[String]): Unit = {
- // Check reusable PVCs for this executor allocation batch
- val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
- for ( _ <- 0 until numExecutorsToAllocate) {
- if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
- logInfo(
- log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
- return
+ if (podAllocationParallelEnabled) {
+ if (stopped) return
+ requestNewExecutorsParallel(numExecutorsToAllocate, applicationId,
+ resourceProfileId, pvcsInUse)
+ } else {
+ // Check reusable PVCs for this executor allocation batch
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ for ( _ <- 0 until numExecutorsToAllocate) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <=
PVC_COUNTER.get()) {
+ logInfo(
+ log"Wait to reuse one of the existing ${MDC(LogKeys.COUNT,
PVC_COUNTER.get())} PVCs.")
+ return
+ }
+ val (newExecutorId, podWithAttachedContainer, resources) =
+ buildExecutorPod(applicationId, resourceProfileId, reusablePVCs)
+ val optCreatedExecutorPod = try {
+ Some(kubernetesClient
+ .pods()
+ .inNamespace(namespace)
+ .resource(podWithAttachedContainer)
+ .create())
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to create executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ None
+ }
+ optCreatedExecutorPod.foreach { createdExecutorPod =>
+ try {
+ addOwnerReference(createdExecutorPod, resources)
+ resources
+ .filter(_.getKind == "PersistentVolumeClaim")
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty)
{
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME,
pvc.getMetadata.getName)} with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+ kubernetesClient
+ .persistentVolumeClaims()
+ .inNamespace(namespace)
+ .resource(pvc)
+ .create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
+ logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ } catch {
+ case NonFatal(e) =>
+ // Register failure with global tracker if lifecycle manager is
available
+ val failureCount = registerPodCreationFailure()
+ logError(log"Failed to add owner reference or create PVC for
executor pod " +
+ log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
+ kubernetesClient.pods()
+ .inNamespace(namespace)
+ .resource(createdExecutorPod)
+ .delete()
+ throw e
+ }
+ }
}
- val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
- if (newExecutorId >= podAllocationMaximum) {
- throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ }
+
+ // Build on the allocation thread: feature steps and PVC reuse mutate shared
state.
+ private def buildExecutorPod(
+ applicationId: String,
+ resourceProfileId: Int,
+ reusablePVCs: mutable.Buffer[PersistentVolumeClaim]): (Long, Pod,
Seq[HasMetadata]) = {
+ val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
+ if (newExecutorId >= podAllocationMaximum) {
+ throw new SparkException(s"Exceed the pod creation limit:
$podAllocationMaximum")
+ }
+ val executorConf = KubernetesConf.createExecutorConf(
+ conf,
+ newExecutorId.toString,
+ applicationId,
+ driverPod,
+ resourceProfileId,
+ Option(secMgr.getSecretKey()))
+ val resolvedExecutorSpec = executorBuilder.buildFromFeatures(executorConf,
+ kubernetesClient, rpIdToResourceProfile(resourceProfileId))
+ val executorPod = resolvedExecutorSpec.pod
+ val podWithAttachedContainer = new PodBuilder(executorPod.pod)
+ .editOrNewSpec()
+ .addToContainers(executorPod.container)
+ .endSpec()
+ .build()
+ val resources = replacePVCsIfNeeded(
+ podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
+ (newExecutorId.toLong, podWithAttachedContainer, resources)
+ }
+
+ // Only the Kubernetes calls run on worker threads; allocator maps are
updated by the caller.
+ private def createExecutorPod(pod: Pod, resources: Seq[HasMetadata]):
Boolean = {
+ if (stopped) return false
+ val createdExecutorPod =
+ kubernetesClient.pods().inNamespace(namespace).resource(pod).create()
+ var succeeded = false
+ var failed = true
+ try {
+ if (!stopped) {
+ addOwnerReference(createdExecutorPod, resources)
+ resources.iterator.filter(_.getKind ==
"PersistentVolumeClaim").takeWhile(_ => !stopped)
+ .foreach { resource =>
+ if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
+ addOwnerReference(driverPod.get, Seq(resource))
+ }
+ val pvc = resource.asInstanceOf[PersistentVolumeClaim]
+ logInfo(log"Trying to create PersistentVolumeClaim " +
+ log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
+ log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
+
kubernetesClient.persistentVolumeClaims().inNamespace(namespace).resource(pvc).create()
+ PVC_COUNTER.incrementAndGet()
+ }
+ succeeded = !stopped
}
- val executorConf = KubernetesConf.createExecutorConf(
- conf,
- newExecutorId.toString,
- applicationId,
- driverPod,
- resourceProfileId,
- Option(secMgr.getSecretKey()))
- val resolvedExecutorSpec =
executorBuilder.buildFromFeatures(executorConf,
- kubernetesClient, rpIdToResourceProfile(resourceProfileId))
- val executorPod = resolvedExecutorSpec.pod
- val podWithAttachedContainer = new PodBuilder(executorPod.pod)
- .editOrNewSpec()
- .addToContainers(executorPod.container)
- .endSpec()
- .build()
- val resources = replacePVCsIfNeeded(
- podWithAttachedContainer,
resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
- val optCreatedExecutorPod = try {
- Some(kubernetesClient
- .pods()
- .inNamespace(namespace)
- .resource(podWithAttachedContainer)
- .create())
- } catch {
- case NonFatal(e) =>
- // Register failure with global tracker if lifecycle manager is
available
- val failureCount = registerPodCreationFailure()
- logError(log"Failed to create executor pod
${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
- log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
- None
+ failed = false
+ succeeded
+ } finally {
+ // Clean up failed creations and, unless pods should be retained,
requests finishing at stop.
+ if (failed || (!succeeded && shouldDeleteExecutors)) {
+ Utils.tryLogNonFatalError {
+
kubernetesClient.pods().inNamespace(namespace).resource(createdExecutorPod).delete()
+ }
}
- optCreatedExecutorPod.foreach { createdExecutorPod =>
+ }
+ }
+
+ private def requestNewExecutorsParallel(
+ numExecutorsToAllocate: Int,
+ applicationId: String,
+ resourceProfileId: Int,
+ pvcsInUse: Set[String]): Unit = {
+ val batchStartTime = clock.getTimeMillis()
+ val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
+ val podSpecs = mutable.ArrayBuffer.empty[(Long, Pod, Seq[HasMetadata])]
+ var reservedPVCs = PVC_COUNTER.get().toLong
+ var canAllocate = true
+ // Build the complete batch before submitting it so a feature-step failure
cannot leave
+ // already-submitted pods untracked. Reserve PVC capacity without counting
it as created.
+ while (podSpecs.size < numExecutorsToAllocate && canAllocate && !stopped) {
+ if (reusablePVCs.isEmpty && podAllocOnPVC && maxPVCs <= reservedPVCs) {
+ canAllocate = false
+ } else {
+ val spec = buildExecutorPod(applicationId, resourceProfileId,
reusablePVCs)
+ // As in serial allocation, admit the whole executor before counting
its PVCs. One
+ // executor may need multiple PVCs, so a strict per-PVC cap could
prevent any progress.
+ podSpecs += spec
+ reservedPVCs += spec._3.count(_.getKind == "PersistentVolumeClaim")
+ }
+ }
+ if (!canAllocate) {
+ logInfo(log"Wait to reuse PVCs: ${MDC(LogKeys.COUNT, PVC_COUNTER.get())}
created, " +
+ log"${MDC(LogKeys.TOTAL, reservedPVCs)} including this batch.")
+ }
+
+ val podBuildTotalMs = clock.getTimeMillis() - batchStartTime
+ val futures = podSpecs.iterator.takeWhile(_ => !stopped).flatMap {
+ case (executorId, pod, resources) =>
try {
- addOwnerReference(createdExecutorPod, resources)
- resources
- .filter(_.getKind == "PersistentVolumeClaim")
- .foreach { resource =>
- if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
- addOwnerReference(driverPod.get, Seq(resource))
- }
- val pvc = resource.asInstanceOf[PersistentVolumeClaim]
- logInfo(log"Trying to create PersistentVolumeClaim " +
- log"${MDC(LogKeys.PVC_METADATA_NAME, pvc.getMetadata.getName)}
with " +
- log"StorageClass ${MDC(LogKeys.CLASS_NAME,
pvc.getSpec.getStorageClassName)}")
- kubernetesClient
- .persistentVolumeClaims()
- .inNamespace(namespace)
- .resource(pvc)
- .create()
- PVC_COUNTER.incrementAndGet()
+ val future = podCreationThreadPool.get.submit(new
Callable[Option[Long]] {
+ override def call(): Option[Long] = {
+ if (createExecutorPod(pod, resources))
Some(clock.getTimeMillis()) else None
+ }
+ })
+ Some((executorId, future))
+ } catch {
+ case _: RejectedExecutionException if stopped => None
+ }
+ }.toVector
+
+ var succeeded = 0
+ futures.foreach { case (executorId, future) =>
+ if (!stopped) {
+ try {
+ awaitPodCreation(future).foreach { timestamp =>
+ if (!stopped) {
+ newlyCreatedExecutors(executorId) = (resourceProfileId,
timestamp)
+ succeeded += 1
}
- newlyCreatedExecutors(newExecutorId) = (resourceProfileId,
clock.getTimeMillis())
- logDebug(s"Requested executor with id $newExecutorId from
Kubernetes.")
+ }
} catch {
case NonFatal(e) =>
- // Register failure with global tracker if lifecycle manager is
available
val failureCount = registerPodCreationFailure()
- logError(log"Failed to add owner reference or create PVC for
executor pod " +
- log"${MDC(LogKeys.EXECUTOR_ID, newExecutorId)}. " +
+ logError(log"Failed to create executor pod
${MDC(LogKeys.EXECUTOR_ID, executorId)}. " +
log"Total failures: ${MDC(LogKeys.TOTAL, failureCount)}", e)
- kubernetesClient.pods()
- .inNamespace(namespace)
- .resource(createdExecutorPod)
- .delete()
- throw e
}
}
}
+ logDebug(log"Parallel pod allocation: submitted=${MDC(LogKeys.COUNT,
futures.size)}, " +
+ log"succeeded=${MDC(LogKeys.TOTAL, succeeded)}, " +
+ log"podBuildTotalMs=${MDC(LogKeys.DURATION, podBuildTotalMs)}, " +
+ log"batchTotalMs=${MDC(LogKeys.TOTAL_TIME, clock.getTimeMillis() -
batchStartTime)}")
+ }
+
+ private def awaitPodCreation(future: Future[Option[Long]]): Option[Long] = {
+ // Keep waiting for live requests during normal allocation, so late
successes stay tracked.
+ // At shutdown the worker owns cleanup; let the snapshot callback exit
even if the API request
+ // outlives the grace period, rather than blocking snapshot-store shutdown
for another period.
+ while (!stopped) {
Review Comment:
**12. [Low] Could this be one wait instead of two?**
If the hook only signals (`stopped = true` and `pool.shutdown()`) and the
snapshot callback waits for its futures without this polling loop, the existing
subscriber grace period already covers in-flight requests: the callback is the
only submitter, and `snapshotsStore.stop()` waits for the callback. The
`shutdownNow()` there interrupts only the subscriber threads, not the pool
workers, and the risk after the grace period expires is the same as after this
timeout.
That would remove `spark.kubernetes.allocation.shutdownTimeout`, this loop,
the `if (!stopped)` at L723, the `synchronized` on `stopAllocation`, and the
interrupt handling in the backend with its test. It also removes the 20s + 20s
stacking in item 2. Since the hook would no longer block, the window in item 5
closes as well.
The costs are that the wait could no longer be tuned separately, the
callback needs its own warning because the grace period expires silently, L732
needs a `stopped` check, and the shutdown tests need rewriting.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]