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