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
@taskthat builds and applies a real Kubernetes Job manifest via akubectlsubprocess, idempotently - Write a polling task that waits on a real Job’s
.status.succeededfield 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 idempotentAll 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_job→poll_job_completion→report_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-foundbeforekubectl applyis 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 rankubectl applysucceeded” — 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-imageturned 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 viakind 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?