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

2 Million Users, 800ms Response, No Tests. Don’t Break It.

You’ve inherited a Python 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:

# monolith/app.py — heavily simplified
from fastapi import FastAPI, Request  # Import FastAPI framework and the Request type for HTTP handler signatures
import pickle  # Import pickle to deserialize the trained model and vectorizer from disk into usable Python objects

app = FastAPI()  # Create the FastAPI application instance — this is the entry point of the entire monolith process

# Loaded at startup — takes 8 seconds
model = pickle.load(open("/models/classifier.pkl", "rb"))  # Load the classifier model at import time, blocking server readiness for ~8s
vectorizer = pickle.load(open("/models/vectorizer.pkl", "rb"))  # Load the vectorizer at import time, adding 8+ seconds to startup

@app.post("/assistant")  # Register the /assistant POST endpoint that handles AI inference for 2 million users
async def assistant(request: Request):  # Define an async handler so FastAPI can interleave other requests during I/O waits
    data = await request.json()  # Await parsing of the incoming JSON body from the client
    user_text = data["text"]  # Extract the 'text' field from the parsed request body

    # Preprocessing
    tokens = tokenize(user_text)  # Tokenize the raw text into tokens — word splits, stemming, stopword removal, etc.
    features = vectorizer.transform([tokens])  # Transform the token list into the feature matrix the model was trained on

    # Inference — the slow part
    prediction = model.predict(features)[0]  # Run the model's predict method — this is where the 800ms average lives
    confidence = max(model.predict_proba(features)[0])  # Get the highest class probability as the confidence score

    # Post-processing
    response = format_response(prediction, confidence)  # Format the raw prediction and confidence into the API response shape
    return response  # Return the final response to the client — every other request is blocked during this call

@app.get("/health")  # Register a /health GET endpoint for Kubernetes liveness probes and load balancer health checks
async def health():  # Keep the health check trivially fast — it must never touch the model or database
    return {"status": "ok"}  # Return a simple status dict to indicate the process is alive and accepting connections

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.

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:

# Step 1: Add an inference client abstraction
import os  # Import os to read environment variables that control the routing mode without code changes
import httpx  # Import the async HTTP client library for making outbound requests to the inference service
import json  # Import json for serializing/deserializing request and response payloads over HTTP

class InferenceClient:
    """Routes to in-process model or remote service based on config."""  # Docstring: this class is the strangler fig facade
    def __init__(self):  # Constructor reads the routing mode from the environment at instantiation time
        self.mode = os.getenv("INFERENCE_MODE", "local")  # Read the routing mode; default "local" means zero change on first deploy
        self.remote_url = os.getenv("INFERENCE_URL", "http://localhost:8001/predict")  # Read the remote service URL with a sensible localhost default for dev
        self.model = None  # Initialize the model reference as None; only populated in local mode to avoid memory waste
        self.vectorizer = None  # Initialize the vectorizer reference as None; populated only when INFERENCE_MODE is "local"

        if self.mode == "local":  # Only load the model files when we are in local mode — saves startup time in remote mode
            import pickle  # Import pickle inside this conditional so it is never imported in remote/canary/shadow modes
            self.model = pickle.load(open("/models/classifier.pkl", "rb"))  # Deserialize the classifier model from disk
            self.vectorizer = pickle.load(open("/models/vectorizer.pkl", "rb"))  # Deserialize the vectorizer from disk

    async def predict(self, text: str):  # Async predict so the calling FastAPI handler does not block the event loop
        if self.mode == "local":  # Local mode: run inference in-process, identical to the original monolith behavior
            tokens = tokenize(text)  # Tokenize the input text using the existing preprocessing pipeline
            features = self.vectorizer.transform([tokens])  # Transform the token list into the feature matrix
            pred = self.model.predict(features)[0]  # Get the predicted class label from the loaded model
            conf = max(self.model.predict_proba(features)[0])  # Get the highest class probability as the confidence score
            return {"prediction": pred, "confidence": float(conf)}  # Return results in the exact shape the remote service will use
        else:  # Remote/shadow/canary mode: delegate the inference to the external inference service over HTTP
            async with httpx.AsyncClient() as client:  # Open an async HTTP client in a context manager for proper connection cleanup
                resp = await client.post(self.remote_url, json={"text": text}, timeout=5)  # POST the text to the inference service, timing out after 5 seconds
                resp.raise_for_status()  # Raise an exception on non-2xx responses so callers can trigger fallback logic
                return resp.json()  # Parse and return the JSON response from the remote service

# Replace the direct model call
client = InferenceClient()  # Instantiate the client once at module level — routing mode is locked in at construction time

@app.post("/assistant")  # Register the same /assistant endpoint the monolith always had — the interface does not change
async def assistant(request: Request):  # The handler signature remains identical, so no client or test code needs updating
    data = await request.json()  # Parse the incoming JSON body from the request
    result = await client.predict(data["text"])  # Delegate all inference work to InferenceClient regardless of the mode
    response = format_response(result["prediction"], result["confidence"])  # Format the prediction into the same response shape as before
    return response  # Return the formatted response — callers cannot tell whether inference ran locally or remotely

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: Extract the Inference Service

While the monolith runs in local mode, build the inference service alongside it:

# inference_service/app.py  # This is a brand-new standalone service, deployed separately from the monolith
from fastapi import FastAPI, Request  # Use FastAPI just like the monolith for consistency and developer familiarity
import pickle  # Import pickle to load the model and vectorizer in this dedicated service's memory
import time  # Import time to measure and report per-request inference latency

app = FastAPI()  # Create the standalone inference service instance — runs on its own port (e.g., 8001)

model = pickle.load(open("/models/classifier.pkl", "rb"))  # Load the classifier model at startup — inference now lives here
vectorizer = pickle.load(open("/models/vectorizer.pkl", "rb"))  # Load the vectorizer at startup — model loading is isolated from the monolith

@app.post("/predict")  # Register the /predict endpoint that the monolith's InferenceClient will call via HTTP
async def predict(request: Request):  # Async handler so this service can handle many concurrent inference requests
    data = await request.json()  # Parse the incoming JSON payload sent by the InferenceClient
    start = time.time()  # Record the start time for computing per-request latency

    tokens = tokenize(data["text"])  # Tokenize the input text using the same preprocessing as the original monolith
    features = vectorizer.transform([tokens])  # Transform the tokens into the feature matrix the model expects
    pred = model.predict(features)[0]  # Run the model to get the predicted class label
    conf = max(model.predict_proba(features)[0])  # Extract the highest class probability as the confidence score

    return {  # Return the response in the exact shape the InferenceClient expects
        "prediction": pred,  # The predicted class label
        "confidence": float(conf),  # The confidence score converted to a JSON-safe float
        "latency_ms": round((time.time() - start) * 1000),  # Report per-request latency so the monolith can track performance metrics
    }

@app.get("/health")  # Register a separate /health endpoint for the inference service's own Kubernetes probe
async def health():  # Dedicated health check — each service has independent lifecycle management
    return {"status": "ok"}  # Return a simple status to indicate the inference service is ready and the model is loaded

Run it on a separate port or as a separate deployment. 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 standalone inference service on port 8001
  -H "Content-Type: application/json" \  # Set the Content-Type header to JSON so the service correctly parses the body
  -d '{"text": "I need help with my tax return"}'  # Send a realistic citizen-assistant query to validate the service independently of the monolith

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 uses the local result:

class InferenceClient:
    async def predict(self, text: str):  # Extend the predict method with shadow-mode support — local result is still the source of truth
        local_result = await self._predict_local(text)  # Always compute the local prediction first — this is what the user actually sees

        if self.mode == "shadow":  # Shadow mode: fire a copy of every request to the remote service without using its response
            # Fire-and-forget to the remote service
            try:  # Wrap the remote call in try/except so a slow or failing remote never blocks the user response
                async with httpx.AsyncClient() as client:  # Open an async HTTP client for the shadow request
                    await client.post(  # Fire the request to the remote service but discard the response — fire-and-forget
                        self.remote_url,  # The URL of the new inference service
                        json={"text": text, "shadow": True},  # Include a "shadow": True flag so the remote can log it differently
                        timeout=2,  # Use a shorter timeout for shadow traffic — the user is not waiting on this call
                    )
            except Exception as e:  # Catch any network error, timeout, or server error from the shadow request
                logger.warning(f"Shadow inference failed: {e}")  # Log the shadow failure but do NOT alert — shadow is best-effort

        return local_result  # Always return the local result — the shadow response is purely for validation and comparison

Compare results in your logs. If the remote service disagrees with the local model, investigate. If latencies look good, move to the next step.

Route the shadow traffic to a separate log stream:

import structlog  # Import structlog for structured, searchable log output that is easy to query in production

shadow_logger = structlog.get_logger("shadow-inference")  # Create a dedicated logger with a distinct name so logs can be routed to a separate stream

def log_shadow_comparison(text, local_result, remote_result, local_latency, remote_latency):  # Log a side-by-side comparison of local vs remote results
    if local_result["prediction"] != remote_result["prediction"]:  # Only log when predictions diverge — agreement is the happy path and too noisy to log
        shadow_logger.warning("prediction_mismatch",  # Emit a warning-level structured log for every prediction mismatch
            local=local_result,  # Include the full local result for debugging
            remote=remote_result,  # Include the full remote result for comparison
            text_hash=hash(text),  # Include a hash of the input text (not the raw text) for lookup without storing PII
        )

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.

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:

class InferenceClient:
    async def predict(self, user_id: str, text: str):  # Extended predict now takes a user_id for deterministic canary routing
        if self.mode == "remote" or (self.mode == "canary" and hash(user_id) % 100 < 1):  # Route to remote if full-remote mode, or if canary mode AND user_id hashes to the first percentile
            try:  # Wrap the remote call so any failure triggers a graceful fallback to the local model
                result = await self._predict_remote(text)  # Call the remote inference service
                return result  # Return the remote result to the user — this is the faster path we want to validate
            except Exception as e:  # Catch any remote failure — network error, timeout, server error, etc.
                logger.error(f"Remote inference failed, falling back to local: {e}")  # Log the failure for operational awareness
                return await self._predict_local(text)  # Fall back to the local model — the user sees slower response instead of an error
        else:  # User is not in the canary cohort or mode is still "local"
            return await self._predict_local(text)  # Use the local model for this request — business as usual

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.

Monitor:

  • Error rate (should be 0)
  • Latency delta (should be lower for remote)
  • Prediction drift (should match local within a tolerance)
# metrics.py
from prometheus_client import Histogram, Counter, Gauge  # Import Prometheus metric types for observability in production

inference_latency = Histogram(  # Create a Histogram to track inference latency distribution across modes
    "inference_latency_seconds",  # Metric name exposed on the /metrics endpoint
    "Inference latency",  # Help text displayed in Prometheus for this metric
    ["mode"],  # Label the histogram by mode (local, remote, shadow) so we can compare latency distributions
)

prediction_mismatch = Counter(  # Create a Counter to track how often local and remote predictions disagree
    "prediction_mismatches_total",  # Metric name — a monotonically increasing counter
    "Count of prediction mismatches between local and remote",  # Help text explaining what this counter represents
)

def record_inference(mode, latency, prediction, expected=None):  # Utility function to record inference metrics consistently
    inference_latency.labels(mode=mode).observe(latency)  # Record the latency in the histogram under the given mode label
    if expected is not None and prediction != expected:  # Only increment the mismatch counter when we have an expected value AND the prediction differs
        prediction_mismatch.inc()  # Increment the mismatch counter — a spike here triggers an alert to halt the canary rollout

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, you flip back:

kubectl set env deployment/monolith INFERENCE_MODE=local  # Set the INFERENCE_MODE environment variable to "local" on the monolith deployment, instantly reverting routing to the in-process model

Takes 2 seconds for a rolling update to propagate. 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:

# Clean up the monolith
class InferenceClient:
    def __init__(self):  # After full rollout, the constructor no longer has a local mode branch — it only knows remote
        self.remote_url = os.getenv("INFERENCE_URL", "http://inference:8001/predict")  # Read the remote service URL; the default now assumes a Kubernetes service DNS name

    async def predict(self, text: str):  # The predict method is now pure HTTP — no local inference code path remains
        async with httpx.AsyncClient() as client:  # Open an async HTTP client for each prediction call
            resp = await client.post(self.remote_url, json={"text": text}, timeout=5)  # POST the text to the inference service with a 5-second timeout
            resp.raise_for_status()  # Raise on non-2xx responses — there is no local fallback anymore, so this is a real error
            return resp.json()  # Parse and return the JSON response from the dedicated inference service

The monolith no longer imports pickle, no longer loads model files, no longer does inference. It’s just a FastAPI app that calls an HTTP service. This cuts its memory usage in half and removes the 8-second startup time for model loading.

What About the 800ms Response Time?

The monolith averaged 800ms because inference was blocking other requests. With inference extracted, you can:

  1. Scale the inference service independently — run more replicas than the monolith
  2. Use GPU for the inference service without GPU-enabling the monolith
  3. Cache predictions:
# inference_service/cache.py
import redis  # Import the Redis client for caching repeated inference results
import json  # Import json for serializing and deserializing cached predictions

cache = redis.Redis(host="redis", port=6379, decode_responses=True)  # Connect to Redis — decode_responses=True returns strings instead of bytes for convenience

@app.post("/predict")  # The same /predict endpoint, now augmented with a cache check before running inference
async def predict(request: Request):  # The handler signature stays the same so the InferenceClient does not need changes
    data = await request.json()  # Parse the incoming request JSON body
    text_hash = hashlib.sha256(data["text"].encode()).hexdigest()  # Compute a SHA-256 hash of the input text to use as the cache key — this avoids storing PII in Redis

    # Check cache
    cached = cache.get(f"pred:{text_hash}")  # Look up the cache key in Redis — returns the cached JSON string or None
    if cached:  # Cache hit: the exact same query was processed recently
        return json.loads(cached)  # Deserialize and return the cached result immediately — this takes under 5ms instead of 800ms

    # Run inference
    result = run_inference(data["text"])  # Cache miss: run the full inference pipeline (tokenize, vectorize, predict, format)

    # Cache for 1 hour
    cache.setex(f"pred:{text_hash}", 3600, json.dumps(result))  # Store the result in Redis with a 1-hour TTL (3600 seconds) as a JSON string
    return result  # Return the freshly computed result — subsequent identical queries will hit the cache

With caching, repeated queries (same “how do I reset my password?”) return in under 5ms instead of 800ms. 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:

# kubernetes/rollback.yaml
apiVersion: apps/v1  # Use the apps/v1 API version for Kubernetes Deployment resources
kind: Deployment  # This manifest targets a Deployment, which manages ReplicaSets and rolling updates
metadata:
  name: monolith  # Identify the Deployment by name — the monolith's deployment in this case
spec:
  template:  # The Pod template section — changes here trigger a rolling update
    spec:
      containers:  # List of containers in the Pod
        - name: app  # The container name within the monolith Pod
          env:  # Environment variables to set on the container
            - name: INFERENCE_MODE  # The environment variable the InferenceClient reads at construction time
              value: "remote"  # Change to "local" to revert — setting this toggles the routing mode without a full deployment rollback

Reverting to local mode requires the monolith to re-load the model files. If you removed the model files from the monolith image (which you should after confirming the remote service works), the rollback is a full deployment rollback:

kubectl rollout undo deployment/monolith  # Roll back the monolith Deployment to the previous revision — this redeploys the image that still has model loading code and model files baked in

This deploys the previous image that still had the model loading code and model files baked in. The inference service keeps running during the rollback — you can switch back and forth without traffic loss.

What You End Up With

Before:
┌─────────────────────────────┐
│         Monolith            │
│  ┌─────────────────────┐    │
│  │ Auth │ Routing │ Inf │   │
│  │ Cache│ DB      │     │   │
│  └─────────────────────┘    │
│  Memory: 4GB  Startup: 12s  │
│  Response: 800ms p50        │
└─────────────────────────────┘

After:
┌──────────────────┐  ┌──────────────────┐
│    Monolith       │  │  Inference       │
│  (lighter)        │  │  Service         │
│  Auth │ Routing   │  │  Model │ Cache   │
│  DB   │ Cache     │  │  GPU   │ Batch   │
│ Memory: 1.5GB     │  │  Scales: 10      │
│ Startup: 2s       │  │  Response: 120ms │
│ Response: 300ms   │  │  p99: 400ms      │
└──────────────────┘  └──────────────────┘

Monolith response time drops because it’s no longer competing with inference for CPU. 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 refactor (add the abstraction), then extract (build the service), then switch (route traffic). Three separate deployments.

  2. Shadow traffic before canary. Don’t route real users to a service you haven’t validated against real data. The model may work perfectly 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 30 seconds. If the canary shows problems, you flip back. If the full rollout fails, you roll back. 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.