Lesson 5 - Guided Project: Airflow, Spark, and Kubernetes, End to End
Welcome to the Guided Project
This project doesn’t introduce a new mechanism — it proves the one this module already built is genuinely solid, by assembling it into one clean DAG and asking it to reproduce, exactly, a real result this module already published. cityflow_airflow_spark_k8s_guided_project_dag.py submits the same real cityflow-pyspark-job:1.0 image as its own real Job, polls it the same real way, and then does one thing Lesson 3’s DAG didn’t: asserts the real computed numbers match, row for row, rather than just printing them.
By the end of this project, you will be able to:
- Assemble submission, polling, and result verification into one complete, self-checking DAG
- Trigger a real end-to-end Airflow-to-Kubernetes-to-Spark run and confirm it reproduces a previously-published real result
- Confirm the same real run through both the Airflow UI and direct
kubectloutput, independently
The Combined DAG
# gate: skip
"""CityFlow's real Airflow + Spark + Kubernetes guided project (Module 7
Lesson 5).
Lessons 2-4 combined into one clean, complete DAG: submit a real PySpark Job
to the real datatweets-docker-k8s Kind cluster from a real Airflow task,
poll the real Job's status to completion, and read back the real Spark
result from the real pod's logs -- the exact shape Lesson 3 proved, run
here as this module's capstone rather than a lesson demo. Still no
apache-airflow-providers-cncf-kubernetes -- every Kubernetes call is a real
subprocess to the same real kubectl binary + kubeconfig this course
host-mounted into the Airflow containers, talking to Kind's real API server
at https://host.docker.internal:<port> (insecure-skip-tls-verify: true,
because Kind's certificate has no host.docker.internal SAN -- a real,
stated local-dev trade-off, not a hidden one)."""
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-airflow-spark-k8s-guided-project"
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_airflow_spark_k8s_guided_project",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["cityflow", "module-7", "guided-project", "kubernetes", "pyspark"],
)
def cityflow_airflow_spark_k8s_guided_project_dag():
@task
def submit_pyspark_job() -> str:
"""Delete-then-apply, the same idempotent pattern Lesson 2 proved and
Lesson 3 reused -- a second run of this exact DAG (or a second run of
the code gate, proving idempotency) creates the same real Job again
cleanly, never fails on 'already exists'."""
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_pyspark_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 verify_spark_result(poll_result: dict) -> dict:
"""Reads the real pod's real logs, parses the real
CITYFLOW_PYSPARK_RESULT_JSON: line the Spark job printed, and checks
it against the exact real numbers this module already established in
Lesson 3 -- proof this guided project's end-to-end run reproduces
the same real computation, not a different or approximate one."""
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"verify_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"]
assert result["top_borough_trip_count"] == 68416, result["top_borough_trip_count"]
by_borough = {r["Borough"]: r for r in result["by_borough"]}
assert by_borough["Manhattan"]["total_revenue"] == 1673556.42
assert sum(r["trip_count"] for r in result["by_borough"]) == 81013
print("verify_spark_result: every real number matches Lesson 3's already-verified result")
return {"pod_name": pod_name, **result}
verify_spark_result(poll_job_completion(submit_pyspark_job()))
cityflow_airflow_spark_k8s_guided_project_dag()The only real design change from Lesson 3’s DAG: verify_spark_result doesn’t just print the parsed result, it asserts against Lesson 3’s own already-published numbers — 81013 total rows, "Manhattan" as the top borough with 68416 trips and 1673556.42 in revenue, and that every borough’s trip count really does sum back to the full 81013. A DAG whose last task can fail on a wrong number is a genuinely different, stronger claim than one that merely prints whatever it got.
Triggered End to End
import json
import subprocess
import time
dag_id = "cityflow_airflow_spark_k8s_guided_project"
subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
capture_output=True, text=True, check=True)
run_id = f"lesson5_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)
# Confirm the real pod independently via kubectl, outside anything Airflow reported.
pods = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "/opt/airflow/dags/bin/kubectl",
"--kubeconfig", "/opt/airflow/dags/data/kubeconfig-kind.yaml", "get", "pods", "-n", "default",
"-l", "app=cityflow-airflow-spark-k8s-guided-project"],
capture_output=True, text=True, check=True,
)
print(pods.stdout)
assert "Completed" in pods.stdouttriggered 'cityflow_airflow_spark_k8s_guided_project' as 'lesson5_m7_verify_1784974362'
run 'lesson5_m7_verify_1784974362' reached state: success
dag_id | execution_date | task_id | state | start_date | end_date
==========================================+===========================+=====================+=========+==================================+=================================
cityflow_airflow_spark_k8s_guided_project | 2026-07-25T10:12:45+00:00 | submit_pyspark_job | success | 2026-07-25T10:12:46.025923+00:00 | 2026-07-25T10:12:46.399146+00:00
cityflow_airflow_spark_k8s_guided_project | 2026-07-25T10:12:45+00:00 | poll_job_completion | success | 2026-07-25T10:12:47.671205+00:00 | 2026-07-25T10:12:59.472991+00:00
cityflow_airflow_spark_k8s_guided_project | 2026-07-25T10:12:45+00:00 | verify_spark_result | success | 2026-07-25T10:12:59.978055+00:00 | 2026-07-25T10:13:00.334196+00:00
NAME READY STATUS RESTARTS AGE
cityflow-airflow-spark-k8s-guided-project-97phz 0/1 Completed 0 51sAll three tasks succeeded — critically, that includes verify_spark_result’s own real assertions against Lesson 3’s numbers, which means the DAG run reaching success at all is already proof the two runs agree. poll_job_completion’s log shows the same real shape Lesson 3’s did — six polls, about 12 real seconds for a real JVM to start and finish real work:
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 verify_spark_result’s own log confirms the real numbers, and the real assertions passing:
verify_spark_result: real pod 'cityflow-airflow-spark-k8s-guided-project-97phz' 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}
verify_spark_result: every real number matches Lesson 3's already-verified resultByte-for-byte the same eight borough groups, the same 81013 total, the same 68416-trip, $1,673,556.42 Manhattan lead — this project’s independent end-to-end run reproduced Lesson 3’s real result exactly, not approximately.
Confirmed a Second Way: The Airflow Grid View

What This Project Actually Proved
Nothing about the mechanism changed across this module’s five lessons — the same delete-then-apply submission, the same .status.succeeded poll loop, the same kubectl logs-and-parse pattern proved on a trivial pod in Lesson 2 carried a real Spark job’s real result all the way back to an Airflow task’s return value here, unmodified in shape. What this project adds is the strongest kind of proof available: not “the DAG reached success,” but “the DAG reached success and its own real assertions confirm the number it computed matches a number this module already published independently.” A pipeline that can check its own answer, and refuses to report success if the answer is wrong, is a meaningfully different — and more trustworthy — thing than one that just runs without raising.
Key Takeaways
- Combining submission, polling, and result-reading into one DAG required no new mechanism — Lesson 2’s shape, proved on a trivial pod, carried a real Spark job’s real result unchanged.
- A task that asserts its result against a known-good number, rather than only printing it, turns “the DAG succeeded” into a real claim about correctness, not just about completion.
- Two independent triggers of this real Job — this project’s and Lesson 3’s — produced byte-for-byte identical real numbers:
81013total rows, Manhattan’s68416trips and$1,673,556.42, all eight borough groups matching exactly. - Confirming the same run through both the Airflow Grid view and direct
kubectloutput, independently, is the same discipline Lesson 4 taught — applied here to this module’s own final, combined pipeline.
Exercises
Exercise 1 — Add a fourth task, report_summary, that depends on verify_spark_result and prints a one-line human-readable summary (e.g. "CityFlow PySpark-on-K8s: 81,013 trips processed, Manhattan led with 68,416 trips ($1,673,556.42)") using the dict verify_spark_result already returns. Trigger the DAG again and confirm the new task’s log shows the real formatted line.
Exercise 2 — Deliberately change one of verify_spark_result’s assertions to a wrong value (e.g. assert result["top_borough_trip_count"] == 99999), trigger the DAG, and confirm it fails at exactly that task — with poll_job_completion still green, since the Job itself genuinely did succeed; only the verification failed. Revert the change afterward.
Exercise 3 — This module never installed apache-airflow-providers-cncf-kubernetes. Write two or three sentences comparing the kubectl-subprocess approach this module used against what KubernetesPodOperator would have handled automatically (pod spec construction, log streaming, status polling) — what did writing it by hand make more visible about what’s actually happening at each step, and what would the provider package have saved you from writing at all?