2 Million Users, 800ms Response and without testing. Don’t Break It.
You’ve inherited a Django monolith. It serves 2 million citizens, runs an AI assistant, has no automated tests, and averages 800ms response times. Your job: extract the inference component into its own service. Zero downtime.
This is open-heart surgery on a patient that’s awake and running a marathon. One wrong move and you’re the person who took down the citizen assistant portal on a Tuesday morning.
Here’s the playbook.
Step 0: Understand What You’re Dealing With
Before touching anything, figure out where inference lives. The monolith probably looks like this:
# assistant/views.py — heavily simplified
import pickle
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
import json
# Loaded at startup — takes 8 seconds
model = pickle.load(open("/models/classifier.pkl", "rb")) # Deserialize the trained ML model from disk once at import time
vectorizer = pickle.load(open("/models/vectorizer.pkl", "rb")) # Deserialize the text vectorizer (e.g., TF-IDF) once at import time
@csrf_exempt # Disable Django CSRF protection for this API-style endpoint
def assistant_view(request):
if request.method != "POST": # Reject non-POST HTTP methods
return JsonResponse({"error": "POST required"}, status=405) # Return 405 Method Not Allowed if not POST
data = json.loads(request.body) # Parse the incoming JSON request body into a Python dict
user_text = data["text"] # Extract the user's input text from the parsed JSON payload
# Preprocessing
tokens = tokenize(user_text) # Tokenize the raw text into tokens (words, subwords, etc.)
features = vectorizer.transform([tokens]) # Convert tokens into a numerical feature vector the model understands
# Inference — the slow part
prediction = model.predict(features)[0] # Run the model to get the predicted class (first/only prediction)
confidence = max(model.predict_proba(features)[0]) # Get the probability of the most likely class as confidence
# Post-processing
response = format_response(prediction, confidence) # Format the raw prediction into a user-friendly response dict
return JsonResponse(response) # Serialize the response dict to JSON and return it to the client
def health(request):
return JsonResponse({"status": "ok"}) # Simple health-check endpoint returning a success status
# urls.py
from django.urls import path
from assistant.views import assistant_view, health
urlpatterns = [
path("assistant", assistant_view), # Route /assistant to the inference endpoint
path("health", health), # Route /health to the health-check endpoint
]
The problem: every request loads the full model pipeline. 800ms average, 3 second p99 when the model’s doing garbage collection. One slow endpoint holding the whole app hostage. Django’s default threading means one slow request blocks a worker thread, and with gunicorn running 4 workers, only 3 other users can be served while inference runs.
Step 1: The Strangler Fig Pattern
You don’t rewrite the monolith in one shot. You wrap the inference call in an abstraction that can route to either the old in-process code or the new service:
# assistant/inference_client.py
import os
import json
import pickle
from urllib.request import Request, urlopen
from urllib.error import URLError
class InferenceClient:
"""Routes to in-process model or remote service based on config."""
def __init__(self):
self.mode = os.getenv("INFERENCE_MODE", "local") # Read the routing mode from env var, default to local inference
self.remote_url = os.getenv("INFERENCE_URL", "http://localhost:8001/predict") # URL of the remote inference service
self.model = None # Placeholder for the ML model (loaded lazily below)
self.vectorizer = None # Placeholder for the vectorizer (loaded lazily below)
if self.mode in ("local", "shadow", "canary"): # Only load the model locally if we might need it
self.model = pickle.load(open("/models/classifier.pkl", "rb")) # Deserialize the trained classifier into memory
self.vectorizer = pickle.load(open("/models/vectorizer.pkl", "rb")) # Deserialize the text vectorizer into memory
def predict(self, text: str) -> dict:
if self.mode == "local": # In local mode, run inference in-process
return self._predict_local(text) # Delegate to the local prediction method
else: # In remote/shadow/canary mode, call the external inference service
return self._predict_remote(text) # Delegate to the remote prediction method
def _predict_local(self, text: str) -> dict:
tokens = tokenize(text) # Tokenize the input text
features = self.vectorizer.transform([tokens]) # Vectorize the tokens into numerical features
pred = self.model.predict(features)[0] # Get the model's predicted class label
conf = max(self.model.predict_proba(features)[0]) # Get the confidence score (highest probability)
return {"prediction": pred, "confidence": float(conf)} # Return structured result with native float type
def _predict_remote(self, text: str) -> dict:
body = json.dumps({"text": text}).encode() # Serialize the input text to JSON bytes for the HTTP request body
req = Request(self.remote_url, data=body, headers={"Content-Type": "application/json"}) # Build the HTTP POST request
try:
resp = urlopen(req, timeout=5) # Send the request with a 5-second timeout to prevent hanging
return json.loads(resp.read()) # Parse and return the JSON response from the remote service
except URLError as e: # Catch network/connection errors from the remote call
raise RuntimeError(f"Remote inference failed: {e}") # Re-raise as a generic runtime error for the caller
Now update the view to use the client:
# assistant/views.py
from django.http import JsonResponse, HttpResponseNotAllowed
from django.views.decorators.csrf import csrf_exempt
import json
from .inference_client import InferenceClient
client = InferenceClient() # Instantiate the client once at module level (singleton per worker)
@csrf_exempt # Disable CSRF for this API-style endpoint
def assistant_view(request):
if request.method != "POST": # Validate that this is a POST request
return HttpResponseNotAllowed(["POST"]) # Return 405 with allowed methods if not POST
data = json.loads(request.body) # Parse the JSON request body
result = client.predict(data["text"]) # Delegate inference to the client (local or remote based on mode)
response = format_response(result["prediction"], result["confidence"]) # Format the structured result for the user
return JsonResponse(response) # Send the formatted response back to the client
def health(request):
return JsonResponse({"status": "ok"}) # Health-check endpoint returning a simple success status
Deploy this. Nothing changes — INFERENCE_MODE=local uses the same in-process model. But now you have a switch you can flip later without changing application code.
Step 2: Create the Inference Service
While the monolith runs in local mode, build the inference service alongside it. This is a separate Django app or a FastAPI app — doesn’t matter, it’s an independent service:
# inference_service/manage.py — Django project for inference only
# inference_service/inference/views.py
import pickle
import json
import time
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
model = pickle.load(open("/models/classifier.pkl", "rb")) # Load the ML model into memory at startup
vectorizer = pickle.load(open("/models/vectorizer.pkl", "rb")) # Load the vectorizer into memory at startup
@csrf_exempt # Disable CSRF for this internal-service endpoint
def predict_view(request):
if request.method != "POST": # Reject non-POST requests
return JsonResponse({"error": "POST required"}, status=405) # Return 405 with error message
data = json.loads(request.body) # Parse the incoming JSON request
start = time.time() # Record the start time for latency measurement
tokens = tokenize(data["text"]) # Tokenize the user's input text
features = vectorizer.transform([tokens]) # Convert tokens to feature vectors
pred = model.predict(features)[0] # Run inference to get the predicted class
conf = max(model.predict_proba(features)[0]) # Extract the confidence score from class probabilities
return JsonResponse({ # Return a JSON response with prediction details
"prediction": pred, # The predicted class label
"confidence": float(conf), # The confidence score as a native float
"latency_ms": round((time.time() - start) * 1000), # Compute and return the request latency in milliseconds
})
def health(request):
return JsonResponse({"status": "ok"}) # Health-check endpoint for load balancer liveness probes
Run it on a separate port:
python manage.py runserver 0.0.0.0:8001 # Start the inference service on port 8001, binding to all network interfaces
The monolith doesn’t know it exists yet. You can test it independently:
curl -X POST http://localhost:8001/predict \ # Send a POST request to the inference service
-H "Content-Type: application/json" \ # Set the Content-Type header to indicate JSON payload
-d '{"text": "I need help with my tax return"}' # Provide the input text as JSON in the request body
Step 3: Shadow Traffic (The Safety Net)
Before you route real users to the new service, run it in shadow mode. Every request goes to both the local model and the remote service, but the monolith still returns the local result:
# assistant/inference_client.py
import logging
logger = logging.getLogger(__name__) # Create a module-level logger for tracking shadow-mode results
class InferenceClient:
def predict(self, text: str):
local_result = self._predict_local(text) # Always run local inference first to get the baseline result
if self.mode == "shadow": # In shadow mode, also call the remote service but don't use its result
# Fire-and-forget to the remote service — log but don't block
try:
remote_result = self._predict_remote(text) # Send the same text to the remote inference service
if remote_result["prediction"] != local_result["prediction"]: # Check if the two services disagree
logger.warning( # Log the mismatch for offline analysis
"Prediction mismatch",
extra={"local": local_result, "remote": remote_result}, # Include both results in structured log
)
except Exception as e: # Catch any error from the remote call (timeout, network, etc.)
logger.warning(f"Shadow inference failed: {e}") # Log the failure but don't surface it to the user
return local_result # Always return the local result to the caller regardless of shadow outcome
Compare results in your logs:
# assistant/management/commands/compare_predictions.py
from django.core.management.base import BaseCommand
import json
class Command(BaseCommand):
help = "Analyze shadow mode prediction mismatches from logs" # Description shown in manage.py help output
def handle(self, *args, **options): # Entry point called when this management command runs
# Parse your structured logs and report mismatch rate
pass # TODO: implement log parsing and mismatch analysis logic
Run shadow mode for at least a few days. You want to see traffic during peak hours, off-peak hours, and edge cases you didn’t think of. Check that the remote service matches the local model on at least 99.9% of requests before moving on.
Step 4: Route 1% of Users (Canary)
Now flip the switch for a small percentage of users. Use a consistent hash on user ID so the same user always hits the same backend:
# assistant/inference_client.py
class InferenceClient:
def predict(self, user_id: str, text: str) -> dict:
should_canary = (
self.mode == "remote" # Always route to remote in full-remote mode
or (self.mode == "canary" and hash(user_id) % 100 < 1) # In canary mode, route 1% of users by user ID hash
)
if should_canary: # This request should be handled by the remote service
try:
return self._predict_remote(text) # Send the request to the external inference service
except Exception as e: # Remote call failed (timeout, network error, service down)
logger.error(f"Remote failed, falling back to local: {e}") # Log the failure for monitoring
return self._predict_local(text) # Fall back to the in-process model to avoid serving an error
else: # This request stays on the local model (either not in canary group or mode is local)
return self._predict_local(text) # Run inference in-process with the local model
The try/except is critical. If the remote service is down, you fall back to the local model. The user sees slightly degraded performance, not an error page.
Update the view to pass the user ID:
@csrf_exempt # Disable CSRF protection for this API endpoint
def assistant_view(request):
data = json.loads(request.body) # Parse the JSON request body
user_id = data.get("user_id", "anonymous") # Extract user ID from payload, defaulting to "anonymous" if missing
result = client.predict(user_id, data["text"]) # Call the client with user ID for consistent canary routing
return JsonResponse(format_response(result["prediction"], result["confidence"])) # Format and return the result
Monitor using Django’s logging or structured logging:
# assistant/monitoring.py
import logging
import time
inference_logger = logging.getLogger("inference") # Create a dedicated logger for inference events
def record_inference(mode: str, latency_ms: float, user_id: str): # Log inference metrics for observability
inference_logger.info("inference_completed", extra={ # Emit a structured log line for log aggregators
"mode": mode, # Which inference mode was used (local, remote, canary, shadow)
"latency_ms": latency_ms, # How long the inference took in milliseconds
"user_id": user_id[-4:], # Only the last 4 characters of the user ID for privacy compliance
})
Step 5: Ramp Up
Increase the canary percentage gradually:
Day 1: 1% of users → verify
Day 2: 5% of users → verify
Day 3: 25% of users → verify
Day 4: 50% of users → verify
Day 5: 100% of users → flip switch
If at any point errors spike, flip back by changing the environment variable:
# In your deployment
kubectl set env deployment/django INFERENCE_MODE=local # Set the environment variable to force local inference mode
# Or in supervisor/systemd
export INFERENCE_MODE=local # Set the env var for the process manager
supervisorctl restart django # Restart the Django process to pick up the new environment variable
All users go back to the local model. No downtime.
Step 6: Remove the Local Model
Once the remote service handles 100% of traffic and you’re confident, remove the model loading from the monolith:
# assistant/inference_client.py
class InferenceClient:
def __init__(self):
self.remote_url = os.getenv("INFERENCE_URL", "http://inference:8001/predict") # Read remote service URL from env, with K8s service DNS as default
def predict(self, text: str) -> dict:
body = json.dumps({"text": text}).encode() # Serialize the input text to JSON bytes
req = Request(self.remote_url, data=body, headers={"Content-Type": "application/json"}) # Build the HTTP POST request
resp = urlopen(req, timeout=5) # Send the request with a 5-second timeout
return json.loads(resp.read()) # Parse and return the JSON response from the remote service
The monolith no longer imports pickle, no longer loads model files, no longer does inference. The Django app now only handles routing, auth, session management, and orchestration. This cuts its memory usage in half and removes the 8-second startup time for model loading.
You can also slim down your Django settings — remove any inference-related apps and middleware:
# settings.py — before
INSTALLED_APPS = [
"django.contrib.auth", # Authentication framework
"django.contrib.contenttypes", # Content type framework (required by auth and permissions)
"assistant", # The main assistant application
"inference_engine", # No longer needed # Inference-specific app that can be removed after extraction
]
# settings.py — after
INSTALLED_APPS = [
"django.contrib.auth", # Authentication framework
"django.contrib.contenttypes", # Content type framework
"assistant", # The main assistant application
]
What About the 800ms Response Time?
The monolith averaged 800ms because inference was blocking Gunicorn workers. With inference extracted, you can:
- Scale the inference service independently — run more replicas than the monolith
- Use GPU for inference without GPU-enabling the Django app
- Add caching in the inference service for repeated queries:
# inference_service/inference/views.py
import hashlib
import json
from django.core.cache import cache
@csrf_exempt # Disable CSRF for this internal-service endpoint
def predict_view(request):
data = json.loads(request.body) # Parse the incoming JSON request
text_hash = hashlib.sha256(data["text"].encode()).hexdigest() # Compute a SHA-256 hash of the input text to use as cache key
# Check cache
cached = cache.get(f"pred:{text_hash}") # Look up the prediction result in the cache by its key
if cached: # Cache hit — a previous identical query was made recently
return JsonResponse(json.loads(cached)) # Return the cached result directly, skipping inference entirely
# Run inference
tokens = tokenize(data["text"]) # Tokenize the input text
features = vectorizer.transform([tokens]) # Convert tokens to feature vectors
pred = model.predict(features)[0] # Run the model to get the predicted class
conf = max(model.predict_proba(features)[0]) # Get the confidence score
result = {"prediction": pred, "confidence": float(conf)} # Build the result dictionary
# Cache for 1 hour
cache.set(f"pred:{text_hash}", json.dumps(result), timeout=3600) # Store the serialized result in cache with a 1-hour TTL
return JsonResponse(result) # Return the fresh prediction result to the client
With caching, repeated queries (same “how do I reset my password?”) return in under 5ms. In a citizen assistant, about 30% of queries are repeats. That alone drops average response time by 200ms.
The Rollback Plan
If something goes wrong after full rollout, you have options:
# Option 1: Switch back via environment variable
export INFERENCE_MODE=local # Set the inference mode environment variable to force local model usage
supervisorctl restart django # Restart the Django process to reload the environment variable
# Option 2: Roll back the Django deployment
kubectl rollout undo deployment/django # Revert the Kubernetes deployment to the previous revision
# Option 3: Keep the inference service running as a fallback
# and let the canary logic handle it automatically
If you removed the model files from the Django image (which you should after confirming the remote service works), reverting to local mode means rolling back to a previous image that still had them:
kubectl rollout undo deployment/django --to-revision=3 # Roll back to revision 3, which still has model files in the image
The inference service keeps running during the rollback. You can switch back and forth without traffic loss.
What About Django’s Gunicorn Workers?
Here’s a common concern: Django with gunicorn runs sync workers by default. If inference takes 800ms and you have 4 workers, your monolith can only handle 5 requests per second (4 / 0.8). After extraction, requests to the inference-free monolith take ~100ms, boosting throughput to 40 requests per second with the same 4 workers:
# Before: inference blocks workers
gunicorn config.wsgi:application --workers 4 # Start gunicorn with 4 sync workers (each blocked during 800ms inference)
# Effective throughput: ~5 req/s
# After: workers are free
gunicorn config.wsgi:application --workers 4 # Same gunicorn config, but now each request only takes ~100ms
# Effective throughput: ~40 req/s
You can also switch to async workers for the inference service if using Django with ASGI:
# Inference service with async workers for higher throughput
gunicorn inference_service.wsgi:application --workers 2 --threads 4 # Use 2 workers with 4 threads each for concurrent request handling
# Or with uvicorn for ASGI
uvicorn inference_service.asgi:application --workers 2 # Use Uvicorn with 2 async workers for ASGI-based inference service
What You End Up With
Before:
┌─────────────────────────────────┐
│ Django Monolith │
│ ┌─────────────────────────┐ │
│ │ Auth │ Routing │ Sessions│ │
│ │ Cache│ DB │ Infer. │ │
│ │ Pickle loading at startup│ │
│ └─────────────────────────┘ │
│ Memory: 4GB Startup: 12s │
│ Response: 800ms p50 │
│ Throughput: 5 req/s (4 workers) │
└─────────────────────────────────┘
After:
┌──────────────────┐ ┌──────────────────┐
│ Django App │ │ Inference │
│ (lighter) │ │ Service │
│ Auth │ Routing │ │ Model │ Cache │
│ DB │ Sessions │ │ GPU │ Batch │
│ Memory: 1.5GB │ │ Scales: 10 │
│ Startup: 2s │ │ Response: 120ms │
│ Throughput: 40/s │ │ p99: 400ms │
└──────────────────┘ └──────────────────┘
Monolith response time drops because Django workers aren’t blocked by inference. Inference response time drops because it’s on its own hardware with a cache.
The Golden Rules
-
Never refactor and extract at the same time. First add the abstraction (InferenceClient), then build the service, then switch traffic. Three separate deployments. Each one is reversible.
-
Shadow traffic before canary. Don’t route real users to a service you haven’t validated against real data. The model may work on your test cases but fail on edge cases your users hit daily.
-
Fallback must work. If the remote service is down, the user should see slightly degraded performance, not an error. Test this by killing the inference service and watching the monolith fall back gracefully.
-
Canary by user, not by request. Consistently route the same user to the same backend. If you don’t, a user might get different answers on consecutive requests and think the system is broken.
-
Remove the old code once the new service is stable. Leaving dead code paths in the monolith increases cognitive load and the risk of someone accidentally using the old path.
Zero downtime extraction is not about avoiding all risk. It’s about making every change reversible in under a minute. If the canary shows problems, you flip back with an environment variable. If the full rollout fails, you roll back the deployment. The users never notice because the fallback path is always a working system — just slightly slower.
Comments
Leave a comment