Lesson 3 - Wiring In a Real PySpark Job
Welcome to Wiring In a Real PySpark Job
Lesson 2 proved the mechanism on the smallest possible pod. This lesson keeps that mechanism completely unchanged and replaces one thing: the image. python:3.13-slim printing a line becomes a real Spark job — the JVM, the driver, the executors, all of it — reading real CityFlow data and computing a real result. If Lesson 2’s submit-poll-log pattern is genuinely correct, it shouldn’t need to change at all to run something this much heavier. It doesn’t.
By the end of this lesson, you will be able to:
- Build a slim PySpark image using a headless JRE instead of a full JDK, and explain why that distinction matters for image size
- Bake a small real dataset directly into an image via
COPY, and explain when that’s the right call instead of a mount or a registry - Submit a real PySpark job as a Kubernetes Job’s pod using the exact mechanism Lesson 2 already proved
- Parse a real computed result back out of
kubectl logs, and cross-check it against a known real number
The Image: A Headless JRE, Not a Full JDK
Course 2’s own PySpark setup lesson established the requirement plainly: Spark 4.x runs on the JVM and needs Java 17 or 21. For a long-lived development machine, a full JDK — compiler, debugger, javadoc tooling — makes sense. For a container that only ever runs compiled Spark, none of that is needed; only a JRE (Java Runtime Environment) is required to execute already-compiled bytecode. Debian’s openjdk-21-jre-headless package is exactly that: no GUI toolkit, no compiler, the smallest real thing capable of running Spark.
FROM python:3.13-slim
# Spark 4.x runs on the JVM (Course 2 Module 1 Lesson 3) and needs Java 17 or
# 21 -- a headless JRE is enough (no compiler, no javadoc, no javafx), which
# keeps this image far slimmer than a full JDK would. python:3.13-slim's
# current Debian base (trixie) only ships JDK 21 in its repos (no standalone
# "openjdk-17-jre-headless" package there), so this image uses 21 -- still
# squarely inside Spark 4's supported 17/21 range.
RUN apt-get update \
&& apt-get install --no-install-recommends -y openjdk-21-jre-headless \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-arm64
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY cityflow_pyspark_job.py .
COPY cityflow_trips_2024-01-01.parquet /opt/cityflow/data/cityflow_trips_2024-01-01.parquet
COPY taxi_zone_lookup.csv /opt/cityflow/data/taxi_zone_lookup.csv
CMD ["python", "cityflow_pyspark_job.py"]pysparkOne real surprise worth flagging honestly: this Dockerfile originally requested openjdk-17-jre-headless, matching Course 2’s own JDK 17-or-21 guidance to the letter. The build failed — python:3.13-slim’s current Debian base (trixie) has moved its package repositories on since that lesson was written, and no longer ships a standalone openjdk-17-jre-headless package, only openjdk-21-jre-headless. Java 21 is still squarely inside Spark 4’s supported range, so the fix was a one-line change, not a workaround — but it’s a real, current example of exactly the kind of drift a base image can introduce between when a lesson is written and when a reader rebuilds it.
Baking the data in. This job’s whole input — a real 81,013-row CityFlow slice from January 1st, 2024, the same file whose exact totals Module 6 Lesson 1’s regression test already reproduced — is about 1.5 MB. For a single-run Job with no registry and no PersistentVolumeClaim in play, COPY-ing that file straight into the image at build time is the simplest correct choice: no mount to wire up, no volume to provision, and the image is still small enough that baking in a fixed, versioned input is a feature, not a shortcut. A job that ran on a growing dataset, or one invoked repeatedly on a schedule, would call for a different design — Course 3’s own capstone cached its input on a PersistentVolumeClaim for exactly that reason. This job runs once, on one known slice, so it doesn’t need that machinery.
docker build -t cityflow-pyspark-job:1.0 .
docker images cityflow-pyspark-jobIMAGE ID DISK USAGE CONTENT SIZE
cityflow-pyspark-job:1.0 cc27c616e3e0 869MB 0B869 MB — real weight, almost entirely pyspark itself and the JVM, not this job’s tiny real data file. kind load docker-image cityflow-pyspark-job:1.0 --name datatweets-docker-k8s puts it on the cluster’s node the same way every image in this course has arrived there — no registry, no push.
The Job: Real Data In, Real Aggregation, Real Output
# gate: skip
"""CityFlow's real PySpark-on-Kubernetes job (Module 7 Lessons 3 and 5).
Runs inside the cityflow-pyspark-job:1.0 image, on a real Kind Kubernetes Job
pod, submitted by an Airflow DAG. Reads the real CityFlow data baked into the
image at build time (a single-day slice, Jan 1 2024 -- the same real
81,013-row slice Module 6 Lesson 1's regression test already reproduced from
this course's own pandas logic), joins it to the real taxi zone lookup table,
and aggregates real trip counts and revenue by borough with a real Spark
groupBy -- small, real, CityFlow-relevant work, not a placeholder computation.
The result is printed to stdout as one line prefixed CITYFLOW_PYSPARK_RESULT_JSON:
so the Airflow task (and anyone reading `kubectl logs`) can find and parse it
without scraping Spark's own log noise.
"""
import json
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
TRIPS_PATH = "/opt/cityflow/data/cityflow_trips_2024-01-01.parquet"
ZONES_PATH = "/opt/cityflow/data/taxi_zone_lookup.csv"
def main():
spark = (
SparkSession.builder
.appName("cityflow-k8s-pyspark-job")
.master("local[*]")
.config("spark.ui.showConsoleProgress", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
trips = spark.read.parquet(TRIPS_PATH)
total_rows = trips.count()
zones = spark.read.csv(ZONES_PATH, header=True, inferSchema=True)
joined = trips.join(zones, trips.PULocationID == zones.LocationID, "left")
# A left join leaves a real NULL Borough for any PULocationID that matched
# no row in the zone lookup at all -- a *different*, more honest signal
# than the lookup table's own "Unknown" Borough category. pandas'
# groupby() drops NULL-key rows silently by default; Spark's groupBy()
# keeps them as their own real group, which is why this coalesce is here
# instead of being silently lost the way a naive pandas merge would lose it.
joined = joined.withColumn("Borough", F.coalesce(F.col("Borough"), F.lit("Unmatched Zone")))
by_borough = (
joined.groupBy("Borough")
.agg(
F.count("*").alias("trip_count"),
F.round(F.sum("total_amount"), 2).alias("total_revenue"),
)
.orderBy(F.col("trip_count").desc())
)
rows = [r.asDict() for r in by_borough.collect()]
top = rows[0]
result = {
"total_rows": total_rows,
"by_borough": rows,
"top_borough": top["Borough"],
"top_borough_trip_count": top["trip_count"],
}
print("CityFlow PySpark job on Kubernetes -- real result:")
print(f" total rows (real, baked-in CityFlow slice): {total_rows}")
for r in rows:
print(f" {r['Borough']:>14}: {r['trip_count']:>6} trips, ${r['total_revenue']:>12,.2f} revenue")
print(f" busiest borough: {top['Borough']} ({top['trip_count']} trips)")
print("CITYFLOW_PYSPARK_RESULT_JSON:" + json.dumps(result))
spark.stop()
if __name__ == "__main__":
main()The coalesce line is worth pausing on, because it’s a real bug this lesson’s own first draft hit and fixed, not a hypothetical. The first version of this job crashed with TypeError: unsupported format string passed to NoneType.__format__ while formatting a revenue number — because 106 of the 81,013 rows have a PULocationID that matches nothing in the zone lookup table at all, and Spark’s groupBy correctly kept that as its own real NULL-keyed group rather than dropping it. A quick pandas cross-check confirmed why this was surprising: pandas’ own groupby() drops NaN-key rows silently by default, so an equivalent pandas merge-and-groupby never showed those 106 rows as a group at all — it just quietly lost them. Spark’s more honest default surfaced a real data-quality fact pandas’ default would have hidden; the fix was to coalesce the null into an explicit "Unmatched Zone" label rather than let it vanish.
The Same Real Job Manifest, One Image Swapped
cityflow_pyspark_on_k8s_dag.py reuses Lesson 2’s exact submit → poll → read-result shape. The only real differences: the image, and how the result is parsed back out of the logs.
# gate: skip
"""CityFlow's real Airflow-submits-a-real-PySpark-job-to-Kubernetes DAG
(Module 7 Lesson 3).
Same submit -> poll -> read-logs shape Lesson 2's cityflow_k8s_submit_demo_dag
proved with a trivial pod, with the trivial python:3.13-slim command replaced
by the real cityflow-pyspark-job:1.0 image: python:3.13-slim + a headless
JDK 21 JRE + PySpark, running a real Spark job against a real 81,013-row
CityFlow slice baked into the image. Still no
apache-airflow-providers-cncf-kubernetes -- the same real kubectl subprocess
pattern, against the same real kubectl binary + kubeconfig this course
host-mounted into the Airflow containers."""
import re
from datetime import datetime
from airflow.decorators import dag, task
KUBECTL_BIN = "/opt/airflow/dags/bin/kubectl"
KUBECONFIG_PATH = "/opt/airflow/dags/data/kubeconfig-kind.yaml"
NAMESPACE = "default"
JOB_NAME = "cityflow-pyspark-on-k8s"
IMAGE = "cityflow-pyspark-job:1.0"
JOB_MANIFEST = f"""\
apiVersion: batch/v1
kind: Job
metadata:
name: {JOB_NAME}
namespace: {NAMESPACE}
labels:
app: {JOB_NAME}
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app: {JOB_NAME}
spec:
restartPolicy: Never
containers:
- name: cityflow-pyspark
image: {IMAGE}
imagePullPolicy: IfNotPresent
"""
@dag(
dag_id="cityflow_pyspark_on_k8s",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["cityflow", "module-7", "kubernetes", "pyspark"],
)
def cityflow_pyspark_on_k8s_dag():
@task
def submit_job() -> str:
import subprocess
subprocess.run(
[KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "delete", "job", JOB_NAME,
"-n", NAMESPACE, "--ignore-not-found"],
capture_output=True, text=True, check=True,
)
result = subprocess.run(
[KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "apply", "-f", "-"],
input=JOB_MANIFEST, capture_output=True, text=True,
)
print(f"submit_job: kubectl apply -> {result.stdout.strip()}")
if result.returncode != 0:
raise RuntimeError(f"kubectl apply failed: {result.stderr}")
return JOB_NAME
@task
def poll_job_completion(job_name: str) -> dict:
import subprocess
import time
deadline = time.time() + 180
polls = 0
while time.time() < deadline:
polls += 1
succeeded = subprocess.run(
[KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "get", "job", job_name,
"-n", NAMESPACE, "-o", "jsonpath={.status.succeeded}"],
capture_output=True, text=True, check=True,
).stdout.strip()
failed = subprocess.run(
[KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "get", "job", job_name,
"-n", NAMESPACE, "-o", "jsonpath={.status.failed}"],
capture_output=True, text=True, check=True,
).stdout.strip()
print(f"poll_job_completion: poll #{polls} succeeded={succeeded!r} failed={failed!r}")
if succeeded == "1":
return {"job_name": job_name, "polls": polls, "outcome": "succeeded"}
if failed and int(failed) > 0:
raise RuntimeError(f"real Job {job_name} reported {failed} failed pod(s)")
time.sleep(2)
raise TimeoutError(f"real Job {job_name} did not succeed within the poll deadline")
@task
def report_spark_result(poll_result: dict) -> dict:
"""Reads the real pod's real logs and parses the real
CITYFLOW_PYSPARK_RESULT_JSON: line the Spark job itself printed --
proof the actual PySpark computation ran on the actual Kind cluster,
not just that the Job's status turned green."""
import json
import subprocess
job_name = poll_result["job_name"]
pod_name = subprocess.run(
[KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "get", "pods", "-n", NAMESPACE,
"-l", f"app={job_name}", "-o", "jsonpath={.items[0].metadata.name}"],
capture_output=True, text=True, check=True,
).stdout.strip()
logs = subprocess.run(
[KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "logs", pod_name, "-n", NAMESPACE],
capture_output=True, text=True, check=True,
).stdout
match = re.search(r"CITYFLOW_PYSPARK_RESULT_JSON:(\{.*\})", logs)
if not match:
raise RuntimeError(f"no CITYFLOW_PYSPARK_RESULT_JSON line found in real pod logs:\n{logs}")
result = json.loads(match.group(1))
print(f"report_spark_result: real pod {pod_name!r} computed: {result}")
assert result["total_rows"] == 81013, result["total_rows"]
assert result["top_borough"] == "Manhattan", result["top_borough"]
return result
report_spark_result(poll_job_completion(submit_job()))
cityflow_pyspark_on_k8s_dag()Triggered for Real, Through Airflow
import json
import subprocess
import time
dag_id = "cityflow_pyspark_on_k8s"
subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
capture_output=True, text=True, check=True)
run_id = f"lesson3_m7_verify_{int(time.time())}"
trigger = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
capture_output=True, text=True,
)
if trigger.returncode != 0:
subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "reserialize"],
capture_output=True, text=True, check=True)
subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
capture_output=True, text=True, check=True,
)
print(f"triggered {dag_id!r} as {run_id!r}")
deadline = time.time() + 180
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
time.sleep(3)
r = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
capture_output=True, text=True, check=True,
)
runs = json.loads(r.stdout)
match = [x for x in runs if x["run_id"] == run_id]
state = match[0]["state"] if match else "queued"
print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"
r = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "tasks", "states-for-dag-run", dag_id, run_id],
capture_output=True, text=True, check=True,
)
print(r.stdout)triggered 'cityflow_pyspark_on_k8s' as 'lesson3_m7_verify_1784973224'
run 'lesson3_m7_verify_1784973224' reached state: success
dag_id | execution_date | task_id | state | start_date | end_date
=========================+===========================+=====================+=========+==================================+=================================
cityflow_pyspark_on_k8s | 2026-07-25T09:53:47+00:00 | submit_job | success | 2026-07-25T09:53:48.613644+00:00 | 2026-07-25T09:53:49.066573+00:00
cityflow_pyspark_on_k8s | 2026-07-25T09:53:47+00:00 | report_spark_result | success | 2026-07-25T09:54:02.641760+00:00 | 2026-07-25T09:54:03.025350+00:00
cityflow_pyspark_on_k8s | 2026-07-25T09:53:47+00:00 | poll_job_completion | success | 2026-07-25T09:53:49.387434+00:00 | 2026-07-25T09:54:01.422136+00:00poll_job_completion’s own log shows six real polls — about 12 real seconds for a real JVM to start, read a real parquet file, join it to a real CSV, and aggregate:
poll_job_completion: poll #1 succeeded='' failed=''
poll_job_completion: poll #2 succeeded='' failed=''
poll_job_completion: poll #3 succeeded='' failed=''
poll_job_completion: poll #4 succeeded='' failed=''
poll_job_completion: poll #5 succeeded='' failed=''
poll_job_completion: poll #6 succeeded='1' failed=''And report_spark_result parsed the real Spark output straight out of the real pod’s logs:
report_spark_result: real pod 'cityflow-pyspark-on-k8s-g7g5f' computed: {'total_rows': 81013, 'by_borough': [{'Borough': 'Manhattan', 'trip_count': 68416, 'total_revenue': 1673556.42}, {'Borough': 'Queens', 'trip_count': 10199, 'total_revenue': 681048.44}, {'Borough': 'Brooklyn', 'trip_count': 1633, 'total_revenue': 56522.26}, {'Borough': 'Bronx', 'trip_count': 344, 'total_revenue': 10005.46}, {'Borough': 'Unknown', 'trip_count': 287, 'total_revenue': 9510.23}, {'Borough': 'Unmatched Zone', 'trip_count': 106, 'total_revenue': 9695.35}, {'Borough': 'EWR', 'trip_count': 21, 'total_revenue': 1991.52}, {'Borough': 'Staten Island', 'trip_count': 7, 'total_revenue': 513.57}], 'top_borough': 'Manhattan', 'top_borough_trip_count': 68416}81,013 total rows, matching Module 6 Lesson 1’s already-published regression check exactly. Manhattan leads with 68,416 trips and $1,673,556.42 in revenue, followed by Queens (10,199 / $681,048.44), Brooklyn (1,633 / $56,522.26), Bronx (344 / $10,005.46), the zone lookup’s own “Unknown” category (287 / $9,510.23), the real “Unmatched Zone” group this lesson’s coalesce fix surfaced (106 / $9,695.35), EWR — Newark Airport (21 / $1,991.52), and Staten Island (7 / $513.57). Every trip in the file lands in exactly one of those eight groups; nothing was silently dropped.
Key Takeaways
- A headless JRE (
openjdk-21-jre-headless) is the right choice for a container that only runs Spark, not one that compiles anything — real image weight saved for no lost capability. - Baking a small, fixed, real input directly into an image via
COPYis the right call for a single-run Job with no registry or PVC in play — Course 3’s own capstone shows the different design a repeatedly-firing job needs instead. - A base image’s own package repository can drift out from under a lesson written against it — this lesson’s real
openjdk-17-jre-headless→openjdk-21-jre-headlessswap is a genuine example, not a hypothetical warning. - Spark’s
groupBykeeps a realNULL-keyed group that pandas’ defaultgroupby()would silently drop — this lesson’s realTypeErrorcrash, and itscoalesce-based fix, came directly from that difference. - Lesson 2’s exact submit-poll-read-logs mechanism needed zero changes to run a real, much heavier Spark job — only the image and the log-parsing logic changed.
Exercises
Exercise 1 — Modify cityflow_pyspark_job.py to also compute the average trip_distance per borough (F.round(F.avg("trip_distance"), 2)), rebuild the image, kind load it again, and re-trigger the DAG. Confirm the new field appears in the real CITYFLOW_PYSPARK_RESULT_JSON line.
Exercise 2 — Deliberately break the join by changing trips.PULocationID == zones.LocationID to compare against the wrong column (e.g. zones.OtherID, which doesn’t exist). Rebuild, re-load, and re-trigger — read the real Spark traceback in kubectl logs and identify which real exception type Spark raises for a reference to a nonexistent column.
Exercise 3 — Using the real per-borough numbers this lesson published, compute each borough’s share of total revenue by hand (its total_revenue divided by the sum of all eight). Does Manhattan’s share of revenue match its share of trips, or does it differ — and what would a difference tell CityFlow’s analysts about average fare size by borough?