ML Stack Implementation Plan
ML Stack Implementation Plan
Environment Approach
Docker Swarm · 3 GPU Nodes Phased rollout, 16 layers
Cluster Topology
node-1 (manager) Swarm manager. Runs control-plane services: Airflow scheduler, Prefect server,
Temporal server, metadata DBs, Vault, Keycloak, OpenMetadata.
node-2 (worker) GPU worker. Primary training node. Hosts Ray head node, JupyterHub, Triton
Inference Server.
node-3 (worker) GPU worker. Secondary compute. Ray worker, BentoML, Evidently, ClickHouse.
Shared (all nodes) MinIO (already running), Prometheus node exporters, cAdvisor, NVIDIA DCGM
exporter.
Network Design
Create dedicated Docker overlay networks to isolate traffic concerns:
• ml-data-net — data pipeline traffic (Kafka, Spark, Flink, Delta Lake reads/writes to MinIO)
• ml-train-net — training traffic (Ray, JupyterHub, MLflow, Feast)
• ml-serve-net — inference traffic (Triton, BentoML, Ray Serve, Redis)
• ml-ops-net — control plane (Airflow, Prefect, Temporal, Vault, Keycloak)
• ml-obs-net — observability (Prometheus, Grafana, cAdvisor, DCGM)
⚠ All services must join ml-ops-net for Vault secret injection. Services needing cross-network communication use
attachable overlay networks.
1 Phase 1 — Foundation (Week 1)
Deploy the security and platform foundation before any application services. Everything else depends on these.
volumes:
vault_data:
networks:
ml-ops-net:
external: true
Initialization Steps
1. Deploy: docker stack deploy -c [Link] vault
2. Initialize: docker exec <vault_container> vault operator init -key-shares=5 -key-threshold=3
3. Save all 5 unseal keys and the root token in a secure offline location
4. Unseal with any 3 keys: vault operator unseal <key>
5. Enable secrets engine: vault secrets enable -path=ml-stack kv-v2
6. Create a policy per service (example for Airflow): vault policy write airflow /policies/[Link]
📝 Use Vault Agent Sidecar or envconsul to inject secrets into containers at startup. Avoid passing Vault tokens directly
to app containers.
Deployment
# [Link]
version: "3.8"
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./[Link]:/docker-entrypoint-initdb.d/[Link]
networks: [ml-ops-net]
deploy:
placement:
constraints: [[Link] == manager]
secrets:
- pg_password
secrets:
pg_password:
external: true # stored in Docker Swarm secrets, sourced from Vault
volumes:
postgres_data:
Build the lakehouse foundation on top of your existing MinIO. Delta Lake provides ACID transactions, schema
evolution, and time travel on object storage.
Time travel Query data as-of any previous version: SELECT * FROM table VERSION AS OF 10
Compaction (OPTIMIZE) Merge small Parquet files into larger ones for read performance
Z-ORDER clustering Co-locate related data in the same files for predicate pushdown
[Link] [Link]
[Link].spark_catalog [Link]
spark = [Link] \
.appName("delta-init") \
.getOrCreate()
2.2 — LakeFS
Purpose
Git-like branching and versioning for your data lake. Create branches for experiments, merge changes after
validation, roll back bad data loads — all without copying data.
configs:
lakefs_config:
external: true # contains MinIO endpoint and DB connection
blockstore:
type: s3
s3:
force_path_style: true
endpoint: [Link]
credentials:
access_key_id: <from-vault>
secret_access_key: <from-vault>
auth:
encrypt:
secret_key: <random-32-char-string-from-vault>
Validate & merge Run Great Expectations on branch, then lakectl merge feature/new-dataset main
3.1 — RabbitMQ
Purpose
Message broker for event-driven ingestion, decoupling producers from consumers, task queuing, and as the
Celery broker for Airflow (replacing Redis for that role if preferred).
Exchange: [Link] (fanout) Broadcasts system events to all subscribers (pipeline completions, alerts)
Queue: airflow-tasks Celery queue for Airflow worker tasks (alternative to Redis broker)
Queue: model-retraining Triggers from monitoring drift detection to Prefect training flows
connection = [Link](
[Link](
host='rabbitmq',
virtual_host='ml_stack',
credentials=[Link]('user', 'pass')
)
)
channel = [Link]()
channel.exchange_declare(exchange='[Link]', exchange_type='topic', durable=True)
channel.basic_publish(
exchange='[Link]',
routing_key='[Link]',
body=[Link]({"bucket": "delta-raw", "key": "images/batch_001.tar"}),
properties=[Link](delivery_mode=2) # persistent
)
[Link]()
Airflow Integration
Use RabbitMQ as Airflow's Celery broker by updating the Airflow Helm/Compose config:
# airflow environment
AIRFLOW__CELERY__BROKER_URL: amqp://user:pass@rabbitmq:5672/ml_stack
AIRFLOW__CELERY__RESULT_BACKEND: db+postgresql://airflow_user:pass@postgres:5432/airflow
4 Phase 4 — Batch Processing (Week 4)
spark-worker:
image: bitnami/spark:3.5
environment:
- SPARK_MODE=worker
- SPARK_MASTER_URL=spark://spark-master:7077
- SPARK_WORKER_MEMORY=16G
- SPARK_WORKER_CORES=8
networks: [ml-data-net]
deploy:
replicas: 3 # one per node
placement:
max_replicas_per_node: 1
spark = [Link]("ingest-bronze").getOrCreate()
bronze_df.write \
.format("delta") \
.mode("append") \
.option("mergeSchema", "true") \
.partitionBy("date") \
.save("s3a://delta-bronze/events")
spark_ingest = SparkSubmitOperator(
task_id="ingest_to_bronze",
application="/opt/airflow/jobs/ingest_bronze.py",
conn_id="spark_default", # points to spark://spark-master:7077
packages="[Link]:delta-spark_2.12:3.2.0",
conf={"[Link]": "[Link]"},
dag=dag
)
ray-worker:
image: rayproject/ray:2.20.0-gpu
command: >
ray start --address=ray-head:6379
--num-cpus=16 --num-gpus=2
--block
volumes:
- /tmp/ray:/tmp/ray
runtime: nvidia
environment:
- NVIDIA_VISIBLE_DEVICES=all
networks: [ml-train-net]
deploy:
replicas: 2
placement:
max_replicas_per_node: 1
constraints: [[Link] == true]
@flow(task_runner=RayTaskRunner(address="ray://ray-head:10001"))
def training_pipeline(experiment_name: str, config: dict):
preprocessed_data = preprocess_data(config)
model_uri = train_model(preprocessed_data, config, experiment_name)
evaluate_model(model_uri, experiment_name)
return model_uri
@task
def train_model(data_ref, config, experiment_name):
trainer = TorchTrainer(
train_loop_per_worker=train_func,
train_loop_config=config,
scaling_config=[Link](
num_workers=2, use_gpu=True, resources_per_worker={"GPU": 1}
),
)
result = [Link]()
return [Link]
5 Phase 5 — Databases & Query (Week 5)
5.1 — ClickHouse
Purpose
Column-oriented OLAP database for sub-second analytical queries on large datasets. Use for: model monitoring
metrics, pipeline run analytics, event analytics, and dashboards (Metabase connects here).
Integrations
Evidently AI Evidently pushes drift reports as JSON; a lightweight Python consumer writes rows
to ClickHouse
6.1 — Feast
Architecture
Feast bridges your offline (Delta Lake on MinIO) and online (Redis) stores, eliminating training/serving skew.
Offline store Delta Lake / Parquet on MinIO. Historical features for training.
Materialization Feast job that syncs features from offline to online store on a schedule.
# feature_store.yaml
project: ml_stack
registry: postgresql://feast_user:<pw>@postgres:5432/feast_registry
provider: local
offline_store:
type: file # reads Parquet/Delta directly from MinIO via s3fs
online_store:
type: redis
connection_string: redis://:password@redis:6379
user_activity_source = FileSource(
path="s3a://delta-gold/user_activity/",
timestamp_field="event_timestamp"
)
user_activity_fv = FeatureView(
name="user_activity",
entities=["user_id"],
ttl=timedelta(days=30),
features=[
Feature(name="session_count_7d", dtype=ValueType.INT32),
Feature(name="avg_session_duration", dtype=[Link]),
Feature(name="last_active_days_ago", dtype=ValueType.INT32),
],
source=user_activity_source
)
def materialize_features():
import subprocess
[Link]([
"feast", "-c", "/feature_store",
"materialize-incremental",
[Link]().isoformat()
], check=True)
materialize_task = PythonOperator(
task_id="feast_materialize",
python_callable=materialize_features,
dag=dag
)
7 Phase 7 — Training & Experimentation (Week 7)
7.1 — JupyterHub
Purpose
Multi-user notebook environment with GPU access, connected to your full stack: MinIO, MLflow, Feast, Ray, and
Delta Lake — all accessible from notebooks.
# jupyterhub_config.py
from dockerspawner import DockerSpawner
[Link].spawner_class = DockerSpawner
[Link] = "your-registry/ml-notebook:latest"
# [Link] example
name: "resnet50_classifier"
platform: "onnxruntime_onnx"
max_batch_size: 64
dynamic_batching {
preferred_batch_size: [8, 16, 32]
max_queue_delay_microseconds: 5000
}
input [{ name: "input", data_type: TYPE_FP32, dims: [3, 224, 224] }]
output [{ name: "output", data_type: TYPE_FP32, dims: [1000] }]
# 2. Save to BentoML
bento_model = [Link].save_model("my_model", mlflow_model)
8.2 — BentoML
Purpose
Python-first model packaging, preprocessing/postprocessing logic, and serving abstraction. Sits in front of Triton
for models requiring Python business logic, or serves simpler models independently.
svc = [Link]("image_classifier")
@[Link](input=NumpyNdarray(), output=JSON())
def predict(input_data: [Link]):
# Preprocessing
processed = preprocess(input_data)
# Forward to Triton
client = [Link]("triton:8001")
inputs = [[Link]("input", [Link], "FP32")]
inputs[0].set_data_from_numpy(processed)
response = [Link]("resnet50_classifier", inputs)
# Postprocessing
logits = response.as_numpy("output")
return {"class": int([Link](logits)), "confidence": float([Link](logits))}
9 Phase 9 — Monitoring & Quality (Week 9)
9.1 — Evidently AI
Purpose
Model and data monitoring. Detects data drift, prediction drift, and data quality issues in production. Reports feed
into ClickHouse and trigger retraining via RabbitMQ.
@flow
def run_model_monitoring(model_name: str, date: str):
report = check_drift(model_name, date)
store_to_clickhouse(report, model_name, date)
if report["drift_detected"]:
trigger_retraining(model_name)
@task
def check_drift(model_name: str, date: str):
# Pull reference data (training distribution)
ref_df = pd.read_parquet(f"s3a://delta-gold/reference/{model_name}/")
return report.as_dict()
@task
def trigger_retraining(model_name: str):
import pika
# Publish to RabbitMQ model-retraining queue
# ... (see RabbitMQ section for pattern)
Missing value rate Data quality metrics tracked per batch in ClickHouse
Prediction distribution Histogram of model outputs over time — spot distribution shift visually
Retraining trigger log Table of when drift thresholds were crossed and retraining was triggered
10 Phase 10 — Orchestration Layer (Week 10)
You already have Airflow and Prefect. This phase adds Temporal for durable long-running workflows and
completes the integration wiring between all three.
10.1 — Temporal
When to use Temporal vs Airflow vs Prefect
Temporal Long-running workflows (hours/days), human-in-the-loop (annotation review →
approve → retrain), durable retries across failures, async operations. Workflow state
survives cluster restarts.
Prefect ML training flows, dynamic pipelines, Ray job submission, hyperparameter sweeps,
model evaluation. Python-native, fast iteration.
Airflow Scheduled batch ETL, Spark job submission, dbt runs, fixed-schedule SLA-bound
pipelines, Great Expectations checkpoint runs.
temporal-ui:
image: temporalio/ui:2.26
environment:
TEMPORAL_ADDRESS: temporal:7233
ports:
- "8088:8080"
networks: [ml-ops-net]
@[Link]
class AnnotationToTrainingWorkflow:
@[Link]
async def run(self, dataset_id: str):
# 1. Submit to CVAT for annotation
task_id = await workflow.execute_activity(
create_cvat_task, dataset_id, start_to_close_timeout=timedelta(minutes=5)
)
# 2. Wait for human annotation (could take days — Temporal handles this durably)
await workflow.execute_activity(
wait_for_annotation_complete, task_id,
start_to_close_timeout=timedelta(days=7),
heartbeat_timeout=timedelta(hours=1)
)
@[Link]
def approve(self): self._approved = True
@[Link]
def reject(self): self._approved = False
11 Phase 11 — Observability (Week 11)
dcgm-exporter:
image: [Link]/nvidia/k8s/dcgm-exporter:3.3.5-3.4.0-ubuntu22.04
runtime: nvidia
environment:
NVIDIA_VISIBLE_DEVICES: all
ports:
- "9400:9400"
networks: [ml-obs-net]
deploy:
mode: global
placement:
constraints: [[Link] == true]
prometheus:
image: prom/prometheus:v2.53
volumes:
- ./[Link]:/etc/prometheus/[Link]
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks: [ml-obs-net]
deploy:
placement:
constraints: [[Link] == manager]
grafana:
image: grafana/grafana:11.1.0
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
ports:
- "3001:3000"
networks: [ml-obs-net]
deploy:
placement:
constraints: [[Link] == manager]
Ray Dashboard integration Ray cluster utilization via custom Prometheus metrics
Airflow Metrics (ID: 11855) DAG run durations, task success rates, queue depth
Triton Server Metrics Inference throughput, latency, queue depth, GPU utilization per model
scrape_configs:
- job_name: cadvisor
static_configs: [{targets: ['cadvisor:8080']}]
- job_name: dcgm
static_configs: [{targets: ['node-2:9400', 'node-3:9400']}]
- job_name: triton
static_configs: [{targets: ['triton:8002']}]
- job_name: ray
static_configs: [{targets: ['ray-head:8080']}] # Ray exposes Prometheus metrics
- job_name: rabbitmq
static_configs: [{targets: ['rabbitmq:15692']}] # RabbitMQ Prometheus plugin
- job_name: airflow
static_configs: [{targets: ['airflow-webserver:8080']}]
12 Phase 12 — Data Catalog (Week 12)
12.1 — OpenMetadata
Purpose
Central catalog for all data assets: Delta Lake tables, ClickHouse tables, ML models, pipelines, and feature views.
Provides data lineage (end-to-end: RabbitMQ message → Delta table → Feast feature → Triton model),
ownership, documentation, and discovery.
opensearch:
image: opensearchproject/opensearch:2.14
environment:
[Link]: single-node
DISABLE_SECURITY_PLUGIN: "true"
volumes:
- opensearch_data:/usr/share/opensearch/data
networks: [ml-ops-net]
deploy:
placement:
constraints: [[Link] == manager]
Connectors to Configure
Delta Lake / MinIO Use the S3/Datalake connector — discovers Parquet/Delta metadata automatically
ClickHouse Built-in ClickHouse connector — auto-profiles tables, column types, row counts
Apache Spark Spark lineage via OpenLineage integration in your Spark jobs
Prefect HTTP connector via OpenMetadata REST API from Prefect flow hooks
13.1 — Metabase
Purpose
Self-hosted BI for non-technical stakeholders. Connect to ClickHouse for model monitoring dashboards,
PostgreSQL for operational metrics, and expose pre-built dashboards to business users.
Recommended Dashboards
Model Performance Overview Prediction accuracy, drift scores, latency — connects to ClickHouse
[Link]
Pipeline Health DAG success rates, processing volumes, SLA compliance — from ClickHouse
ops.pipeline_runs
Data Quality Report Missing values, outliers per feature over time — from Evidently via ClickHouse
GPU Utilization (via Copy GPU metrics from Prometheus into ClickHouse for SQL-queryable
ClickHouse) history
14 Phase 14 — Vector DB (Week 14)
14.1 — Qdrant
Purpose
High-performance vector database for semantic search, similarity-based retrieval, embedding storage, and RAG
(Retrieval-Augmented Generation) pipelines.
# [Link]
storage:
storage_path: /qdrant/storage
service:
max_request_size_mb: 64
max_workers: 0 # auto-detect
optimizers_config:
default_segment_number: 4
Usage Patterns
Embedding indexing (Prefect After training, extract embeddings from Triton, upsert into Qdrant collection
flow)
Similarity search BentoML service queries Qdrant before Triton for cache-hit or retrieval
augmentation
Feature-based ANN Store Feast features as vectors for nearest-neighbour lookup at serving time
RAG pipeline Store document chunks as vectors; query at inference time for context injection
client.create_collection(
collection_name="image_embeddings",
vectors_config=VectorParams(size=2048, distance=[Link])
)
Storage LakeFS Git branching for data — branch, merge, rollback data
loads
Processing Apache Spark Batch ETL, Delta Lake transforms, medallion pipeline
Orchestration Airflow (existing) Scheduled batch DAGs — Spark, dbt, GE, ETL
Registry MinIO (existing) S3-compatible object storage — all data and model
artifacts
Rollout Timeline
6 Feature Store Feast with Delta offline + Redis online store, materialization DAG
⚠ This timeline assumes one person per phase. With a team, phases 3-5 can run in parallel after Week 2, compressing
the timeline to ~8-9 weeks.
📝 Always deploy Vault and PostgreSQL in Week 1 regardless of team size. Every other service depends on them.
Rushing this foundation is the most common cause of painful rework later.
Triton gRPC Use 8003 — avoid conflict with Airflow (8080) and cAdvisor (8081)
GPU Swarm Gotcha
⚠ Docker Swarm does not natively support the NVIDIA Container Runtime in the same way Kubernetes does. You must
set the default runtime to nvidia on each GPU worker node in /etc/docker/[Link]: { "default-runtime": "nvidia",
"runtimes": { "nvidia": { "path": "nvidia-container-runtime" } } } — then services using 'runtime: nvidia' will work correctly in
Swarm stacks.