You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs: update README with deployment issues and fixes
Added full Deployment Issues & Fixes section documenting all 5 production
problems encountered (503/504/403/404/500) with root causes and fixes.
Updated Step 7 API description to reflect simplified predict endpoint.
Updated Step 8 S3 section with correct single-bucket setup and holdout
regeneration difficulty. Updated ALB section with path-based routing table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: README.md
+84-19Lines changed: 84 additions & 19 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -223,38 +223,41 @@ Once I was happy with the model in notebooks, I rewrote everything as proper Pyt
223
223
224
224
With the code modularized, I built a REST API to serve predictions and a dashboard to explore them.
225
225
226
-
**FastAPI** (`src/api/main.py`) — on startup it downloads the model and training features from S3 if not already cached locally.
226
+
**FastAPI** (`src/api/main.py`) — on startup it downloads the model from S3 if not already cached locally, loads it into memory, and reads the expected feature names directly from the XGBoost booster. All subsequent requests use the in-memory model with no I/O.
227
227
228
228
| Method | Endpoint | Description |
229
-
|--------|----------|-------------|
230
-
|`GET`|`/`|Health check |
231
-
|`GET`|`/health`| Model status |
229
+
|--------|----------|--------------|
230
+
|`GET`|`/`|Root check |
231
+
|`GET`|`/health`| Model status + feature count |
232
232
|`POST`|`/predict`| Batch prediction — list of records → predicted prices |
<imgwidth="726"height="88"alt="Screenshot 2026-03-08 at 8 55 21PM"src="https://github.com/user-attachments/assets/2fb2dd3c-7614-4521-a7bf-f4165b8d5fc4" />
236
+
<imgwidth="726"height="88"alt="Screenshot 2026-03-08 at 8 55 21PM"src="https://github.com/user-attachments/assets/2fb2dd3c-7614-4521-a7bf-f4165b8d5fc4" />
237
237
238
238
239
-
**Streamlit** (`app.py`) pulls holdout data from S3, calls the FastAPI `/predict` endpoint, and displays predictions vs actuals with MAE, RMSE, and % error metrics. Users can filter by year, month, and region.
239
+
**Streamlit** (`app.py`) pulls holdout data from S3 on startup, calls the FastAPI `/predict` endpoint, and displays predictions vs actuals with MAE, RMSE, and % error metrics. Users filter by year, month, and region.
240
240
241
241
**Difficulties:**
242
-
- The API needed to be stateless but also fast — loading a model from S3 on every request would be too slow. I added local caching so the model is downloaded once on startup and reused for all subsequent requests.
243
-
- Getting the Streamlit app to talk to the FastAPI service across Docker containers required configuring the `API_URL` environment variable properly — localhost doesn't work when services are in separate containers.
242
+
- The API originally passed already-feature-engineered data through the raw-data preprocessing pipeline (`clean_and_merge`, `drop_duplicates`, `remove_outliers`). This caused silent row drops: `drop_duplicates` excluded `year` from the dedup subset, so valid holdout rows with identical features across different years were both removed. Fixed by bypassing preprocessing in `/predict` entirely — the data from Streamlit is already engineered, so the endpoint now just `reindex`es to model feature names and predicts.
243
+
- A subtle import ordering bug: `inference.py` loaded `TRAIN_FEATURE_COLUMNS` from disk at module import time, but `main.py` only downloaded that file from S3 after the import completed. So `TRAIN_FEATURE_COLUMNS` was always `None` at runtime, schema alignment was silently skipped, and the model received wrong-shaped input. Fixed by reading feature names directly from the booster at startup (`model.get_booster().feature_names`), which needs no external file.
244
+
- Getting Streamlit to call the API across containers required setting `API_URL` as an environment variable — localhost doesn't route between separate ECS tasks.
244
245
245
246
---
246
247
247
248
## Step 8 — Push Model & Data to AWS S3
248
249
249
250
Before deployment, I pushed everything the deployed services would need up to S3.
-`processed/feature_engineered_train.csv` → used by the API for schema alignment
254
-
-`processed/feature_engineered_holdout.csv` → used by the Streamlit dashboard
252
+
**What I uploaded to `model-regression-data` (us-east-2):**
253
+
-`models/xgb_best_model.pkl` — tuned production model
254
+
-`processed/feature_engineered_train.csv` — used by the API for schema alignment
255
+
-`processed/feature_engineered_holdout.csv` — used by the Streamlit dashboard
256
+
-`processed/cleaning_holdout.csv` — raw cleaned holdout (source for regenerating features)
255
257
256
258
**Difficulties:**
257
-
- The FastAPI and Streamlit services use different S3 buckets and different AWS regions in the current setup (`us-east-2` vs `eu-west-2`). This was something I'd unify in a cleaner version but worked for the scope of this project.
259
+
- The `feature_engineered_holdout.csv` was generated at a point in the project when `lat` and `lng` were not being preserved through the feature engineering pipeline. The model was trained with them, so the deployed API would crash on every prediction request. I had to regenerate the holdout from `cleaning_holdout.csv` (which retained lat/lng from the geo merge step) using the saved encoders, then re-upload it to S3.
260
+
- The feature engineering code had a naming inconsistency: it was creating a `city_full_encoded` column during training but the model's booster stored the feature as `city_encoded`. The holdout regeneration had to produce the column name the model actually expected.
258
261
259
262
---
260
263
@@ -322,17 +325,79 @@ Two services running in the same cluster, both Active:
322
325
<imgwidth="1526"height="1756"alt="Screenshot 2026-03-08 at 8 56 18 PM"src="https://github.com/user-attachments/assets/3746af61-db7f-429e-a39e-c6a2dd0a7140" />
323
326
324
327
### Application Load Balancer
325
-
An internet-facing ALB (`housing-price-prediction`) routes incoming traffic across two availability zones (us-east-2a, us-east-2b) to the ECS tasks.
328
+
An internet-facing ALB (`housing-price-prediction`) routes incoming traffic across two availability zones (us-east-2a, us-east-2b) using path-based routing rules:
<imgwidth="1612"height="1530"alt="Screenshot 2026-03-08 at 8 56 44 PM"src="https://github.com/user-attachments/assets/07797732-a8b7-4dea-b41b-440e0edcefff" />
335
+
336
+
<imgwidth="1612"height="1530"alt="Screenshot 2026-03-08 at 8 56 44 PM"src="https://github.com/user-attachments/assets/07797732-a8b7-4dea-b41b-440e0edcefff" />
329
337
330
338
**Difficulties with AWS setup:**
331
-
- Setting up the ECS task definitions to inject AWS credentials as environment variables (so the containers can access S3) without hardcoding them took several iterations through IAM roles and task execution policies.
332
-
- The ALB target group health checks initially failed because the FastAPI startup takes a few seconds to download the model from S3. I had to increase the health check grace period so ECS didn't kill the task before it was ready.
333
-
<imgwidth="1214"height="788"alt="Screenshot 2026-03-09 at 12 07 17 AM"src="https://github.com/user-attachments/assets/f5e9e730-d240-4187-9ddd-b61dc9e0f6f7" />
339
+
- The ALB was initially created with only a single default rule forwarding everything to Streamlit. There was no target group for the API at all — it was reachable within the VPC but completely invisible to the outside world. The API target group and path-based routing rule had to be added after the fact.
340
+
- Setting up ECS task definitions to use IAM task roles (rather than hardcoded credentials) for S3 access took several iterations through `ecsTaskExecutionRole` vs `taskRoleArn` — these are different roles with different purposes and it's easy to mix them up.
341
+
- The health check for the Streamlit target group (`/dashboard/_stcore/health`) only becomes reachable once Streamlit finishes its startup sequence, which includes downloading files from S3. ECS was killing the task as unhealthy before the app was ready.
342
+
<imgwidth="1214"height="788"alt="Screenshot 2026-03-09 at 12 07 17 AM"src="https://github.com/user-attachments/assets/f5e9e730-d240-4187-9ddd-b61dc9e0f6f7" />
343
+
344
+
<imgwidth="1180"height="742"alt="Screenshot 2026-03-09 at 12 07 30 AM"src="https://github.com/user-attachments/assets/02a06f1f-8279-4b67-b3a7-6c85603bc193" />
345
+
346
+
---
347
+
348
+
## Deployment Issues & Fixes
349
+
350
+
This section documents the real problems encountered getting the system running end-to-end on AWS. These weren't theoretical edge cases — every one of these caused the service to be completely down.
351
+
352
+
### 1. ECS Tasks Refusing to Start (503 on ALB)
353
+
Both services had 0 running tasks from the moment they were deployed, making the site return 503 immediately.
354
+
355
+
**API service:** The task definition referenced a CloudWatch log group (`/ecs/housing-api-task-ecs`) that didn't exist, and had no `awslogs-create-group` flag. ECS refused to start the task at all rather than failing gracefully.
356
+
357
+
**Streamlit service:** The task definition did have `awslogs-create-group: true`, but `ecsTaskExecutionRole` was missing the `logs:CreateLogGroup` IAM permission. Same result — task refused to start.
358
+
359
+
**Fix:** Created both log groups manually and added a `CloudWatchLogsCreateLogGroup` inline policy to `ecsTaskExecutionRole`.
After the tasks started, the ALB returned 504 on every request. The target was registered but health checks were timing out.
365
+
366
+
**Cause:** The ECS task security group only allowed inbound traffic on port 80. Streamlit runs on port 8501. The ALB couldn't reach the container because there was no inbound rule for 8501.
367
+
368
+
**Fix:** Added an inbound rule for TCP 8501 to `sg-03249030d2d81ad03`.
369
+
370
+
---
371
+
372
+
### 3. 403 Forbidden on S3 (Wrong Bucket Name)
373
+
Once the Streamlit container started, it immediately crashed trying to download the holdout CSV from S3.
374
+
375
+
**Cause:**`app.py` had `S3_BUCKET = "housing-regression-data"` hardcoded as the default — a bucket that doesn't exist. The actual bucket is `model-regression-data`. Additionally, the app defaulted `AWS_REGION` to `eu-west-2` while the bucket is in `us-east-2`. With SigV4 signing, a region mismatch causes a 403 rather than a redirect.
376
+
377
+
**Fix:** Corrected the default bucket name in `app.py`, added `AWS_REGION=us-east-2` and `S3_BUCKET=model-regression-data` as explicit environment variables in the ECS task definition.
378
+
379
+
---
380
+
381
+
### 4. 404 on /predict (No ALB Rule for API)
382
+
The Streamlit app loaded successfully and could be reached, but every prediction request returned 404.
383
+
384
+
**Cause:** The ALB only had a single default rule forwarding all traffic to the Streamlit target group. There was no routing rule for `/predict` and no target group for the API service. The API container was running but completely unreachable through the load balancer.
385
+
386
+
**Fix:** Created a new target group (`regression-project-api`, port 8000), added an ALB listener rule to forward `/predict` and `/predict/*` to it, opened port 8000 on the security group, and registered the API task's IP. Also attached the target group to the ECS service for automatic re-registration on task replacement.
387
+
388
+
---
389
+
390
+
### 5. 500 Internal Server Error (Feature Mismatch)
391
+
With routing fixed, predictions returned 500. The API was receiving requests but crashing before producing output.
392
+
393
+
**Root cause 1 — Missing features in holdout CSV:** The `feature_engineered_holdout.csv` in S3 was missing `lat` and `lng` columns. The model was trained with them and the booster enforced their presence. The file had been generated at a point in the project when those columns were being dropped before save. The `cleaning_holdout.csv` retained them but the feature engineering output didn't.
394
+
395
+
**Root cause 2 — Import ordering bug:**`inference.py` loaded `TRAIN_FEATURE_COLUMNS` from `feature_engineered_train.csv` at module import time. But in `main.py`, the S3 download of that file happened after the import. So `TRAIN_FEATURE_COLUMNS` was always `None` at runtime, the `reindex` schema alignment step was silently skipped, and the model received a dataframe with wrong columns on every single request.
396
+
397
+
**Root cause 3 — Preprocessing pipeline mismatch:** The `/predict` endpoint piped already-feature-engineered data through a preprocessing function designed for raw input. `drop_duplicates` excluded `year` from the dedup key, causing rows that shared the same feature values across different years to be treated as duplicates and removed from the batch.
398
+
399
+
**Fix:** Regenerated `feature_engineered_holdout.csv` from `cleaning_holdout.csv` using the saved encoders, preserving `lat`/`lng` and using `city_encoded` to match the trained model's feature names. Re-uploaded to S3. Rewrote the `/predict` endpoint to load the model once at startup, derive feature names from `model.get_booster().feature_names`, and do only `reindex(fill_value=0)` before predicting — no preprocessing pipeline involved.
334
400
335
-
<imgwidth="1180"height="742"alt="Screenshot 2026-03-09 at 12 07 30 AM"src="https://github.com/user-attachments/assets/02a06f1f-8279-4b67-b3a7-6c85603bc193" />
0 commit comments