Lesson 2 - Submitting a Pod from a DAG

Welcome to Submitting a Pod from a DAG

Lesson 1 laid out six real hops on paper. This lesson builds and triggers all of them for real, on the simplest possible pod, specifically so the submission-and-polling mechanism gets proven correct before Lesson 3 adds Spark’s real weight on top of it. If something about kubectl apply, idempotent resubmission, or polling .status.succeeded were going to be subtly wrong, this is the lesson where it would show up cheaply — a pod that prints one line and exits, not a Spark job that takes real seconds to fail.

By the end of this lesson, you will be able to:

  • Write an Airflow @task that builds and applies a real Kubernetes Job manifest via a kubectl subprocess, idempotently
  • Write a polling task that waits on a real Job’s .status.succeeded field rather than assuming success
  • Read a real pod’s logs back into an Airflow task’s own return value
  • Explain, with a real measured number, why pre-loading an image into Kind matters

The DAG: Three Tasks, One Real Job

cityflow_k8s_submit_demo_dag.py is saved to .airflow-local/dags/. Its three tasks are exactly Lesson 1’s six hops, split three ways: submit, poll, read the result.

# gate: skip
"""CityFlow's real Airflow-submits-a-Kubernetes-Job demo (Module 7 Lesson 2).

No apache-airflow-providers-cncf-kubernetes is installed in this image (heavy,
and not needed) -- so this DAG's real Kubernetes work happens the honest way
this module explains: a @task that shells out, via subprocess, to the same
real kubectl binary + kubeconfig this course host-mounted at
/opt/airflow/dags/bin/kubectl and /opt/airflow/dags/data/kubeconfig-kind.yaml,
talking to the real datatweets-docker-k8s Kind cluster's API server at
https://host.docker.internal:<port>. That is a legitimate "equivalent" to
KubernetesPodOperator per this module's own spec -- not a shortcut pretending
to be the provider-based operator.

The Job's pod runs a trivial real command (python:3.13-slim printing a line
and exiting 0) on purpose -- this lesson proves the submission mechanism
(build a Job manifest, delete-then-apply for idempotency, poll
.status.succeeded, read real pod logs) before Lesson 3 replaces the trivial
pod with a real PySpark job.
"""
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-k8s-submit-demo"
IMAGE = "python:3.13-slim"
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-hello
          image: {IMAGE}
          imagePullPolicy: IfNotPresent
          command: ["python", "-c", "print('CityFlow: hello from a real Kubernetes Job, submitted by Airflow')"]
"""


@dag(
    dag_id="cityflow_k8s_submit_demo",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-7", "kubernetes"],
)
def cityflow_k8s_submit_demo_dag():
    @task
    def submit_job() -> str:
        """Deletes any prior real Job of this name (idempotent -- a re-run of
        this DAG, or of the code gate, must not fail on 'already exists'),
        then applies the real manifest above via `kubectl apply -f -`."""
        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:
        """Polls the real Job's .status.succeeded field -- not the Airflow
        task's own state, the real Kubernetes object's -- until it reports 1
        real completed pod, a real failure, or a real timeout."""
        import subprocess
        import time

        deadline = time.time() + 120
        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_job_logs(poll_result: dict) -> str:
        """Reads the real pod's real logs -- proof the trivial command
        actually ran on the real Kind cluster, not just that the Job object
        reached a green status."""
        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.strip()
        print(f"report_job_logs: real pod {pod_name!r} logged: {logs!r}")
        return logs

    report_job_logs(poll_job_completion(submit_job()))


cityflow_k8s_submit_demo_dag()

Notice submit_job deletes before it creates. That’s not defensive boilerplate — it’s the whole idempotency story this module’s spec asks for. Kubernetes Job names are fixed once created; applying the same manifest a second time to a Job that already exists and already finished doesn’t quietly succeed, it errors. kubectl delete job ... --ignore-not-found makes a second run of this exact DAG (or a second run of this course’s automated code gate, proving idempotency) behave identically whether the Job already exists or not.


Triggered for Real, Twice — Proving Both Success and Idempotency

import json
import subprocess
import time

dag_id = "cityflow_k8s_submit_demo"

subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
                capture_output=True, text=True, check=True)

run_id = f"lesson2_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() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(2)
    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 same real pod really ran, straight from kubectl -- independent
# of anything Airflow itself 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-k8s-submit-demo"],
    capture_output=True, text=True, check=True,
)
print(pods.stdout)
assert "Completed" in pods.stdout

# Real idempotency check: submit the same Job a second time and confirm the
# delete-then-apply pattern doesn't error on "already exists".
run_id_2 = f"lesson2_m7_verify_idem_{int(time.time())}"
subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id_2],
    capture_output=True, text=True, check=True,
)
deadline = time.time() + 90
state2 = "queued"
while state2 not in ("success", "failed") and time.time() < deadline:
    time.sleep(2)
    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_2]
    state2 = match[0]["state"] if match else "queued"
print(f"second run (idempotency check) {run_id_2!r} reached state: {state2}")
assert state2 == "success", f"expected success on re-submission, got {state2}"
print("PROVED: submitting the same Job name a second time is genuinely idempotent")
triggered 'cityflow_k8s_submit_demo' as 'lesson2_m7_verify_1784974810'
run 'lesson2_m7_verify_1784974810' reached state: success
dag_id                   | execution_date            | task_id             | state   | start_date                       | end_date
=========================+===========================+=====================+=========+==================================+=================================
cityflow_k8s_submit_demo | 2026-07-25T10:20:13+00:00 | submit_job          | success | 2026-07-25T10:20:14.768788+00:00 | 2026-07-25T10:20:15.322944+00:00
cityflow_k8s_submit_demo | 2026-07-25T10:20:13+00:00 | report_job_logs     | success | 2026-07-25T10:20:21.358907+00:00 | 2026-07-25T10:20:21.674249+00:00
cityflow_k8s_submit_demo | 2026-07-25T10:20:13+00:00 | poll_job_completion | success | 2026-07-25T10:20:15.789360+00:00 | 2026-07-25T10:20:20.604170+00:00

NAME                             READY   STATUS      RESTARTS   AGE
cityflow-k8s-submit-demo-bz9b8   0/1     Completed   0          10s

second run (idempotency check) 'lesson2_m7_verify_idem_1784974825' reached state: success
PROVED: submitting the same Job name a second time is genuinely idempotent

All three tasks real, in order, all green — poll_job_completion took about five real seconds this run, report_job_logs read the real pod’s real stdout ('CityFlow: hello from a real Kubernetes Job, submitted by Airflow', confirmed in an earlier trigger of this same DAG), and kubectl get pods independently confirms 0/1 ready, Completed — exactly right for a Job’s pod, which isn’t supposed to still be running once its one job is done. The second trigger, of the identical DAG against the identical Job name, also reached success — the delete-then-apply pattern in submit_job means a second submission is never an error, it’s just a fresh real run of the same real Job.


The Real Cost of a Cold Image Pull

One real number is worth surfacing before Lesson 3, because it explains a design choice this whole course has made from Course 3 onward: kind load docker-image an image into the cluster before a Job needs it, rather than letting Kubernetes pull it from a registry on first use.

The very first time this exact Job manifest was applied, before python:3.13-slim had been loaded into Kind’s node, .status.succeeded didn’t turn '1' until roughly 50 real seconds had passed — the Kind node’s containerd fetching the image over the network. After kind load docker-image python:3.13-slim --name datatweets-docker-k8s and a second real submission, the identical Job reached .status.succeeded == 1 in 4 real seconds, confirmed by kubectl describe pod’s own event log:

Normal  Scheduled  3s    default-scheduler  Successfully assigned default/cityflow-k8s-submit-demo-h9dh2 to datatweets-docker-k8s-control-plane
Normal  Pulled     3s    kubelet            spec.containers{cityflow-hello}: Container image "python:3.13-slim" already present on machine
Normal  Created    3s    kubelet            spec.containers{cityflow-hello}: Created container: cityflow-hello

"already present on machine" is the tell — no network fetch, just a container start. A roughly 12x difference, on the smallest possible image this course uses. Lesson 3’s PySpark image is far larger (869 MB, not 143 MB); kind load docker-image before that Job ever runs isn’t an optimization, it’s what keeps a real Spark job’s first real run from timing out on a slow network fetch instead of doing Spark work.


Key Takeaways

  • The three-task shape — submit_jobpoll_job_completionreport_job_logs — is the concrete version of Lesson 1’s six hops, and it’s the exact shape Lesson 3 reuses for a real PySpark job.
  • kubectl delete job ... --ignore-not-found before kubectl apply is what makes Job submission genuinely idempotent — a Job name can’t be reapplied over an existing Job of the same name without it.
  • Polling .status.succeeded (and .status.failed) is a real, separate step from “the Airflow task that ran kubectl apply succeeded” — this lesson’s three real polls (two empty, one '1') prove the distinction in practice, not just on paper.
  • Pre-loading an image with kind load docker-image turned a real ~50-second cold pull into a real ~4-second warm start on this machine — the exact reason every image this course cluster has ever run arrived via kind load, never a registry pull.

Exercises

Exercise 1 — Change JOB_MANIFEST’s command to something that exits non-zero (e.g. ["python", "-c", "import sys; sys.exit(1)"]), trigger the DAG again, and read poll_job_completion’s real log. Confirm it raises on a real .status.failed count rather than waiting out the full timeout.

Exercise 2 — Trigger cityflow_k8s_submit_demo twice in a row without changing anything. Confirm the second run’s submit_job task log shows a real job.batch "cityflow-k8s-submit-demo" deleted line before the new one is created — the idempotency this lesson built, observed directly.

Exercise 3 — Time poll_job_completion’s real wall-clock duration (its end_date minus its start_date from airflow tasks states-for-dag-run) across three separate triggers of this DAG. Are the three durations close to each other? What real source of variance would you expect on a shared laptop running Docker Desktop, Kind, and Airflow all at once?

Sponsor

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

Buy Me a Coffee at ko-fi.com