Tüm yazılar
Altyapı 6 dk okuma

Hata Bütçesi ve Gürültüsüz Alarm Tasarımı

Tek olaya değil hata bütçesine bağlı alarm: SLO tanımı, kalan bütçe hesabı, tükenme hızıyla iki kademeli alarm ve gruplama için çalışan Python kodu.

#linux #automation #monitoring

Her hatada bildirim gönderen bir izleme sistemi birkaç hafta içinde görmezden gelinir; sorun bildirimin kendisi değil, kararın tek olaya bağlanmasıdır. Hata bütçesi bu kararı tek koşumdan alıp bir zaman penceresine yayar: hedefin altında kalmayı ne kadar tolere edeceğimizi önceden yazarız ve alarm ancak bu tolerans hızla tükendiğinde çalar. Tek başarısız koşum böylece bir veri noktası olur, felaket değil.

Bu yazıda cron ile koşan zamanlanmış işler ve Telegram'a giden bildirimler için pratik bir düzen anlatacağım: basit bir hedef (SLO) tanımı, hata bütçesinin son N koşumdan hesaplanması, tükenme hızına göre iki kademeli alarm ve tekrarlayan olayları gruplayıp sessizleştiren bir yönlendirici. Örnekler Python ile yazıldı; aynı mantık Node ya da kabuk betiğine taşınabilir.

Yaklaşımın kaynağı Google'ın SRE kitabı ile devamı olan SRE Workbook'ta anlatılan hata bütçesi ve tükenme hızı (burn rate) alarmlarıdır. Orada büyük servisler için kurulan model, ölçek küçültülünce tek sunucuda tek kişilik bir ekipte de işe yarar. Hedef basit: gece uyandıran alarmın gerçekten uyandırmaya değer olması.

Hata bütçesi nedir ve nasıl hesaplanır

Hata bütçesi, bir hizmetin belirli bir pencerede hedefin altına düşmesine izin verilen toplam paydır; hedef yüzde 99,5 ise bütçe yüzde 0,5'tir. Otuz günlük pencerede bu pay 720 saatin yüzde 0,5'i, yani 3,6 saat kesinti demektir. Zamanlanmış işlerde birim saat değil koşumdur: ayda 720 kez koşan saatlik bir işte bütçe 3,6 koşumdur, pratikte 3 başarısız koşum.

Bütçeyi hesaplamadan önce hedefin ne olduğu yazılmalı. Zamanlanmış bir iş için SLO üç parçadan oluşur:

  • Pencere: son 30 gün ya da son 30 koşum. Sık koşan işte zaman, seyrek koşan işte koşum sayısı daha dürüst bir pencere verir.
  • Başarı ölçütü: çıkış kodu sıfır ve süre belirlenen eşiğin altında. Yalnızca çıkış koduna bakmak, yavaşlayarak bozulan işi gizler.
  • Hedef oran: yüzde 99,5 gibi. Sıfır hata hedefi bütçe bırakmaz; her hata alarm olur ve baştaki gürültü sorununa geri dönülür.

Hesap için her koşumun kaydı gerekir. Her koşum zaman, iş adı, durum ve süre alanlarıyla tek satır JSON olarak yazılırsa hesap tek fonksiyona iner; aynı kayda korelasyon kimliği eklemek alarmdan ilgili loga geçişi kolaylaştırır, bu düzeni yapılandırılmış loglama ve korelasyon kimliği yazısında anlattım. Aşağıdaki fonksiyon son N günün kayıtlarını okur, başarı oranını ve kalan bütçeyi verir. Python 3.7 ve üstü gerekir (dataclass); sunucudaki python3 sürümünü önceden kontrol edin. zaman alanı saat dilimi içermeli (2026-09-19T09:02:00+00:00 gibi).

import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List


@dataclass
class ButceDurumu:
    toplam: int
    basarisiz: int
    basari_orani: float
    kalan_butce: float  # 1.0 hiç harcanmadı, 0.0 tükendi


def kayitlari_oku(dosya: Path, pencere: timedelta) -> List[dict]:
    esik = datetime.now(timezone.utc) - pencere
    kayitlar = []
    with dosya.open(encoding='utf-8') as f:
        for satir in f:
            satir = satir.strip()
            if not satir:
                continue
            kayit = json.loads(satir)
            if datetime.fromisoformat(kayit['zaman']) >= esik:
                kayitlar.append(kayit)
    return kayitlar


def butce_hesapla(kayitlar: List[dict], hedef: float = 0.995) -> ButceDurumu:
    toplam = len(kayitlar)
    izin = 1.0 - hedef
    if toplam == 0 or izin <= 0:
        return ButceDurumu(0, 0, 1.0, 1.0)
    basarisiz = sum(1 for k in kayitlar if k.get('durum') != 'ok')
    hata_orani = basarisiz / toplam
    harcanan = hata_orani / izin
    return ButceDurumu(toplam, basarisiz, 1.0 - hata_orani, max(0.0, 1.0 - harcanan))


def tukenme_hizi(kayitlar: List[dict], hedef: float = 0.995) -> float:
    """Penceredeki hata oranının izin verilen orana bölümü."""
    if not kayitlar:
        return 0.0
    basarisiz = sum(1 for k in kayitlar if k.get('durum') != 'ok')
    return (basarisiz / len(kayitlar)) / (1.0 - hedef)


if __name__ == '__main__':
    dosya = Path('/var/log/otomasyon/kosumlar.jsonl')
    son30 = kayitlari_oku(dosya, timedelta(days=30))
    durum = butce_hesapla(son30)
    print(f'{durum.toplam} koşum, {durum.basarisiz} hata, '
          f'başarı %{durum.basari_orani * 100:.2f}, kalan bütçe %{durum.kalan_butce * 100:.0f}')
    print('hız (1 saat):', round(tukenme_hizi(kayitlari_oku(dosya, timedelta(hours=1))), 1))

Kayıt hiç yoksa fonksiyon bütçeyi dolu sayar; bu davranış hiç koşmayan işi görünmez kılar. O boşluğu hata bütçesi değil, sessiz başarısızlık nöbetçisi yakalar: bütçe başarısız koşumu ölçer, nöbetçi koşumun hiç olmadığını. İkisi birbirinin yerine geçmez, yan yana çalışır.

Tükenme hızı ve iki kademeli alarm

Tükenme hızı, bütçenin gerçekte harcanma hızının pencereye eşit yayılmış ideal harcamaya oranıdır: hız 1 ise bütçe tam pencere sonunda biter, hız 10 ise onda bir sürede tükenir. İki kademeli alarm bu hızı iki farklı pencerede ölçer; kısa pencerede yüksek hız insanı hemen çağırır, uzun pencerede düşük hız sabah bakılacak bir bilet açar.

Eşikler tanımdan türer. Otuz günlük pencere 720 saattir; bir saatlik pencerede tükenme hızı 14,4 ise o bir saatte aylık bütçenin yüzde 2'si gitmiştir (14,4 × 1/720 = 0,02). SRE Workbook'un önerdiği çok pencereli düzen bu aritmetiğe dayanır:

  • Bir saatlik pencerede hız 14,4 ve üstü (bütçenin yüzde 2'si bir saatte): sayfa çağır.
  • Altı saatlik pencerede hız 6 ve üstü (bütçenin yüzde 5'i altı saatte): sayfa çağır.
  • Üç günlük pencerede hız 1 ve üstü (bütçenin yüzde 10'u üç günde): bilet aç.

Günde bir kez koşan işte saat tabanlı pencere anlamsızdır; bir saatte ya sıfır ya bir koşum vardır ve tek hata hızı sonsuza fırlatır. Bu durumda pencereyi koşum sayısıyla tanımlamak gerekir: kısa pencere son 3 koşum, uzun pencere son 30 koşum. Formül aynı kalır, yalnızca payda saat yerine koşum olur.

Hedef de koşum sayısına göre seçilmeli. Otuz koşumluk pencerede yüzde 99,5 hedef 0,15 koşumluk bütçe demektir; tek hata bile bütçeyi aşar ve iki kademeli düzen tek kademeye çöker. Günlük iş için yüzde 90 hedef (30 koşumda 3 hata) daha dürüst bir başlangıçtır; hata oranı bir süre izlendikten sonra hedef yukarı çekilir.

Alarm yönlendiricisi: sayfa çağır, bilet aç ya da sus

Alarm yönlendiricisi, tükenme hızını ve önceki gönderimlerin kaydını alıp üç karardan birini veren küçük bir bileşendir: sayfa çağır (Telegram'a hemen yaz), bilet aç (sabah bakılacak listeye ekle) ya da sus. Aynı kaynaktan gelen tekrarları grupladığı ve bir kez sesli alarm verdikten sonra belirli süre sessiz kaldığı için aynı arıza on koşum boyunca on bildirim üretmez.

Aşağıdaki sınıf iki pencereyle çalışır; uzun pencere hem 6 hem 1 eşiğine bakarak Workbook'un üç pencereli düzenini ikiye indirir. Durum diske yazılır, çünkü cron her çağrıda yeni bir süreç açar ve bellek içi sayaç bir sonraki dakikaya ulaşamaz.

import json
import time
from collections import defaultdict
from pathlib import Path

SESSIZLIK_SN = {'sayfa': 30 * 60, 'bilet': 6 * 60 * 60}
DURUM_DOSYASI = Path('/var/lib/otomasyon/alarm-durum.json')


def karar_ver(hiz_kisa: float, hiz_uzun: float) -> str:
    """hiz_kisa: 1 saat ya da son 3 koşum; hiz_uzun: 6 saat ya da son 30 koşum."""
    if hiz_kisa >= 14.4 and hiz_uzun >= 14.4:
        return 'sayfa'
    if hiz_uzun >= 6.0:
        return 'sayfa'
    if hiz_uzun >= 1.0:
        return 'bilet'
    return 'sus'


class Yonlendirici:
    def __init__(self, durum_dosyasi: Path = DURUM_DOSYASI):
        self.dosya = durum_dosyasi
        self.son_gonderim = defaultdict(float)
        self.bastirilan = defaultdict(int)
        if self.dosya.exists():
            veri = json.loads(self.dosya.read_text(encoding='utf-8'))
            self.son_gonderim.update(veri.get('son_gonderim', {}))
            self.bastirilan.update(veri.get('bastirilan', {}))

    def kaydet(self) -> None:
        self.dosya.parent.mkdir(parents=True, exist_ok=True)
        veri = {'son_gonderim': dict(self.son_gonderim), 'bastirilan': dict(self.bastirilan)}
        self.dosya.write_text(json.dumps(veri), encoding='utf-8')

    def isle(self, kaynak: str, hiz_kisa: float, hiz_uzun: float, gonder) -> str:
        karar = karar_ver(hiz_kisa, hiz_uzun)
        if karar == 'sus':
            return karar
        anahtar = f'{kaynak}:{karar}'
        simdi = time.time()
        if simdi - self.son_gonderim[anahtar] < SESSIZLIK_SN[karar]:
            self.bastirilan[anahtar] += 1
            self.kaydet()
            return 'gruplandi'
        bastirilan = self.bastirilan.pop(anahtar, 0)
        ek = f' (sessizlikte {bastirilan} tekrar bastırıldı)' if bastirilan else ''
        gonder(f'[{karar.upper()}] {kaynak}: tükenme hızı kısa={hiz_kisa:.1f} uzun={hiz_uzun:.1f}{ek}')
        self.son_gonderim[anahtar] = simdi
        self.kaydet()
        return karar


if __name__ == '__main__':
    y = Yonlendirici()
    # gonder yerine Telegram'a yazan kendi fonksiyonunuzu verin; örnekte print yeterli
    print(y.isle('mezunlar-yayin', hiz_kisa=20.0, hiz_uzun=15.0, gonder=print))

Gruplama ve sessizleştirme kuralları kodda şu şekilde yer alıyor:

  • Gruplama anahtarı kaynak ve karardan oluşur; aynı iş için "sayfa" ve "bilet" ayrı sayılır, farklı işler birbirini susturmaz.
  • Sessizlik süresi sayfa için 30 dakika, bilet için 6 saattir. Sessizlikte bastırılan tekrarlar sayılır ve bir sonraki mesaja eklenir; bilgi kaybolmaz, yalnızca ertelenir.
  • "Sus" kararı mesaj üretmez ama koşum kaydına yazılır; haftalık gözden geçirmede eşiğin doğru olup olmadığını bu satırlar gösterir.
  • Toparlanma mesajı kodda yok: hız eşik altına indiğinde tek bir "düzeldi" satırı göndermek, kapatılmamış alarm hissini ortadan kaldırır ve birkaç satırla eklenir.

Kimin nöbeti var, alarm insana ne yapar

Alarm tasarımı bir insan sorunudur; her sayfa çağrısı birinin dikkatini böler, gece geliyorsa uykusunu keser. Tek kişilik ekipte nöbetçi hep aynı kişidir, bu yüzden "sayfa" kararı yalnızca o an müdahale edilmezse kayba yol açacak durumlara ayrılmalı; kalan her şey sabah listesine gitmelidir.

Üç kararın her birine bir sorumluluk cümlesi bağlanmalı:

  • Sayfa: şu an bir şey yapmam gerekiyor ve yapabilirim. Mesajda ne yapılacağı tek satırla yazmalı; "hata oldu" değil, "tamponu kontrol et, gerekirse paketi yeniden gönder".
  • Bilet: yapılacak bir şey var ama bekleyebilir. Sabah listesi tek bir dosya ya da Telegram'da ayrı bir sohbet olabilir; önemli olan sayfa kanalından ayrı durmasıdır.
  • Sus: yalnızca veri noktası. Kimse okumaz, yalnızca sayılır.

Alarm gelip de yapılacak bir şey yoksa sorun alarmda değil eşiktedir; eşik düzeltilir, alarm görmezden gelinmez. Haftada bir kez bastırılan sayaçlara ve gelen sayfa çağrılarının kaçının gerçekten eylem gerektirdiğine bakmak, düzenin zamanla gürültüye kaymasını önler.

Statik bir sitede alarmın konusu sitenin kendisi değildir. Bu blog Cloudflare Pages üzerinde yayınlanır; arkasında Nginx ya da erişim günlüğü yoktur. Bütçeye bağlanan şey içeriği üreten otomasyonlardır: yayın zinciri, tampon doluluğu, yedekleme işi. Alarm bu işlerin koşum kayıtlarından türer.

Sonuç

Gürültüsüz alarmın yolu daha az ölçmek değil, kararı hata bütçesine bağlamaktır. Hedefi yazın, her koşumu kaydedin, tükenme hızını iki pencerede ölçün, sonucu üç karara indirin ve tekrarları gruplayın. Bu düzen kurulduktan sonra gelen her sayfa çağrısı bir anlam taşır ve görmezden gelinmez.

Kendi otomasyonlarınız için hedef ve eşik seçiminde ya da yönlendiriciyi Telegram'a bağlamada yardım isterseniz iletişim bölümünden yazabilirsiniz; sunduğum diğer hizmetler ana sayfada yer alıyor.