Definition
- Shadow deployment – runs a new version alongside the current one, sending it a copy of real production traffic without returning its responses to clients.
- Side-effect isolation – the shadow's writes go to an isolated datastore and external calls are sandboxed so mirrored traffic never causes real-world actions.
- Response comparison – the live and shadow responses are compared (traffic diffing) to catch correctness regressions and performance issues.
- Implementation – Istio VirtualService mirroring, Spring Cloud Gateway async filter, or Nginx/Envoy request mirroring.
- Versus canary – a canary serves real responses to a slice of users (real risk); a shadow serves no responses (zero user risk).
Imagine National Bank of India is training a new loan officer. Instead of letting the trainee approve real loans, the bank quietly hands the trainee a photocopy of every real application that the experienced officer handles. The trainee works through each one and writes down what they would decide, but their decisions are never acted upon. Later, the manager compares the trainee's answers against the real ones to see if the trainee is ready. The trainee is the shadow version, the photocopies are mirrored traffic, and the rule that the trainee's decisions never touch real accounts is side-effect isolation.
Understanding the Concepts
What is a shadow deployment?
A shadow deployment, also called traffic mirroring or dark launch, runs a new version of a service alongside the current one. The live version serves every real response. The shadow version silently processes the mirrored requests for comparison.
Shadow deployment flow:
Client Request
│
▼
┌────────────────────────┐
│ Routing Layer │
│ (Gateway / Mesh) │
└───┬────────────┬────────┘
│ │ (copy)
▼ ▼
┌────────┐ ┌────────────┐
│ LIVE │ │ SHADOW │
│ v1.0 │ │ v2.0 │
│ │ │ │
│Response│ │Response │
│returned│ │DISCARDED │
└────────┘ └────────────┘
Why is side-effect isolation critical?
If the shadow service shares the live database and writes to it, mirrored traffic will double-write records. If it calls the real payment rail, it will move real money twice.
package com.bank.shadow;
import java.math.BigDecimal;
import java.util.logging.Logger;
public class ShadowServiceConfig {
private static final Logger LOG = Logger.getLogger(ShadowServiceConfig.class.getName());
// WRONG: Shadow shares live database and payment rail.
public void wrongShadow(String paymentId, BigDecimal amount) {
// liveDatabase.save(payment); <- double-writes records
// paymentRail.send(amount); <- moves real money twice
LOG.severe("Shadow caused real side effects.");
}
// CORRECT: Shadow uses isolated datastore and mocked externals.
public void correctShadow(String paymentId, BigDecimal amount) {
// shadowDatabase.save(payment); <- isolated, no real impact
// noopPaymentRail.send(amount); <- no-op, money never moves
LOG.info("Shadow processed safely in isolation.");
}
}
Isolation requirements for banking:
- Separate database or read-only connection
- Payment rail: no-op or sandbox mode
- Notifications: disabled (no SMS, no email)
- Kafka: separate topic or no-op producer
- Compliance: same encryption and access controls as production
How do you implement response comparison?
You capture the live and shadow responses for the same request and compare them, flagging mismatches in status, body, or timing.
package com.bank.shadow;
import java.util.logging.Logger;
public class ResponseComparator {
private static final Logger LOG = Logger.getLogger(ResponseComparator.class.getName());
public void compare(String requestId, String liveResponse, String shadowResponse,
long liveLatencyMs, long shadowLatencyMs) {
if (!liveResponse.equals(shadowResponse)) {
LOG.warning("MISMATCH requestId=" + requestId
+ " live=" + liveResponse
+ " shadow=" + shadowResponse);
}
if (shadowLatencyMs > liveLatencyMs * 2) {
LOG.warning("SLOW SHADOW requestId=" + requestId
+ " liveMs=" + liveLatencyMs
+ " shadowMs=" + shadowLatencyMs);
}
}
}
Expected comparison output:
MATCH: requestId=R001 live={balance:50000} shadow={balance:50000}
MISMATCH: requestId=R002 live={balance:0} shadow={balance:1.00}
-> Edge case bug: zero-principal account returns 1 rupee in shadow.
-> Customer got correct live answer (zero impact).
-> Bug found before promotion.
How do you implement mirroring in Istio?
In Istio, mirroring is a first-class VirtualService feature. You specify a mirror destination and a mirror percentage.
# Istio VirtualService with traffic mirroring
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: interest-calculation
spec:
hosts:
- interest-service
http:
- route:
- destination:
host: interest-service
subset: stable # live version
mirror:
host: interest-service
subset: shadow # new version
mirrorPercentage:
value: 10.0 # mirror 10% of traffic
Expected behavior:
100% of traffic served by stable (live responses to clients)
10% of traffic copied to shadow (responses discarded)
Shadow environment has isolated DB and no-op externals.
How do you implement mirroring in Spring Cloud Gateway?
In Spring Cloud Gateway, implement a filter that dispatches to the primary route and asynchronously fires a duplicate to the shadow.
package com.bank.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class ShadowMirrorFilter implements GatewayFilter {
private final WebClient shadowClient;
public ShadowMirrorFilter(WebClient.Builder builder) {
this.shadowClient = builder.baseUrl("http://shadow-interest-service").build();
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// Fire-and-forget: never delay the real response
shadowClient.method(exchange.getRequest().getMethod())
.uri(exchange.getRequest().getURI().getPath())
.exchange()
.subscribe(); // result discarded
return chain.filter(exchange); // real response proceeds normally
}
}
Key: fire-and-forget. A slow shadow NEVER delays the real response.
How is shadow different from canary? (comparison table)
| Aspect | Shadow | Canary |
|---|---|---|
| Serves real responses | No (discarded) | Yes (to a slice of users) |
| User risk | Zero | Some (small slice) |
| Side effects | Isolated/sandboxed | Real |
| Purpose | Validate correctness before exposure | Gradual rollout with real traffic |
| Order | Shadow first, then canary | After shadow proves safe |
What are common mistakes?
The most common mistake is failing to isolate side effects.
package com.bank.antipattern;
// WRONG: Shadow shares live database.
public class WrongShadow {
// Mirrored transfer writes to live payments table -> double records
// Mirrored notification sends real SMS -> customer confused
}
// CORRECT: Shadow uses isolated DB and no-op externals.
public class CorrectShadow {
// shadowPaymentsDb -> isolated, periodically wiped
// noOpNotifier -> logs but does not send
// noOpPaymentRail -> no money moves
}
What should a senior engineer know?
Mirroring doubles the load on the mirrored path, so mirror only a sampled percentage and ensure fire-and-forget so a slow shadow never delays real responses. Mirrored requests carry real customer data, so the shadow environment must meet the same security and compliance controls as production. Shadow is the safer first step before a canary.
Visual Overview
Shadow Deployment Architecture:
Client Request
│
▼
┌─────────────────────────────────┐
│ API Gateway / Service Mesh │
│ (Istio VirtualService) │
└───┬────────────────┬─────────────┘
│ 100% traffic │ 10% mirrored
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ LIVE v1.0 │ │ SHADOW v2.0 │
│ │ │ │
│ Live DB │ │ Isolated DB │
│ Real Rails │ │ No-op Rails │
│ Real Notif │ │ No-op Notif │
│ │ │ │
│ Response │ │ Response │
│ -> CLIENT │ │ -> DISCARDED │
└──────────────┘ │ -> COMPARED │
└──────────────────┘
Key Takeaway
A shadow deployment runs a new version of a service next to the current one and sends it a copy of real production traffic, but its responses are never returned to clients. The live version keeps serving every real response, and the shadow processes silently so you can compare behavior under genuine production load with zero user risk.
The single most important rule is side-effect isolation. If the shadow shares the live database or calls the real payment rail, mirrored traffic will double-write records or move real money twice. At National Bank of India, Pankaj shadow-tested a rewritten interest-calculation service: the live version served customers and wrote the real ledger, while the shadow computed against an isolated ledger and a no-op notifier. Response comparison caught a subtle edge-case bug where a zero-principal account returned one rupee in the shadow, with zero customer impact.
The implementation is straightforward: Istio offers mirroring as a first-class VirtualService feature, Spring Cloud Gateway uses an async fire-and-forget filter, and Nginx/Envoy support request mirroring directly. Shadow differs from canary: a canary serves real responses with real risk, a shadow serves no responses with zero risk, making shadowing the safer first step.
Remember one sentence: mirror real traffic to the new version, discard its responses, isolate its side effects, and compare its answers against the live version to validate correctness before any user is exposed.
---