DEV Community

DevOps Start
DevOps Start

Posted on • Originally published at devopsstart.com

Testing in Production: Guide to Progressive Delivery

This guide was originally published on devopsstart.com. Learn how to decouple deployment from release to reduce risk using progressive delivery strategies.

Introduction

You've spent weeks polishing a feature in a staging environment that is a mirror image of production. The tests pass, the QA team gives the thumbs up, and the deployment is scheduled for 2:00 AM. Then, the moment the code hits live traffic, everything collapses. A database deadlock occurs because the production dataset is 1,000 times larger than staging. A race condition emerges because of a specific traffic pattern that only exists in the wild. You realize that your "perfect" staging environment was a lie.

The hard truth of modern distributed systems is that production is the only environment that truly matters. Trying to replicate the complexity of a live global cluster in a pre-production environment is a losing game of whack-a-mole. To solve this, elite engineering teams have shifted toward Progressive Delivery. This isn't about being reckless with user data; it's about acknowledging that the only way to truly verify a change is to test it against real traffic, but in a way that minimizes the blast radius.

In this guide, you'll learn how to decouple deployment from release, implement canary strategies, and build the observability loops required to make testing in production safer than traditional releases. You'll move from a mindset of preventing all failures to one of rapid detection and recovery.

The Fallacy of Staging and the Shift to MTTR

Staging environments are often treated as the ultimate safety net, but they are fundamentally flawed. They suffer from "environment drift," where the configuration, data volume and network topology slowly diverge from production. Even if you use a Terraform testing pyramid to ensure your infrastructure is consistent, you cannot simulate the unpredictability of human users or the sheer volume of a production database.

When you rely solely on pre-production testing, you are optimizing for Mean Time Between Failures (MTBF). You're trying to ensure that a crash never happens. In a complex microservices architecture, this is impossible. I've seen this fail in clusters with >50 nodes where the network jitter alone creates failure modes that simply don't exist in a 3-node staging environment.

Instead, the industry is shifting toward optimizing Mean Time to Recovery (MTTR). The goal is no longer "zero bugs," but "zero prolonged outages."

To achieve this, you must stop treating a "deployment" (the act of moving binaries to a server) as a "release" (the act of exposing a feature to a user). By separating these two events, you can push code to production in a dormant state, verify its health with internal users and then gradually roll it out. This requires a fundamental change in how you handle your application logic. You no longer write code that is either "on" or "off"; you write code that is conditionally active based on a runtime toggle.

For example, consider a new pricing algorithm. Instead of replacing the old one, you wrap the new logic in a feature flag.

# Example using a conceptual feature flag client (e.g., Unleash or LaunchDarkly)
import feature_flags

def calculate_price(order):
    # The feature flag is checked at runtime, not compile time
    # This allows instant kill-switching without a redeploy
    if feature_flags.is_enabled("new_pricing_engine", user_id=order.user_id):
        return new_pricing_logic(order)

    return legacy_pricing_logic(order)

def new_pricing_logic(order):
    # New logic that might have a bug under high load
    return order.total * 0.95

def legacy_pricing_logic(order):
    return order.total
Enter fullscreen mode Exit fullscreen mode

In this scenario, the code is deployed to 100% of your servers, but the risk is 0% until you flip the switch for a small subset of users.

Implementing Canary Releases with Service Mesh

While feature flags handle application logic, Canary Releases handle the network. A canary release involves routing a small percentage of live traffic to a new version of your service while the majority remains on the stable version. If the canary version shows an increase in 5xx errors or latency spikes, the traffic is instantly routed back to the stable version.

To do this effectively at scale, you need a service mesh or a sophisticated ingress controller. Using Istio v1.21, you can define a VirtualService that splits traffic based on weights. This allows you to test the "plumbing" of your application (memory leaks, connection pool exhaustion, CPU spikes) which feature flags often miss.

Here is how you configure a 90/10 traffic split between the stable and canary versions of a service.

# Istio VirtualService for Canary Traffic Splitting
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: product-page-route
spec:
  hosts:
  - product-page.example.com
  http:
  - route:
    - destination:
        host: product-page-service
        subset: v1
      weight: 90
    - destination:
        host: product-page-service
        subset: v2
      weight: 10
Enter fullscreen mode Exit fullscreen mode

To make this work, you also need a DestinationRule to define the subsets based on Kubernetes labels.

# Istio DestinationRule to define version subsets
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: product-page-destination
spec:
  host: product-page-service
  subsets:
  - name: v1
    labels:
      version: v1.0.0
  - name: v2
    labels:
      version: v1.1.0
Enter fullscreen mode Exit fullscreen mode

Once this is applied, you monitor your telemetry. If you see the canary version (v2) throwing errors, you don't need to perform a full redeployment. You simply update the VirtualService weight back to 100/0.

Advanced Safety: Shadow Traffic and the Safety Matrix

Canary releases are great, but they still expose real users to potential bugs. For high-risk changes, such as a database migration or a critical security patch, you should use Shadow Traffic (also known as Dark Launching).

Shadowing mirrors live traffic. When a request hits your production environment, the load balancer sends it to the stable version (which returns the response to the user) and asynchronously sends a copy of that request to the new version. The new version processes the request, but its response is discarded. You compare the results of the stable version and the shadow version in your logs. If the shadow version produces a different result or crashes, you've found a bug without a single user ever seeing an error page.

Because different changes carry different risks, you shouldn't use the same testing method for everything. Use this Safety Matrix to decide your approach:

Change Type Risk Level Recommended Method Primary Goal
UI/UX tweak, CSS change Low Feature Flags User feedback, A/B testing
New API endpoint, Minor logic Medium Canary Release Performance, Error rates
Database Schema change, Core Engine High Shadow Traffic Data correctness, Latency
Global Config change Critical Feature Flags + Canary Blast radius control

Imagine you are migrating from a legacy SQL query to a new NoSQL implementation. A canary release is too risky because a bug could corrupt production data. Instead, you shadow the traffic. You send the request to both the SQL and NoSQL paths. You log the results of both. If the NoSQL path returns a "null" where the SQL path returned a "user_id," you know your migration logic is flawed.

This approach requires high-cardinality observability. You cannot rely on a simple "CPU usage" graph. You need distributed tracing (e.g., Jaeger or Honeycomb) to see exactly which request failed in the shadow path and why. You need to be able to query: "Show me all requests where the shadow response differed from the production response by more than 5%."

Best Practices for Testing in Production

Transitioning to progressive delivery is as much a cultural shift as it is a technical one. If you don't have the right guardrails, "testing in production" becomes a euphemism for "breaking things for users."

  1. Automate the Rollback Loop: Do not rely on a human to watch a dashboard and click "rollback." Link your monitoring system (e.g., Prometheus) to your deployment tool (e.g., ArgoCD). If the 99th percentile latency for the canary version exceeds 500ms for more than two minutes, the system should automatically revert the traffic weight to 0%. This can reduce the window of impact from hours to seconds.
  2. Define Strict Error Budgets: Establish a Service Level Objective (SLO). If your error budget for the month is 0.1% and a canary release consumes 0.05% of that budget in ten minutes, stop the rollout immediately. This removes the emotional tension between developers wanting to move fast and SREs wanting stability.
  3. Start with Internal "Dogfooding": Your first "canary" should always be your own employees. Use headers or cookie-based routing to ensure that only users with an @company.com email address hit the new version.
  4. Keep Feature Flags Short-Lived: Feature flags introduce technical debt. Once a feature is 100% rolled out and stable, create a ticket to remove the flag logic from the code. A codebase littered with old if (flag_enabled) statements becomes an unmaintainable nightmare.
  5. Invest in High-Cardinality Metrics: Standard metrics tell you that something is wrong. High-cardinality metrics (including user_id, region and version_id) tell you who is affected. Without this, you cannot effectively limit the blast radius.

FAQ

Is testing in production just a fancy way of saying "we don't test our code"?
No. Testing in production is the final stage of a rigorous pipeline. You still run unit tests, integration tests and contract tests in CI. Progressive delivery addresses the "unknown unknowns" that only appear under real-world load and state, which no amount of pre-production testing can fully uncover.

How do I handle database migrations with canary releases?
Database changes are the hardest part of progressive delivery. You must use "expand and contract" patterns. First, add the new column or table (Expand) while keeping the old one. Deploy the code that writes to both but reads from the old. Then, migrate the data. Finally, deploy the code that reads from the new and delete the old column (Contract). Never perform a destructive database change in a single deployment.

What happens if a shadow test modifies data?
Shadow traffic must be read-only. If the service you are shadowing performs writes, you must use a "mock" or "shadow" database that mimics production but doesn't affect real users. Alternatively, use a transactional wrapper that always rolls back the transaction at the end of the shadow request.

Can I use this approach for a small team with limited resources?
Yes. You don't need a full service mesh like Istio to start. You can start with a simple feature flag library in your code or a basic weighted load balancer at the DNS level. The mindset shift—decoupling deployment from release—is free and provides immediate value.

Conclusion

Testing in production is not about recklessness; it is about precision. By accepting that staging is an imperfect proxy for reality, you can implement strategies like feature flags, canary releases and traffic shadowing to reduce the blast radius of any given failure. The transition from optimizing for MTBF to optimizing for MTTR allows your team to deploy more frequently with significantly less anxiety.

To get started, don't try to overhaul your entire pipeline overnight. Start with one low-risk service. Implement a basic feature flag for a UI change, then move to a 5% canary rollout for a backend API. Once you have the observability in place to detect failures in seconds rather than hours, you'll find that the "safest" way to deploy is to do it progressively in the environment where it actually matters.

Your next steps are clear: identify your most unstable service, set up a basic traffic split and define your first automated rollback trigger. Stop fearing production and start using it as your most accurate testing tool.

Top comments (0)