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 /health size yalan söyleyebilir. Veritabanı ve kritik bağımlılıkları kontrol eden 'derin' bir endpoint yazın.
  • active sayacını try/finally dışında tutmak: Exception attığında sayaç sızar, least-connections yanlış karar verir. Mutlaka finally bloğunda azaltın.
  • Connection draining yapmamak: Bir backend'i listeden çıkarırken üzerinde açık istekler olabilir. Anında düşürmek yerine DRAINING durumuna 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.