Skip to content

Repository files navigation

Data Engineering Lab: NYC Taxi Data Platform (Spark · Kafka · Flink)

A self-contained lab for running distributed PySpark analytics over the full NYC Yellow Taxi dataset (2016–2025, ~655M rides) on a local Kubernetes cluster, plus a streaming tier that replays those trips as Kafka events and processes them with Apache Flink. Everything - the dev environment, the cluster, the Spark standalone cluster, Kafka, Flink, and the data - is provisioned reproducibly from code, so the whole stack comes up with a single devcontainer build.

┌──────────────────────── VS Code Devcontainer (Python 3.11) ────────────────────────────┐
│                                                                                        │
│   Jupyter / spark_benchmark.py  ──► Spark Driver ─┐                                    │
│   (taxi_data_platform)                       │  spark://…:7077                         │
│                                              ▼                                         │
│   ┌──────────────── kind cluster (Kubernetes, provisioned by Terraform) ───────────┐   │
│   │                                                                                │   │
│   │   spark-master (Deployment + Service)                                          │   │
│   │        ▲                                                                       │   │
│   │        │  registers                                                            │   │
│   │   spark-worker × N  (custom Python 3.11 image, 1 core / 8G each)               │   │
│   │        │                                                                       │   │
│   │        └── mounts taxi-pvc (hostPath PV) ◄── seeded parquet (2016–2025)        │   │
│   │                         │                                                      │   │
│   │   ┌─────────────────────┼──────────────── streaming tier ──────────────────┐   │   │
│   │   │  taxi-producer Job ─┘  reads PV, time-ordered replay                   │   │   │
│   │   │        │ JSON events                                                   │   │   │
│   │   │        ▼                                                               │   │   │
│   │   │  Kafka (KRaft, 1 broker)  topic: taxi-trips                            │   │   │
│   │   │        │                                                               │   │   │
│   │   │        ▼                                                               │   │   │
│   │   │  Flink session cluster (JobManager + TaskManagers, PyFlink)            │   │   │
│   │   │        │  tumbling event-time windows                                  │   │   │
│   │   │        ▼                                                               │   │   │
│   │   │  Kafka  topic: taxi-metrics  ──► console consumer                      │   │   │
│   │   └────────────────────────────────────────────────────────────────────────┘   │   │
│   └────────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                        │
│   Tilt orchestrates the k8s manifests + port-forwards Spark (:8080) & Flink (:8081)    │
└────────────────────────────────────────────────────────────────────────────────────────┘

1. Project setup (Devcontainer)

The entire environment is defined under .devcontainer/ and builds from .devcontainer/Dockerfile. Open the repo in VS Code and "Reopen in Container" — no host tooling required beyond Docker.

What the image provides:

  • Python 3.11 (python:3.11-bookworm) with uv for dependency management
  • OpenJDK 17 (required by the Spark client)
  • Infra CLIs baked in: kubectl (v1.33), terraform, kind (v0.32), tilt
  • Docker-in-Docker so the container can build images and run the kind cluster

Bring-up sequence (run automatically on container create/start):

Hook Script Does
postCreateCommand post-create-commands.sh uv sync deps → create kind cluster + seed data → build & load the custom Spark worker image
postStartCommand post-start-commands.sh Wait for Docker, recreate the kind cluster if it didn't survive a restart, refresh kubeconfig

Forwarded ports & UIs (available after make up-everything):

Service URL Notes
Tilt UI http://localhost:10350 Resource status, logs, and controls for all k8s workloads
Spark UI http://localhost:8080 Jobs, stages, executors, and storage for the Spark standalone cluster
Flink UI http://localhost:8081 Running jobs, TaskManagers, and checkpoints for the Flink session cluster

2. Infrastructure

All cluster resources are declarative. The Makefile wires the steps together.

Tool What it does
Docker Container runtime underpinning everything. Runs the devcontainer and, via Docker-in-Docker, hosts the kind node container and builds the custom Spark worker image.
Terraform Infrastructure-as-code. Declaratively provisions (and destroys) the local kind cluster through the tehcyx/kind provider, so the cluster is reproducible from terraform/main.tf.
kind Kubernetes-in-Docker — runs a full Kubernetes cluster inside a single Docker container, no cloud or VM required. Hosts the Spark master/workers and the taxi-data volume.
Kubernetes Container orchestrator that kind runs. Schedules the Spark master/worker Pods, exposes the master via a Service, persists data with a PV/PVC, and handles scaling/self-healing of the worker Deployment.
Tilt Dev orchestrator. Applies the Spark manifests and port-forwards the master (7077) and UI (8080), so one command (tilt up) brings the cluster workloads up with a live dashboard.

Terraform + kind

terraform/main.tf provisions a local Kubernetes cluster via the tehcyx/kind provider — a single-node kind cluster named terraform-kind (kindest/node:v1.29.2). make create-k8-cluster runs terraform apply, exports the kubeconfig, and seeds the taxi data.

Data seeding

The taxi parquet files are downloaded to the host with datasets/NYC_Yellow_Taxi_Trip/download.sh (120 monthly files, 2016–2025). make seed-data then:

  1. Applies a 15Gi ReadWriteMany hostPath PV/PVC (taxi-pv.yaml, taxi-pvc.yaml)
  2. Spins up a busybox seeder pod (taxi-seeder.yaml)
  3. kubectl cps each year's parquet into the volume, then tears the pod down

Spark cluster (Kubernetes manifests)

Spark runs in standalone mode inside the cluster (kubernetes/spark/):

Manifest Resource
spark-rbac.yaml spark ServiceAccount + RBAC
spark-master.yaml Master Deployment + spark-master-svc (ports 7077 / 8080)
spark-workers.yaml Worker Deployment — N replicas, 1 core / 8G each
spark-worker.Dockerfile Custom worker image with Python 3.11

Kafka (Kubernetes manifests)

A single-broker KRaft Kafka (no ZooKeeper) defined in raw manifests (kubernetes/kafka/):

Manifest Resource
kafka.yaml Kafka StatefulSet (KRaft combined broker+controller) + headless kafka-svc
kafka-init.yaml Job that creates the taxi-trips (6 partitions) and taxi-metrics topics
taxi-producer.yaml Job that runs the producer — reads from the shared PV, replays trips in event-time order
taxi-producer.Dockerfile Producer image (Python 3.11 + kafka-python)

Flink session cluster (Kubernetes manifests)

A standalone session cluster mirroring the Spark master/workers layout (kubernetes/flink/):

Manifest Resource
flink-jobmanager.yaml JobManager Deployment + Service (REST/UI 8081)
flink-taskmanager.yaml TaskManager Deployment (N replicas, 4 slots each)
flink.Dockerfile Custom image: Flink 2.3 + Kafka SQL connector

Tilt

Tiltfile applies all manifests (Spark, Kafka, Flink) and port-forwards the Spark master (7077 / 8080), Kafka (9092, external 9094), and Flink (8081). Bring the whole stack up with:

make up-everything   # create-k8-cluster + tilt up

3. The taxi_data_platform project

Dataset

Both tiers run on the public NYC Yellow Taxi Trip Records (NYC Taxi & Limousine Commission), 2016–2025: 120 monthly parquet files, ~655 million rides (~15 GB). Rows are filtered to the valid year range before processing, and the Spark jobs read only the columns they need (tpep_pickup_datetime, fare_amount) so unused columns are pruned at the parquet level. Download via download.sh.

Spark (batch)

A PySpark analytics application that answers four questions about New York City taxi traffic over a decade of trip records. The Spark driver runs inside the devcontainer while the computing is distributed across the Spark workers.

Layout

  • spark/session.pySparkSession factory + spark_session context manager. Resolves a driver host IP the executor pods can route back to (the devcontainer is the kind bridge gateway, not a peer) and pins python3.11 on the executors so UDFs work.
  • spark/jobs/ — one module per analysis. Each exposes a pure DataFrame → DataFrame function (imported by both the notebook and the benchmark) plus a standalone main() for one-off CLI runs. _common.py holds the shared multi-year loader.
  • spark/taxi_data_analysis.ipynb — loads the dataset once (cached, MEMORY_AND_DISK), runs all four jobs, and renders + exports the charts at 300 DPI to charts/.
  • spark_benchmark.py — runs every job x times at y workers, uncached, reporting avg/std (see §4).

The four tasks & results

All charts were produced on the full dataset (~655M rides) using a 10-worker Spark cluster (1 core / 8 GB each).

Chart Question
Rides per month How many rides per month?rides_per_month.py
groupBy(year, month).count() over the full dataset. The COVID-19 lockdown collapse in March 2020 dominates the trend.
Average rides per month Which month is busiest on average?avg_rides_per_month.py
Per-year monthly totals collapsed into an average per calendar month, drawn as parallel coordinates with the average overlaid.
Month-over-month fare change Month-over-month fare changemom_fare_change.py
Spark SQL with a LAG(...) OVER (ORDER BY year, month) window function to compute the percentage change in average fare versus the prior month.
Rides by time of day Rides by time of dayrides_by_time_of_day.py
A vectorized Pandas UDF classifies each pickup hour into a time-of-day bucket (Night / Morning Rush / … / Evening), then aggregates.

Streaming tier (Kafka + Flink)

The same parquet data also feeds a streaming pipeline: a producer replays trips in pickup-time order as Kafka events, and Flink computes windowed metrics that it writes back to Kafka. All components are managed by the same Tilt/Makefile workflow as Spark.

Producer

flink/kafka/producer.py reads the parquet from the shared PV, prunes out-of-range timestamps (matching the Spark loader), sorts each file by pickup time, and publishes one JSON event per trip. A --speedup factor maps event-time gaps to wall-clock sleeps, so the historical data plays back as a (compressed) live stream with event-time semantics. It runs as a Job from a small image (Python 3.11 + kafka-python).

Flink

A standalone session cluster. Jobs are built and submitted straight from Pythonflink/session.py is the Flink analog of create_spark_session: it returns a remote TableEnvironment pointed at the JobManager REST endpoint (localhost:8081), so there is no flink CLI, kubectl, or ConfigMap involved. Each job in flink/jobs/ exposes a run(t_env) function (shared Kafka source DDL with an event-time watermark lives in _common.py):

Both emit append-only rows (one per closed window) to the taxi-metrics topic.

Streaming notebook

flink/taxi_streaming_analysis.ipynb is the streaming analog of the Spark notebook: it submits both Flink jobs (via flink.session), replays trips into Kafka, reads the windowed metrics back from taxi-metrics, and renders + exports charts (300 DPI) to charts/:

Chart Question
Rides per window Rides per day — the live demand curve
Average fare per window Average fare per day
Rides vs fare Demand vs. fare on a shared timeline

4. Benchmark results

Spark (batch)

Run with spark_benchmark.py: It reads the full dataset fresh on every run (no caching, identical work per run) so timings are comparable across worker counts. Below: average seconds ± std over 5 runs, by number of workers (1 core each).

Job 1 worker 5 workers 10 workers 20 workers
rides_per_month 76.64 ± 2.71 17.86 ± 2.04 10.68 ± 2.42 8.74 ± 3.88
avg_rides_per_month 72.05 ± 0.56 16.34 ± 0.33 9.25 ± 0.47 6.79 ± 0.46
mom_fare_change 87.18 ± 0.56 20.26 ± 0.35 11.30 ± 0.64 8.27 ± 1.01
rides_by_time_of_day 146.98 ± 1.13 35.17 ± 1.65 22.02 ± 2.14 20.42 ± 2.42
Total 382.85 89.63 53.25 44.22

Scaling from 1 → 20 workers cuts the end-to-end suite from ~383s to ~44s, roughly an 8.7× speedup.

Flink (streaming)

Run with flink_benchmark.py. It loads 1,000,000 trips (January 2025) into Kafka once, then submits the bounded job at increasing parallelism, timing each run. Below: average seconds ± std over 5 runs, by Flink parallelism (= workers, 1 task slot each).

Job 1 worker 5 workers 10 workers 20 workers
rides_per_window 9.20 ± 0.78 3.19 ± 0.77 3.25 ± 0.75 3.44 ± 1.91
avg_fare 8.88 ± 0.03 2.85 ± 0.03 4.93 ± 4.52 2.42 ± 0.73
Total 18.08 6.03 8.18 5.86

Quick reference

# Full stack (cluster + data + Spark + Kafka + Flink + Tilt UI)
make up-everything

# Spark batch benchmark
make spark-benchmark runs=5 workers=10

# open notebook
make notebook

# Flink benchmark (run for each worker count)
make flink-benchmark runs=5 workers=10

# Quality gates
make run-tests check-linting check-types

About

Spark, Flink & Kafka deployed with Kubernetes & Terraform

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages