jongkwan.dev
Development · Essay №045

Blue/Green Deployment (4): Operations and Optimization

Monitoring, cost optimization, networking considerations, a troubleshooting guide, and real-world case studies

Jongkwan Lee2026년 2월 5일13 min read
Contents

Blue/Green operations stay stable only when the cutover checklist, the post-cutover observation window, and the immediate-rollback criteria are treated as three separate concerns.

TL;DR

  • Blue/Green operations come down to a cutover checklist, monitoring, and clear immediate-rollback criteria.
  • Automation should focus less on the deployment itself and more on holding the 5 to 10 minute observation window after the cutover steady.
  • Runbooks and thresholds need regular updates based on real incidents, otherwise the same failures repeat.

Cutover and rollback checklist

Most of the cutover's stability is decided by how complete the checklist is. Separating pre-deployment verification, immediate post-cutover monitoring, and rollback conditions makes the response faster.

Before the cutover (verifying Green)

Infrastructure

  • All Green pods/instances Running and Ready
  • Health check endpoint responds (HTTP 200)
  • Resource usage in normal range (CPU < 80%, memory < 80%)

Application

  • Smoke test passes
  • Critical API endpoints tested
  • Dependency connections verified (DB, Redis, external APIs)

Database

  • Migration scripts ran successfully
  • Schema backward compatibility confirmed
  • Data backfill complete where required

Monitoring

  • Alert channels verified (Slack, PagerDuty)
  • Dashboards reachable
  • Rollback owner on standby

Right after the cutover (5 minutes of monitoring)

Critical metrics (first 5 minutes)

  • HTTP 5xx error rate < 0.1%
  • No spike in HTTP 4xx error rate
  • P99 (99th percentile) latency no more than 20% above the previous version
  • Throughput (RPS, requests per second) holding steady

Application logs

  • No spike in ERROR/CRITICAL level logs
  • No exception stack traces
  • No connection refused/timeout entries

Business metrics

  • No drop in key conversion rates (login, payment)
  • User sessions preserved

Rollback decision criteria

MetricThresholdAction
5xx error rate> 1% (over 5 minutes)Roll back immediately
P99 latency> 2x previous valueInvestigate, then decide
Core feature failurePayment/login failingRoll back immediately
CPU utilization> 90% (sustained)Scale up or roll back
OOM (out of memory) killOccurredRoll back immediately

Monitoring setup

Defining a small set of SLIs (service level indicators) and SLOs (service level objectives) you can act on immediately beats watching a large number of metrics. Alert rules only pay off when each one is designed alongside the action it triggers (rollback or scaling).

Defining the core SLIs and SLOs

yaml
# SLO example
slos:
  availability:
    target: 99.9%  # allows roughly 43.2 minutes of downtime per month
    window: 30d
    indicator: |
      sum(rate(http_requests_total{status!~"5.."}[5m])) /
      sum(rate(http_requests_total[5m]))
 
  latency:
    target: 95%    # 95% of requests under 200ms
    window: 30d
    indicator: |
      histogram_quantile(0.95,
        sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
      )
 
  error_budget:
    monthly: 43.2m  # 99.9% SLO = 43.2 minutes/month
    alert_threshold: 50%  # alert when half the budget is spent

Prometheus alert rules

yaml
# prometheus-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: blue-green-alerts
spec:
  groups:
  - name: deployment
    rules:
    # 5xx error rate spike
    - alert: HighErrorRate
      expr: |
        sum(rate(http_requests_total{status=~"5.."}[5m])) /
        sum(rate(http_requests_total[5m])) > 0.01
      for: 2m
      labels:
        severity: critical
        action: rollback
      annotations:
        summary: "High error rate detected after deployment"
        description: "Error rate is {{ $value | humanizePercentage }} (threshold: 1%)"
        runbook_url: "https://wiki.example.com/runbooks/high-error-rate"
 
    # latency spike
    - alert: HighLatency
      expr: |
        histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
        > 0.5
      for: 5m
      labels:
        severity: warning
      annotations:
        summary: "P99 latency exceeded 500ms"
        description: "Current P99: {{ $value | humanizeDuration }}"
 
    # pod restart detection
    - alert: PodRestartLoop
      expr: |
        increase(kube_pod_container_status_restarts_total{
          pod=~"my-app-.*"
        }[15m]) > 3
      labels:
        severity: critical
        action: rollback
      annotations:
        summary: "Pod restart loop detected"
 
    # post-deployment rollback detection
    - alert: DeploymentRollback
      expr: |
        kube_deployment_status_observed_generation{deployment="my-app"}
        < kube_deployment_metadata_generation{deployment="my-app"}
      for: 1m
      labels:
        severity: info
      annotations:
        summary: "Deployment rollback in progress"

Grafana dashboard

The dashboard below compares the Blue/Green cutover at a glance through four panels. Those panels are traffic distribution, error rate per version, P99 latency per version, and deployment event markers.

json
{
  "dashboard": {
    "title": "Blue/Green Deployment Dashboard",
    "panels": [
      {
        "title": "Traffic Distribution",
        "type": "piechart",
        "targets": [
          {
            "expr": "sum(rate(http_requests_total{color=\"blue\"}[5m]))",
            "legendFormat": "Blue"
          },
          {
            "expr": "sum(rate(http_requests_total{color=\"green\"}[5m]))",
            "legendFormat": "Green"
          }
        ]
      },
      {
        "title": "Error Rate by Version",
        "type": "timeseries",
        "targets": [
          {
            "expr": "sum(rate(http_requests_total{status=~\"5..\",color=\"blue\"}[5m])) / sum(rate(http_requests_total{color=\"blue\"}[5m]))",
            "legendFormat": "Blue 5xx"
          },
          {
            "expr": "sum(rate(http_requests_total{status=~\"5..\",color=\"green\"}[5m])) / sum(rate(http_requests_total{color=\"green\"}[5m]))",
            "legendFormat": "Green 5xx"
          }
        ]
      },
      {
        "title": "P99 Latency Comparison",
        "type": "timeseries",
        "targets": [
          {
            "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{color=\"blue\"}[5m])) by (le))",
            "legendFormat": "Blue P99"
          },
          {
            "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{color=\"green\"}[5m])) by (le))",
            "legendFormat": "Green P99"
          }
        ]
      },
      {
        "title": "Deployment Events",
        "type": "annotations",
        "datasource": "-- Grafana --",
        "query": "tags=deployment"
      }
    ]
  }
}

Recording deployment events

python
# deploy_marker.py - record deployment events in Grafana
import os
import requests
from datetime import datetime
 
def mark_deployment(version: str, environment: str, color: str):
    """Create a deployment event marker in Grafana"""
    grafana_url = "https://grafana.example.com"
    api_key = os.environ["GRAFANA_API_KEY"]
 
    annotation = {
        "time": int(datetime.now().timestamp() * 1000),
        "tags": ["deployment", environment, color],
        "text": f"Deployed {version} to {color} ({environment})"
    }
 
    response = requests.post(
        f"{grafana_url}/api/annotations",
        json=annotation,
        headers={"Authorization": f"Bearer {api_key}"}
    )
    return response.status_code == 200

Cost optimization

Blue/Green runs two environments, so cost goes up; the policy for steady state and the policy for deployment windows have to be separated. Combining spot/preemptible instances, autoscaling, and shrinking the idle environment keeps both stability and cost in range.

Cost breakdown

Using spot/preemptible instances

yaml
# Use spot instances for the Green environment (AWS EKS)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-green
spec:
  template:
    spec:
      nodeSelector:
        node.kubernetes.io/lifecycle: spot
      tolerations:
      - key: "node.kubernetes.io/lifecycle"
        operator: "Equal"
        value: "spot"
        effect: "NoSchedule"
      # graceful shutdown for spot interruptions
      terminationGracePeriodSeconds: 30
      containers:
      - name: app
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 10"]

Autoscaling configuration

yaml
# green-hpa.yaml - keep the Green environment minimal
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-app-green
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app-green
  minReplicas: 1  # minimum footprint in steady state
  maxReplicas: 10 # expand during deployment
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0  # scale up immediately
      policies:
      - type: Percent
        value: 100
        periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300  # wait 5 minutes before scaling down

Shrinking Green outside deployment hours

bash
#!/bin/bash
# scale-green.sh - shrink the Green environment outside business hours
 
HOUR=$(date +%H)
DAY=$(date +%u)
 
# scale down outside business hours (Mon-Fri 09:00-18:00)
if [ $DAY -gt 5 ] || [ $HOUR -lt 9 ] || [ $HOUR -gt 18 ]; then
  echo "Off-hours: Scaling down Green environment"
  kubectl scale deployment my-app-green --replicas=0
else
  echo "Business hours: Maintaining Green environment"
  kubectl scale deployment my-app-green --replicas=1
fi

Registering this script as two CronJobs takes Green down to replicas=0 at 19:00 on weekdays and brings it back to replicas=1 at 08:00.

yaml
# automated with CronJobs
apiVersion: batch/v1
kind: CronJob
metadata:
  name: scale-green-down
spec:
  schedule: "0 19 * * 1-5"  # weekdays at 19:00
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: deployment-scaler
          containers:
          - name: kubectl
            image: bitnami/kubectl
            command:
            - kubectl
            - scale
            - deployment/my-app-green
            - --replicas=0
          restartPolicy: OnFailure
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: scale-green-up
spec:
  schedule: "0 8 * * 1-5"  # weekdays at 08:00
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: deployment-scaler
          containers:
          - name: kubectl
            image: bitnami/kubectl
            command:
            - kubectl
            - scale
            - deployment/my-app-green
            - --replicas=1
          restartPolicy: OnFailure

Networking considerations

A large share of failed cutovers originates in DNS, CDN, or session handling rather than in application code. Agreeing on TTL, cache, and connection draining policies in advance removes most of the incidents that happen mid-cutover.

Managing DNS TTL

bash
# Route53 TTL example
aws route53 change-resource-record-sets \
  --hosted-zone-id Z123456 \
  --change-batch '{
    "Changes": [{
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "api.example.com",
        "Type": "A",
        "TTL": 60,
        "ResourceRecords": [{"Value": "1.2.3.4"}]
      }
    }]
  }'

CDN cache invalidation

bash
# CloudFront cache invalidation
aws cloudfront create-invalidation \
  --distribution-id E123456789 \
  --paths "/*"
 
# Fastly cache purge
curl -X POST "https://api.fastly.com/service/{service_id}/purge_all" \
  -H "Fastly-Key: $FASTLY_API_KEY"

Connection draining

yaml
# Kubernetes Service - connection draining settings
apiVersion: v1
kind: Service
metadata:
  name: my-app
  annotations:
    # AWS ALB
    service.beta.kubernetes.io/aws-load-balancer-connection-draining-enabled: "true"
    service.beta.kubernetes.io/aws-load-balancer-connection-draining-timeout: "60"
spec:
  # ...
yaml
# Pod - graceful shutdown
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
      - name: app
        lifecycle:
          preStop:
            exec:
              # stop new connections, wait for existing ones to finish
              command:
              - /bin/sh
              - -c
              - |
                # make the health check fail
                touch /tmp/shutdown
                # wait for existing connections to drain
                sleep 30

Handling sticky sessions

yaml
# Externalizing sessions through Redis (Spring Boot)
# application.yml
spring:
  session:
    store-type: redis
    redis:
      namespace: spring:session
  redis:
    host: redis.example.com
    port: 6379
 
---
# When using session affinity in Kubernetes
apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 3600

Troubleshooting guide

How fast a problem gets resolved depends on whether the path from symptom to likely cause to immediate action is written down. Standardizing the patterns that recur in production lets a new team member respond the same way as everyone else.

Common problems and fixes

Problem 1: the Green environment never becomes Ready

bash
# diagnose
kubectl get pods -l color=green
kubectl describe pod my-app-green-xxx
 
# common causes
# 1. image pull failure
kubectl get events --field-selector reason=Failed
 
# 2. readiness probe failure
kubectl logs my-app-green-xxx
 
# 3. insufficient resources
kubectl describe node | grep -A5 "Allocated resources"

Problem 2: 5xx errors spike after the cutover

bash
# roll back immediately
kubectl patch svc my-app -p '{"spec":{"selector":{"color":"blue"}}}'
 
# root cause analysis
# 1. check logs
kubectl logs -l color=green --tail=100
 
# 2. check dependency connectivity
kubectl exec -it my-app-green-xxx -- curl -v http://db-service:5432
 
# 3. compare environment variables
kubectl get deployment my-app-blue -o json | jq '.spec.template.spec.containers[0].env'
kubectl get deployment my-app-green -o json | jq '.spec.template.spec.containers[0].env'

Problem 3: sessions are lost

bash
# check Redis sessions
redis-cli KEYS "spring:session:*" | wc -l
 
# fix: verify session migration
# 1. check session store connectivity
kubectl exec my-app-green-xxx -- nc -zv redis.example.com 6379
 
# 2. check session key format (cross-version compatibility)
redis-cli GET "spring:session:sessions:xxx"

Troubleshooting flowchart

Case studies

Real cases are what calibrate design principles against operational reality. Recording failures and rollbacks, not only the clean runs, is what makes the next deployment safer.

Case 1: e-commerce platform (payment system)

Situation: Blue/Green deployment of a payment module update

Environment

ItemSpec
InfrastructureAWS EKS (Kubernetes 1.28)
Deployment toolArgo Rollouts
Traffic10,000 RPS (peak)

Deployment strategy

Result

MetricValue
Downtime0 seconds
Rollback neededNone
Total elapsed time55 minutes

Case 2: API server (including a DB schema change)

Situation: adding a new column to the users table

Deployment plan (expand and contract)

Problem and fix

StageDetail
ProblemAfter the v2 deployment in Phase 1, some APIs returned null errors
CauseOlder clients sent data without the new column
FixAdded a default value and redeployed

Lessons

  • DB schema changes need at least a two-week deployment plan
  • Client compatibility testing is mandatory

Case 3: microservices (deploying several services together)

Situation: updating three services at once (API Gateway, Auth, User)

Deployment order (dependency aware)

Automation (GitHub Actions)

StageDescription
BuildParallel builds per service
DeploySequential deployment (dependency order)
ApprovalManual approval at each stage

Problem and fix

StageDetail
ProblemAuth calls failed after the User service cutover
CauseUser was unaware of the API change in the new Auth version
FixRolled User back immediately, patched Auth for backward compatibility, redeployed

Lessons

  • Deploying microservices together requires API contract testing
  • Consumer-driven contract testing is worth adopting

Summary

The theme across this series is that a verifiable cutover matters more than an automatic one. Tool choice should follow team capability and operational maturity, starting simple and expanding gradually. The checklist, the SLO-based alerts, and the rollback thresholds are what turn a cutover from a hope into a decision.

Key points

TopicCore content
FundamentalsTwo environments running side by side, immediate rollback available
KubernetesArgo Rollouts or Istio recommended
CloudALB weights, Cloud Run tags, App Service slots
CI/CDAutomated verification, manual approval, monitoring
OperationsSLO-based monitoring, cost optimization, checklists

Recommendations

  1. Start simple: begin with a plain Kubernetes Service selector
  2. Add sophistication gradually: adopt Argo Rollouts first, Istio later
  3. Automate: integrate Blue/Green into the CI/CD pipeline
  4. Monitoring first: build the SLO and alerting system before deploying
  5. Practice rollbacks: run rollback drills on a regular schedule