Carving Inference Out of a 2M-User Django Monolith with Zero Downtime

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:

  1. Scale the inference service independently — run more replicas than the monolith
  2. Use GPU for inference without GPU-enabling the Django app
  3. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

What is 2 + 3?
Please solve the arithmetic question above to verify you're human.