Deploy an ML Model with FastAPI, Docker and AWS
Train a fraud model, serve it with FastAPI, ship it in Docker to AWS EC2 via ECR, then load test with Locust and fix p95 latency.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project takes a fraud model from a training script to a live test endpoint on AWS: you train a scikit-learn model with a validation gate, serve it with FastAPI, package it in a Docker image, push the image to Amazon ECR, run it on one EC2 instance, and load test it with Locust. You record p50, p95 and error rate before and after one real fix, then delete every AWS resource with one script.
It is the first portfolio piece in the MLOps roadmap and uses only Step 1 to 3 skills: Python, Git, Docker, one cloud and GitHub Actions. No model registry and no Kubernetes yet. Those come later.
The problem
The acme-fraud team at the fictional payments company acme-shop has a model that scores well in a notebook. Nobody else can call it. A teammate's laptop gives a different number, the "API" reloads the model on every call, and the last cloud demo left an instance running for three weeks. Swiggy, Razorpay and Stripe all hit this gap between "model trained" and "model answering requests", and it is the gap this project closes.
What you build
+ metadata)] C --> D[Docker image] L[Locust] end subgraph AWS["AWS ap-south-1"] E[(ECR repo)] --> F[EC2 + Docker] F --> G[FastAPI :8000] end D -->|push by SHA| E L -->|load test| G classDef code fill:#DBEAFE,color:#1E293B,stroke:#326CE5 classDef store fill:#EDE9FE,color:#1E293B,stroke:#8B5CF6 classDef cloud fill:#F97316,color:#FFFFFF,stroke:#F97316 class A,B,D,L code class C,E store class F,G cloud
The flow of trust matters as much as the flow of data. Every number has one place it comes from:
| Decision | Made on | Why |
|---|---|---|
| Decision threshold | Validation set | Tuning on test leaks it |
| Ship or not (gate) | Validation PR-AUC | Floor fixed before training |
| Final quality claim | Test set, once | Recorded in a file you commit |
Repository layout
acme-fraud-api/├── params.yaml # every knob, one file├── requirements.txt # pinned runtime deps (go into the image)├── requirements-dev.txt # + pytest, httpx2, locust├── pytest.ini├── src/acme_fraud_api/│ ├── data.py # make or load data, seeded 64/16/20 split│ ├── train.py # train, pick threshold, gate, save│ ├── evaluate_test.py # score the test set once, safely│ └── app.py # FastAPI service├── tests/ # test_train.py, test_api.py├── loadtest/ # locustfile.py, bench.sh, summary.py├── deploy/ # aws_up.sh, aws_down.sh, user_data.sh├── reports/ # test_report.json + load test CSVs (committed)├── Dockerfile .dockerignore compose.yaml└── .github/workflows/ci.ymlPrerequisites (by module title)
- Python for AI Engineering, Git and GitHub, Linux Fundamentals.
- Machine Learning for AI Engineers and Reproducible ML Training and Project Structure.
- Docker Containerization, AWS, and GitHub Actions.
- For Milestones 7 and 8 only: the basics of AWS IAM roles, EC2, ECR and security groups (the AWS module covers them), an AWS account you can bill, and a region with a default VPC.
- Tools: Python 3.12, Docker Desktop or Docker Engine with Compose v2, Git, AWS CLI v2,
bcandcurl(for the benchmark script).
Versions used
| Tool | Version | Checked |
|---|---|---|
| FastAPI / Uvicorn / Pydantic | 0.162.0 / 0.54.0 / 2.14.2 | PyPI, 5 Nov 2026 |
| scikit-learn / pandas / numpy | 1.9.1 / 3.1.1 / 2.5.4 | PyPI, 5 Nov 2026 |
| Locust / pytest / httpx2 | 2.47.0 / 9.2.0 / 2.16.0 | PyPI, 5 Nov 2026 |
| actions/checkout / setup-python | v7.0.1 / v7.1.0 (pinned by SHA) | GitHub tags, 5 Nov 2026 |
TipWhat was actually run for this guide: Milestones 1, 2, 3 and 5, the Python steps of Milestone 6, all tests and the break-it steps ran on Python 3.12 in a 2 vCPU Linux sandbox, and every output block marked "real run" is pasted from those runs. The Docker image build, the GitHub Actions run and everything on AWS were not run here (no Docker Hub, GitHub or AWS access from the sandbox). Those steps say so where they appear, and the AWS scripts were checked with
bash -nandshellcheckonly. Treat the project as locally verified, not fully validated, until you have run one real deploy and teardown yourself.
Milestone 1: Set Up the Repository and Train a Gated Model
This milestone gives you one command that trains the model the same way every time, picks the threshold on validation data, and refuses to save a model that misses a quality floor you set in advance.
Why a gate before anything else
A deployment pipeline is only as good as the thing it ships. If training can quietly produce a weak model, every later step (image, deploy, load test) faithfully delivers a weak model. The gate is a single if that stops the chain. The floor (min_val_pr_auc: 0.20) is illustrative and was written into params.yaml before the first training run. You never lower it after seeing a result.
Step 1: Create the project and environment
mkdir acme-fraud-api && cd acme-fraud-apigit initpython3.12 -m venv .venvsource .venv/bin/activatemkdir -p src/acme_fraud_api tests loadtest deploy reports .github/workflowstouch src/acme_fraud_api/__init__.pyexport PYTHONPATH=src # so "python -m acme_fraud_api.train" finds the packagerequirements.txt (only what the running service needs, all pinned):
fastapi==0.162.0uvicorn==0.54.0pydantic==2.14.2scikit-learn==1.9.1pandas==3.1.1numpy==2.5.4joblib==1.6.0pyyaml==6.0.3requirements-dev.txt (tools for your laptop and CI, never baked into the image):
-r requirements.txtpytest==9.2.0httpx2==2.16.0locust==2.47.0pip install -r requirements-dev.txtTip
httpx2is the client Starlette'sTestClientnow asks for. With the olderhttpxyour tests still pass but print a deprecation warning on every run.
Step 2: Put every knob in params.yaml
seed: 42data: path: data/payments.csv rows: 20000 fraud_rate: 0.03split: test_size: 0.20 # 20 percent, looked at once val_size: 0.20 # 20 percent of the rest = 16 percent overallmodel: max_iter: 200 learning_rate: 0.05 max_leaf_nodes: 31gate: # Chosen before any number was seen. Illustrative, not a business target. min_val_pr_auc: 0.20 recall_target: 0.75Step 3: Write the data module
If you finished Reproducible ML Training and Project Structure you already have mlops-lab with data/payments.csv. This project is self-contained instead: data.py regenerates a seeded 20,000-row synthetic payments file on every run, so nobody trains on a stale or hand-edited copy, and training records the file's SHA-256 in the metadata. The fraud signal is weak and noisy on purpose, like real fraud.
src/acme_fraud_api/data.py:
"""Make the payments dataset from the seed and split it the same way every time."""import hashlibfrom pathlib import Path import numpy as npimport pandas as pdimport yamlfrom sklearn.model_selection import train_test_split FEATURES = ["amount", "hour", "merchant_category", "country_mismatch", "card_age_days", "txns_last_24h"]CATEGORIES = ["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] def load_params(path: str = "params.yaml") -> dict: with open(path) as f: return yaml.safe_load(f) def make_payments(rows: int, fraud_rate: float, seed: int) -> pd.DataFrame: """Synthetic card payments with a weak, noisy fraud signal.""" rng = np.random.default_rng(seed) df = pd.DataFrame({ "txn_id": [f"txn_{i:06d}" for i in range(rows)], "amount": np.round(rng.lognormal(mean=3.5, sigma=1.0, size=rows), 2), "hour": rng.integers(0, 24, size=rows), "merchant_category": rng.choice(CATEGORIES, size=rows), "country_mismatch": rng.binomial(1, 0.08, size=rows), "card_age_days": rng.integers(1, 3650, size=rows), "txns_last_24h": rng.poisson(2.0, size=rows), }) score = ( 0.9 * np.log1p(df["amount"]) / 5 + 1.2 * df["country_mismatch"] + 0.6 * df["merchant_category"].isin(["electronics", "gaming"]) + 0.5 * ((df["hour"] <= 4) | (df["hour"] >= 23)) + 0.25 * np.minimum(df["txns_last_24h"], 8) / 2 - 0.6 * (df["card_age_days"] > 365) + rng.normal(0, 1.0, size=rows) # noise: the signal is weak on purpose ) cutoff = np.quantile(score, 1 - fraud_rate) df["is_fraud"] = (score >= cutoff).astype(int) return df def write_dataset(params: dict) -> str: """Regenerate the CSV from the seed every time, so nobody trains on a stale or hand-edited copy. Returns the file's sha256 (recorded in metadata).""" path = Path(params["data"]["path"]) path.parent.mkdir(parents=True, exist_ok=True) make_payments(params["data"]["rows"], params["data"]["fraud_rate"], params["seed"]).to_csv(path, index=False) return hashlib.sha256(path.read_bytes()).hexdigest() def load_splits(params: dict): """Return train, val, test frames. Stratified, seeded: 64/16/20.""" write_dataset(params) df = pd.read_csv(params["data"]["path"]) seed = params["seed"] rest, test = train_test_split(df, test_size=params["split"]["test_size"], stratify=df["is_fraud"], random_state=seed) train, val = train_test_split(rest, test_size=params["split"]["val_size"], stratify=rest["is_fraud"], random_state=seed) return train, val, testStep 4: Write the training script with the gate
src/acme_fraud_api/train.py:
"""Train the model, pick the threshold on validation, gate it, save it. Exit codes: 0 = model saved, 1 = gate failed (nothing saved)."""import hashlibimport jsonimport osimport sysfrom datetime import datetime, timezonefrom pathlib import Path import joblibimport numpy as npimport sklearnfrom sklearn.compose import ColumnTransformerfrom sklearn.ensemble import HistGradientBoostingClassifierfrom sklearn.metrics import average_precision_score, precision_score, recall_scorefrom sklearn.pipeline import Pipelinefrom sklearn.preprocessing import OneHotEncoder from acme_fraud_api.data import FEATURES, load_params, load_splits MODEL_DIR = Path(os.environ.get("MODEL_DIR", "model")) def sha256_of(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def build_model(params: dict, seed: int) -> Pipeline: pre = ColumnTransformer( [("cat", OneHotEncoder(handle_unknown="ignore"), ["merchant_category"])], remainder="passthrough", ) clf = HistGradientBoostingClassifier( max_iter=params["model"]["max_iter"], learning_rate=params["model"]["learning_rate"], max_leaf_nodes=params["model"]["max_leaf_nodes"], class_weight="balanced", random_state=seed, ) return Pipeline([("pre", pre), ("clf", clf)]) def pick_threshold(y_true, proba, recall_target: float) -> float: """Highest threshold that still reaches the recall target on validation. 0.0 is the last candidate (flag everything); if even that misses, stop.""" for t in np.round(np.arange(0.99, -0.001, -0.01), 2): if recall_score(y_true, proba >= t) >= recall_target: return float(t) raise ValueError(f"no threshold reaches recall {recall_target} on validation") def main() -> int: params = load_params() seed = params["seed"] train, val, _test = load_splits(params) # test is NOT touched here model = build_model(params, seed) model.fit(train[FEATURES], train["is_fraud"]) val_proba = model.predict_proba(val[FEATURES])[:, 1] pr_auc = average_precision_score(val["is_fraud"], val_proba) threshold = pick_threshold(val["is_fraud"], val_proba, params["gate"]["recall_target"]) pred = val_proba >= threshold recall = recall_score(val["is_fraud"], pred) precision = precision_score(val["is_fraud"], pred, zero_division=0) print(f"val_pr_auc={pr_auc:.4f} threshold={threshold:.2f} " f"val_recall={recall:.4f} val_precision={precision:.4f}") floor = params["gate"]["min_val_pr_auc"] if pr_auc < floor: print(f"GATE FAILED: val_pr_auc {pr_auc:.4f} < floor {floor}. Nothing saved.") return 1 MODEL_DIR.mkdir(parents=True, exist_ok=True) tmp = MODEL_DIR / "model.joblib.tmp" joblib.dump(model, tmp) os.replace(tmp, MODEL_DIR / "model.joblib") # atomic swap meta = { "model_sha256": sha256_of(MODEL_DIR / "model.joblib"), "params_sha256": sha256_of(Path("params.yaml")), "data_sha256": sha256_of(Path(params["data"]["path"])), "threshold": threshold, "val_pr_auc": round(pr_auc, 4), "val_recall": round(recall, 4), "val_precision": round(precision, 4), "features": FEATURES, "sklearn_version": sklearn.__version__, "trained_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } tmp_meta = MODEL_DIR / "metadata.json.tmp" tmp_meta.write_text(json.dumps(meta, indent=2)) os.replace(tmp_meta, MODEL_DIR / "metadata.json") # atomic swap, like the model print(f"saved {MODEL_DIR}/model.joblib sha256={meta['model_sha256'][:12]}") return 0 if __name__ == "__main__": sys.exit(main())Four details worth saying out loud in a review:
pick_thresholduses only validation data. The test set is loaded byload_splitsbut never used here.pick_thresholdtries 0.0 last (flag everything). If even that misses the recall target, training stops with an error instead of saving a model that breaks its own promise.- The model and the metadata are each written to a temp file and swapped in with
os.replace, so a crash mid-write never leaves a half-written file for the API to load. metadata.jsonstores the SHA-256 of the model and of the data. The API compares the model hash before loading (Milestone 3).
Step 5: Train twice and prove it is reproducible
python -m acme_fraud_api.trainpython -m acme_fraud_api.traincat model/metadata.jsonExpected output (real run):
val_pr_auc=0.2322 threshold=0.47 val_recall=0.7500 val_precision=0.1057saved model/model.joblib sha256=e70c4a9d2e04val_pr_auc=0.2322 threshold=0.47 val_recall=0.7500 val_precision=0.1057saved model/model.joblib sha256=e70c4a9d2e04Same seed, same data, same library versions, same bytes: both runs produce the identical file hash. If yours differs from the hash above, that is fine (a different CPU or OS can change float rounding), but your two runs must match each other.
Acceptance check
-
python -m acme_fraud_api.trainexits 0 and prints the samesha256twice in a row. -
model/model.joblibandmodel/metadata.jsonexist.
Common MistakeChoosing the threshold on the test set "because it is the honest data". The moment you tune anything on test, it becomes a second validation set and you no longer have an unbiased final number.
Milestone 2: Score the Test Set Once and Keep the Record
This milestone produces your one honest quality claim: the test-set result, written once to a file you commit, with guards that refuse a second look but still let you retry if the evaluation itself crashed.
Why "once" needs code, not willpower
If you can rerun the test evaluation, you will, after "just one small tweak". Each rerun quietly turns the test set into a tuning set. The script enforces "once" in three moves:
- Reserve: create
reports/.test_report.lockwithO_EXCL, so two evaluations cannot run at the same time. - Evaluate first: check the model hash against its metadata (the threshold must belong to this exact file), score the test set, and write the result to a temp file.
- Publish atomically:
os.linkthe temp file toreports/test_report.json. The link fails if the report already exists. The lock is always released, so a crash leaves no report and you can fix the problem and retry.
Step 1: Write the test-once script
src/acme_fraud_api/evaluate_test.py:
"""Score the held-out test set ONCE and keep a durable record. Exit codes: 0 = report written, 3 = already evaluated or in progress (refused), 1 = evaluation failed (no report written, safe to fix and retry)."""import jsonimport osimport sysfrom datetime import datetime, timezonefrom pathlib import Path import joblibfrom sklearn.metrics import average_precision_score, precision_score, recall_score from acme_fraud_api.data import FEATURES, load_params, load_splitsfrom acme_fraud_api.train import MODEL_DIR, sha256_of REPORT = Path("reports/test_report.json")LOCK = Path("reports/.test_report.lock") # reservation while an evaluation runs def evaluate() -> dict: meta = json.loads((MODEL_DIR / "metadata.json").read_text()) model_hash = sha256_of(MODEL_DIR / "model.joblib") if model_hash != meta["model_sha256"]: # the threshold in metadata belongs to a different model file raise RuntimeError("model hash does not match training metadata") _train, _val, test = load_splits(load_params()) model = joblib.load(MODEL_DIR / "model.joblib") proba = model.predict_proba(test[FEATURES])[:, 1] pred = proba >= meta["threshold"] return { "model_sha256": model_hash, "data_sha256": meta["data_sha256"], "threshold": meta["threshold"], "test_pr_auc": round(average_precision_score(test["is_fraud"], proba), 4), "test_recall": round(recall_score(test["is_fraud"], pred), 4), "test_precision": round(precision_score(test["is_fraud"], pred, zero_division=0), 4), "rows": int(len(test)), "positives": int(test["is_fraud"].sum()), "evaluated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } def main() -> int: REPORT.parent.mkdir(parents=True, exist_ok=True) if REPORT.exists(): print(f"REFUSED: {REPORT} already exists. The test set is looked at once.") return 3 try: # O_EXCL: only one evaluation can hold the reservation at a time os.close(os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY)) except FileExistsError: print(f"REFUSED: {LOCK} exists (another evaluation running, or a crashed one: check, then delete it).") return 3 try: report = evaluate() # 1. evaluate first tmp = REPORT.with_suffix(".json.tmp") tmp.write_text(json.dumps(report, indent=2)) os.link(tmp, REPORT) # 2. publish: fails if REPORT already exists tmp.unlink() except Exception as err: # nothing published, retry is allowed print(f"EVALUATION FAILED, no report written: {err}") return 1 finally: LOCK.unlink(missing_ok=True) # 3. always release the reservation print(json.dumps(report, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())Step 2: Prove a crash does not use up your one look
Hide the model, run the script, and check that nothing was published and no lock was left:
mv model/model.joblib /tmp/model.joblibpython -m acme_fraud_api.evaluate_test; echo "exit=$?"for f in reports/test_report.json reports/.test_report.lock; do [ -e "$f" ] && echo "$f: EXISTS" || echo "$f: none"; donemv /tmp/model.joblib model/model.joblibExpected output (real run):
EVALUATION FAILED, no report written: [Errno 2] No such file or directory: 'model/model.joblib'exit=1reports/test_report.json: nonereports/.test_report.lock: noneStep 3: Run it once, then try again
python -m acme_fraud_api.evaluate_test; echo "exit=$?"python -m acme_fraud_api.evaluate_test; echo "exit=$?"Expected output (real run):
{ "model_sha256": "e70c4a9d2e0434dd995eb1414938f3a2f993f502f53ffeecc9e3e6b437223db2", "data_sha256": "7e1b05cb61997fbb5af9b386055d4c4f549e5d56b6cb2708bd73d8215773aaca", "threshold": 0.47, "test_pr_auc": 0.1804, "test_recall": 0.7583, "test_precision": 0.104, "rows": 4000, "positives": 120, "evaluated_at": "2026-11-04T19:47:58+00:00"}exit=0REFUSED: reports/test_report.json already exists. The test set is looked at once.exit=3Step 4: Read the number honestly
Test PR-AUC (0.18) came in below validation (0.23). That is normal here, not a bug. The test set has only 120 fraud rows and validation has 96, so a handful of hard cases moves PR-AUC by several points. Recall held at the target (0.76 vs 0.75). Report both numbers and the row counts. Do not retrain to "fix" the test score: that is exactly the peeking this script exists to stop.
git add reports/test_report.json params.yamlgit commit -m "Record test-set result for model e70c4a9d2e04"Acceptance check
-
reports/test_report.jsonis committed and contains the samemodel_sha256anddata_sha256asmodel/metadata.json. - A second run exits 3 and leaves the file unchanged.
- No
reports/.test_report.lockis left behind.
Common MistakeDeleting
test_report.jsonto "rerun cleanly". If you change the model, that is a new model with a new hash and a new record. Keep the old record in Git history.
Milestone 3: Serve the Model with FastAPI
This milestone wraps the model in a small HTTP API with strict input validation, a liveness check, a readiness check, and a check that the model file matches the hash recorded in its metadata before it is loaded.
Why validation and a hash check
A model will happily score hour = 37 or a negative amount and return a confident, meaningless number. Pydantic rejects those with a 422 before the model sees them. The hash comparison is a consistency check: it catches a model file that was swapped, corrupted or retrained without updating its metadata, which matters because joblib.load runs pickle code. It is not proof of origin. Anyone who can replace both files can replace the recorded hash too. Proving a model came from a trusted training run needs signing or a registry, which later roadmap steps cover.
Step 1: Write the first version of the API
Write it the way most notebook-to-API ports start. Note the one line marked v1: it reloads the model inside the request handler. Predictions work, so it looks fine. Keep it for now. Milestone 5 measures what it costs.
src/acme_fraud_api/app.py (v1):
"""FastAPI service for the acme-fraud model (v1: first working version)."""import hashlibimport jsonimport osimport threadingfrom contextlib import asynccontextmanagerfrom pathlib import Pathfrom typing import Literal import joblibimport pandas as pdfrom fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel, Field MODEL_DIR = Path(os.environ.get("MODEL_DIR", "model"))STATE: dict = {} # holds the loaded model and its metadataPREDICT_LOCK = threading.Lock() # serialises predictions in this process (see Milestone 5) Category = Literal["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] # ---- Request and response contracts: bad input gets a 422, never reaches the model ----class Payment(BaseModel): txn_id: str = Field(min_length=1, max_length=64) amount: float = Field(gt=0, le=1_000_000) hour: int = Field(ge=0, le=23) merchant_category: Category country_mismatch: Literal[0, 1] card_age_days: int = Field(ge=0, le=36_500) txns_last_24h: int = Field(ge=0, le=1_000) class Score(BaseModel): txn_id: str fraud_probability: float is_fraud: bool threshold: float model_version: str def load_model() -> None: """Check the model file matches the hash in its metadata, then load it.""" meta = json.loads((MODEL_DIR / "metadata.json").read_text()) digest = hashlib.sha256((MODEL_DIR / "model.joblib").read_bytes()).hexdigest() if digest != meta["model_sha256"]: # A consistency check: catches a swapped or corrupted file. It is NOT proof of # origin: someone who can replace both files can replace the hash too. raise RuntimeError(f"model sha256 {digest[:12]} != metadata {meta['model_sha256'][:12]}") STATE["model"] = joblib.load(MODEL_DIR / "model.joblib") STATE["meta"] = meta @asynccontextmanagerasync def lifespan(app: FastAPI): yield STATE.clear() app = FastAPI(title="acme-fraud-api", lifespan=lifespan) @app.get("/health")def health() -> dict: """Liveness: the process is up. Says nothing about the model.""" return {"status": "ok"} @app.get("/ready")def ready() -> dict: """Readiness: the model is loaded and can answer.""" if "model" not in STATE: raise HTTPException(status_code=503, detail="model not loaded") return {"status": "ready", "model_version": STATE["meta"]["model_sha256"][:12]} @app.post("/predict", response_model=Score)def predict(payment: Payment) -> Score: # plain "def": FastAPI runs it in a thread pool load_model() # v1: reload the model on every request (we measure this later) meta = STATE["meta"] row = pd.DataFrame([payment.model_dump()])[meta["features"]] # same column order as training with PREDICT_LOCK: proba = float(STATE["model"].predict_proba(row)[0, 1]) return Score( txn_id=payment.txn_id, fraud_probability=round(proba, 4), is_fraud=proba >= meta["threshold"], # threshold was chosen on validation, not here threshold=meta["threshold"], model_version=meta["model_sha256"][:12], )Tip
/healthand/readyanswer different questions. Liveness asks "should this process be restarted?" Readiness asks "should traffic be sent here yet?" In v1,/readyreturns 503 until the first prediction loads the model, which is your first hint that something is off.
Step 2: Run it and call it
uvicorn acme_fraud_api.app:app --port 8000In a second terminal:
curl -s -X POST localhost:8000/predict -H 'Content-Type: application/json' \ -d '{"txn_id":"txn_demo_1","amount":129.99,"hour":2,"merchant_category":"electronics","country_mismatch":1,"card_age_days":40,"txns_last_24h":6}'curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/predict -H 'Content-Type: application/json' \ -d '{"txn_id":"x","amount":-5,"hour":2,"merchant_category":"electronics","country_mismatch":1,"card_age_days":40,"txns_last_24h":6}'Expected output (real run):
{"txn_id":"txn_demo_1","fraud_probability":0.8637,"is_fraud":true,"threshold":0.47,"model_version":"e70c4a9d2e04"}422Step 3: Add tests
pytest.ini:
[pytest]pythonpath = srctestpaths = teststests/test_train.py:
import jsonfrom pathlib import Path from acme_fraud_api.data import load_params, load_splits def test_splits_are_disjoint_and_sized(): train, val, test = load_splits(load_params()) assert len(train) + len(val) + len(test) == 20_000 assert len(test) == 4_000 and len(val) == 3_200 ids = [set(df["txn_id"]) for df in (train, val, test)] assert not (ids[0] & ids[1]) and not (ids[0] & ids[2]) and not (ids[1] & ids[2]) def test_saved_model_passed_gate(): meta = json.loads(Path("model/metadata.json").read_text()) params = load_params() assert meta["val_pr_auc"] >= params["gate"]["min_val_pr_auc"] assert meta["val_recall"] >= params["gate"]["recall_target"] def test_model_matches_its_metadata(): from acme_fraud_api.train import sha256_of meta = json.loads(Path("model/metadata.json").read_text()) assert sha256_of(Path("model/model.joblib")) == meta["model_sha256"]tests/test_api.py:
import jsonfrom pathlib import Path import pytestfrom fastapi.testclient import TestClient from acme_fraud_api.app import app GOOD = { "txn_id": "txn_test_1", "amount": 129.99, "hour": 2, "merchant_category": "electronics", "country_mismatch": 1, "card_age_days": 40, "txns_last_24h": 6,} @pytest.fixture(scope="module")def client(): with TestClient(app) as c: # "with" runs the lifespan, so the model loads yield c def test_ready(client): r = client.get("/ready") assert r.status_code == 200 assert r.json()["status"] == "ready" def test_predict_shape(client): r = client.post("/predict", json=GOOD) assert r.status_code == 200 body = r.json() assert 0.0 <= body["fraud_probability"] <= 1.0 assert body["threshold"] == json.loads(Path("model/metadata.json").read_text())["threshold"] @pytest.mark.parametrize("field,value", [ ("amount", -5), ("hour", 24), ("merchant_category", "casino"), ("country_mismatch", 2),])def test_rejects_bad_input(client, field, value): r = client.post("/predict", json={**GOOD, field: value}) assert r.status_code == 422 def test_missing_field(client): bad = dict(GOOD) bad.pop("amount") assert client.post("/predict", json=bad).status_code == 422pytest -qExpected output with v1 (real run, last lines):
FAILED tests/test_api.py::test_ready - assert 503 == 2001 failed, 9 passed in 1.55sThe test is right and the code is wrong. v1 has no model in memory until the first prediction, so a load balancer asking /ready would never send it traffic. Leave the failure in place: Milestone 5 shows the same line also costs you latency, and one fix clears both.
Acceptance check
- A valid payment returns a probability and
model_version. An invalid one returns422. -
pytest -qshows 9 passed and onlytest_readyfailing (all 10 pass after Milestone 5).
Common MistakeWriting
async def predictaround a scikit-learn call. Model inference is CPU work that blocks the event loop, so every other request (including/health) waits behind it. A plaindeflets FastAPI run it in a thread pool.
Milestone 4: Containerise with Docker and Compose
This milestone packages code, pinned dependencies and the trained model into one image that runs as a non-root user, plus a Compose file that runs the same image on your laptop as the free local fallback for the AWS milestones.
Why bake the model into the image
At this stage the image tag (a git commit) then identifies code, dependencies and model together. One tag, one behaviour, which makes rollback "run the previous tag". The cost is a rebuild for every new model. Later roadmap steps move the model into a registry, when that trade starts to hurt.
Step 1: Write the Dockerfile and .dockerignore
Dockerfile:
# Small official Python base. After your first pull, pin it by digest (see Milestone 4).FROM python:3.12-slim # No .pyc files, logs straight to stdout, one OpenMP thread per process,# and tell the app where the model and the code live.ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ OMP_NUM_THREADS=1 \ MODEL_DIR=/app/model \ PYTHONPATH=/app/src WORKDIR /app # Dependencies first: this layer is cached until requirements.txt changes.COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt # Code and the trained model (model/ must exist: run training before building).COPY src/ src/COPY model/ model/ # Never run as root inside the container.RUN useradd --create-home --uid 10001 appUSER app EXPOSE 8000# Liveness only: is the process answering? Readiness (/ready) is checked by the deploy script.HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" CMD ["uvicorn", "acme_fraud_api.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"].dockerignore (keeps .venv, data and any .env out of the build context):
# Keep the build context small and free of secrets.venv/.git/data/reports/tests/loadtest/deploy/**/__pycache__/*.env.env*OMP_NUM_THREADS=1 stops scikit-learn's histogram gradient boosting from starting one OpenMP thread per CPU for every single-row prediction. Under concurrent load those threads fight each other. Scale with processes instead (Milestone 5 has the experiment).
Step 2: Add the Compose file
compose.yaml:
# Local fallback: the same image you ship to AWS, on your laptop.services: api: build: . image: acme-fraud-api:local ports: - "8000:8000" deploy: resources: limits: # roughly the size of a small cloud VM cpus: "2" memory: 1g restart: unless-stoppedStep 3: Build, run and check
python -m acme_fraud_api.train # model/ must exist before the builddocker compose up -d --builddocker compose ps # wait for "healthy"curl -s localhost:8000/healthExpected output (the /health reply is from the sandbox run of the same app outside Docker; the Docker build itself was not run here):
{"status":"ok"}Step 4: Pin the base image by digest
A tag like python:3.12-slim moves when a new patch ships. After your first successful build, look up the exact digest you got and pin it:
docker image inspect python:3.12-slim --format '{{index .RepoDigests 0}}'# then in the Dockerfile: FROM python:3.12-slim@sha256:<the digest printed above>Acceptance check
-
docker compose psshows the service ashealthy. -
docker compose exec api whoamiprintsapp, notroot. -
docker image ls acme-fraud-api:localshows an image well under 1 GB.
SecurityNever
COPY . .without a.dockerignore. It copies.env, your.venv, and the raw training data into an image you are about to push to a registry.
Milestone 5: Load Test with Locust and Fix the Bottleneck
This milestone puts the v1 service under steady load three times, records p50, p95, error rate, CPU and memory, makes one fix, and repeats the same measurement with identical settings so the comparison is fair.
Why percentiles, and what kind of load this is
An average hides the slow tail. p50 is what a typical request feels. p95 is what 1 in 20 users feels, and it is where queueing shows up first. Error rate tells you whether "fast" is only fast because requests are failing.
This test is closed-loop: 20 simulated users each send a request, wait for the answer, pause 100 ms, and send the next. A faster server therefore receives more requests per second. That is fine for comparing two versions under identical settings, but it is not a fixed arrival rate. To ask "what happens at exactly 150 requests per second?" you need an open-loop, arrival-rate workload (k6's constant-arrival-rate executor, for example) and a load generator that can sustain it.
Step 1: Write the Locust file, a benchmark script and a summary script
loadtest/locustfile.py:
"""Locust load test: each simulated user sends a random valid payment."""import random from locust import HttpUser, constant, task CATEGORIES = ["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] class PaymentUser(HttpUser): wait_time = constant(0.1) # each user pauses 100 ms between requests @task def predict(self): payload = { "txn_id": f"lt_{random.randint(0, 10**9)}", "amount": round(random.lognormvariate(3.5, 1.0), 2), "hour": random.randint(0, 23), "merchant_category": random.choice(CATEGORIES), "country_mismatch": random.choice([0, 0, 0, 1]), "card_age_days": random.randint(1, 3650), "txns_last_24h": random.randint(0, 10), } # name= groups every call under one row in the report with self.client.post("/predict", json=payload, name="/predict", catch_response=True) as r: if r.status_code != 200: r.failure(f"status {r.status_code}")loadtest/bench.sh starts a fresh server per run, throws away a 10-second warm-up, measures 45 seconds, records server CPU and memory, and kills exactly the server it started (by PID):
#!/usr/bin/env bash# Benchmark the API 3 times with identical settings. Usage: loadtest/bench.sh <label> [app module]# Each run: start a fresh server, 10 s warm-up (thrown away), 45 s measured run.set -euo pipefailLABEL="$1"; APP="${2:-acme_fraud_api.app:app}"USERS=20; SPAWN=10; WARMUP=10s; DURATION=45s; RUNS=3export PYTHONPATH=src OMP_NUM_THREADS=1mkdir -p reportsfor run in $(seq 1 "$RUNS"); do uvicorn "$APP" --port 8000 --workers 1 --log-level warning 2> "reports/${LABEL}_server_run${run}.log" & PID=$! # kill exactly this process later until curl -sf localhost:8000/health >/dev/null; do sleep 0.5; done locust -f loadtest/locustfile.py --headless -u $USERS -r $SPAWN -t $WARMUP \ --host http://127.0.0.1:8000 --only-summary >/dev/null 2>&1 # warm-up, discarded LINUX=0; [ -r "/proc/$PID/stat" ] && LINUX=1 # CPU sampling needs Linux /proc if [ "$LINUX" = 1 ]; then CPU0=$(awk '{print $14+$15}' "/proc/$PID/stat"); T0=$(date +%s.%N); fi locust -f loadtest/locustfile.py --headless -u $USERS -r $SPAWN -t $DURATION \ --host http://127.0.0.1:8000 --csv "reports/${LABEL}_run${run}" --only-summary >/dev/null 2>&1 CPU="n/a (use docker stats or Activity Monitor)" if [ "$LINUX" = 1 ]; then CPU1=$(awk '{print $14+$15}' "/proc/$PID/stat"); T1=$(date +%s.%N) CPU=$(printf "%.0f%%" "$(echo "($CPU1-$CPU0)/$(getconf CLK_TCK)/($T1-$T0)*100" | bc -l)") fi RSS=$(( $(ps -o rss= -p "$PID") / 1024 )) # works on Linux and macOS WARN=$(grep -c UserWarning "reports/${LABEL}_server_run${run}.log" || true) echo "${LABEL} run${run}: server cpu=${CPU} rss=${RSS}MB workers=1 sklearn_warnings=${WARN}" kill "$PID"; wait "$PID" 2>/dev/null || truedoneloadtest/summary.py:
"""Print p50, p95, error rate and throughput from Locust --csv runs,then the median p95 (and range) for each label. Usage: python loadtest/summary.py reports/before_run1 reports/before_run2 ..."""import csvimport statisticsimport sysfrom collections import defaultdict by_label = defaultdict(list)print(f"{'run':<14}{'requests':>10}{'fails':>7}{'p50 ms':>8}{'p95 ms':>8}{'errors':>9}{'req/s':>8}")for prefix in sys.argv[1:]: with open(f"{prefix}_stats.csv") as f: row = next(r for r in csv.DictReader(f) if r["Name"] == "Aggregated") total, fails = int(row["Request Count"]), int(row["Failure Count"]) err = 100 * fails / total if total else 0.0 name = prefix.split("/")[-1] print(f"{name:<14}{total:>10}{fails:>7}{row['50%']:>8}{row['95%']:>8}" f"{err:>8.2f}%{float(row['Requests/s']):>8.1f}") by_label[name.split("_run")[0]].append(float(row["95%"])) print()for label, p95s in by_label.items(): print(f"{label:<14} p95 median {statistics.median(p95s):.0f} ms " f"(range {min(p95s):.0f} to {max(p95s):.0f}, {len(p95s)} runs)")chmod +x loadtest/bench.shTipLocust and the API share your CPU in this setup, which inflates latency for both versions. That is acceptable for a before and after comparison on the same machine. For absolute numbers, run Locust from a second machine.
Step 2: Measure v1 (before)
With app.py still at v1:
./loadtest/bench.sh beforeExpected output (real run, 2 vCPU sandbox):
before run1: server cpu=94% rss=192MB workers=1 sklearn_warnings=0before run2: server cpu=95% rss=188MB workers=1 sklearn_warnings=0before run3: server cpu=95% rss=187MB workers=1 sklearn_warnings=0The server process is pinned near 100 percent of one core while serving about 72 requests per second. Something is expensive per request.
Step 3: Make the fix
The v1 handler calls load_model() on every request: it reads a 170 KB file, hashes it, and unpickles a whole scikit-learn pipeline, every time. The fix is two lines: load in the lifespan hook, once per process, before traffic arrives.
@asynccontextmanager async def lifespan(app: FastAPI):+ load_model() # v2 FIX: load once per worker process, before any traffic arrives yield STATE.clear()@@ def predict(payment: Payment) -> Score: # plain "def": FastAPI runs it in a thread pool- load_model() # v1: reload the model on every request (we measure this later) meta = STATE["meta"]Also change the first line of the docstring to v2: load the model once. The full v2 file is:
"""FastAPI service for the acme-fraud model (v2: load the model once)."""import hashlibimport jsonimport osimport threadingfrom contextlib import asynccontextmanagerfrom pathlib import Pathfrom typing import Literal import joblibimport pandas as pdfrom fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel, Field MODEL_DIR = Path(os.environ.get("MODEL_DIR", "model"))STATE: dict = {} # holds the loaded model and its metadataPREDICT_LOCK = threading.Lock() # serialises predictions in this process (see Milestone 5) Category = Literal["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] # ---- Request and response contracts: bad input gets a 422, never reaches the model ----class Payment(BaseModel): txn_id: str = Field(min_length=1, max_length=64) amount: float = Field(gt=0, le=1_000_000) hour: int = Field(ge=0, le=23) merchant_category: Category country_mismatch: Literal[0, 1] card_age_days: int = Field(ge=0, le=36_500) txns_last_24h: int = Field(ge=0, le=1_000) class Score(BaseModel): txn_id: str fraud_probability: float is_fraud: bool threshold: float model_version: str def load_model() -> None: """Check the model file matches the hash in its metadata, then load it.""" meta = json.loads((MODEL_DIR / "metadata.json").read_text()) digest = hashlib.sha256((MODEL_DIR / "model.joblib").read_bytes()).hexdigest() if digest != meta["model_sha256"]: # A consistency check: catches a swapped or corrupted file. It is NOT proof of # origin: someone who can replace both files can replace the hash too. raise RuntimeError(f"model sha256 {digest[:12]} != metadata {meta['model_sha256'][:12]}") STATE["model"] = joblib.load(MODEL_DIR / "model.joblib") STATE["meta"] = meta @asynccontextmanagerasync def lifespan(app: FastAPI): load_model() # v2 FIX: load once per worker process, before any traffic arrives yield STATE.clear() app = FastAPI(title="acme-fraud-api", lifespan=lifespan) @app.get("/health")def health() -> dict: """Liveness: the process is up. Says nothing about the model.""" return {"status": "ok"} @app.get("/ready")def ready() -> dict: """Readiness: the model is loaded and can answer.""" if "model" not in STATE: raise HTTPException(status_code=503, detail="model not loaded") return {"status": "ready", "model_version": STATE["meta"]["model_sha256"][:12]} @app.post("/predict", response_model=Score)def predict(payment: Payment) -> Score: # plain "def": FastAPI runs it in a thread pool meta = STATE["meta"] row = pd.DataFrame([payment.model_dump()])[meta["features"]] # same column order as training with PREDICT_LOCK: proba = float(STATE["model"].predict_proba(row)[0, 1]) return Score( txn_id=payment.txn_id, fraud_probability=round(proba, 4), is_fraud=proba >= meta["threshold"], # threshold was chosen on validation, not here threshold=meta["threshold"], model_version=meta["model_sha256"][:12], )Step 4: Measure v2 (after) and compare
./loadtest/bench.sh afterpython loadtest/summary.py reports/before_run{1,2,3} reports/after_run{1,2,3}pytest -qExpected output (real run, same sandbox, same settings):
after run1: server cpu=79% rss=177MB workers=1 sklearn_warnings=0after run2: server cpu=79% rss=177MB workers=1 sklearn_warnings=0after run3: server cpu=79% rss=177MB workers=1 sklearn_warnings=0run requests fails p50 ms p95 ms errors req/sbefore_run1 3136 0 190 290 0.00% 71.2before_run2 3183 0 190 270 0.00% 72.3before_run3 3220 0 180 280 0.00% 73.2after_run1 6507 0 30 69 0.00% 147.7after_run2 6479 0 31 66 0.00% 147.1after_run3 6362 0 33 73 0.00% 144.4 before p95 median 280 ms (range 270 to 290, 3 runs)after p95 median 69 ms (range 66 to 73, 3 runs)10 passed in 1.51sAcross three runs each, median p95 fell from 280 ms to 69 ms, throughput doubled, failures stayed at zero, and the server used less CPU while doing twice the work. The run-to-run range is small compared with the change, so the improvement is not noise. Your numbers will differ by machine; the direction and rough size should not.
Step 5: Understand the lock (an experiment, not a rule)
app.py holds PREDICT_LOCK around predict_proba, so predictions inside one process run one at a time. It was added after an observation under 20 concurrent users, with the lock absent: the server log occasionally printed this warning, while predictions stayed correct.
UserWarning: `sklearn.utils.parallel.delayed` should be used with `sklearn.utils.parallel.Parallel` to make it possible to propagate the scikit-learn configuration of the current thread to the joblib workers.It appeared only with concurrent requests (0 times with 1 user, 19 times in one 15-second run with 20 users, before OMP_NUM_THREADS=1 and the lock were added). Our best explanation is that scikit-learn copies the calling thread's configuration and warning settings when it dispatches internal work, and under many simultaneous threads that copy is sometimes made at the wrong moment. We did not prove the mechanism, so treat it as a hypothesis.
Do not read this as "the GIL makes the lock free". The GIL does not force all inference to run one call at a time: NumPy and scikit-learn release it inside native code, so threads can overlap. A lock is a real serialisation point, and it can become the bottleneck. So measure it. Remove the two lines with PREDICT_LOCK: (de-indent the line below it), then:
./loadtest/bench.sh nolockpython loadtest/summary.py reports/after_run{1,2,3} reports/nolock_run{1,2,3}Real run in the sandbox:
| Variant (3 runs each) | p95 median | Throughput, CPU |
|---|---|---|
| v2 with lock | 69 ms (66 to 73) | 146 req/s, 79% CPU |
| v2 without lock | 140 ms (140 to 140) | 100 req/s, 59% CPU |
| Warnings without lock | 2 in run 1, 0 in runs 2 and 3 | predictions unaffected |
On this machine, with this model, the lock made the service faster, most likely because 20 threads fighting over one interpreter cost more than queueing politely. On a different machine or a heavier model the result can flip. That is the lesson: keep the lock only because you measured it, and re-measure when anything changes. Put the lock back before you continue.
To use more CPU cores, compare --workers 2 under the same benchmark. Each worker is a separate process with its own copy of the model (about 180 MB of memory each here), so more workers are not automatically better on a small instance.
Acceptance check
-
reports/before_run*_stats.csvandreports/after_run*_stats.csvare committed. -
summary.pyshows a lower median p95 after the fix, with ranges that do not overlap, and 0 failures in every run. -
/readynow returns 200 immediately after startup, andpytest -qshows 10 passed.
Common MistakeComparing a 30-second run with 10 users against a 45-second run with 20 users, or including the first seconds of a cold server. Change one thing at a time, warm up first, and keep users, spawn rate, duration and machine identical.
Milestone 6: Add CI with GitHub Actions
This milestone makes every pull request train the model, run the gate, run the tests and build the image, with a read-only token and no secrets.
Why CI trains instead of downloading a model
The gate only protects you if it runs on every change. Training here takes about 5 seconds, so CI can retrain from scratch and fail the pull request when the gate fails. Bigger models later move this to scheduled pipelines with tracked artifacts.
Step 1: Write the workflow
.github/workflows/ci.yml:
name: cion: pull_request: push: branches: [main] permissions: contents: read # read-only token; this workflow needs no secrets jobs: test-and-build: runs-on: ubuntu-24.04 steps: # Actions pinned by full commit SHA (checkout v7.0.1, setup-python v7.1.0) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.12" cache: pip - run: pip install -r requirements-dev.txt - name: Train (fails the job if the validation gate fails) run: python -m acme_fraud_api.train env: PYTHONPATH: src - run: pytest -q - name: Build the image (not pushed) run: docker build -t acme-fraud-api:ci .Step 2: Push and open a pull request
git add . && git commit -m "acme-fraud-api: train, gate, serve, containerise"git remote add origin git@github.com:<you>/acme-fraud-api.gitgit push -u origin maingit switch -c try-ci && git commit --allow-empty -m "trigger CI" && git push -u origin try-ciOpen the pull request and watch the ci check. (Not run for this guide: no GitHub access from the sandbox. The train and pytest steps ran locally and passed. The docker build step was not run.)
Acceptance check
- The
cicheck is green on the pull request. - Actions are referenced by 40-character commit SHA, not
@v7.
SecurityThis workflow gets
contents: readand no secrets, which is what any workflow triggered by a pull request should get. Pushing to ECR from CI needs AWS credentials through OIDC onmainonly. That is covered in CI/CD and Continuous Training for ML, not here.
Milestone 7: Deploy to AWS EC2 from ECR
This milestone pushes the image to a private ECR repository tagged with the git commit, and starts one EC2 instance that pulls and runs it, with no SSH port, no stored keys, and port 8000 open to your IP only. Every resource carries a unique deployment ID, and teardown deletes only resources with that exact tag.
Cost and account check first
SecurityThis milestone creates billable AWS resources. Do it in one sitting and run the teardown in Milestone 8 the same day.
- Accounts created on or after 15 July 2025 get USD 100 sign-up credit (up to USD 200 with onboarding tasks) on the Free plan for 6 months, or until the credits run out. AWS lists
t3.smallandt3.microas free-tier eligible for these accounts, paid from the credits. - Accounts created before 15 July 2025 were on the legacy 12-month Free Tier, which has most likely expired by now. Check your account's Free Tier status and the Billing console before deploying. Do not assume
t3.microis free. - Rough on-demand cost if you pay:
t3.smallinap-south-1is about USD 0.0224 per hour, a public IPv4 address USD 0.005 per hour, plus cents for an 8 GB disk and a few hundred MB in ECR. A 3-hour session is roughly USD 0.10. Prices checked 5 Nov 2026; confirm in the AWS Pricing Calculator. - Set an AWS Budgets alert at USD 1 before you start.
- No AWS account? Skip to Milestone 8 Step 1 and run the same load test against
docker compose. That is the supported free fallback.
SecurityThis is a temporary learning deployment. Port 8000 is plain HTTP and the API has no authentication. Do not send real payment data or anything sensitive to it. Production needs TLS and an authentication and authorisation layer in front of the service.
How the deploy works
The script does not trust "instance running". An instance can be running for minutes while Docker installs and the image pulls. It only reports success when /ready answers 200 from your laptop.
Step 1: Configure the AWS CLI and pick the profile
aws configure # access keys, or:aws sso login --profile <yours>export AWS_PROFILE=<yours> # both scripts use whatever profile this namesexport AWS_REGION=ap-south-1aws sts get-caller-identity # check this is the account you expectaws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output textThe last command must print a VPC ID, not None. The script needs a default VPC and stops with a clear error if the region has none.
Step 2: Write the boot script
deploy/user_data.sh:
#!/bin/bash# Runs once as root when the EC2 instance first boots.# __IMAGE__ and __REGION__ are filled in by aws_up.sh.set -euxo pipefaildnf install -y docker # Amazon Linux 2023 ships the AWS CLI alreadysystemctl enable --now dockerIMAGE="__IMAGE__"REGISTRY="${IMAGE%%/*}" # the part before the first "/"# The instance role (not a stored key) lets us pull from ECR.aws ecr get-login-password --region "__REGION__" | docker login --username AWS --password-stdin "$REGISTRY"docker run -d --name acme-fraud-api --restart unless-stopped \ -p 8000:8000 --memory 1g "$IMAGE"Step 3: Write the deploy script
deploy/aws_up.sh:
#!/usr/bin/env bash# Build, push and run acme-fraud-api on one EC2 instance. Run from the repo root.# Every resource gets a unique deployment ID and is tagged with it.# aws_down.sh deletes ONLY resources carrying that exact tag.set -euo pipefail REGION="${AWS_REGION:-ap-south-1}"TYPE="${INSTANCE_TYPE:-t3.small}"ID_FILE="deploy/.deploy-id" # gitignored; keeps the ID between up and down # 0. Who am I? (uses AWS_PROFILE if you exported one)echo "aws identity: $(aws sts get-caller-identity --query Arn --output text) region: ${REGION}" # 1. Deploy only committed code, so the image tag points at a real source tree.if [ -n "$(git status --porcelain)" ]; then echo "ERROR: commit or discard all changes (including untracked files) before deploying." git status --short; exit 1fiTAG=$(git rev-parse --short=12 HEAD) # 2. Unique deployment ID, reused on reruns of this same deployment.if [ -f "$ID_FILE" ]; then DEPLOY_ID=$(cat "$ID_FILE")else DEPLOY_ID="acme-fraud-api-$(od -An -N3 -tx1 /dev/urandom | tr -d ' \n')"; echo "$DEPLOY_ID" > "$ID_FILE"; fiROLE="${DEPLOY_ID}-ec2"TAGS_KV="Key=project,Value=acme-fraud-api Key=deploy-id,Value=${DEPLOY_ID}"echo "deployment: ${DEPLOY_ID}" ACCOUNT=$(aws sts get-caller-identity --query Account --output text)REGISTRY="${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com"IMAGE="${REGISTRY}/${DEPLOY_ID}:${TAG}" stop_if_not_ours() { # $1 = what, $2 = deploy-id tag value found on it if [ "$2" != "$DEPLOY_ID" ]; then echo "ERROR: $1 exists but is not tagged deploy-id=${DEPLOY_ID}. Not touching it. Resolve the conflict first." exit 1 fi} # 3. Private ECR repository: immutable tags, scan on push.if aws ecr describe-repositories --repository-names "$DEPLOY_ID" --region "$REGION" >/dev/null 2>&1; then ARN="arn:aws:ecr:${REGION}:${ACCOUNT}:repository/${DEPLOY_ID}" stop_if_not_ours "ECR repo ${DEPLOY_ID}" "$(aws ecr list-tags-for-resource --resource-arn "$ARN" \ --region "$REGION" --query "tags[?Key=='deploy-id'].Value | [0]" --output text)"else # shellcheck disable=SC2086 aws ecr create-repository --repository-name "$DEPLOY_ID" --region "$REGION" \ --image-tag-mutability IMMUTABLE --image-scanning-configuration scanOnPush=true \ --tags $TAGS_KV >/dev/nullfi # 4. Build for x86_64 and push (skip if this exact tag is already there).aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$REGISTRY"if aws ecr describe-images --repository-name "$DEPLOY_ID" --image-ids imageTag="$TAG" --region "$REGION" >/dev/null 2>&1; then echo "tag ${TAG} already in ECR, not pushing again"else docker build --platform linux/amd64 -t "$IMAGE" . docker push "$IMAGE"fi # 5. Instance role that can only pull from THIS repository. Every piece is checked# and repaired, so a half-finished earlier run is completed, not skipped.if aws iam get-role --role-name "$ROLE" >/dev/null 2>&1; then stop_if_not_ours "IAM role ${ROLE}" "$(aws iam list-role-tags --role-name "$ROLE" \ --query "Tags[?Key=='deploy-id'].Value | [0]" --output text)"else # shellcheck disable=SC2086 aws iam create-role --role-name "$ROLE" --tags $TAGS_KV \ --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' >/dev/nullfiaws iam put-role-policy --role-name "$ROLE" --policy-name ecr-pull-one-repo --policy-document "{ \"Version\":\"2012-10-17\",\"Statement\":[ {\"Effect\":\"Allow\",\"Action\":\"ecr:GetAuthorizationToken\",\"Resource\":\"*\"}, {\"Effect\":\"Allow\",\"Action\":[\"ecr:BatchGetImage\",\"ecr:GetDownloadUrlForLayer\"], \"Resource\":\"arn:aws:ecr:${REGION}:${ACCOUNT}:repository/${DEPLOY_ID}\"}]}" # same call creates or updatesif aws iam get-instance-profile --instance-profile-name "$ROLE" >/dev/null 2>&1; then stop_if_not_ours "instance profile ${ROLE}" "$(aws iam list-instance-profile-tags --instance-profile-name "$ROLE" \ --query "Tags[?Key=='deploy-id'].Value | [0]" --output text)"else # shellcheck disable=SC2086 aws iam create-instance-profile --instance-profile-name "$ROLE" --tags $TAGS_KV >/dev/nullfiif [ "$(aws iam get-instance-profile --instance-profile-name "$ROLE" --query 'length(InstanceProfile.Roles)' --output text)" = "0" ]; then aws iam add-role-to-instance-profile --instance-profile-name "$ROLE" --role-name "$ROLE"fi # 6. Firewall: port 8000 open to your CURRENT IP only. No SSH port at all.VPC=$(aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text --region "$REGION")if [ "$VPC" = "None" ]; then echo "ERROR: no default VPC in ${REGION}. Create one (aws ec2 create-default-vpc) or pick another region."; exit 1fiMYIP=$(curl -s https://checkip.amazonaws.com)SG=$(aws ec2 describe-security-groups --region "$REGION" --filters Name=group-name,Values="$DEPLOY_ID" \ --query 'SecurityGroups[0].GroupId' --output text)if [ "$SG" = "None" ]; then SG=$(aws ec2 create-security-group --group-name "$DEPLOY_ID" --description "acme-fraud-api port 8000" \ --vpc-id "$VPC" --region "$REGION" --query GroupId --output text \ --tag-specifications "ResourceType=security-group,Tags=[{Key=project,Value=acme-fraud-api},{Key=deploy-id,Value=${DEPLOY_ID}}]")else stop_if_not_ours "security group ${DEPLOY_ID}" "$(aws ec2 describe-security-groups --group-ids "$SG" --region "$REGION" \ --query "SecurityGroups[0].Tags[?Key=='deploy-id'].Value | [0]" --output text)"fiOLD_RULES=$(aws ec2 describe-security-groups --group-ids "$SG" --region "$REGION" --query 'SecurityGroups[0].IpPermissions' --output json)if [ "$OLD_RULES" != "[]" ]; then # drop rules for an old IP, then add the current one aws ec2 revoke-security-group-ingress --group-id "$SG" --ip-permissions "$OLD_RULES" --region "$REGION" >/dev/nullfiaws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 8000 \ --cidr "${MYIP}/32" --region "$REGION" >/dev/nullecho "port 8000 open to ${MYIP}/32 only" # 7. One instance per deployment. If one is already up, stop here.RUNNING=$(aws ec2 describe-instances --region "$REGION" \ --filters Name=tag:deploy-id,Values="$DEPLOY_ID" Name=instance-state-name,Values=pending,running,stopping,stopped \ --query 'Reservations[].Instances[].InstanceId' --output text)if [ -n "$RUNNING" ]; then echo "instance already exists for ${DEPLOY_ID}: ${RUNNING}. Run deploy/aws_down.sh first."; exit 1; fi # 8. Launch the latest Amazon Linux 2023. Retry while the new instance profile propagates.AMI=$(aws ssm get-parameter --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \ --query Parameter.Value --output text --region "$REGION")sed -e "s|__IMAGE__|${IMAGE}|" -e "s|__REGION__|${REGION}|" deploy/user_data.sh > /tmp/acme_user_data.shID=""for attempt in 1 2 3 4 5 6; do if ID=$(aws ec2 run-instances --region "$REGION" --image-id "$AMI" --instance-type "$TYPE" \ --security-group-ids "$SG" --iam-instance-profile Name="$ROLE" \ --metadata-options HttpTokens=required \ --user-data file:///tmp/acme_user_data.sh \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=${DEPLOY_ID}},{Key=project,Value=acme-fraud-api},{Key=deploy-id,Value=${DEPLOY_ID}}]" \ "ResourceType=volume,Tags=[{Key=project,Value=acme-fraud-api},{Key=deploy-id,Value=${DEPLOY_ID}}]" \ --query 'Instances[0].InstanceId' --output text 2>/tmp/acme_run_err); then break; fi echo "launch attempt ${attempt} failed: $(tail -1 /tmp/acme_run_err)"; ID=""; sleep 10done[ -n "$ID" ] || { echo "could not launch instance"; exit 1; }aws ec2 wait instance-running --instance-ids "$ID" --region "$REGION"IP=$(aws ec2 describe-instances --instance-ids "$ID" --region "$REGION" \ --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)echo "instance ${ID} at ${IP}" # 9. Do not trust "running": wait until /ready answers (Docker install + pull takes minutes).for _ in $(seq 1 40); do if curl -sf "http://${IP}:8000/ready"; then echo; echo "DEPLOYED: http://${IP}:8000"; exit 0; fi sleep 10doneecho "not ready after ~7 minutes. Check: aws ec2 get-console-output --instance-id ${ID} --latest --output text --region ${REGION}"exit 1What each choice protects against:
| Choice | Protects against | Where |
|---|---|---|
| Clean tree, tag = 12-char commit | Image from uncommitted files | step 1 |
Unique deploy-id tag on everything |
Touching resources you did not create | steps 2 to 8 |
| Stop if a name exists without our tag | Name clashes in a shared account | stop_if_not_ours |
| Policy, profile, rule re-applied every run | Half-finished earlier runs, changed IP | steps 5 and 6 |
| Role scoped to one repository | Leaked role reading all images | put-role-policy |
| Port 8000 to your /32, no SSH | Internet scanners | step 6 |
HttpTokens=required |
SSRF stealing role credentials | --metadata-options |
Poll /ready, not state |
False "deployed" | step 9 |
Step 4: Deploy
Commit first. The script refuses to run with any uncommitted or untracked file, because an image built from such a tree cannot be traced back to a commit.
echo "deploy/.deploy-id" >> .gitignoregit add .gitignore src/acme_fraud_api/app.py reports/git commit -m "v2: load model once"git status --short # must print nothingchmod +x deploy/*.sh./deploy/aws_up.shExpected output shape (not run for this guide; the IDs and IP will be yours):
aws identity: arn:aws:sts::123456789012:assumed-role/... region: ap-south-1deployment: acme-fraud-api-3fa9c1port 8000 open to 203.0.113.7/32 onlyinstance i-0abc123def4567890 at 13.233.xx.xx{"status":"ready","model_version":"e70c4a9d2e04"}DEPLOYED: http://13.233.xx.xx:8000Check that the cloud is serving the exact model you recorded in Milestone 2:
curl -s http://<IP>:8000/readypython -c "import json; print(json.load(open('reports/test_report.json'))['model_sha256'][:12])"Both must show the same 12-character prefix.
Acceptance check
-
/readyon the public IP returns the samemodel_versionasreports/test_report.json. -
curlfrom another network (your phone's hotspot) times out, because port 8000 is open only to your IP. - Every resource in the console carries the tag
deploy-idwith your ID.
Common MistakeBuilding on an Apple Silicon Mac without
--platform linux/amd64. The image is ARM, the instance is x86_64, and the container exits withexec format error. Read it withaws ec2 get-console-output --instance-id <id> --latest --output text.
Milestone 8: Load Test in the Cloud and Tear Down
This milestone records the same numbers against the live endpoint, then deletes every resource tagged with your deployment ID and proves nothing is left.
Step 1: Run the load test
locust -f loadtest/locustfile.py --headless -u 20 -r 10 -t 10s --host http://<IP>:8000 --only-summary # warm-up, ignorefor run in 1 2 3; do locust -f loadtest/locustfile.py --headless -u 20 -r 10 -t 45s \ --host http://<IP>:8000 --csv reports/aws_run$run --only-summarydonepython loadtest/summary.py reports/aws_run1 reports/aws_run2 reports/aws_run3No AWS? Run docker compose up -d and point the same loop at http://127.0.0.1:8000, saving as reports/compose_run*.
Expect cloud p50 to be higher than your laptop run, because every request now crosses the internet. For a rough network baseline, time a few /health calls, which do almost no server work:
for i in 1 2 3 4 5; do curl -o /dev/null -s -w "%{time_connect} %{time_total}\n" http://<IP>:8000/health; doneThis is an approximation, not a precise split of network time and server time: it is one tiny request at a time, with no load.
Step 2: Fill in your results table
| Run (3 x 45 s each) | p95 ms, median (range) | Error rate |
|---|---|---|
| Before fix (sandbox) | 280 (270 to 290) | 0.00% |
| After fix (sandbox) | 69 (66 to 73) | 0.00% |
| After fix on EC2 | yours | yours |
Step 3: Tear everything down
deploy/aws_down.sh:
#!/usr/bin/env bash# Delete ONLY the resources tagged with this deployment's ID. Safe to run more than once.# Usage: deploy/aws_down.sh (reads deploy/.deploy-id)# deploy/aws_down.sh <deploy-id>set -uo pipefailREGION="${AWS_REGION:-ap-south-1}"ID_FILE="deploy/.deploy-id"DEPLOY_ID="${1:-$(cat "$ID_FILE" 2>/dev/null || true)}"case "$DEPLOY_ID" in acme-fraud-api-*) ;; # only IDs that aws_up.sh could have made *) echo "ERROR: no valid deployment ID (got '${DEPLOY_ID}')."; exit 1 ;;esacROLE="${DEPLOY_ID}-ec2"ACCOUNT=$(aws sts get-caller-identity --query Account --output text)echo "tearing down ${DEPLOY_ID} in ${REGION} (account ${ACCOUNT})" # 1. Instances carrying our tag.IDS=$(aws ec2 describe-instances --region "$REGION" \ --filters Name=tag:deploy-id,Values="$DEPLOY_ID" Name=instance-state-name,Values=pending,running,stopping,stopped \ --query 'Reservations[].Instances[].InstanceId' --output text)if [ -n "$IDS" ]; then # shellcheck disable=SC2086 aws ec2 terminate-instances --instance-ids $IDS --region "$REGION" >/dev/null # shellcheck disable=SC2086 aws ec2 wait instance-terminated --instance-ids $IDS --region "$REGION" echo "terminated: $IDS"fi # 2. Security group carrying our tag (deletable only after the instance is gone).for SG in $(aws ec2 describe-security-groups --region "$REGION" --filters Name=tag:deploy-id,Values="$DEPLOY_ID" \ --query 'SecurityGroups[].GroupId' --output text); do aws ec2 delete-security-group --group-id "$SG" --region "$REGION" && echo "deleted security group $SG"done # 3. Instance profile and role, each only if tagged with our ID.if [ "$(aws iam list-instance-profile-tags --instance-profile-name "$ROLE" \ --query "Tags[?Key=='deploy-id'].Value | [0]" --output text 2>/dev/null)" = "$DEPLOY_ID" ]; then aws iam remove-role-from-instance-profile --instance-profile-name "$ROLE" --role-name "$ROLE" 2>/dev/null aws iam delete-instance-profile --instance-profile-name "$ROLE" && echo "deleted instance profile $ROLE"fiif [ "$(aws iam list-role-tags --role-name "$ROLE" \ --query "Tags[?Key=='deploy-id'].Value | [0]" --output text 2>/dev/null)" = "$DEPLOY_ID" ]; then aws iam delete-role-policy --role-name "$ROLE" --policy-name ecr-pull-one-repo 2>/dev/null aws iam delete-role --role-name "$ROLE" && echo "deleted role $ROLE"fi # 4. ECR repository (and its images), only if tagged with our ID.ARN="arn:aws:ecr:${REGION}:${ACCOUNT}:repository/${DEPLOY_ID}"if [ "$(aws ecr list-tags-for-resource --resource-arn "$ARN" --region "$REGION" \ --query "tags[?Key=='deploy-id'].Value | [0]" --output text 2>/dev/null)" = "$DEPLOY_ID" ]; then aws ecr delete-repository --repository-name "$DEPLOY_ID" --force --region "$REGION" >/dev/null && echo "deleted ecr repo $DEPLOY_ID"fi # 5. Prove it. Every line must say "none".echo "--- leftovers check for ${DEPLOY_ID} ---"LEFT=0check() { if [ -n "$2" ] && [ "$2" != "None" ]; then echo "$1: STILL THERE ($2)"; LEFT=1; else echo "$1: none"; fi; }check instances "$(aws ec2 describe-instances --region "$REGION" --filters Name=tag:deploy-id,Values="$DEPLOY_ID" \ Name=instance-state-name,Values=pending,running,stopping,stopped --query 'Reservations[].Instances[].InstanceId' --output text)"check security-groups "$(aws ec2 describe-security-groups --region "$REGION" --filters Name=tag:deploy-id,Values="$DEPLOY_ID" \ --query 'SecurityGroups[].GroupId' --output text)"check instance-profile "$(aws iam get-instance-profile --instance-profile-name "$ROLE" --query InstanceProfile.InstanceProfileName --output text 2>/dev/null)"check role "$(aws iam get-role --role-name "$ROLE" --query Role.RoleName --output text 2>/dev/null)"check ecr-repo "$(aws ecr describe-repositories --repository-names "$DEPLOY_ID" --region "$REGION" --query 'repositories[0].repositoryName' --output text 2>/dev/null)"if [ "$LEFT" = "0" ]; then rm -f "$ID_FILE"; echo "all clean"; else echo "something is left: rerun, or delete it in the console"; exit 1; fi./deploy/aws_down.shExpected output shape (not run for this guide):
tearing down acme-fraud-api-3fa9c1 in ap-south-1 (account 123456789012)terminated: i-0abc123def4567890deleted security group sg-0123456789abcdef0deleted instance profile acme-fraud-api-3fa9c1-ec2deleted role acme-fraud-api-3fa9c1-ec2deleted ecr repo acme-fraud-api-3fa9c1--- leftovers check for acme-fraud-api-3fa9c1 ---instances: nonesecurity-groups: noneinstance-profile: nonerole: noneecr-repo: noneall cleanThe script finds resources by the deploy-id tag and checks the tag before deleting anything, so a repository or role that merely shares a name is never touched. The ID is read from deploy/.deploy-id, which is removed only when the leftovers check is clean. If you lost that file, pass the ID shown by aws_up.sh: ./deploy/aws_down.sh acme-fraud-api-3fa9c1. Running it a second time should only print the leftovers check (this was reasoned from the code, not observed against AWS: confirm it on your run).
Acceptance check
- The leftovers check prints
nonefive times andall clean. - The next day, the Billing console shows no running EC2 or ECR charges for this project.
Common MistakeStopping the instance instead of terminating it. A stopped instance still bills for its disk, and an allocated Elastic IP bills while unattached.
Break It on Purpose
These three exercises break the real files the pipeline reads, so you see each guard fire with your own eyes. Each one ran for this guide and the output is real.
Step 1: Raise the gate and watch training refuse to ship
sha256sum model/model.joblib | cut -c1-12sed -i 's/min_val_pr_auc: 0.20/min_val_pr_auc: 0.40/' params.yamlpython -m acme_fraud_api.train; echo "exit=$?"sha256sum model/model.joblib | cut -c1-12sed -i 's/min_val_pr_auc: 0.40/min_val_pr_auc: 0.20/' params.yaml # put it backExpected output:
e70c4a9d2e04val_pr_auc=0.2322 threshold=0.47 val_recall=0.7500 val_precision=0.1057GATE FAILED: val_pr_auc 0.2322 < floor 0.4. Nothing saved.exit=1e70c4a9d2e04The old model is untouched. In CI, exit 1 fails the pull request. (On macOS use sed -i ''.)
Step 2: Tamper with the model file and watch the API refuse to start
cp model/model.joblib /tmp/good.joblibprintf 'x' >> model/model.joblib # one extra byteuvicorn acme_fraud_api.app:app --port 8001Expected output (last lines):
RuntimeError: model sha256 9f49bafd0788 != metadata e70c4a9d2e04ERROR: Application startup failed. Exiting.A service that will not start is loud and safe. A service that serves a file that does not match its metadata is quiet and dangerous. (Replace both files together and this check passes: it guards consistency, not origin.) Restore with cp /tmp/good.joblib model/model.joblib and confirm /ready returns e70c4a9d2e04 again.
Step 3: Try to peek at the test set a second time
python -m acme_fraud_api.evaluate_test; echo "exit=$?"Expected output:
REFUSED: reports/test_report.json already exists. The test set is looked at once.exit=3Clean up after breaking
git status # params.yaml and model/ should show no changespython -m acme_fraud_api.train && pytest -qValidation & Testing
Run this from a fresh clone to prove the whole project works end to end. Everything here is free and local.
Step 1: Rebuild from nothing
rm -rf data modelpython -m acme_fraud_api.train # same sha256 as your committed test reportpytest -q # 10 passeddocker compose up -d --build && docker compose pspython loadtest/summary.py reports/before_run{1,2,3} reports/after_run{1,2,3}docker compose downDefinition of done
- One command trains, and two runs give the same model hash.
- The gate floor was set before training and exits 1 when not met.
-
reports/test_report.jsonis committed, and a second test run exits 3. - The API rejects bad input with 422 and refuses a model file that does not match its metadata.
- The image runs as a non-root user, with the base image pinned by digest.
- CI trains, tests and builds on every pull request with no secrets and SHA-pinned actions.
- Three before and three after load test runs are committed, with median p95 (and range), error rate, CPU and memory in the README.
- The EC2
/readyshowed the same model hash as the test report (or the Compose fallback was used and the README says so). -
aws_down.shran and the leftovers check printedall clean.
Write the README
Your README is what a hiring manager reads. Keep it to five parts: what it does (one sentence and the diagram), how to run it (three commands), the results table from Milestone 8 labelled with machine, run count and test type (closed-loop, 20 users), the one fix and why it worked, and what you would do next (model registry, autoscaling, HTTPS and authentication behind a load balancer). Say plainly what you did not run, if anything.
Present This Project in an Interview
The 60-second version
"I took a fraud model from a training script to a deployed API on AWS. Training is driven by one params file and gated: the threshold is picked on validation, and if validation PR-AUC misses a floor I set before training, nothing is saved. The test set is scored once by a script that refuses a second run, and that record is committed. The model is served with FastAPI, with strict input validation, and the service checks the model file against the hash in its metadata before loading it. I packaged it in Docker as a non-root image, pushed it to ECR tagged with the git commit, and ran it on EC2 using an instance role scoped to that one repository, with no SSH. Under a Locust load test, three runs each, the median p95 was 280 ms. The cause was reloading the model on every request. Loading it once at startup took the median p95 to 69 ms with zero errors under identical settings."
Questions to expect
- "Why is your test PR-AUC lower than validation?" Small sample: 120 fraud rows in test. I report both with row counts and did not retrain to chase it.
- "How would you roll back?" Every image is tagged by a commit from a clean tree and tags are immutable, so rollback is starting the previous tag. The model ships inside the image, so code and model roll back together.
- "What breaks at 10 times the traffic?" One worker process tops out near one core. I would benchmark
--workers 2(each worker holds its own model copy, about 180 MB here), then run several instances behind a load balancer with/readyas the health check. I would also switch to an arrival-rate load test, because my closed-loop test cannot say what happens at a fixed request rate. - "Why the lock around predict?" I measured it: on my machine the version with the lock had half the p95 of the version without. It is a measured choice for this model and machine, not a rule, and I would re-measure on new hardware.
- "What is missing for production?" HTTPS, authentication, a model registry instead of baking the model in, monitoring for latency and prediction drift, and a deploy pipeline instead of a laptop script. Those are the next steps of the roadmap.
What to show on screen
- The
summary.pytable with three before and three after runs. - The gate failing with exit 1 and the model hash unchanged.
/readyon EC2 showing the same hash astest_report.json.