Apache Spark jobs look simple until they don't finish. A driver that restarted under OOM, executors churning through retries, a shuffle service that became unavailable — all of these can let spark-submit hang for hours before anyone notices. Standard Spark monitoring (Spark UI, History Server) requires someone to look at it; you need proactive, external alerting.
Vigilmon gives Spark pipelines external visibility: HTTP monitors for the Spark REST API and shuffle service, heartbeat monitors so you know immediately when a job fails to deliver output on schedule, and response time alerting to catch driver GC pressure before it cascades.
Spark Failure Modes Worth Monitoring
- Driver OOM or crash — job fails with no retry unless you're using cluster mode with restartPolicy
- Executor failure rate — high executor churn causes task retries, inflating job duration past SLO without a hard failure
- Job/stage duration SLO breach — the job runs but takes 3× longer than expected, meaning downstream is late
- Shuffle service unavailability — in yarn/external shuffle service setups, an unavailable shuffle service causes all pending stages to hang
- Dynamic allocation starvation — job waits for executor allocation that never arrives due to cluster capacity limits
Step 1: Build a Spark Health Endpoint
Spark exposes a REST API on the driver (port 4040 for running apps, or History Server port 18080 for completed apps).
Probe a Running Driver
# Running app metrics via Spark UI REST API (driver port 4040)
GET http://spark-driver:4040/api/v1/applications/{appId}/stages
GET http://spark-driver:4040/api/v1/applications/{appId}/executors
Node.js Health Sidecar
Build a health sidecar that wraps the Spark REST API and exposes a clean 200/503 endpoint for Vigilmon:
// spark-health.js
const express = require('express');
const axios = require('axios');
const app = express();
const SPARK_UI = process.env.SPARK_UI_URL || 'http://localhost:4040';
const APP_ID = process.env.SPARK_APP_ID; // or read from first /applications response
const MAX_EXECUTOR_FAILURE_RATE = parseFloat(process.env.MAX_EXECUTOR_FAILURE_RATE || '0.2');
const MAX_FAILED_TASKS = parseInt(process.env.MAX_FAILED_TASKS || '100');
async function getAppId() {
const resp = await axios.get(`${SPARK_UI}/api/v1/applications`, { timeout: 5000 });
return resp.data[0]?.id;
}
app.get('/health/spark', async (req, res) => {
try {
const appId = APP_ID || await getAppId();
const [execResp, stageResp] = await Promise.all([
axios.get(`${SPARK_UI}/api/v1/applications/${appId}/executors`, { timeout: 5000 }),
axios.get(`${SPARK_UI}/api/v1/applications/${appId}/stages`, { timeout: 5000 }),
]);
const executors = execResp.data.filter(e => e.id !== 'driver');
const activeExecutors = executors.filter(e => e.activeTasks >= 0 && !e.isBlacklisted);
const failedTasks = executors.reduce((sum, e) => sum + e.failedTasks, 0);
const issues = [];
if (executors.length === 0) {
issues.push('no executors allocated');
}
const failureRate = executors.length > 0
? (executors.filter(e => e.isBlacklisted).length / executors.length)
: 0;
if (failureRate > MAX_EXECUTOR_FAILURE_RATE) {
issues.push(`executor failure rate ${(failureRate * 100).toFixed(0)}%`);
}
if (failedTasks > MAX_FAILED_TASKS) {
issues.push(`${failedTasks} failed tasks`);
}
if (issues.length > 0) {
return res.status(503).json({ status: 'degraded', issues, appId });
}
return res.status(200).json({
status: 'ok',
appId,
executors: activeExecutors.length,
failed_tasks: failedTasks,
});
} catch (err) {
// If we can't reach the driver, it's down
return res.status(503).json({ status: 'down', error: err.message });
}
});
app.get('/health/spark/shuffle', async (req, res) => {
// External shuffle service exposes a metrics servlet on port 7337
const SHUFFLE_URL = process.env.SHUFFLE_SERVICE_URL || 'http://localhost:7337';
try {
const resp = await axios.get(`${SHUFFLE_URL}/metrics/json`, { timeout: 3000 });
return res.status(200).json({ status: 'ok', shuffle_metrics: Object.keys(resp.data).length });
} catch (err) {
return res.status(503).json({ status: 'down', error: err.message });
}
});
app.listen(3011, () => console.log('Spark health sidecar on :3011'));
Python Health Probe (for Spark-on-Kubernetes)
# spark_health.py
import os, requests
from flask import Flask, jsonify
app = Flask(__name__)
SPARK_UI = os.environ.get('SPARK_UI_URL', 'http://localhost:4040')
@app.route('/health/spark')
def spark_health():
try:
apps = requests.get(f'{SPARK_UI}/api/v1/applications', timeout=5).json()
if not apps:
return jsonify(status='down', reason='no_active_applications'), 503
app_id = apps[0]['id']
executors = requests.get(
f'{SPARK_UI}/api/v1/applications/{app_id}/executors', timeout=5
).json()
workers = [e for e in executors if e['id'] != 'driver']
failed = sum(e['failedTasks'] for e in workers)
blacklisted = sum(1 for e in workers if e.get('isBlacklisted', False))
if not workers:
return jsonify(status='degraded', reason='no_executors'), 503
if blacklisted / max(len(workers), 1) > 0.2:
return jsonify(status='degraded', reason='high_executor_failure', blacklisted=blacklisted), 503
return jsonify(status='ok', executors=len(workers), failed_tasks=failed)
except Exception as e:
return jsonify(status='down', error=str(e)), 503
if __name__ == '__main__':
app.run(port=3011)
Step 2: Job Duration SLO Monitoring via Heartbeats
HTTP monitors tell you whether Spark is running. Heartbeat monitors tell you whether it's delivering output on time — which is what your downstream SLAs actually care about.
Configure a Heartbeat in Vigilmon
- Monitors → New Monitor → Heartbeat
- Name:
spark-etl-nightly-batch - Expected interval: match your job's scheduled completion time (e.g.,
60 minutesfor a nightly job that should finish within an hour of start) - Grace period:
15 minutes - Alert channel: PagerDuty
Wire the Heartbeat Into Your Spark Job
# At the end of your PySpark job, ping Vigilmon on successful completion
import requests, os
def ping_vigilmon_heartbeat():
url = os.environ.get('VIGILMON_HEARTBEAT_URL')
if url:
try:
requests.get(url, timeout=5)
except Exception:
pass # don't let monitoring failure kill the job
# In your Spark driver:
if __name__ == '__main__':
spark = SparkSession.builder.appName('nightly-etl').getOrCreate()
try:
run_etl(spark)
ping_vigilmon_heartbeat()
finally:
spark.stop()
For Scala/Java Spark jobs:
// Add to your driver main()
import java.net.{HttpURLConnection, URL}
def pingVigilmon(url: String): Unit = {
try {
val conn = new URL(url).openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod("GET")
conn.setConnectTimeout(3000)
conn.setReadTimeout(3000)
conn.getResponseCode
conn.disconnect()
} catch {
case _: Exception => // monitoring failure must never kill the job
}
}
// At end of successful job execution:
pingVigilmon(sys.env.getOrElse("VIGILMON_HEARTBEAT_URL", ""))
Stage-Level Heartbeats for Long-Running Jobs
For jobs with distinct stages (ingest → transform → load), send a heartbeat at each stage boundary. This lets you identify which stage is hanging:
def with_stage_heartbeat(stage_name: str, fn):
result = fn()
url = os.environ.get(f'VIGILMON_HEARTBEAT_{stage_name.upper()}')
if url:
requests.get(url, timeout=5)
return result
with_stage_heartbeat('ingest', lambda: run_ingest(spark))
with_stage_heartbeat('transform', lambda: run_transform(spark))
with_stage_heartbeat('load', lambda: run_load(spark))
Step 3: Configure Vigilmon HTTP Monitors
- Monitors → New Monitor → HTTP / HTTPS
- URL:
https://your-spark-host.example.com/health/spark - Interval: 2 minutes
- Expected:
200, body contains"status":"ok" - Response time threshold:
8000ms - Alert channel: PagerDuty (P1 for active job failures)
Add a second monitor for shuffle service:
- URL:
https://your-spark-host.example.com/health/spark/shuffle - Interval: 2 minutes
- Alert channel: Slack (shuffle outages degrade jobs but don't fail them immediately)
Step 4: Alert Routing for Spark Pipelines
| Monitor | Trigger | Channel | Priority |
|---|---|---|---|
| HTTP: /health/spark | Driver down or executor failure >20% | PagerDuty | P1 |
| HTTP: /health/spark/shuffle | Shuffle service unavailable | Slack | P2 |
| Heartbeat: job completion | Job didn't finish within SLO | PagerDuty | P1 |
| Heartbeat: ingest stage | Stage hung for >30 min | Slack | P2 |
| Heartbeat: load stage | Output not delivered | PagerDuty | P1 |
Summary
Spark jobs fail in three ways that are easy to miss: the driver crashes silently, executor churn inflates job duration past SLO, or the job runs successfully but produces no output due to a logic error. Cover all three:
| Monitor Type | What It Catches | |---|---| | HTTP: driver health | Driver down, executor failures, blacklisted workers | | HTTP: shuffle service | Shuffle service outages causing stage hangs | | Heartbeat: job completion | SLO breach, job crash before output | | Heartbeat: per-stage | Identifies which stage in a multi-stage job is hanging |
Set up Spark monitoring at vigilmon.online in under five minutes.