Skip to content

Commit 949aea7

Browse files
csviriclaude
andcommitted
Replace dynamic-config informer with mounted-ConfigMap file watcher
Rework the dynamic-config feature so the operator no longer runs a dedicated Operator instance with a ConfigMap informer. The dynamic-config ConfigMap is now mounted into the operator pod as a volume, and a new DynamicConfigMonitor periodically re-reads the properties file from disk, refreshing SparkOperatorConfManager and the watched-namespaces updater on change. - Add DynamicConfigMonitor with start/stop/isRunning lifecycle - Simplify SparkOperator from List<Operator> to a single Operator plus an optional DynamicConfigMonitor; remove SparkOperatorConfigMapReconciler - Replace dynamicConfig.selector with dynamicConfig.filePath and dynamicConfig.reloadIntervalSeconds - Helm: drop configmaps RBAC Role/RoleBinding, mount the dynamic ConfigMap volume, render a single spark-operator-dynamic.properties key - Probes: switch to a single Operator and include the monitor's running state in /healthz and /readyz Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e875263 commit 949aea7

19 files changed

Lines changed: 476 additions & 292 deletions

File tree

build-tools/helm/spark-kubernetes-operator/templates/operator-rbac.yaml

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -118,37 +118,6 @@ metadata:
118118
{{- template "spark-operator.operatorRbacRules" $ }}
119119
---
120120
{{- end }}
121-
{{- if .Values.operatorConfiguration.dynamicConfig.enable }}
122-
apiVersion: rbac.authorization.k8s.io/v1
123-
kind: Role
124-
metadata:
125-
name: {{ .Values.operatorRbac.configManagement.roleName }}
126-
namespace: {{ .Release.Namespace }}
127-
{{- template "spark-operator.rbacLabelsAnnotations" $ }}
128-
rules:
129-
- apiGroups:
130-
- ""
131-
resources:
132-
- configmaps
133-
verbs:
134-
- '*'
135-
---
136-
apiVersion: rbac.authorization.k8s.io/v1
137-
kind: RoleBinding
138-
metadata:
139-
name: {{ .Values.operatorRbac.configManagement.roleBindingName }}
140-
namespace: {{ .Release.Namespace }}
141-
{{- template "spark-operator.rbacLabelsAnnotations" $ }}
142-
roleRef:
143-
apiGroup: rbac.authorization.k8s.io
144-
kind: Role
145-
name: {{ .Values.operatorRbac.configManagement.roleName }}
146-
subjects:
147-
- kind: ServiceAccount
148-
name: {{ .Values.operatorRbac.serviceAccount.name }}
149-
namespace: {{ .Release.Namespace }}
150-
---
151-
{{- end }}
152121

153122
{{- $workloadResources := .Values.workloadResources -}}
154123
{{- $systemNs := .Release.Namespace -}}

build-tools/helm/spark-kubernetes-operator/templates/spark-operator.yaml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ spec:
148148
mountPath: /opt/spark-operator/conf
149149
- name: logs-volume
150150
mountPath: /opt/spark-operator/logs
151+
{{- if .Values.operatorConfiguration.dynamicConfig.enable }}
152+
- name: spark-operator-dynamic-config-volume
153+
mountPath: /opt/spark-operator/dynamic-conf
154+
readOnly: true
155+
{{- end }}
151156
{{- with .Values.operatorDeployment.operatorPod.operatorContainer.volumeMounts }}
152157
{{- toYaml . | nindent 12 }}
153158
{{- end }}
@@ -170,6 +175,11 @@ spec:
170175
name: spark-kubernetes-operator-configuration
171176
- name: logs-volume
172177
emptyDir: { }
178+
{{- if .Values.operatorConfiguration.dynamicConfig.enable }}
179+
- name: spark-operator-dynamic-config-volume
180+
configMap:
181+
name: spark-kubernetes-operator-dynamic-configuration
182+
{{- end }}
173183
{{- with .Values.operatorDeployment.operatorPod.volumes }}
174184
{{- toYaml . | nindent 8 }}
175185
{{- end }}
@@ -215,8 +225,9 @@ metadata:
215225
{{- include "spark-operator.dynamicConfigLabels" . | nindent 4 }}
216226
annotations:
217227
{{- toYaml .Values.operatorConfiguration.dynamicConfig.annotations | nindent 4 }}
218-
{{- with .Values.operatorConfiguration.dynamicConfig.data }}
219228
data:
220-
{{- toYaml . | nindent 2 }}
221-
{{- end }}
229+
spark-operator-dynamic.properties: |
230+
{{- range $key, $value := .Values.operatorConfiguration.dynamicConfig.data }}
231+
{{ $key }}={{ $value }}
232+
{{- end }}
222233
{{- end }}

docs/config_properties.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
| spark.kubernetes.operator.api.secondaryResourceCreateMaxAttempts | Long | 3 | false | Maximal number of retry attempts of requesting secondary resource for Spark application. This would be performed on top of k8s client spark.kubernetes.operator.retry.maxAttempts to overcome potential conflicting reconcile on the same SparkApplication, as well as API server errors (408/500/502/503/504) and network-level timeouts. Exponential backoff with jitter is applied before retrying on 409 (Conflict) and 429 (Too Many Requests) responses. Other retryable errors are retried immediately. This should be positive number. |
1111
| spark.kubernetes.operator.api.secondaryResourceCreateMaxBackoffMillis | Long | 40000 | false | Maximum backoff (in milliseconds) between retries when creating secondary resources for Spark application. |
1212
| spark.kubernetes.operator.api.statusPatchMaxAttempts | Long | 3 | false | Maximal number of retry attempts of requests to k8s server for resource status update. This would be performed on top of k8s client spark.kubernetes.operator.retry.maxAttempts to overcome potential conflicting update on the same SparkApplication. This should be positive number. |
13-
| spark.kubernetes.operator.dynamicConfig.enabled | Boolean | false | false | When enabled, operator would use config map as source of truth for config property override. The config map need to be created in spark.kubernetes.operator.namespace, and labeled with operator name. |
13+
| spark.kubernetes.operator.dynamicConfig.enabled | Boolean | false | false | When enabled, operator periodically reloads property overrides from a properties file mounted from a ConfigMap. The file path is controlled by spark.kubernetes.operator.dynamicConfig.filePath. |
14+
| spark.kubernetes.operator.dynamicConfig.filePath | String | /opt/spark-operator/dynamic-conf/spark-operator-dynamic.properties | false | Path of the properties file holding dynamic configuration overrides. Typically populated by mounting a ConfigMap as a volume. |
1415
| spark.kubernetes.operator.dynamicConfig.reconcilerParallelism | Integer | 1 | false | Parallelism for dynamic config reconciler. Unbounded pool would be used if set to non-positive number. |
15-
| spark.kubernetes.operator.dynamicConfig.selector | String | app.kubernetes.io/name=spark-kubernetes-operator,app.kubernetes.io/component=operator-dynamic-config-overrides | false | The selector str applied to dynamic config map. |
16+
| spark.kubernetes.operator.dynamicConfig.reloadIntervalSeconds | Long | 60 | false | Interval (in seconds) at which the dynamic config file is re-read. |
1617
| spark.kubernetes.operator.health.probePort | Integer | 19091 | false | The port used for health/readiness check probe status. |
1718
| spark.kubernetes.operator.health.sentinelExecutorPoolSize | Integer | 3 | false | Size of executor service in Sentinel Managers to check the health of sentinel resources. |
1819
| spark.kubernetes.operator.health.sentinelResourceReconciliationDelaySeconds | Integer | 60 | true | Allowed max time(seconds) between spec update and reconciliation for sentinel resources. |

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ slf4j = "2.0.18"
2929
junit = "6.1.0"
3030
jacoco = "0.8.14"
3131
mockito = "5.23.0"
32+
awaitility = "4.3.0"
3233

3334
# Build Analysis
3435
checkstyle = "13.4.2"
@@ -64,6 +65,7 @@ metrics-jvm = { group = "io.dropwizard.metrics", name = "metrics-jvm", version.r
6465
spark-core = { group = "org.apache.spark", name = "spark-core_2.13", version.ref = "spark"}
6566
spark-kubernetes = { group = "org.apache.spark", name = "spark-kubernetes_2.13", version.ref = "spark"}
6667
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito"}
68+
awaitility = { group = "org.awaitility", name = "awaitility", version.ref = "awaitility"}
6769
junit-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit"}
6870
junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit"}
6971
junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher"}

spark-operator/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ dependencies {
7171
testCompileOnly(libs.spotbugs.annotations)
7272
testImplementation(libs.mockito.core)
7373
testImplementation(libs.kube.api.test.client.inject)
74+
testImplementation(libs.awaitility)
7475
}
7576

7677
jar.dependsOn shadowJar

spark-operator/src/main/java/org/apache/spark/k8s/operator/SparkOperator.java

Lines changed: 26 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
import static org.apache.spark.k8s.operator.utils.Utils.getClusterStatusListener;
2424
import static org.apache.spark.k8s.operator.utils.Utils.getWatchedNamespaces;
2525

26+
import java.nio.file.Paths;
2627
import java.time.Duration;
2728
import java.util.ArrayList;
2829
import java.util.Arrays;
2930
import java.util.HashSet;
3031
import java.util.List;
32+
import java.util.Optional;
3133
import java.util.Set;
3234
import java.util.concurrent.ExecutorService;
3335
import java.util.concurrent.Executors;
@@ -43,9 +45,9 @@
4345
import lombok.extern.slf4j.Slf4j;
4446

4547
import org.apache.spark.k8s.operator.client.KubernetesClientFactory;
48+
import org.apache.spark.k8s.operator.config.DynamicConfigMonitor;
4649
import org.apache.spark.k8s.operator.config.SparkOperatorConf;
4750
import org.apache.spark.k8s.operator.config.SparkOperatorConfManager;
48-
import org.apache.spark.k8s.operator.config.SparkOperatorConfigMapReconciler;
4951
import org.apache.spark.k8s.operator.metrics.MetricsService;
5052
import org.apache.spark.k8s.operator.metrics.MetricsSystem;
5153
import org.apache.spark.k8s.operator.metrics.MetricsSystemFactory;
@@ -59,6 +61,7 @@
5961
import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
6062
import org.apache.spark.k8s.operator.utils.SparkClusterStatusRecorder;
6163
import org.apache.spark.k8s.operator.utils.StringUtils;
64+
import org.apache.spark.k8s.operator.utils.Utils;
6265

6366
/**
6467
* Entry point for Spark Operator. Bootstrap the operator app by starting watch and reconciler for
@@ -67,7 +70,7 @@
6770
*/
6871
@Slf4j
6972
public class SparkOperator {
70-
private final List<Operator> registeredOperators;
73+
private final Operator operator;
7174
private final KubernetesClient client;
7275
private final SparkAppSubmissionWorker appSubmissionWorker;
7376
private final SparkClusterSubmissionWorker clusterSubmissionWorker;
@@ -82,6 +85,7 @@ public class SparkOperator {
8285
private final MetricsService metricsService;
8386
private final ExecutorService metricsResourcesSingleThreadPool;
8487
private final ScheduledExecutorService periodicGcScheduler;
88+
private final DynamicConfigMonitor dynamicConfigMonitor;
8589

8690
/** Constructs a new SparkOperator, initializing all its components. */
8791
public SparkOperator() {
@@ -99,21 +103,19 @@ public SparkOperator() {
99103
this.watchedNamespaces = getWatchedNamespaces();
100104
this.sparkApplicationSentinelManager = new SentinelManager<>();
101105
this.sparkClusterSentinelManager = new SentinelManager<>();
102-
this.registeredOperators = new ArrayList<>();
103-
this.registeredOperators.add(registerSparkOperator());
106+
this.operator = registerSparkOperator();
104107
if (SparkOperatorConf.LOG_CONF.getValue()) {
105108
for (var entry : SparkOperatorConfManager.INSTANCE.getAll().entrySet()) {
106109
log.info("{} = {}", entry.getKey(), entry.getValue());
107110
}
108111
}
109-
if (SparkOperatorConf.DYNAMIC_CONFIG_ENABLED.getValue()) {
110-
this.registeredOperators.add(registerSparkOperatorConfMonitor());
111-
}
112+
this.dynamicConfigMonitor = registerConfMonitor().orElse(null);
112113
this.metricsResourcesSingleThreadPool = Executors.newSingleThreadExecutor();
113114
this.probeService =
114115
new ProbeService(
115-
registeredOperators,
116+
operator,
116117
Arrays.asList(sparkApplicationSentinelManager, sparkClusterSentinelManager),
118+
dynamicConfigMonitor,
117119
null);
118120
this.metricsService = new MetricsService(metricsSystem, metricsResourcesSingleThreadPool);
119121
long periodicGcIntervalSeconds = SparkOperatorConf.PERIODIC_GC_INTERVAL_SECONDS.getValue();
@@ -175,27 +177,20 @@ protected Operator registerSparkOperator() {
175177
}
176178

177179
/**
178-
* Registers a monitor for dynamic configuration changes via ConfigMaps.
180+
* Registers a monitor for dynamic configuration changes via a mounted ConfigMap.
179181
*
180-
* @return The Operator instance for the config monitor.
182+
* @return The DynamicConfigMonitor wrapped in an Optional, empty if dynamic config is disabled.
181183
*/
182-
protected Operator registerSparkOperatorConfMonitor() {
183-
Operator op = new Operator(this::overrideConfigMonitorConfigs);
184-
String operatorNamespace = SparkOperatorConf.OPERATOR_NAMESPACE.getValue();
185-
String confSelector = SparkOperatorConf.DYNAMIC_CONFIG_SELECTOR.getValue();
186-
log.info(
187-
"Starting conf monitor in namespace: {}, with selector: {}",
188-
operatorNamespace,
189-
confSelector);
190-
op.register(
191-
new SparkOperatorConfigMapReconciler(
192-
this::updateWatchingNamespaces, unused -> getWatchedNamespaces()),
193-
c -> {
194-
c.withRateLimiter(SparkOperatorConf.getOperatorRateLimiter());
195-
c.settingNamespaces(operatorNamespace);
196-
c.withLabelSelector(confSelector);
197-
});
198-
return op;
184+
protected Optional<DynamicConfigMonitor> registerConfMonitor() {
185+
if (Boolean.FALSE.equals(SparkOperatorConf.DYNAMIC_CONFIG_ENABLED.getValue())) {
186+
return Optional.empty();
187+
}
188+
return Optional.of(
189+
new DynamicConfigMonitor(
190+
Paths.get(SparkOperatorConf.DYNAMIC_CONFIG_FILE_PATH.getValue()),
191+
Duration.ofSeconds(SparkOperatorConf.DYNAMIC_CONFIG_RELOAD_INTERVAL_SECONDS.getValue()),
192+
Utils::getWatchedNamespaces,
193+
this::updateWatchingNamespaces));
199194
}
200195

201196
/**
@@ -263,23 +258,6 @@ protected void overrideOperatorConfigs(ConfigurationServiceOverrider overrider)
263258
overrider.withUseSSAToPatchPrimaryResource(false);
264259
}
265260

266-
/**
267-
* Overrides the configuration for the dynamic config monitor.
268-
*
269-
* @param overrider The ConfigurationServiceOverrider to apply changes to.
270-
*/
271-
protected void overrideConfigMonitorConfigs(ConfigurationServiceOverrider overrider) {
272-
overrider.withKubernetesClient(client);
273-
overrider.withConcurrentReconciliationThreads(
274-
SparkOperatorConf.DYNAMIC_CONFIG_RECONCILER_PARALLELISM.getValue());
275-
overrider.withStopOnInformerErrorDuringStartup(true);
276-
overrider.withCloseClientOnStop(false);
277-
overrider.withInformerStoppedHandler(
278-
(informer, ex) ->
279-
log.error("Dynamic config informer stopped: operator will not accept config updates."));
280-
overrider.withUseSSAToPatchPrimaryResource(false);
281-
}
282-
283261
/**
284262
* Overrides the default configuration for individual controllers.
285263
*
@@ -328,8 +306,10 @@ public static void main(String[] args) {
328306
log.info("Java Version: {}", Runtime.version().toString());
329307
log.info("Built-in Spark Version: {}", org.apache.spark.package$.MODULE$.SPARK_VERSION());
330308
SparkOperator sparkOperator = new SparkOperator();
331-
for (Operator operator : sparkOperator.registeredOperators) {
332-
operator.start();
309+
sparkOperator.operator.start();
310+
311+
if (sparkOperator.dynamicConfigMonitor != null) {
312+
sparkOperator.dynamicConfigMonitor.start();
333313
}
334314
sparkOperator.probeService.start();
335315
// Single thread queue to ensure MetricsService starts after the MetricsSystem

0 commit comments

Comments
 (0)