Webhook Performansı: Yüksek Trafikte Ölçeklendirme Stratejileri
Üretim ortamında webhook alan bir servis kurduğunuzda, ilk birkaç hafta her şey güzel gider. Sonra bir gün bakıyorsunuz ki GitHub Actions tetikleyicileriniz kuyruğa takılmış, Stripe ödeme bildirimleri gecikmeli geliyor ve monitoring sisteminiz alarm üstüne alarm yağdırıyor. İşte bu noktada “webhook performansı” meselesinin ne kadar kritik olduğunu anlıyorsunuz. Bu yazıda yüksek trafikte webhook mimarisini nasıl ölçeklendireceğinizi, darboğazları nasıl tespit edeceğinizi ve gerçek dünya senaryolarında ne tür çözümler üretebileceğinizi ele alacağız.
Webhook’un Doğası ve Performans Sorunlarının Kaynağı
Webhook’lar özünde basit HTTP POST istekleridir. Ancak bu basitlik, yüksek hacimde iş yapılınca yanıltıcı olabiliyor. Bir e-ticaret platformu düşünün: Black Friday günü saniyede 500 sipariş girişi oluyor ve her biri için hem ödeme sağlayıcısından hem lojistik sistemden hem de stok yönetiminden webhook geliyor. Bu durumda tek bir uygulama instance’ının senkron biçimde bu istekleri işlemesi felakete davetiye çıkarmaktır.
Performans sorunları genellikle şu noktalardan kaynaklanır:
- Senkron işleme: Her webhook isteği işi bitirmeden HTTP yanıtı dönmüyor
- Tek nokta başarısızlığı: Tüm işlem tek bir servis üzerinden akıyor
- Veri tabanı darboğazı: Her webhook’ta ağır DB sorguları çalıştırılıyor
- Yeniden deneme fırtınaları: Başarısız webhook’lar birikip sistemi çöküntüye sürüklüyor
- Timeout yönetimi eksikliği: Downstream servisler yavaşlayınca tüm zincir tıkıyor
Temel Mimari: Hızlı Kabul, Asenkron İşleme
Webhook performansının altın kuralı şudur: İsteği hızlıca kabul et, işi arka planda yap. Webhook gönderen taraf (Stripe, GitHub, Shopify) genellikle 5-30 saniye içinde 2xx yanıtı görmek ister. Göremezse yeniden dener. Ve bu yeniden denemeler katlanarak artar.
Temel mimari şöyle olmalı: Webhook endpoint’iniz isteği alır, basit bir doğrulama yapar, mesaj kuyruğuna atar ve anında 200 OK döner. Asıl iş worker’lar tarafından arka planda işlenir.
# Nginx ile webhook endpoint'i için hızlı proxy yapılandırması
# /etc/nginx/sites-available/webhook-service
upstream webhook_receivers {
least_conn;
server 127.0.0.1:8001 weight=3;
server 127.0.0.1:8002 weight=3;
server 127.0.0.1:8003 weight=3;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name webhooks.sirketiniz.com;
# Webhook endpoint'leri için özel ayarlar
location /webhooks/ {
proxy_pass http://webhook_receivers;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Upstream'in hızlı yanıt vermesi için kısa timeout
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 30s;
# Buffer ayarları - küçük payload'lar için optimize
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# Rate limiting
limit_req zone=webhook_zone burst=200 nodelay;
limit_req_status 429;
}
}
# Rate limit zone tanımı (http bloğunda)
limit_req_zone $binary_remote_addr zone=webhook_zone:10m rate=100r/s;
Redis Kuyruk Sistemi ile Asenkron İşleme
Python tabanlı bir webhook receiver örneği üzerinden gidelim. Bu senaryo, bir SaaS platformunun Stripe webhook’larını işlemesini simüle ediyor.
# Python webhook receiver servisi
# webhook_receiver.py
from flask import Flask, request, jsonify
import redis
import json
import hmac
import hashlib
import os
from datetime import datetime
app = Flask(__name__)
redis_client = redis.Redis(
host=os.environ.get('REDIS_HOST', 'localhost'),
port=6379,
db=0,
socket_connect_timeout=2,
socket_timeout=2,
decode_responses=True
)
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')
def verify_stripe_signature(payload, sig_header, secret):
"""Stripe imza doğrulaması - güvenlik kritik"""
try:
timestamp = sig_header.split(',')[0].split('=')[1]
sig = sig_header.split(',')[1].split('=')[1]
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
except Exception:
return False
@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_data()
sig_header = request.headers.get('Stripe-Signature', '')
# Hızlı doğrulama - işlemeden önce
if not verify_stripe_signature(payload, sig_header, STRIPE_WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
event = json.loads(payload)
# Kuyruğa at ve hemen dön - asenkron işleme burada başlıyor
job_data = {
'event_id': event.get('id'),
'event_type': event.get('type'),
'payload': event,
'received_at': datetime.utcnow().isoformat(),
'retry_count': 0
}
# Event tipine göre öncelikli kuyruklama
queue_name = get_queue_by_priority(event.get('type'))
redis_client.lpush(queue_name, json.dumps(job_data))
# Metrik güncelle
redis_client.incr(f"webhook:received:{event.get('type')}")
return jsonify({'status': 'accepted', 'event_id': event.get('id')}), 200
def get_queue_by_priority(event_type):
"""Kritik event'ler öncelikli kuyruğa gider"""
high_priority = ['payment_intent.succeeded', 'charge.refunded', 'customer.subscription.deleted']
if event_type in high_priority:
return 'webhook:queue:high'
return 'webhook:queue:normal'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8001, threaded=True)
Worker Sistemi ve Yatay Ölçeklendirme
Kuyruğa aldığınız işleri işleyecek worker’ların yönetimi de en az receiver kadar önemli. Systemd ile worker havuzu kurmak ve dinamik olarak ölçeklendirmek için şu yapıyı kullanabilirsiniz:
# /etc/systemd/system/[email protected]
# Template servis - birden fazla instance çalıştırmak için
[Unit]
Description=Webhook Worker Instance %i
After=network.target redis.service
Requires=redis.service
[Service]
Type=simple
User=webhook
Group=webhook
WorkingDirectory=/opt/webhook-service
Environment=WORKER_ID=%i
Environment=REDIS_HOST=localhost
EnvironmentFile=/etc/webhook-service/env
ExecStart=/opt/webhook-service/venv/bin/python worker.py
Restart=always
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3
# Kaynak limitleri
LimitNOFILE=65536
MemoryMax=512M
CPUQuota=50%
[Install]
WantedBy=multi-user.target
# Worker instance'larını başlatma ve yönetme
# 4 worker instance başlat
for i in 1 2 3 4; do
systemctl enable webhook-worker@$i
systemctl start webhook-worker@$i
done
# Durum kontrolü
systemctl status 'webhook-worker@*'
# Yük durumuna göre worker sayısını artır
scale_workers() {
local target_count=$1
local current_count=$(systemctl list-units 'webhook-worker@*' --state=running | grep -c 'running')
echo "Mevcut worker: $current_count, Hedef: $target_count"
if [ $target_count -gt $current_count ]; then
for i in $(seq $((current_count + 1)) $target_count); do
systemctl start webhook-worker@$i
echo "Worker $i başlatıldı"
done
elif [ $target_count -lt $current_count ]; then
for i in $(seq $((target_count + 1)) $current_count); do
systemctl stop webhook-worker@$i
echo "Worker $i durduruldu"
done
fi
}
# Kullanım: Yoğun saatlerde 8 worker'a çıkar
scale_workers 8
Kuyruk İzleme ve Otomatik Ölçeklendirme Scripti
Gerçek dünyada kuyruk derinliğine bakarak otomatik ölçeklendirme yapmak çok değerli. Şu senaryo, Redis kuyruk boyutuna göre worker sayısını dinamik olarak ayarlıyor:
#!/bin/bash
# /opt/webhook-service/scripts/autoscale.sh
# Cron ile her dakika çalıştırılır: * * * * * /opt/webhook-service/scripts/autoscale.sh
REDIS_CLI="redis-cli"
MIN_WORKERS=2
MAX_WORKERS=16
SCALE_UP_THRESHOLD=100 # Kuyrukta bu kadar iş varsa scale up
SCALE_DOWN_THRESHOLD=10 # Kuyrukta bu kadar iş varsa scale down
LOG_FILE="/var/log/webhook-autoscale.log"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> $LOG_FILE
}
# Mevcut kuyruk derinliğini al
HIGH_QUEUE_DEPTH=$($REDIS_CLI llen webhook:queue:high 2>/dev/null || echo 0)
NORMAL_QUEUE_DEPTH=$($REDIS_CLI llen webhook:queue:normal 2>/dev/null || echo 0)
TOTAL_DEPTH=$((HIGH_QUEUE_DEPTH + NORMAL_QUEUE_DEPTH))
# Aktif worker sayısını al
ACTIVE_WORKERS=$(systemctl list-units 'webhook-worker@*' --state=running --no-legend | wc -l)
log "Kuyruk: $TOTAL_DEPTH (high: $HIGH_QUEUE_DEPTH, normal: $NORMAL_QUEUE_DEPTH), Worker: $ACTIVE_WORKERS"
# Ölçeklendirme kararı
if [ $TOTAL_DEPTH -gt $SCALE_UP_THRESHOLD ] && [ $ACTIVE_WORKERS -lt $MAX_WORKERS ]; then
NEW_COUNT=$((ACTIVE_WORKERS + 2))
[ $NEW_COUNT -gt $MAX_WORKERS ] && NEW_COUNT=$MAX_WORKERS
log "Scale UP: $ACTIVE_WORKERS -> $NEW_COUNT worker (kuyruk: $TOTAL_DEPTH)"
for i in $(seq $((ACTIVE_WORKERS + 1)) $NEW_COUNT); do
systemctl start webhook-worker@$i && log "Worker $i başlatıldı"
done
# Slack bildirimi gönder
curl -s -X POST "$SLACK_WEBHOOK_URL"
-H 'Content-type: application/json'
-d "{"text":"Webhook autoscale: $ACTIVE_WORKERS -> $NEW_COUNT worker (kuyruk: $TOTAL_DEPTH)"}"
> /dev/null
elif [ $TOTAL_DEPTH -lt $SCALE_DOWN_THRESHOLD ] && [ $ACTIVE_WORKERS -gt $MIN_WORKERS ]; then
NEW_COUNT=$((ACTIVE_WORKERS - 1))
[ $NEW_COUNT -lt $MIN_WORKERS ] && NEW_COUNT=$MIN_WORKERS
log "Scale DOWN: $ACTIVE_WORKERS -> $NEW_COUNT worker (kuyruk: $TOTAL_DEPTH)"
systemctl stop webhook-worker@$ACTIVE_WORKERS && log "Worker $ACTIVE_WORKERS durduruldu"
fi
# Metrikler için Redis'e yaz (Prometheus veya Grafana okuyabilir)
$REDIS_CLI set "webhook:metrics:queue_depth" $TOTAL_DEPTH EX 120
$REDIS_CLI set "webhook:metrics:active_workers" $ACTIVE_WORKERS EX 120
Yeniden Deneme Mantığı ve Zehirli Mesaj Yönetimi
Yüksek trafikte en tehlikeli senaryolardan biri “retry storm”dur. Bir downstream servis yavaşladığında, başarısız webhook’lar birikir ve sürekli yeniden deneme yapar. Bu da sistemi tamamen çökertebilir. Exponential backoff ile bu sorunu engelleyebilirsiniz:
# worker.py - Retry mantığı ile webhook işleme
import redis
import json
import time
import logging
from datetime import datetime, timedelta
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
logger = logging.getLogger(__name__)
MAX_RETRIES = 5
DEAD_LETTER_QUEUE = 'webhook:queue:dead_letter'
def calculate_backoff(retry_count):
"""Exponential backoff: 30s, 60s, 120s, 240s, 480s"""
base_delay = 30
return min(base_delay * (2 ** retry_count), 480)
def process_webhook(job_data):
"""Gerçek iş burada yapılır"""
event_type = job_data.get('event_type')
payload = job_data.get('payload')
# Event tipine göre handler çağır
handlers = {
'payment_intent.succeeded': handle_payment_success,
'charge.refunded': handle_refund,
'customer.subscription.deleted': handle_subscription_cancel,
}
handler = handlers.get(event_type, handle_generic_event)
return handler(payload)
def worker_loop(queue_names):
logger.info(f"Worker başladı, kuyruklar: {queue_names}")
while True:
try:
# Önce yüksek öncelikli kuyruktan al
result = redis_client.brpop(queue_names, timeout=5)
if result is None:
continue
_, raw_data = result
job_data = json.loads(raw_data)
retry_count = job_data.get('retry_count', 0)
event_id = job_data.get('event_id')
logger.info(f"İşleniyor: {event_id} (deneme: {retry_count + 1})")
try:
process_webhook(job_data)
redis_client.incr(f"webhook:processed:{job_data.get('event_type')}")
logger.info(f"Başarılı: {event_id}")
except Exception as e:
logger.error(f"Hata: {event_id} - {str(e)}")
if retry_count < MAX_RETRIES:
# Backoff hesapla ve delayed queue'ya at
delay = calculate_backoff(retry_count)
job_data['retry_count'] = retry_count + 1
job_data['last_error'] = str(e)
job_data['retry_at'] = (datetime.utcnow() + timedelta(seconds=delay)).isoformat()
# Delayed retry için sorted set kullan (score = unix timestamp)
retry_at = time.time() + delay
redis_client.zadd('webhook:queue:delayed', {json.dumps(job_data): retry_at})
logger.info(f"Yeniden denenecek: {event_id}, {delay}s sonra")
else:
# Max retry aşıldı, dead letter queue'ya gönder
job_data['failed_at'] = datetime.utcnow().isoformat()
redis_client.lpush(DEAD_LETTER_QUEUE, json.dumps(job_data))
redis_client.incr('webhook:metrics:dead_letter_count')
logger.error(f"Dead letter queue'ya taşındı: {event_id}")
except redis.ConnectionError as e:
logger.error(f"Redis bağlantı hatası: {e}")
time.sleep(5)
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
worker_loop(['webhook:queue:high', 'webhook:queue:normal'])
Veritabanı Darboğazını Aşmak: Connection Pooling ve Caching
Webhook işlerken her event için ayrı DB bağlantısı açmak, yoğun trafikte veri tabanınızı anında doldurur. PostgreSQL için pgBouncer kullanımı bu noktada hayat kurtarıcıdır:
# /etc/pgbouncer/pgbouncer.ini
# Webhook worker'lar için optimize edilmiş connection pool
[databases]
webhook_db = host=localhost port=5432 dbname=production_db
[pgbouncer]
listen_port = 6432
listen_addr = 127.0.0.1
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# Transaction mode - webhook worker'lar için ideal
pool_mode = transaction
# Webhook servisine özel pool boyutu
default_pool_size = 50
max_client_conn = 500
reserve_pool_size = 10
reserve_pool_timeout = 3
# Bağlantı temizleme
server_idle_timeout = 600
client_idle_timeout = 0
server_lifetime = 3600
# Performans metrikleri için
stats_period = 60
log_connections = 0
log_disconnections = 0
# Webhook servis performansını izleme scripti
# /opt/webhook-service/scripts/health_check.sh
#!/bin/bash
REDIS_CLI="redis-cli"
ALERT_THRESHOLD_QUEUE=500
ALERT_THRESHOLD_DLQ=50
check_queue_health() {
local high=$($REDIS_CLI llen webhook:queue:high)
local normal=$($REDIS_CLI llen webhook:queue:normal)
local delayed=$($REDIS_CLI zcard webhook:queue:delayed)
local dlq=$($REDIS_CLI llen webhook:queue:dead_letter)
local processed=$($REDIS_CLI get webhook:metrics:queue_depth || echo 0)
echo "=== Webhook Kuyruk Durumu ==="
echo "Yüksek öncelik : $high"
echo "Normal : $normal"
echo "Geciktirilmiş : $delayed"
echo "Dead letter : $dlq"
echo ""
# Uyarı koşulları
if [ "$((high + normal))" -gt "$ALERT_THRESHOLD_QUEUE" ]; then
echo "UYARI: Kuyruk derinliği eşiği aşıldı! ($((high + normal)) > $ALERT_THRESHOLD_QUEUE)"
# PagerDuty veya alertmanager'a bildir
send_alert "webhook_queue_overflow" "$((high + normal))"
fi
if [ "$dlq" -gt "$ALERT_THRESHOLD_DLQ" ]; then
echo "UYARI: Dead letter queue'da çok fazla mesaj! ($dlq)"
send_alert "webhook_dlq_overflow" "$dlq"
fi
}
check_worker_health() {
local active=$(systemctl list-units 'webhook-worker@*' --state=running --no-legend | wc -l)
local failed=$(systemctl list-units 'webhook-worker@*' --state=failed --no-legend | wc -l)
echo "=== Worker Durumu ==="
echo "Aktif worker : $active"
echo "Başarısız : $failed"
if [ "$failed" -gt 0 ]; then
echo "UYARI: $failed worker başarısız durumda!"
systemctl list-units 'webhook-worker@*' --state=failed --no-legend
fi
}
check_processing_rate() {
# Son 1 dakikadaki işleme hızı
local before=$($REDIS_CLI get "webhook:rate:checkpoint" || echo 0)
local current=$($REDIS_CLI get "webhook:metrics:total_processed" || echo 0)
$REDIS_CLI set "webhook:rate:checkpoint" "$current" EX 120
local rate=$((current - before))
echo ""
echo "=== İşleme Hızı ==="
echo "Son 1 dk işlenen: $rate event"
}
send_alert() {
local alert_type=$1
local value=$2
# Gerçek ortamda alertmanager API'sine veya PagerDuty'ye gönderilir
logger -t webhook-health "ALERT: $alert_type value=$value"
}
check_queue_health
check_worker_health
check_processing_rate
Gerçek Dünya Senaryosu: E-Ticaret Platformu
Bir müşterim için kurduğumuz sistemde, Shopify ve birkaç ödeme sağlayıcısından gelen webhook trafiği kampanya dönemlerinde saatte 200.000 event’e ulaşıyordu. Başlangıçta tek bir sunucuda monolitik bir yapı vardı ve her yoğun kampanyada sistem çöküyordu.
Uyguladığımız mimari şöyle özetlenebilir:
- 2 adet Nginx load balancer (aktif-pasif, keepalived ile)
- 4 adet receiver instance (her biri 8 thread, Gunicorn ile)
- Redis Cluster (3 master, 3 replica) kuyruk yönetimi için
- 8-16 arası dinamik worker (autoscale scripti ile)
- pgBouncer önünde PostgreSQL (RDS Multi-AZ)
- Prometheus + Grafana izleme için
Bu yapıyla normal günlerde 2 worker yeterliydi. Kampanya günlerinde sistem otomatik olarak 14 worker’a çıkıyordu ve hiçbir event kaybı yaşanmadı. Önemli bir nokta şuydu: Dead letter queue’yu boş tutmak bir KPI haline getirildi. Her sabah DLQ’daki event’ler analiz edilip tekrar işlemeye sokuluyordu.
Sonuç
Webhook performansı meselesi temelde iki prensibi doğru uygulamaktan geçiyor: hızlı kabul, asenkron işleme. Buna ek olarak retry storm’u önlemek için exponential backoff, worker sayısını dinamik tutmak için kuyruk bazlı autoscale ve tüm sistemi izlemek için sağlam bir monitoring altyapısı şart.
Küçük başlamaktan korkmayın. Önce basit bir Redis kuyruğu ve birkaç worker ile başlayıp sisteminizi gözlemleyin. Darboğazlar kendiliğinden ortaya çıkacak. O noktada pgBouncer, öncelikli kuyruklar veya consumer group gibi daha ileri çözümlere geçebilirsiniz.
Son olarak şunu söyleyeyim: Dead letter queue’nuzu asla görmezden gelmeyin. Orada biriken her mesaj, ya bir bug’ı ya da bir downstream sorununu temsil eder. Düzenli olarak bu mesajları incelemek, hem sistemin sağlığını korumanızı sağlar hem de zaman zaman çok ciddi iş hatalarını fark etmenize yardımcı olur.
