Python ile Health Check'li Load Balancer Yazmak
Selamlar, bu yazımda asyncio ile küçük bir load balancer yazacağız. Round-robin, weighted ve least-connections algoritmalarını deneyecek, arka planda koşan bir health check coroutine'i kuracak ve hasta düşen backend'leri otomatik rotasyondan düşeceğiz. Niyetim sizi 'kendi load balancer'ını prod'da koş' diye ikna etmek değil; tam tersi, sonda neden bunu prod'da yazmamanız gerektiğini de konuşacağız. Ama bir kez içini görmek, nginx ya da HAProxy ne iş yapıyor anlamak için bence çok faydalı bir egzersiz.
Load balancer ne yapar?
Tek bir sunucu tek bir hata noktasıdır. Load balancer (Türkçe karşılığıyla yük dengeleyici) gelen istekleri birden fazla backend'e dağıtır, böylece hem ölçek hem yedeklilik kazanırız. Health check ise 'şu sunucu hâlâ ayakta mı?' sorusunu periyodik soran ve rotasyon listesini güncelleyen küçük arkadaş. Buradaki kritik nokta şu: trafiği dağıtmak kolay, asıl iş hasta düşen backend'i hızla fark edip ondan uzaklaşmak.
Backend modelimiz
Her backend'in adresi, ağırlığı ve runtime durumu olmalı:
from dataclasses import dataclass
from enum import Enum, auto
class Status(Enum):
HEALTHY = auto()
UNHEALTHY = auto()
UNKNOWN = auto()
@dataclass
class Backend:
host: str
port: int
weight: int = 1
status: Status = Status.UNKNOWN
active: int = 0
fails: int = 0
successes: int = 0
@property
def url(self) -> str:
return f'http://{self.host}:{self.port}'
@property
def is_healthy(self) -> bool:
return self.status == Status.HEALTHY
active o an açık bağlantı sayısı, least-connections bunu kullanacak. fails ve successes ise health check'in 'üst üste kaç kez başarısız/başarılı' eşiğini takip etmesi için.
Health checker coroutine
aiohttp ile her backend'e /health isteği atıyoruz, eşik dolunca durumu güncelliyoruz:
import asyncio
import aiohttp
async def check_one(session, b: Backend, healthy_th=2, unhealthy_th=3):
try:
async with session.get(f'{b.url}/health', timeout=3) as r:
ok = r.status == 200
except Exception:
ok = False
if ok:
b.fails = 0
b.successes += 1
if b.successes >= healthy_th:
b.status = Status.HEALTHY
else:
b.successes = 0
b.fails += 1
if b.fails >= unhealthy_th:
b.status = Status.UNHEALTHY
async def health_loop(backends, interval=5.0):
async with aiohttp.ClientSession() as session:
while True:
await asyncio.gather(*(check_one(session, b) for b in backends))
await asyncio.sleep(interval)
İki eşiği ayrı tutmak tesadüf değil; hızlı düş, yavaş kalk. Ağ bir an titrediğinde flapping yaşamamak için.
Algoritmalar
Üç klasik strateji:
from itertools import count
class RoundRobin:
def __init__(self):
self._i = count()
def pick(self, pool):
healthy = [b for b in pool if b.is_healthy]
if not healthy:
return None
return healthy[next(self._i) % len(healthy)]
class Weighted:
def pick(self, pool):
healthy = [b for b in pool if b.is_healthy]
if not healthy:
return None
expanded = [b for b in healthy for _ in range(b.weight)]
import random
return random.choice(expanded)
class LeastConn:
def pick(self, pool):
healthy = [b for b in pool if b.is_healthy]
if not healthy:
return None
return min(healthy, key=lambda b: b.active)
Round-robin basit ve adil. Weighted büyük makineye fazla, küçüğe az iş düşürür; ben burada listeyi şişiren naif yöntemi seçtim, gerçek smooth weighted round-robin biraz daha matematik ister. Least-connections ise istek süreleri eşit değilse (bir endpoint 50ms, diğeri 5sn) en mantıklısıdır.
Failover
Bir istek geldiğinde algoritma backend seçer, active sayacını artırıp yollarız, biter bitmez düşürürüz:
async def forward(session, picker, pool, method, path, body=None, retries=2):
tried = set()
for _ in range(retries + 1):
b = picker.pick([x for x in pool if x.url not in tried])
if b is None:
return 503, b'no healthy backend'
tried.add(b.url)
b.active += 1
try:
async with session.request(method, f'{b.url}{path}', data=body) as r:
return r.status, await r.read()
except Exception:
continue
finally:
b.active -= 1
return 502, b'all retries failed'
Bir backend yanıt vermezse tried setine ekleyip diğerine geçiyoruz. Health check yeni durumu yakalayana kadar bu retry mekanizması bizi koruyor.
Sık karşılaşılan tuzaklar
- Tek eşik kullanmak: Aynı sayıyı hem 'hasta' hem 'sağlam' için kullanırsan flapping kaçınılmaz olur, backend liste içine girip çıkar durur.
- Yüzeysel health endpoint: Sadece 200 dönen
/healthsize yalan söyleyebilir. Veritabanı ve kritik bağımlılıkları kontrol eden 'derin' bir endpoint yazın. activesayacını try/finally dışında tutmak: Exception attığında sayaç sızar, least-connections yanlış karar verir. Mutlakafinallybloğunda azaltın.- Connection draining yapmamak: Bir backend'i listeden çıkarırken üzerinde açık istekler olabilir. Anında düşürmek yerine
DRAININGdurumuna alıp bitmelerini bekleyin.
Peki bunu prod'da yazmalı mıyım?
Açıkçası hayır. Bence bu kodu laptop'unuzda çalıştırıp 'aha' deyin, sonra kapatın. Prod'da nginx, HAProxy, Envoy ya da bulut sağlayıcınızın L7 load balancer'ı var; arkalarında yıllarca savaş tecrübesi, TLS termination, observability, kernel-bypass optimizasyonları var. Tabii çok özel bir yönlendirme mantığınız varsa (tenant'a göre ayırma, A/B) bu kod başlangıç noktası olabilir, ama çoğu zaman cevap yine 'nginx + lua' ya da 'Envoy filter' oluyor.
Kapanış
Bu yazıda asyncio ile küçük ama tam bir load balancer kurduk: backend modeli, health check coroutine'i, üç algoritma ve basit failover. Bana sorarsanız asıl faydası, ileride gerçek load balancer'ları konfigüre ederken unhealthy_threshold gibi parametrelerin niye böyle olduğunu hissederek bilmek. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.
