A solo data engineering project that ingests live cryptocurrency trade data from the Binance WebSocket API, streams it through Kafka, processes it with Spark Structured Streaming, lands it as Parquet in a MinIO (S3-compatible) data lake, orchestrates maintenance with Airflow, and serves it through Spark SQL to Superset dashboards.
Binance WebSocket (BTC/USDT live trades)
|
v
Kafka Producer (Python, confluent-kafka)
|
v
Kafka Topic: raw_trades (3 partitions)
|
v
Spark Structured Streaming
- parses JSON, casts types
- Sink 1: raw trade events -> Parquet (partitioned by date)
- Sink 2: 1-minute OHLC aggregation (tumbling window) -> Parquet
|
v
MinIO (S3-compatible data lake)
s3a://streaming-lake/raw_trades
s3a://streaming-lake/ohlc_1min
|
+-----------------------------+
| |
v v
Airflow (containerized) Spark Thrift Server
- parquet_compaction DAG - exposes SQL tables over JDBC/Hive
- runs every 10 minutes - reads raw_trades + ohlc_1min_compacted
- writes to a separate |
ohlc_1min_compacted path v
(never overwrites the Superset (containerized)
live streaming sink) - live tick-level BTC price chart
- 1-minute OHLC chart
- dashboard auto-refreshes every 30s
- Kafka decouples ingestion from processing. The producer doesn't know or care what consumes its data; Spark could be swapped for another consumer without touching the producer.
- Two independent streaming sinks off one parsed stream demonstrate both raw event storage (for reprocessing/audit) and real-time aggregation (for analytics), a common pattern in production streaming systems.
- MinIO instead of real S3 keeps the whole stack local and free to run, while using the same S3A APIs that would work unchanged against real AWS S3.
- Airflow orchestrates the batch/maintenance layer, not the stream itself. Streaming jobs are long-running processes; Airflow's role here is compaction of small Parquet files, run on a schedule, completely separate from the always-on streaming job.
- Compaction writes to a separate output path (
ohlc_1min_compacted), never the live streaming sink. Structured Streaming maintains a_spark_metadatalog to guarantee exactly-once output; a batch job overwriting that same path directly corrupts the log. This was discovered and fixed during development (see Lessons Learned below) and shapes the bronze/silver-style separation in the architecture. - Spark Thrift Server, not a full Trino+Hive Metastore setup, for the query layer. It reuses the same Spark install already proven out in the streaming job, avoids standing up an extra metastore service, and still gives a real JDBC/Hive-compatible SQL interface that Superset (or any BI tool) can connect to like a normal database.
| Component | Tool |
|---|---|
| Message broker | Apache Kafka (KRaft mode) |
| Stream processing | Spark Structured Streaming (PySpark 3.5.3) |
| Object storage | MinIO (S3-compatible) |
| File format | Parquet |
| Kafka producer client | confluent-kafka (librdkafka) |
| Orchestration | Apache Airflow 2.10.3 (containerized, custom image w/ Java + PySpark) |
| Query engine | Spark Thrift Server (JDBC/Hive protocol) |
| Dashboards | Apache Superset (containerized) |
producer/
binance_producer.py # Streams live BTC/USDT trades from Binance -> Kafka
requirements.txt
spark/
streaming_job.py # Kafka -> Parquet on MinIO (raw + OHLC aggregation)
compact_parquet.py # Compacts small OHLC Parquet files into ohlc_1min_compacted
verify_read.py # Utility script to read back and sanity-check output
start_thrift_server.sh # Starts the Spark Thrift Server (SQL over the lake)
sql/
create_tables.sql # Registers raw_trades + ohlc_1min_compacted as SQL tables
requirements.txt
airflow/
Dockerfile # Custom Airflow image with Java + PySpark baked in
dags/
compaction_dag.py # Triggers spark/compact_parquet.py every 10 minutes
superset/
Dockerfile # Custom Superset image with Hive/Postgres drivers
superset_config.py # Points Superset at its Postgres metadata DB
docker-compose.yml # Kafka, Kafka UI, MinIO, Airflow, Superset, Postgres (x2)
Prerequisites: Docker Desktop, Python 3.11+, Java 17 (for PySpark), Homebrew (for librdkafka on Mac).
docker compose up -dThis brings up Kafka, Kafka UI, MinIO, Postgres (x2, for Airflow and Superset), Airflow (webserver + scheduler), and Superset. First run will build the custom Airflow and Superset images, which takes a few minutes.
docker exec kafka /opt/kafka/bin/kafka-topics.sh \
--create --topic raw_trades \
--bootstrap-server localhost:9092 \
--partitions 3 --replication-factor 1Create a streaming-lake bucket at http://localhost:9001 (login minioadmin / minioadmin).
python3 -m venv venv
source venv/bin/activate
pip install -r producer/requirements.txt
pip install -r spark/requirements.txtpython producer/binance_producer.pyspark-submit \
--packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.3,org.apache.hadoop:hadoop-aws:3.3.4 \
spark/streaming_job.pyexport SPARK_HOME=<path-to-venv>/lib/python3.14/site-packages/pyspark
./spark/start_thrift_server.shThen register the SQL tables (one-time, or after recreating the lake):
$SPARK_HOME/bin/beeline -u jdbc:hive2://localhost:10000 -f spark/sql/create_tables.sql- Kafka UI: http://localhost:8081 — inspect topics and messages
- MinIO Console: http://localhost:9001 — browse the raw Parquet files
- Airflow: http://localhost:8082 (
admin/admin) — view/trigger theparquet_compactionDAG - Superset: http://localhost:8088 (
admin/admin) — the dashboard
To connect Superset to the Thrift Server: add a database with SQLAlchemy URI hive://host.docker.internal:10000/default (not localhost — Superset runs in Docker and needs the host-machine bridge address).
- Kafka + MinIO infrastructure (Docker Compose)
- Live Binance trade producer
- Spark Structured Streaming: raw events + 1-minute OHLC aggregation to Parquet
- Airflow orchestration: containerized, custom image, Parquet compaction DAG every 10 minutes
- Spark Thrift Server + Superset dashboards with live and periodic charts, 30s auto-refresh
- Failure handling, monitoring, checkpointing hardening
- Demo video / GIF
Raw trade event:
{
"symbol": "BTCUSDT",
"trade_id": 6490112780,
"price": 62337.48,
"quantity": 0.00341,
"trade_time": 1783532490176,
"is_buyer_maker": false,
"ingested_at": "2026-07-08T17:41:30.022805+00:00"
}1-minute OHLC candle: window_start: 2026-07-08 22:41:00 window_end: 2026-07-08 22:42:00 symbol: BTCUSDT open: 62334.0 high: 62344.0 low: 62290.0 close: 62290.0 volume: 37.09975
A few non-obvious things this project surfaced, worth knowing if you're building something similar:
- Never batch-write into a live Structured Streaming sink's exact output path. Spark Structured Streaming maintains a
_spark_metadatalog to guarantee exactly-once semantics on its output directory. A separate batch job (like a compaction script) that overwrites that same path directly will desync the metadata log from the actual files on disk, breaking the streaming job. The fix: compact into a separate output location, never the original sink. - Reading and overwriting the same path in one Spark job is a race condition. Spark's
overwritemode deletes the target directory's contents as part of the write commit, but reads are lazy — so a plaindf = spark.read.parquet(path); df.write.mode("overwrite").parquet(path)can delete files before they've actually been read. Fix:.cache()and materialize (.count()) the DataFrame before writing. - Bitnami's free Docker Hub images for Kafka were deprecated mid-project (moved behind a paid tier). The official
apache/kafkaimage, which now ships with built-in KRaft support, was a clean drop-in replacement. - Superset's official Docker image uses
uv(not pip) to manage its virtualenv. Installing extra Python packages (e.g., Hive/Postgres drivers) requiresuv pip install --python /app/.venv/bin/python3 ...rather than plainpip install, since the systempipbinary doesn't target the app's actual runtime environment. - Containers need
host.docker.internal, notlocalhost, to reach services running on the host machine. This came up connecting Superset (in Docker) to the Spark Thrift Server (running directly on the host).
