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 kubectl output, 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.stdout
triggered '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          51s

All 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 result

Byte-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

Screenshot of the Airflow Grid view for cityflow_airflow_spark_k8s_guided_project, showing three task rows -- submit_pyspark_job, poll_job_completion, verify_spark_result -- all green, Status success, Run duration 00:00:15.
The real Grid view for this guided project's own run: all three tasks green, Status `success`, real run duration `00:00:15` -- the same real event the CLI output above already confirmed, from Airflow's own UI.

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: 81013 total rows, Manhattan’s 68416 trips and $1,673,556.42, all eight borough groups matching exactly.
  • Confirming the same run through both the Airflow Grid view and direct kubectl output, 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?

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com