Circuit Breaker Pattern: Nginx ile Uygulama

Mikro servis mimarisine geçiş yaptıktan sonra karşılaşılan en sinir bozucu sorunlardan biri şu: bir backend servis çöktü, ama Nginx hâlâ ona istek yönlendirmeye devam ediyor. Kullanıcılar timeout alıyor, log dosyaları şişiyor, ve sen de gece yarısı pagerdan uyandırılıyorsun. İşte tam bu noktada Circuit Breaker pattern devreye giriyor.

Circuit Breaker, elektrik sigortasından esinlenilmiş bir tasarım deseni. Bir servis belirli sayıda hata verdikten sonra devre açılıyor, o servise giden istekler geçici olarak engelleniyor, bir süre sonra servis tekrar test ediliyor ve sağlıklıysa devre kapanıyor. Bu sayede hem downstream servisler gereksiz yükten kurtarılıyor hem de kullanıcılara anlamlı hata mesajları verilebiliyor.

Nginx bu pattern’i native olarak desteklemiyor, ama ngx_http_upstream_module ve biraz akıllıca konfigürasyon ile oldukça güçlü bir circuit breaker implementasyonu yapabilirsin. Daha gelişmiş seçenekler için ise Nginx Plus veya OpenResty’ye bakabiliriz.

Circuit Breaker Neden Önemli?

Diyelim ki şöyle bir senaryon var: Payment servisin 3 saniyelik timeout ile yanıt vermeye başladı. Anlık 500 concurrent kullanıcın var. Her istek 3 saniye boyunca bir worker thread tuttuğu için Nginx’in upstream connection pool’u doldu, diğer servisler de etkilenmeye başladı. Buna cascading failure deniyor ve production’da yaşanması gerçekten kötü.

Circuit Breaker bu problemi şu şekilde çözüyor:

  • Hızlı başarısızlık (fail fast): Bozuk servise istek göndermek yerine hemen hata dönüyor
  • Kaynak tasarrufu: Thread ve connection pool boşta beklemek yerine başka isteklere hizmet edebiliyor
  • Otomatik iyileşme: Servis düzelince devre otomatik kapanıyor
  • Gözlemlenebilirlik: Hangi servislerin sorunlu olduğu net görünüyor

Nginx ile Temel Implementasyon

Nginx’in ticari olmayan açık kaynak versiyonunda tam anlamıyla bir circuit breaker yok. Ama max_fails ve fail_timeout direktifleriyle pasif sağlık kontrolü yapabilirsin. Bu aslında circuit breaker’ın basit bir versiyonu.

upstream payment_service {
    server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    
    # Tüm sunucular down olursa son çare
    server 10.0.1.99:8080 backup;
    
    keepalive 32;
}

server {
    listen 80;
    server_name api.example.com;
    
    location /payment/ {
        proxy_pass http://payment_service;
        proxy_connect_timeout 2s;
        proxy_read_timeout 5s;
        proxy_send_timeout 5s;
        
        proxy_next_upstream error timeout http_500 http_502 http_503;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 10s;
    }
}

Burada ne oluyor:

  • max_fails=3: 30 saniye içinde 3 başarısız istek gelirse sunucu “down” olarak işaretlenir
  • fail_timeout=30s: Sunucu 30 saniye down kalır, sonra tekrar denenir
  • proxy_next_upstream: Hata alınca bir sonraki upstream’e geç
  • proxy_next_upstream_tries: Maksimum 2 upstream dene

Bu yaklaşımın bir sorunu var: passive health check, yani Nginx gerçekten istek göndermeden önce sunucunun sağlıklı olup olmadığını bilmiyor. Sunucu 30 saniye sonra tekrar “up” işaretleniyor ama gerçekten up olup olmadığından emin olamıyoruz.

Active Health Check ile Daha Güvenilir Circuit Breaker

Nginx Plus’ta health_check direktifi gelir ama açık kaynak versiyonda bunu kendin çözmek zorunda kalırsın. OpenResty kullanıyorsan lua-resty-upstream-healthcheck modülü işini çok kolaylaştırır.

Önce standart Nginx ile ne yapabileceğimize bakalım. Ayrı bir health check endpoint’i ile upstream durumunu yönetebilirsin:

# /etc/nginx/conf.d/upstream_health.conf

upstream order_service {
    zone order_service_zone 64k;
    
    server 10.0.2.10:9090 max_fails=5 fail_timeout=60s weight=3;
    server 10.0.2.11:9090 max_fails=5 fail_timeout=60s weight=3;
    server 10.0.2.12:9090 max_fails=5 fail_timeout=60s weight=1;
}

# Health check için internal endpoint
server {
    listen 8080;
    server_name _;
    
    allow 127.0.0.1;
    allow 10.0.0.0/8;
    deny all;
    
    location /upstream-status {
        stub_status on;
    }
    
    # Manuel circuit breaker kontrolü
    location /circuit-breaker/order {
        internal;
        proxy_pass http://order_service/health;
        proxy_connect_timeout 1s;
        proxy_read_timeout 2s;
    }
}

Şimdi bir script ile bu health check’i otomatikleştirelim:

#!/bin/bash
# /usr/local/bin/nginx_circuit_breaker.sh

NGINX_BIN="/usr/sbin/nginx"
UPSTREAM_NAME="order_service"
HEALTH_ENDPOINT="http://localhost:8080/circuit-breaker/order"
STATE_FILE="/var/run/nginx_cb_${UPSTREAM_NAME}.state"
FAILURE_THRESHOLD=3
SUCCESS_THRESHOLD=2
CHECK_INTERVAL=10

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a /var/log/nginx_circuit_breaker.log
}

check_health() {
    local response
    response=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 3 "${HEALTH_ENDPOINT}")
    echo "${response}"
}

get_state() {
    if [ -f "${STATE_FILE}" ]; then
        cat "${STATE_FILE}"
    else
        echo "CLOSED"
    fi
}

set_state() {
    echo "$1" > "${STATE_FILE}"
    log "Circuit state changed to: $1"
}

FAIL_COUNT=0
SUCCESS_COUNT=0

while true; do
    CURRENT_STATE=$(get_state)
    HTTP_CODE=$(check_health)
    
    if [ "${HTTP_CODE}" = "200" ]; then
        FAIL_COUNT=0
        SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
        
        if [ "${CURRENT_STATE}" = "HALF_OPEN" ] && [ "${SUCCESS_COUNT}" -ge "${SUCCESS_THRESHOLD}" ]; then
            set_state "CLOSED"
            SUCCESS_COUNT=0
            log "Circuit CLOSED - service recovered"
        fi
    else
        SUCCESS_COUNT=0
        FAIL_COUNT=$((FAIL_COUNT + 1))
        
        if [ "${FAIL_COUNT}" -ge "${FAILURE_THRESHOLD}" ] && [ "${CURRENT_STATE}" = "CLOSED" ]; then
            set_state "OPEN"
            log "Circuit OPENED - failure count: ${FAIL_COUNT}, HTTP code: ${HTTP_CODE}"
            
            # Nginx'e durumu bildir (örn: maintenance moda geç)
            $NGINX_BIN -s reload
        fi
    fi
    
    # OPEN state'te belirli süre sonra HALF_OPEN'a geç
    if [ "${CURRENT_STATE}" = "OPEN" ]; then
        OPEN_TIME=$(stat -c %Y "${STATE_FILE}")
        CURRENT_TIME=$(date +%s)
        ELAPSED=$((CURRENT_TIME - OPEN_TIME))
        
        if [ "${ELAPSED}" -ge 60 ]; then
            set_state "HALF_OPEN"
            SUCCESS_COUNT=0
        fi
    fi
    
    sleep "${CHECK_INTERVAL}"
done

OpenResty ile Gelişmiş Circuit Breaker

Gerçek anlamda esnek bir circuit breaker istiyorsan OpenResty (Nginx + LuaJIT) kullanman gerekiyor. OpenResty, Nginx’in gücüne Lua programlama dilini ekliyor.

Önce OpenResty kurulumu:

# Ubuntu/Debian için
wget -qO - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" 
    | sudo tee /etc/apt/sources.list.d/openresty.list
sudo apt-get update
sudo apt-get install openresty

# CentOS/RHEL için
sudo yum install yum-utils
sudo yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
sudo yum install openresty

Şimdi asıl sihir burada. Lua ile circuit breaker implementasyonu:

# /usr/local/openresty/nginx/conf/lua/circuit_breaker.lua

local _M = {}

-- Shared memory zone'u kullan (nginx.conf'ta tanımlanmalı)
local circuit_states = ngx.shared.circuit_states

-- Circuit Breaker konfigürasyonu
local config = {
    failure_threshold = 5,      -- Kaç hatadan sonra açılsın
    success_threshold = 2,      -- Kaç başarıdan sonra kapansın
    timeout = 60,               -- OPEN state'te kaç saniye bekle
    half_open_max_calls = 3,    -- HALF_OPEN'da kaç istek dene
}

local STATE = {
    CLOSED = "CLOSED",
    OPEN = "OPEN",
    HALF_OPEN = "HALF_OPEN"
}

function _M.get_state(service_name)
    local state = circuit_states:get(service_name .. ":state")
    if not state then
        circuit_states:set(service_name .. ":state", STATE.CLOSED)
        circuit_states:set(service_name .. ":failures", 0)
        circuit_states:set(service_name .. ":successes", 0)
        return STATE.CLOSED
    end
    return state
end

function _M.can_request(service_name)
    local state = _M.get_state(service_name)
    
    if state == STATE.CLOSED then
        return true
    elseif state == STATE.OPEN then
        -- Timeout geçtiyse HALF_OPEN'a geç
        local open_time = circuit_states:get(service_name .. ":open_time")
        if open_time and (ngx.now() - open_time) >= config.timeout then
            circuit_states:set(service_name .. ":state", STATE.HALF_OPEN)
            circuit_states:set(service_name .. ":half_open_calls", 0)
            ngx.log(ngx.WARN, "Circuit HALF_OPEN for service: " .. service_name)
            return true
        end
        return false
    elseif state == STATE.HALF_OPEN then
        local calls = circuit_states:get(service_name .. ":half_open_calls") or 0
        if calls < config.half_open_max_calls then
            circuit_states:incr(service_name .. ":half_open_calls", 1, 0)
            return true
        end
        return false
    end
    
    return false
end

function _M.record_success(service_name)
    local state = _M.get_state(service_name)
    
    if state == STATE.HALF_OPEN then
        local successes = circuit_states:incr(service_name .. ":successes", 1, 0)
        if successes >= config.success_threshold then
            -- Devre kapat
            circuit_states:set(service_name .. ":state", STATE.CLOSED)
            circuit_states:set(service_name .. ":failures", 0)
            circuit_states:set(service_name .. ":successes", 0)
            ngx.log(ngx.WARN, "Circuit CLOSED for service: " .. service_name)
        end
    elseif state == STATE.CLOSED then
        -- Başarılarda failure count'u sıfırla
        circuit_states:set(service_name .. ":failures", 0)
    end
end

function _M.record_failure(service_name)
    local state = _M.get_state(service_name)
    
    if state == STATE.HALF_OPEN then
        -- HALF_OPEN'da hata olursa tekrar OPEN'a dön
        circuit_states:set(service_name .. ":state", STATE.OPEN)
        circuit_states:set(service_name .. ":open_time", ngx.now())
        ngx.log(ngx.ERR, "Circuit re-OPENED for service: " .. service_name)
        return
    end
    
    if state == STATE.CLOSED then
        local failures = circuit_states:incr(service_name .. ":failures", 1, 0)
        if failures >= config.failure_threshold then
            circuit_states:set(service_name .. ":state", STATE.OPEN)
            circuit_states:set(service_name .. ":open_time", ngx.now())
            ngx.log(ngx.ERR, "Circuit OPENED for service: " .. service_name .. 
                    " after " .. failures .. " failures")
        end
    end
end

return _M

Bu Lua modülünü Nginx konfigürasyonunda kullanmak için:

# /usr/local/openresty/nginx/conf/nginx.conf

worker_processes auto;
error_log logs/error.log warn;

events {
    worker_connections 1024;
    use epoll;
    multi_accept on;
}

http {
    # Shared memory zone - tüm worker process'ler paylaşır
    lua_shared_dict circuit_states 10m;
    
    # Lua modüllerinin yolu
    lua_package_path "/usr/local/openresty/nginx/conf/lua/?.lua;;";
    
    upstream payment_backend {
        server 10.0.1.10:8080;
        server 10.0.1.11:8080;
        keepalive 32;
    }
    
    upstream order_backend {
        server 10.0.2.10:9090;
        server 10.0.2.11:9090;
        keepalive 32;
    }
    
    server {
        listen 80;
        server_name api.example.com;
        
        # Payment servisi
        location /api/payment/ {
            # İstek öncesi circuit breaker kontrolü
            access_by_lua_block {
                local cb = require "circuit_breaker"
                local service = "payment"
                
                if not cb.can_request(service) then
                    ngx.status = 503
                    ngx.header["X-Circuit-State"] = "OPEN"
                    ngx.header["Retry-After"] = "60"
                    ngx.say('{"error":"Service temporarily unavailable","circuit":"open"}')
                    return ngx.exit(503)
                end
                
                -- Service adını context'e kaydet
                ngx.ctx.cb_service = service
            }
            
            proxy_pass http://payment_backend;
            proxy_connect_timeout 2s;
            proxy_read_timeout 10s;
            
            # Yanıt sonrası circuit breaker güncelle
            log_by_lua_block {
                local cb = require "circuit_breaker"
                local service = ngx.ctx.cb_service
                
                if service then
                    local status = ngx.status
                    if status >= 500 or status == 0 then
                        cb.record_failure(service)
                    else
                        cb.record_success(service)
                    end
                end
            }
        }
        
        # Circuit breaker durumunu gösteren endpoint
        location /internal/circuit-status {
            allow 10.0.0.0/8;
            deny all;
            
            content_by_lua_block {
                local circuit_states = ngx.shared.circuit_states
                local services = {"payment", "order", "inventory"}
                local result = {}
                
                for _, svc in ipairs(services) do
                    local state = circuit_states:get(svc .. ":state") or "CLOSED"
                    local failures = circuit_states:get(svc .. ":failures") or 0
                    result[svc] = {state = state, failures = failures}
                end
                
                ngx.header["Content-Type"] = "application/json"
                local json = require "cjson"
                ngx.say(json.encode(result))
            }
        }
    }
}

Fallback Mekanizması

Circuit breaker açıldığında kullanıcılara sadece hata vermek yerine bir fallback yanıt döndürebilirsin. Bu, gerçek dünya senaryolarında çok önemli.

# Statik fallback yanıtlar için ayrı bir location
server {
    listen 80;
    server_name api.example.com;
    
    # Fallback yanıtlar için static dosyalar
    location /fallback/ {
        internal;
        root /usr/local/nginx/fallback;
        default_type application/json;
    }
    
    location /api/inventory/ {
        access_by_lua_block {
            local cb = require "circuit_breaker"
            
            if not cb.can_request("inventory") then
                -- Cache'den son bilinen değeri döndür
                local cache = ngx.shared.response_cache
                local cached = cache:get(ngx.var.request_uri)
                
                if cached then
                    ngx.header["Content-Type"] = "application/json"
                    ngx.header["X-Served-From"] = "cache-fallback"
                    ngx.header["X-Circuit-State"] = "OPEN"
                    ngx.say(cached)
                    return ngx.exit(200)
                end
                
                -- Cache de yoksa graceful degradation
                ngx.exec("/fallback/inventory_default.json")
                return
            end
            
            ngx.ctx.cb_service = "inventory"
        }
        
        proxy_pass http://inventory_backend;
        
        # Başarılı yanıtları cache'e al
        body_filter_by_lua_block {
            local cb_service = ngx.ctx.cb_service
            if cb_service and ngx.status == 200 then
                local cache = ngx.shared.response_cache
                local body = ngx.arg[1]
                if ngx.arg[2] then  -- son chunk
                    cache:set(ngx.var.request_uri, ngx.ctx.response_body, 300)
                else
                    ngx.ctx.response_body = (ngx.ctx.response_body or "") .. (body or "")
                end
            end
        }
    }
}

Fallback JSON dosyasını da oluştur:

# /usr/local/nginx/fallback/inventory_default.json
mkdir -p /usr/local/nginx/fallback

cat > /usr/local/nginx/fallback/inventory_default.json << 'EOF'
{
    "status": "degraded",
    "message": "Inventory service is temporarily unavailable",
    "data": [],
    "fallback": true,
    "timestamp": null
}
EOF

Monitoring ve Alerting

Circuit breaker implementasyonu yaptın, güzel. Ama onu izlemezsen değeri yarıya düşer. Prometheus ve Grafana ile birleştirdiğinde çok işe yarıyor.

# Nginx log formatını circuit breaker state'ini içerecek şekilde ayarla
# /etc/nginx/nginx.conf

http {
    log_format circuit_breaker_log '$remote_addr - $remote_user [$time_local] '
                                   '"$request" $status $body_bytes_sent '
                                   '"$http_referer" "$http_user_agent" '
                                   'upstream="$upstream_addr" '
                                   'upstream_status="$upstream_status" '
                                   'upstream_rt="$upstream_response_time" '
                                   'cb_state="$upstream_http_x_circuit_state"';
    
    access_log /var/log/nginx/circuit_breaker.log circuit_breaker_log;
}

Basit bir monitoring scripti:

#!/bin/bash
# /usr/local/bin/cb_monitor.sh
# Bu script her dakika çalışır ve metrikleri toplar

CIRCUIT_STATUS_URL="http://localhost/internal/circuit-status"
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL}"
ALERT_FILE="/var/run/cb_alerts"

get_circuit_status() {
    curl -sf "${CIRCUIT_STATUS_URL}" 2>/dev/null
}

send_slack_alert() {
    local service=$1
    local state=$2
    local message="*[Circuit Breaker Alert]* Service *${service}* circuit is *${state}*"
    
    if [ -n "${SLACK_WEBHOOK}" ]; then
        curl -s -X POST -H 'Content-type: application/json' 
            --data "{"text":"${message}"}" 
            "${SLACK_WEBHOOK}"
    fi
    
    logger -t circuit_breaker "Service ${service} circuit state: ${state}"
}

STATUS=$(get_circuit_status)

if [ -z "${STATUS}" ]; then
    send_slack_alert "nginx" "STATUS_ENDPOINT_DOWN"
    exit 1
fi

# Python ile JSON parse et
python3 << PYEOF
import json
import sys
import os

status = json.loads('${STATUS}')
alert_file = '/var/run/cb_alerts'

# Önceki alertleri yükle
previous = {}
if os.path.exists(alert_file):
    with open(alert_file) as f:
        try:
            previous = json.load(f)
        except:
            previous = {}

current = {}
for service, info in status.items():
    state = info.get('state', 'UNKNOWN')
    current[service] = state
    
    prev_state = previous.get(service, 'CLOSED')
    
    if state != prev_state:
        print(f"STATE_CHANGE:{service}:{state}")
        
        if state == 'OPEN':
            print(f"ALERT:CRITICAL:{service} circuit opened!")
        elif state == 'HALF_OPEN':
            print(f"ALERT:WARNING:{service} circuit in half-open state")
        elif state == 'CLOSED' and prev_state in ('OPEN', 'HALF_OPEN'):
            print(f"ALERT:INFO:{service} circuit closed - service recovered")

# Yeni durumu kaydet
with open(alert_file, 'w') as f:
    json.dump(current, f)
PYEOF

# Cron'a ekle
# * * * * * /usr/local/bin/cb_monitor.sh >> /var/log/cb_monitor.log 2>&1

Gerçek Dünya Senaryosu: E-ticaret Platformu

Bir e-ticaret platformunda şu servislerin önünde Nginx var diyelim:

  • Payment Service: En kritik, 99.99% uptime gerekli
  • Inventory Service: Stok sorgulama, kısa süreli outage tolere edilebilir
  • Recommendation Service: Nice-to-have, down olsa da satış devam eder
  • Order Service: Kritik ama payment kadar değil

Her servis için farklı circuit breaker eşikleri mantıklı:

# Farklı servisler için farklı thresholdlar
# /usr/local/openresty/nginx/conf/lua/cb_config.lua

return {
    payment = {
        failure_threshold = 3,   -- Çok kritik, az hatayla aç
        success_threshold = 5,   -- Çok kritik, çok başarı gerektir
        timeout = 30,            -- Hızlı recovery dene
        half_open_max_calls = 2
    },
    inventory = {
        failure_threshold = 10,  -- Biraz toleranslı
        success_threshold = 3,
        timeout = 60,
        half_open_max_calls = 5
    },
    recommendation = {
        failure_threshold = 20,  -- Çok toleranslı
        success_threshold = 2,
        timeout = 120,
        half_open_max_calls = 10
    },
    order = {
        failure_threshold = 5,
        success_threshold = 3,
        timeout = 45,
        half_open_max_calls = 3
    }
}

Bu konfigürasyonla recommendation servisi down olsa bile kullanıcı siparişini tamamlayabiliyor, circuit breaker hızla açılıp sistemi koruyor, ve servis düzelince otomatik devreye giriyor.

Yaygın Hatalar ve Çözümleri

Circuit breaker implementasyonunda dikkat etmen gereken birkaç konu var.

Timeout değerlerini yanlış ayarlamak: proxy_read_timeout çok uzunsa circuit breaker geç devreye giriyor. Servisinin normal yanıt süresini ölç, ona göre timeout belirle.

Shared memory boyutunu küçük tutmak: lua_shared_dict circuit_states 1m gibi küçük değerler yüzlerce servis için yetersiz kalıyor. 10m-50m arası makul.

Sadece 5xx hatalarına bakmak: Network timeout’ları da failure olarak sayılmalı. ngx.status == 0 durumunu kontrol etmeyi unutma.

Circuit breaker’ı load balancer’ın önüne koymak: Circuit breaker her upstream sunucu için ayrı ayrı çalışmalı, tüm upstream pool için tek bir devre olmamalı.

Sonuç

Circuit Breaker pattern, production ortamlarında cascading failure’ları önlemenin en etkili yollarından biri. Nginx’in native özellikleriyle basit bir implementasyon yapabilirsin, ama gerçek anlamda esnek ve özelleştirilebilir bir çözüm için OpenResty + Lua kombinasyonu çok daha güçlü.

Başlangıç olarak standart Nginx’in max_fails ve fail_timeout direktiflerini kullan, sistemi izle, pattern’i anla. Sonra ihtiyaç duyarsan OpenResty’ye geç. Her şeyden önce şunu unutma: Circuit breaker bir çözüm değil, bir koruma mekanizması. Asıl sorunları yine de düzeltmen gerekiyor, circuit breaker sadece o sürede sistemi ayakta tutuyor.

Monitoring olmayan bir circuit breaker kör uçmak gibi. Hangi servislerin ne kadar sıklıkla açıldığını, recovery sürelerini ve trend’leri mutlaka takip et. Bu veriler hem anlık müdahale için hem de uzun vadeli kapasite planlaması için altın değerinde.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir