dongjoon-hyun commented on code in PR #58187:
URL: https://github.com/apache/spark/pull/58187#discussion_r4129910495
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
+
+ // SPARK-38079: some of the pre-resources above (e.g. the Kerberos
keytab/delegation token
+ // secrets, the driver Kubernetes credentials secret) carry credentials.
They are created
+ // here without an owner reference -- the owner reference is only added
once the driver pod
+ // exists (see "Refresh all pre-resources' owner references" below), since
Kubernetes owner
+ // references require the owner's UID, which does not exist before the pod
is created. If
+ // this process is terminated abruptly in that window (e.g. Ctrl-C,
SIGTERM, or a fatal JVM
+ // error), the pre-resources would otherwise be silently orphaned in the
namespace forever,
+ // since Kubernetes only garbage-collects via owner references. This
shutdown hook makes a
+ // best-effort attempt to delete them (and the driver pod, if this
submission created it) in
+ // that case. It is a no-op -- and removed entirely, see the `finally`
below -- once the
+ // owner-reference refresh has completed; from that point on the existing
owner references
+ // make normal Kubernetes garbage collection sufficient, and e.g. Ctrl-C
while waiting for
+ // the application to complete must keep today's behavior of just
detaching.
+ val preResourcesApplied = new AtomicBoolean(false)
+ val podCreatedByUs = new AtomicBoolean(false)
+ val cleanupHookRef = shutdownHookOps.addHook(() =>
cleanupOrphanedPreResources(
+ preKubernetesResources, driverPodName, preResourcesApplied.get(),
podCreatedByUs.get()))
var watch: Watch = null
var createdDriverPod: Pod = null
try {
- createdDriverPod =
-
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- logError("Please check \"kubectl auth can-i create pod\" first. It
should be yes.")
- throw e
- }
+ try {
+ kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
+ preResourcesApplied.set(true)
+ } catch {
+ case NonFatal(e) =>
+ logError("Please check \"kubectl auth can-i create [resource]\"
first." +
+ " It should be yes. And please also check your feature step
implementation.")
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
+ throw e
+ }
- // Refresh all pre-resources' owner references
- try {
- addOwnerReference(createdDriverPod, preKubernetesResources)
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.pods().resource(createdDriverPod).delete()
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
+ try {
+ createdDriverPod =
+
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
+ podCreatedByUs.set(true)
+ } catch {
+ case NonFatal(e) =>
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
+ logError("Please check \"kubectl auth can-i create pod\" first. It
should be yes.")
+ throw e
+ }
+
+ // Refresh all pre-resources' owner references
+ try {
+ addOwnerReference(createdDriverPod, preKubernetesResources)
+ kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
+ } catch {
+ case NonFatal(e) =>
+ kubernetesClient.pods().resource(createdDriverPod).delete()
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
+ throw e
+ }
+ } finally {
+ // Past this point, the pre-resources either have an owner reference
(success) or have
+ // already been explicitly deleted by one of the catch blocks above
(failure) -- either
+ // way, the shutdown hook has nothing left to do, so remove it instead
of leaving a no-op
+ // hook registered for the remaining lifetime of this process.
+ shutdownHookOps.removeHook(cleanupHookRef)
Review Comment:
This `finally` removes the cleanup hook even when the pre-resources were not
cleaned up.
- A catch block's own `delete()` throws. For example, the connection drops
during `create()`, and L234 fails for the same reason. This is the case
mentioned in the previous review: "a dropped connection where the catch
cleanup's delete also fails".
- If the pod deletion at L245 throws, L246 is skipped.
- A fatal error (e.g. `OutOfMemoryError`, `StackOverflowError`,
`LinkageError`) doesn't match `case NonFatal(e)`, so no catch block runs, but
this `finally` still removes the hook.
In these cases, the conf ConfigMap and the
keytab/delegation-token/credentials Secrets remain ownerless forever. So, the
comments at L201-202 ("or a fatal JVM error") and L250-253 ("already been
explicitly deleted by one of the catch blocks above") are not accurate either.
Since the deletion ignores 404, it would be safer to remove the hook only on
the success path (after L242), or only after a catch block's cleanup succeeds.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
+
+ // SPARK-38079: some of the pre-resources above (e.g. the Kerberos
keytab/delegation token
+ // secrets, the driver Kubernetes credentials secret) carry credentials.
They are created
+ // here without an owner reference -- the owner reference is only added
once the driver pod
+ // exists (see "Refresh all pre-resources' owner references" below), since
Kubernetes owner
+ // references require the owner's UID, which does not exist before the pod
is created. If
+ // this process is terminated abruptly in that window (e.g. Ctrl-C,
SIGTERM, or a fatal JVM
+ // error), the pre-resources would otherwise be silently orphaned in the
namespace forever,
+ // since Kubernetes only garbage-collects via owner references. This
shutdown hook makes a
+ // best-effort attempt to delete them (and the driver pod, if this
submission created it) in
+ // that case. It is a no-op -- and removed entirely, see the `finally`
below -- once the
+ // owner-reference refresh has completed; from that point on the existing
owner references
+ // make normal Kubernetes garbage collection sufficient, and e.g. Ctrl-C
while waiting for
+ // the application to complete must keep today's behavior of just
detaching.
+ val preResourcesApplied = new AtomicBoolean(false)
+ val podCreatedByUs = new AtomicBoolean(false)
+ val cleanupHookRef = shutdownHookOps.addHook(() =>
cleanupOrphanedPreResources(
+ preKubernetesResources, driverPodName, preResourcesApplied.get(),
podCreatedByUs.get()))
var watch: Watch = null
var createdDriverPod: Pod = null
try {
- createdDriverPod =
-
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- logError("Please check \"kubectl auth can-i create pod\" first. It
should be yes.")
- throw e
- }
+ try {
+ kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
+ preResourcesApplied.set(true)
+ } catch {
+ case NonFatal(e) =>
+ logError("Please check \"kubectl auth can-i create [resource]\"
first." +
+ " It should be yes. And please also check your feature step
implementation.")
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
Review Comment:
`KubernetesClientUtils.configMapNameDriver` is a per-JVM `val`, so all
submissions from the same JVM (e.g. via `InProcessLauncher`) share this
ConfigMap name. Now that it is a pre-resource, this catch block deletes it by
name when the apply fails. If app B is submitted while app A's driver pod is
still pending, B's apply fails with 422 (the ConfigMap is immutable and its
data differs), and this line deletes A's live ConfigMap. As a result, A's
driver is stuck with `configmap ... not found`. On master, B also fails, but
the post-resource catch block deletes only B's pod.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/PodTemplateConfigMapStep.scala:
##########
@@ -74,7 +74,9 @@ private[spark] class PodTemplateConfigMapStep(conf:
KubernetesConf)
}
}
- override def getAdditionalKubernetesResources(): Seq[HasMetadata] = {
+ // SPARK-38079: this config map is mounted by the driver pod, so it must be
created as a
+ // pre-resource (before the pod itself) to avoid a "configmap ... not found"
mount race.
+ override def getAdditionalPreKubernetesResources(): Seq[HasMetadata] = {
Review Comment:
This also changes the output of `KubernetesDriverBuilder` (`@DeveloperApi`),
which `apache/spark-kubernetes-operator` consumes. The operator's `AppInitStep`
creates the pre-resources before the driver pod without any owner reference,
and `AppCleanUpStep` deletes the pre-resources only for a `SchedulingFailure`
attempt. So, if the operator stops abruptly between the pre-resource creation
and the pod creation, and the `SparkApplication` is deleted in the meantime,
the pod template ConfigMap (common via `executorSpec.podTemplateSpec`) and the
credentials/Kerberos Secrets are never deleted. Previously, they were
post-resources created with the driver pod as the owner.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -209,6 +293,54 @@ private[spark] class Client(
log"submission ID ${MDC(SUBMISSION_ID, sId)} into Kubernetes")
}
}
+
+ // SPARK-38079: best-effort cleanup for pre-resources (and the driver pod,
if it was created)
+ // that were left without an owner reference because this process was
terminated abruptly
+ // before the owner-reference refresh in run() completed. See the comment
above the shutdown
+ // hook registration in run() for why this window exists and why it must
stop mattering once
+ // that refresh completes.
+ //
+ // package-private (rather than private) so it can be unit-tested directly
-- actually
+ // triggering a JVM shutdown hook from a test is impractical, so tests
instead call this
+ // method directly with injected pre/post-state and a fake
recoveryClientFactory.
+ private[submit] def cleanupOrphanedPreResources(
+ preResources: Seq[HasMetadata],
+ driverPodName: String,
+ preResourcesApplied: Boolean,
+ podCreatedByUs: Boolean): Unit = {
+ if (preResourcesApplied) {
+ try {
+ Utils.tryWithResource(recoveryClientFactory()) { recoveryClient =>
Review Comment:
This hook runs blocking network I/O without any deadline inside Spark's
single Hadoop-managed shutdown hook. It builds a new client (kubeconfig
auto-configuration, possibly with an exec credential plugin) and deletes the
resources sequentially. If the API server becomes unreachable after the first
apply (e.g. a VPN drop) and the user presses Ctrl-C while `create()` hangs,
each DELETE can take ~40s (10s connection timeout with fabric8 retries). So,
`spark-submit` hangs until Hadoop's shutdown hook timeout (30s by default)
cancels it. A second Ctrl-C is ignored during the shutdown, and lower-priority
hooks such as the temp directory deletion are delayed. Previously, Ctrl-C
exited immediately. Could you bound this with a short request timeout without
retries, or with an explicit deadline?
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
Review Comment:
In terms of the trade-off, this moves the
credentials/keytab/delegation-token Secrets and the conf ConfigMap (plain-text
`spark.properties`) into a window without an owner reference, which doesn't
exist on master. SIGKILL, OOM kill, node loss, or the grace period expiry
between L218 and L242 (where no shutdown hook runs) leaves them behind forever,
without any labels to find them. On the other hand, the benefit is to remove a
transient `FailedMount`. The kubelet retries missing ConfigMap/Secret volumes
with a backoff (up to about 2 minutes) and doesn't fail the pod, so the claim
that this race "can cause the driver pod to fail to start altogether" doesn't
seem to be accurate. The live test in the PR description also reports only
`FailedMount` events.
Have you considered `spec.schedulingGates` (GA since K8s 1.30, while Spark
requires K8s >= 1.35)? Creating the driver pod first with a scheduling gate,
applying all resources once with the owner reference, and then removing the
gate would avoid the ownerless window, the shutdown hook, the second apply, and
the `OwnerReferencesPermissionEnforcement` issue altogether. A pod template
with a fixed `spec.nodeName` would need a fallback.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -209,6 +293,54 @@ private[spark] class Client(
log"submission ID ${MDC(SUBMISSION_ID, sId)} into Kubernetes")
}
}
+
+ // SPARK-38079: best-effort cleanup for pre-resources (and the driver pod,
if it was created)
+ // that were left without an owner reference because this process was
terminated abruptly
+ // before the owner-reference refresh in run() completed. See the comment
above the shutdown
+ // hook registration in run() for why this window exists and why it must
stop mattering once
+ // that refresh completes.
+ //
+ // package-private (rather than private) so it can be unit-tested directly
-- actually
+ // triggering a JVM shutdown hook from a test is impractical, so tests
instead call this
+ // method directly with injected pre/post-state and a fake
recoveryClientFactory.
+ private[submit] def cleanupOrphanedPreResources(
+ preResources: Seq[HasMetadata],
+ driverPodName: String,
+ preResourcesApplied: Boolean,
+ podCreatedByUs: Boolean): Unit = {
+ if (preResourcesApplied) {
+ try {
+ Utils.tryWithResource(recoveryClientFactory()) { recoveryClient =>
+ try {
+ recoveryClient.resourceList(preResources: _*).delete()
Review Comment:
fabric8's `resourceList(...).delete()` deletes the items sequentially and
stops at the first non-404 exception. Since the driver conf ConfigMap is
appended last (L194), any failure in front of it skips it. For example, with
`VolcanoFeatureStep`, if the submitter cannot delete `podgroups` (403), the
conf ConfigMap (plain-text `spark.properties`) remains ownerless with only one
warning log. The same applies to the catch blocks at L224, L234, and L246.
Could you delete each item independently?
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
+
+ // SPARK-38079: some of the pre-resources above (e.g. the Kerberos
keytab/delegation token
+ // secrets, the driver Kubernetes credentials secret) carry credentials.
They are created
+ // here without an owner reference -- the owner reference is only added
once the driver pod
+ // exists (see "Refresh all pre-resources' owner references" below), since
Kubernetes owner
+ // references require the owner's UID, which does not exist before the pod
is created. If
+ // this process is terminated abruptly in that window (e.g. Ctrl-C,
SIGTERM, or a fatal JVM
+ // error), the pre-resources would otherwise be silently orphaned in the
namespace forever,
+ // since Kubernetes only garbage-collects via owner references. This
shutdown hook makes a
+ // best-effort attempt to delete them (and the driver pod, if this
submission created it) in
+ // that case. It is a no-op -- and removed entirely, see the `finally`
below -- once the
+ // owner-reference refresh has completed; from that point on the existing
owner references
+ // make normal Kubernetes garbage collection sufficient, and e.g. Ctrl-C
while waiting for
+ // the application to complete must keep today's behavior of just
detaching.
+ val preResourcesApplied = new AtomicBoolean(false)
+ val podCreatedByUs = new AtomicBoolean(false)
+ val cleanupHookRef = shutdownHookOps.addHook(() =>
cleanupOrphanedPreResources(
+ preKubernetesResources, driverPodName, preResourcesApplied.get(),
podCreatedByUs.get()))
var watch: Watch = null
var createdDriverPod: Pod = null
try {
- createdDriverPod =
-
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- logError("Please check \"kubectl auth can-i create pod\" first. It
should be yes.")
- throw e
- }
+ try {
+ kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
+ preResourcesApplied.set(true)
+ } catch {
+ case NonFatal(e) =>
+ logError("Please check \"kubectl auth can-i create [resource]\"
first." +
+ " It should be yes. And please also check your feature step
implementation.")
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
+ throw e
+ }
- // Refresh all pre-resources' owner references
- try {
- addOwnerReference(createdDriverPod, preKubernetesResources)
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.pods().resource(createdDriverPod).delete()
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
+ try {
+ createdDriverPod =
+
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
+ podCreatedByUs.set(true)
+ } catch {
+ case NonFatal(e) =>
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
Review Comment:
Since the driver conf ConfigMap is always a pre-resource now, this
`delete()` is no longer a no-op for default submissions (on master,
`driverPreKubernetesResources` is empty unless Volcano or a user feature step
is used). If it throws, its exception replaces the original `e`, and the
`logError` hint in the next line is not printed.
For example, if the pod creation is rejected by `ResourceQuota`, PodSecurity
admission, or a webhook, and the submitter has no `delete` permission on
ConfigMaps (the docs only require pod permissions), users will see only `cannot
delete resource "configmaps"` instead of the real cause. The same applies to
L224 and L246. Could you guard these cleanup deletions (e.g. `case NonFatal(t)
=> e.addSuppressed(t)`) and log the hint before the cleanup?
##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -227,6 +230,203 @@ class ClientSuite extends SparkFunSuite with
BeforeAndAfter {
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf2key=conf2value"))
}
+ test("SPARK-38079: driver's own config map is created before the driver pod,
" +
+ "to avoid a mount race") {
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher)
+ submissionClient.run()
+ // The first resourceList(...) call is always the pre-resources
application, which
+ // happens before the driver pod is created. The driver's own config map
must be
+ // included there so that it exists in Kubernetes before the pod that
mounts it.
+ val firstResourceListCall =
createdResourcesArgumentCaptor.getAllValues.get(0)
+ val configMaps = firstResourceListCall.filter(_.isInstanceOf[ConfigMap])
+ assert(configMaps.nonEmpty,
+ "the driver's own config map must be sent as a pre-resource, before the
driver pod " +
+ "is created, to avoid a \"configmap ... not found\" mount race
(SPARK-38079)")
+
+ // Safety check: the pre-resource owner-reference refresh (the second
resourceList()
+ // call) must still set an owner reference on the config map, same as
before this
+ // change, so that it is still garbage-collected along with the driver pod.
+ val secondResourceListCall =
createdResourcesArgumentCaptor.getAllValues.get(1)
+ val refreshedConfigMap = secondResourceListCall
+ .filter(_.isInstanceOf[ConfigMap]).map(_.asInstanceOf[ConfigMap]).head
+ val ownerReferences = refreshedConfigMap.getMetadata.getOwnerReferences
+ assert(ownerReferences.size() === 1)
+ assert(ownerReferences.get(0).getName === POD_NAME)
+ assert(ownerReferences.get(0).getUid === DRIVER_POD_UID)
+ }
+
+ // SPARK-38079: making the driver's own config map (and other
credential-bearing resources,
+ // e.g. Kerberos keytab/delegation-token secrets) a pre-resource above means
they briefly
+ // exist without an owner reference (see the comment on
cleanupOrphanedPreResources in
+ // KubernetesClientApplication.scala). run() registers a shutdown hook to
best-effort clean
+ // those up if this process is terminated abruptly in that window. Actually
triggering a JVM
+ // shutdown hook from a test is impractical, so these tests instead call the
(package-private)
+ // cleanup method directly, the same way the hook itself would.
+ private def newRecoveryClientMocks(): (KubernetesClient, RESOURCE_LIST,
PodResource) = {
+ val recoveryClient = mock[KubernetesClient]
+ val recoveryResourceList = mock[RESOURCE_LIST]
+ val recoveryPodOperations = mock[PODS]
+ val recoveryPodsWithNamespace = mock[PODS_WITH_NAMESPACE]
+ val recoveryNamedPod = mock[PodResource]
+ doReturn(recoveryResourceList)
+
.when(recoveryClient).resourceList(ArgumentMatchers.any[Array[HasMetadata]]():
_*)
+ when(recoveryClient.pods()).thenReturn(recoveryPodOperations)
+
when(recoveryPodOperations.inNamespace(kconf.namespace)).thenReturn(recoveryPodsWithNamespace)
+
when(recoveryPodsWithNamespace.withName(POD_NAME)).thenReturn(recoveryNamedPod)
+ (recoveryClient, recoveryResourceList, recoveryNamedPod)
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup deletes orphaned pre-resources and
the driver pod " +
+ "if this submission created it") {
+ val (recoveryClient, recoveryResourceList, recoveryNamedPod) =
newRecoveryClientMocks()
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => recoveryClient))
+
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = true, podCreatedByUs =
true)
+
+ verify(recoveryResourceList).delete()
+ verify(recoveryNamedPod).delete()
+ // The recovery client is built fresh for this cleanup and must not leak.
+ verify(recoveryClient).close()
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup does not delete the driver pod if
this submission " +
+ "never created it") {
+ val (recoveryClient, recoveryResourceList, recoveryNamedPod) =
newRecoveryClientMocks()
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => recoveryClient))
+
+ // podCreatedByUs = false: e.g. the pod creation API call itself never
succeeded (or a
+ // differently-submitted application happens to be using the same pod
name), so this
+ // cleanup must not delete a pod it did not create.
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = true, podCreatedByUs =
false)
+
+ verify(recoveryResourceList).delete()
+ verify(recoveryNamedPod, never()).delete()
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup is a no-op if pre-resources were
never applied") {
+ var recoveryClientFactoryInvoked = false
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => {
+ recoveryClientFactoryInvoked = true
+ mock[KubernetesClient]
+ }))
+
+ // preResourcesApplied = false: the pre-resource serverSideApply() call
itself never
+ // succeeded, so there is nothing to have been left orphaned -- and in
particular, the
+ // existing catch block for that call (in run()) has already handled
cleanup of whatever
+ // partial state that failed call may have left behind.
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = false, podCreatedByUs =
false)
+
+ assert(!recoveryClientFactoryInvoked,
+ "the recovery client must not be built at all when there is nothing to
clean up")
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup swallows exceptions from the
recovery client " +
+ "(best-effort only)") {
+ val (recoveryClient, recoveryResourceList, recoveryNamedPod) =
newRecoveryClientMocks()
+ doThrow(new RuntimeException("simulated API server failure"))
+ .when(recoveryResourceList).delete()
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => recoveryClient))
+
+ // Must not throw: this runs on the JVM shutdown-hook thread, where an
uncaught exception
+ // would only be printed to stderr and otherwise has no one left to
meaningfully handle it.
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = true, podCreatedByUs =
true)
+
+ // The pod delete is independent of the (failed) pre-resource delete
above, and must still
+ // be attempted.
+ verify(recoveryNamedPod).delete()
+ }
+
+ // SPARK-38079: the tests above all call cleanupOrphanedPreResources
directly, since actually
+ // triggering a JVM shutdown hook from a test is impractical. That leaves
the wiring in run()
+ // itself -- does it register a hook before applying pre-resources, and
remove that exact hook
+ // once done with them -- uncovered by those tests alone. These two tests
close that gap by
+ // injecting a fake ShutdownHookOps instead.
+ test("SPARK-38079: cleanup hook is registered before pre-resources are
applied and " +
+ "removed once run() completes successfully") {
+ var registeredHook: Option[() => Unit] = None
+ var removedRef: Option[Any] = None
+ val fakeOps = ShutdownHookOps(
+ addHook = { hook =>
+ registeredHook = Some(hook)
+ "fake-hook-ref"
+ },
+ removeHook = { ref =>
+ removedRef = Some(ref)
+ true
+ })
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ shutdownHookOpsOverride = Some(fakeOps))
+
+ submissionClient.run()
+
+ assert(registeredHook.isDefined,
Review Comment:
These new tests don't verify the core behaviors.
- The hook lambda registered by `run()` is never invoked. L394 checks only
`registeredHook.isDefined`, and the failure test discards the hook. So,
swapping the two `.get()` arguments at L212, replacing the hook body with `()
=> ()`, or dropping the conf ConfigMap from the list would still pass.
- No test verifies the main client's `resourceList(...).delete()` when the
pod creation fails (L234).
- The test at L244 checks only that the first `resourceList` call contains a
ConfigMap, not that it happens before `namedPods.create()` (e.g. with
`InOrder`). Also, `addOwnerReference` mutates the captured instance in place,
so the owner reference check at L253-259 cannot tell the state at the time of
each call.
- `ArgumentMatchers.any[Array[HasMetadata]]()` at L276 matches exactly one
vararg element in Mockito 5. With two or more pre-resources, the stub returns
`null`, and the NPE is swallowed by `NonFatal`.
`ArgumentMatchers.any(classOf[Array[HasMetadata]])` matches the whole array.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/HadoopConfDriverFeatureStep.scala:
##########
@@ -112,7 +112,9 @@ private[spark] class HadoopConfDriverFeatureStep(conf:
KubernetesConf)
}
}
- override def getAdditionalKubernetesResources(): Seq[HasMetadata] = {
+ // SPARK-38079: this config map is mounted by the driver pod, so it must be
created as a
+ // pre-resource (before the pod itself) to avoid a "configmap ... not found"
mount race.
+ override def getAdditionalPreKubernetesResources(): Seq[HasMetadata] = {
Review Comment:
Since this step emits the ConfigMap whenever `HADOOP_CONF_DIR` is set in the
environment (`System.getenv`), the two `SPARK-37331` tests in
`KubernetesDriverBuilderSuite` now depend on the environment. If
`HADOOP_CONF_DIR` is set (even to a non-existent path), the size assertions at
L72 (`=== 0`) and L85 (`=== 1`) fail, because the forked test JVM inherits the
variable. Previously, this ConfigMap went to `driverKubernetesResources`, which
those tests don't check. Excluding `HadoopConfDriverFeatureStep` via
`KUBERNETES_DRIVER_POD_EXCLUDED_FEATURE_STEPS` in those tests would fix it.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/features/KerberosConfDriverFeatureStep.scala:
##########
@@ -225,7 +225,10 @@ private[spark] class
KerberosConfDriverFeatureStep(kubernetesConf: KubernetesDri
}
}
- override def getAdditionalKubernetesResources(): Seq[HasMetadata] = {
+ // SPARK-38079: the krb5 config map, keytab secret, and delegation token
secret below are
+ // all mounted by the driver pod, so they must be created as pre-resources
(before the pod
+ // itself) to avoid a "configmap/secret ... not found" mount race.
+ override def getAdditionalPreKubernetesResources(): Seq[HasMetadata] = {
Review Comment:
Another corner case for `apache/spark-kubernetes-operator`: when
`AppInitStep` runs again after the driver pod was created (e.g. the status
update to `DriverRequested` failed, or the operator restarted), it rebuilds the
spec and force-applies all pre-resources (`AppInitStep.java:137`). Since the
delegation token Secret is immutable and a new `KerberosConfDriverFeatureStep`
obtains new tokens, the apply fails with 422 (`field is immutable`). This leads
to `SchedulingFailure`, which deletes the running driver. Previously, this
Secret was a driver resource, which is only get-or-created. This requires a
Kerberos-enabled operator JVM, so it is rare.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
+
+ // SPARK-38079: some of the pre-resources above (e.g. the Kerberos
keytab/delegation token
+ // secrets, the driver Kubernetes credentials secret) carry credentials.
They are created
+ // here without an owner reference -- the owner reference is only added
once the driver pod
+ // exists (see "Refresh all pre-resources' owner references" below), since
Kubernetes owner
+ // references require the owner's UID, which does not exist before the pod
is created. If
+ // this process is terminated abruptly in that window (e.g. Ctrl-C,
SIGTERM, or a fatal JVM
+ // error), the pre-resources would otherwise be silently orphaned in the
namespace forever,
+ // since Kubernetes only garbage-collects via owner references. This
shutdown hook makes a
+ // best-effort attempt to delete them (and the driver pod, if this
submission created it) in
+ // that case. It is a no-op -- and removed entirely, see the `finally`
below -- once the
+ // owner-reference refresh has completed; from that point on the existing
owner references
+ // make normal Kubernetes garbage collection sufficient, and e.g. Ctrl-C
while waiting for
+ // the application to complete must keep today's behavior of just
detaching.
+ val preResourcesApplied = new AtomicBoolean(false)
+ val podCreatedByUs = new AtomicBoolean(false)
+ val cleanupHookRef = shutdownHookOps.addHook(() =>
cleanupOrphanedPreResources(
+ preKubernetesResources, driverPodName, preResourcesApplied.get(),
podCreatedByUs.get()))
var watch: Watch = null
var createdDriverPod: Pod = null
try {
- createdDriverPod =
-
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- logError("Please check \"kubectl auth can-i create pod\" first. It
should be yes.")
- throw e
- }
+ try {
+ kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
+ preResourcesApplied.set(true)
+ } catch {
+ case NonFatal(e) =>
+ logError("Please check \"kubectl auth can-i create [resource]\"
first." +
+ " It should be yes. And please also check your feature step
implementation.")
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
+ throw e
+ }
- // Refresh all pre-resources' owner references
- try {
- addOwnerReference(createdDriverPod, preKubernetesResources)
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.pods().resource(createdDriverPod).delete()
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
+ try {
+ createdDriverPod =
+
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
+ podCreatedByUs.set(true)
+ } catch {
+ case NonFatal(e) =>
+ kubernetesClient.resourceList(preKubernetesResources: _*).delete()
+ logError("Please check \"kubectl auth can-i create pod\" first. It
should be yes.")
+ throw e
+ }
+
+ // Refresh all pre-resources' owner references
+ try {
+ addOwnerReference(createdDriverPod, preKubernetesResources)
+ kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
Review Comment:
This refresh now sets `ownerReferences` on objects that already exist (the
driver conf ConfigMap on every submission, plus the moved ConfigMaps/Secrets),
so the API server admits it as an UPDATE. Under the
`OwnerReferencesPermissionEnforcement` admission plugin, which OpenShift
enables by default, an UPDATE that changes `ownerReferences` requires `delete`
permission on the object (`cannot set an ownerRef on a resource you can't
delete` in `plugin/pkg/admission/gc/gc_admission.go`). On master, these
resources are created once with the owner reference already set, which is a
CREATE, and that check is skipped.
As a result, a submitter that can create/patch but cannot delete ConfigMaps
now fails on every submission. For example, the default controller RBAC of
kubeflow/spark-operator v2.5.2, which runs `spark-submit` in the controller
pod, has `get/list/watch/create/update/patch` on `configmaps` but no `delete`.
1. L218 creates the ConfigMap without an owner reference (CREATE, allowed).
2. L242 fails with 403.
3. The catch block deletes the pod, and then L246 also fails with 403 on the
ConfigMap deletion, which replaces the original exception.
4. The `finally` removes the hook, so an ownerless ConfigMap is left behind
on every attempt.
This contradicts "No behavior change for successful submissions" in the PR
description. Note that `docs/running-on-kubernetes.md` only requires pod
permissions for the submitter.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
+
+ // SPARK-38079: some of the pre-resources above (e.g. the Kerberos
keytab/delegation token
+ // secrets, the driver Kubernetes credentials secret) carry credentials.
They are created
+ // here without an owner reference -- the owner reference is only added
once the driver pod
+ // exists (see "Refresh all pre-resources' owner references" below), since
Kubernetes owner
+ // references require the owner's UID, which does not exist before the pod
is created. If
+ // this process is terminated abruptly in that window (e.g. Ctrl-C,
SIGTERM, or a fatal JVM
+ // error), the pre-resources would otherwise be silently orphaned in the
namespace forever,
+ // since Kubernetes only garbage-collects via owner references. This
shutdown hook makes a
+ // best-effort attempt to delete them (and the driver pod, if this
submission created it) in
+ // that case. It is a no-op -- and removed entirely, see the `finally`
below -- once the
+ // owner-reference refresh has completed; from that point on the existing
owner references
+ // make normal Kubernetes garbage collection sufficient, and e.g. Ctrl-C
while waiting for
+ // the application to complete must keep today's behavior of just
detaching.
+ val preResourcesApplied = new AtomicBoolean(false)
+ val podCreatedByUs = new AtomicBoolean(false)
+ val cleanupHookRef = shutdownHookOps.addHook(() =>
cleanupOrphanedPreResources(
+ preKubernetesResources, driverPodName, preResourcesApplied.get(),
podCreatedByUs.get()))
var watch: Watch = null
var createdDriverPod: Pod = null
try {
- createdDriverPod =
-
kubernetesClient.pods().inNamespace(conf.namespace).resource(resolvedDriverPod).create()
- } catch {
- case NonFatal(e) =>
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- logError("Please check \"kubectl auth can-i create pod\" first. It
should be yes.")
- throw e
- }
+ try {
+ kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
+ preResourcesApplied.set(true)
Review Comment:
The flags are set only after the blocking calls return (L219, L231), and the
hook reads them only once when it starts (L211-212), while the main thread
keeps running during the JVM shutdown.
- If SIGINT/SIGTERM arrives while this `serverSideApply()` is in flight
(fabric8 applies the list items one by one), the hook sees
`preResourcesApplied=false` and does nothing. However, the items already
applied (e.g. the credentials Secret, which comes first) remain ownerless
forever.
- If the signal arrives while `create()` (L230) is in flight, the hook reads
`(true, false)` and deletes only the pre-resources. When `create()` succeeds
afterward, the driver pod is stuck in `ContainerCreating`. This contradicts the
comment at L204 ("and the driver pod, if this submission created it").
Since the deletion ignores 404, you can set each flag before the call, and
let the hook read the flags after building its client (or coordinate with the
main thread).
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
+
+ // SPARK-38079: some of the pre-resources above (e.g. the Kerberos
keytab/delegation token
Review Comment:
The comments are too verbose compared to the surrounding code. The main code
adds 75 comment lines vs 71 code lines, and the same rationale (the ownerless
window, "triggering a JVM shutdown hook from a test is impractical") is
repeated 5-6 times. Some of them are inaccurate or will become stale after
merging.
- L205 "It is a no-op ... once the owner-reference refresh has completed":
both flags are `true` after the refresh, and the hook becomes harmless only
because the `finally` removes it.
- L208 "today's behavior", `ClientSuite` L214 "is now a pre-resource", and
`ClientSuite` L423 "unchanged by SPARK-38079" describe the diff rather than the
code.
- `ClientSuite` L357-358 "would only be printed to stderr" is not true,
because `SparkShutdownHookManager.runAll()` logs it via
`Utils.logUncaughtExceptions`.
- The same warning message is duplicated at L324 and L340.
For reference, the executor-side fix for the same race (SPARK-38794) used a
single 3-line comment. Could you keep one concise comment here and trim the
others?
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -136,43 +188,75 @@ private[spark] class Client(
val driverPodName = resolvedDriverPod.getMetadata.getName
// setup resources before pod creation
- val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources
- try {
- kubernetesClient.resourceList(preKubernetesResources:
_*).forceConflicts().serverSideApply()
- } catch {
- case NonFatal(e) =>
- logError("Please check \"kubectl auth can-i create [resource]\"
first." +
- " It should be yes. And please also check your feature step
implementation.")
- kubernetesClient.resourceList(preKubernetesResources: _*).delete()
- throw e
- }
+ // SPARK-38079: the driver's own base config map (mounted as
SPARK_CONF_VOLUME_DRIVER
+ // above) must also be created before the pod itself, to avoid a
"configmap ... not
+ // found" mount race between the driver pod and the config map it depends
on.
+ val preKubernetesResources =
resolvedDriverSpec.driverPreKubernetesResources ++ Seq(configMap)
+
+ // SPARK-38079: some of the pre-resources above (e.g. the Kerberos
keytab/delegation token
+ // secrets, the driver Kubernetes credentials secret) carry credentials.
They are created
+ // here without an owner reference -- the owner reference is only added
once the driver pod
+ // exists (see "Refresh all pre-resources' owner references" below), since
Kubernetes owner
+ // references require the owner's UID, which does not exist before the pod
is created. If
+ // this process is terminated abruptly in that window (e.g. Ctrl-C,
SIGTERM, or a fatal JVM
+ // error), the pre-resources would otherwise be silently orphaned in the
namespace forever,
+ // since Kubernetes only garbage-collects via owner references. This
shutdown hook makes a
+ // best-effort attempt to delete them (and the driver pod, if this
submission created it) in
+ // that case. It is a no-op -- and removed entirely, see the `finally`
below -- once the
+ // owner-reference refresh has completed; from that point on the existing
owner references
+ // make normal Kubernetes garbage collection sufficient, and e.g. Ctrl-C
while waiting for
+ // the application to complete must keep today's behavior of just
detaching.
+ val preResourcesApplied = new AtomicBoolean(false)
+ val podCreatedByUs = new AtomicBoolean(false)
+ val cleanupHookRef = shutdownHookOps.addHook(() =>
cleanupOrphanedPreResources(
Review Comment:
The shutdown hook commit (+326/-30) is about four times larger than the fix
itself (+75/-24), but it covers only a part of the window. It covers none of
SIGKILL, OOM, and "a dropped connection where the catch cleanup's delete also
fails" (see the other comments). In addition, the same ownerless window has
existed for `VolcanoFeatureStep`'s `PodGroup` since SPARK-37331 (Apache Spark
3.3.0) without such a hook. I'd suggest taking the other option in the previous
review ("accept the window and state the trade-off in the PR description"), or
handling this in a separate JIRA issue.
BTW, the PR description needs to be updated. `VolcanoFeatureStep` already
uses `getAdditionalPreKubernetesResources()`, so "no feature step was using it"
is not true. The shutdown hook is not mentioned at all, and the test count
(386) is outdated.
##########
resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/submit/KubernetesClientApplication.scala:
##########
@@ -94,12 +113,45 @@ private[spark] object ClientArguments {
* implemented features.
* @param kubernetesClient the client to talk to the Kubernetes API server
* @param watcher a watcher that monitors and logs the application status
+ * @param recoveryClientFactoryOverride overrides how the SPARK-38079
shutdown-hook cleanup path
+ * (see run()) builds the fresh
Kubernetes client it uses to
+ * best-effort delete pre-resources left
orphaned by an
+ * abrupt termination. Not reused from
`kubernetesClient`
+ * because that one may already be
closed, or concurrently
+ * in use, by the time the hook runs.
Exposed only for
+ * testing; `None` (the default) builds a
real client the
+ * same way
KubernetesClientApplication.run() builds
+ * `kubernetesClient`. A plain default
value can't itself
+ * call `conf.sparkConf` here, since
`conf` is a
+ * constructor parameter, not yet a class
member, at the
+ * point default values are evaluated.
+ * @param shutdownHookOpsOverride overrides how the SPARK-38079 cleanup hook
(see run()) is
+ * registered/removed. Exposed only for
testing -- actually
+ * triggering a JVM shutdown hook from a test
is impractical, so
+ * tests instead inject fakes here to assert
that run() registers
+ * a hook before applying pre-resources and
removes that exact
+ * hook once it is done with them (success or
failure). `None`
+ * (the default) delegates to the real
`ShutdownHookManager`.
*/
private[spark] class Client(
conf: KubernetesDriverConf,
builder: KubernetesDriverBuilder,
kubernetesClient: KubernetesClient,
- watcher: LoggingPodStatusWatcher) extends Logging {
+ watcher: LoggingPodStatusWatcher,
+ recoveryClientFactoryOverride: Option[() => KubernetesClient] = None,
+ shutdownHookOpsOverride: Option[ShutdownHookOps] = None) extends Logging {
+
+ private val recoveryClientFactory: () => KubernetesClient =
recoveryClientFactoryOverride
Review Comment:
This duplicates the client creation in `KubernetesClientApplication.run()`
(L375-384), so both can diverge later (e.g. the auth prefix or the client
type), and the difference would show up only in the untested abrupt-termination
path. The reason for not reusing `kubernetesClient` (L119-121, "may already be
closed, or concurrently in use") doesn't hold except for a narrow race, because
the hook is removed before `run()` returns, i.e. within the
`Utils.tryWithResource` lifetime of `kubernetesClient`, and a fabric8 client
can be shared across threads. Building a new client during the shutdown also
re-runs the kubeconfig auto-configuration (and an exec credential plugin like
`aws eks get-token`), which adds to the shutdown delay. If we keep the hook,
reusing `kubernetesClient` (or `kubernetesClient.newClient(RequestConfig)` with
a short timeout) would remove this override and its 12-line Scaladoc.
Similarly, `ShutdownHookOps` doesn't need to be an `Option`. A plain default
parameter w
ould be enough.
--
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]