Unbound Cluster ile Yüksek Erişilebilir DNS Kurulumu

Prodüksiyonda tek bir DNS sunucusuyla çalışmak, sabah 3’te telefon beklemek demektir. Bunu zor yoldan öğrendim. Küçük bir yapıda bile DNS çöktüğünde her şey durur: web uygulamaları bağlanamaz, servisler birbirini bulamaz, izleme sistemleri kör olur. Unbound tabanlı yüksek erişilebilir bir DNS kümesi kurmak, hem performans hem de dayanıklılık açısından ciddi bir fark yaratıyor. Bu yazıda gerçek bir production ortamına uygun, iki node’lu Unbound küme kurulumunu adım adım ele alacağız.

Mimari Genel Bakış

Kuracağımız yapı şu bileşenlerden oluşuyor:

  • dns1 (192.168.10.11): Birincil Unbound DNS sunucusu
  • dns2 (192.168.10.12): İkincil Unbound DNS sunucusu
  • VIP (192.168.10.10): Keepalived tarafından yönetilen sanal IP adresi
  • Keepalived: VRRP protokolü üzerinden failover yönetimi
  • Unbound: Her iki node’da bağımsız çalışan resolver

İstemciler her zaman VIP adresine (192.168.10.10) bağlanır. Birincil sunucu düştüğünde Keepalived VIP’i ikincil sunucuya taşır. Bu geçiş genellikle 2-3 saniye içinde tamamlanır ve istemciler çoğunlukla bunu fark etmez.

Neden Unbound? BIND’a kıyasla daha az kaynak tüketiyor, konfigürasyonu daha anlaşılır ve özellikle recursive resolver rolü için optimize edilmiş. Küçük ve orta ölçekli altyapılarda BIND’ın otoriter DNS özelliklerine genellikle ihtiyaç duyulmaz. Sadece iç ağ çözümleme ve önbellekleme yapılacaksa Unbound çok daha temiz bir seçim.

Sunucu Hazırlığı

Her iki sunucuda da başlangıç hazırlıklarını yapalım. Ubuntu 22.04 üzerinde çalışıyoruz ama CentOS/RHEL için paket isimleri dışında büyük fark yok.

# Her iki sunucuda çalıştır
apt update && apt upgrade -y
apt install -y unbound unbound-host keepalived

# Unbound servisini şimdilik durdur, önce konfigürasyon yapalım
systemctl stop unbound
systemctl disable systemd-resolved

# systemd-resolved'un 53 portunu bloke etmemesi için
sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf
systemctl restart systemd-resolved

systemd-resolved meselesi Ubuntu’da klasik bir tuzak. Eğer bunu devre dışı bırakmazsanız Unbound 53 portunu bind edemez ve servis başlamaz. Yıllar önce bunu ilk kurulumda atladığımda neden çalışmadığını anlamak için saatler harcadım.

Unbound Ana Konfigürasyonu

Her iki sunucu için temel Unbound konfigürasyonunu oluşturalım. Bu konfigürasyon her iki node’da aynı olacak, yalnızca interface direktiflerinde node’a özgü IP adresleri kullanılacak.

dns1 için /etc/unbound/unbound.conf:

server:
    # Dinleme arayüzleri - hem VIP hem de node IP'si
    interface: 0.0.0.0
    port: 53

    # Erişim kontrolü
    access-control: 127.0.0.0/8 allow
    access-control: 192.168.10.0/24 allow
    access-control: 10.0.0.0/8 allow
    access-control: 0.0.0.0/0 refuse

    # Performans ayarları
    num-threads: 4
    msg-cache-size: 128m
    rrset-cache-size: 256m
    cache-min-ttl: 300
    cache-max-ttl: 86400

    # Gizlilik ve güvenlik
    hide-identity: yes
    hide-version: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    use-caps-for-id: yes
    qname-minimisation: yes

    # DNSSEC doğrulama
    auto-trust-anchor-file: "/var/lib/unbound/root.key"

    # Log ayarları
    verbosity: 1
    log-queries: no
    logfile: "/var/log/unbound/unbound.log"

    # Root hints
    root-hints: "/etc/unbound/root.hints"

    # Prefetch - popüler kayıtları önceden yenile
    prefetch: yes
    prefetch-key: yes

remote-control:
    control-enable: yes
    control-interface: 127.0.0.1
    control-port: 8953

Root hints dosyasını çekmeyi unutmayın. Bu dosya olmadan Unbound recursive resolution yapamaz:

curl -o /etc/unbound/root.hints https://www.internic.net/domain/named.root
# Root key oluştur
unbound-anchor -a /var/lib/unbound/root.key

İç Alan Adı Çözümlemesi

Gerçek dünyada her zaman iç alan adlarınız olur. Unbound’da bunları ayrı zone dosyaları olarak tanımlıyoruz. /etc/unbound/conf.d/ dizinini kullanmak konfigürasyonu düzenli tutar.

# /etc/unbound/conf.d/internal-zones.conf

# Forward zone - iç alan adları için
auth-zone:
    name: "internal.example.com"
    zonefile: "/etc/unbound/zones/internal.example.com.zone"

# Reverse zone
auth-zone:
    name: "10.168.192.in-addr.arpa"
    zonefile: "/etc/unbound/zones/192.168.10.rev"

Zone dosyasını oluşturalım:

mkdir -p /etc/unbound/zones

cat > /etc/unbound/zones/internal.example.com.zone << 'EOF'
$ORIGIN internal.example.com.
$TTL 300

@   IN  SOA dns1.internal.example.com. admin.example.com. (
        2024010101  ; Serial
        3600        ; Refresh
        900         ; Retry
        604800      ; Expire
        300 )       ; Minimum TTL

; Name servers
@   IN  NS  dns1.internal.example.com.
@   IN  NS  dns2.internal.example.com.

; DNS sunucuları
dns1    IN  A   192.168.10.11
dns2    IN  A   192.168.10.12
dns     IN  A   192.168.10.10

; Uygulama sunucuları
web1    IN  A   192.168.10.21
web2    IN  A   192.168.10.22
db1     IN  A   192.168.10.31
db2     IN  A   192.168.10.32
app1    IN  A   192.168.10.41

; Load balancer VIP'leri
web     IN  A   192.168.10.20
db      IN  A   192.168.10.30
EOF

Ana konfigürasyona include direktifini ekleyelim:

echo 'include: "/etc/unbound/conf.d/*.conf"' >> /etc/unbound/unbound.conf

Keepalived Konfigürasyonu

Keepalived kurulumu kritik nokta. VRRP’yi doğru yapılandırmazsanız split-brain durumuna düşersiniz: her iki node da VIP’i sahiplenmeye çalışır ve istemciler tutarsız yanıtlar alır.

dns1 için /etc/keepalived/keepalived.conf (MASTER):

global_defs {
    router_id DNS_CLUSTER
    script_user root
    enable_script_security
}

vrrp_script check_unbound {
    script "/usr/local/bin/check-unbound.sh"
    interval 2
    weight -30
    fall 3
    rise 2
}

vrrp_instance DNS_VIP {
    state MASTER
    interface eth0
    virtual_router_id 51
    priority 110
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass dns_cluster_2024
    }

    virtual_ipaddress {
        192.168.10.10/24 dev eth0
    }

    track_script {
        check_unbound
    }

    notify_master "/usr/local/bin/notify-master.sh"
    notify_backup "/usr/local/bin/notify-backup.sh"
}

dns2 için /etc/keepalived/keepalived.conf (BACKUP):

Aynı konfigürasyon, yalnızca iki satır farklı:

  • state BACKUP
  • priority 100

Unbound sağlık kontrolü scripti:

cat > /usr/local/bin/check-unbound.sh << 'EOF'
#!/bin/bash
# Unbound'un gerçekten DNS sorgusu yanıtlayıp yanıtlamadığını test et
result=$(dig +short +time=2 +tries=1 @127.0.0.1 google.com A 2>/dev/null)

if [ -z "$result" ]; then
    logger -t keepalived "Unbound health check FAILED - no response"
    exit 1
fi

# unbound-control ile servis durumunu da kontrol et
if ! unbound-control status > /dev/null 2>&1; then
    logger -t keepalived "Unbound health check FAILED - control unreachable"
    exit 1
fi

exit 0
EOF

chmod +x /usr/local/bin/check-unbound.sh

Bu sağlık kontrolü ikili bir yaklaşım kullanıyor: hem gerçek bir DNS sorgusu atıyor hem de unbound-control ile servise erişilebilirliği doğruluyor. Sadece process varlığını kontrol etmek yetmez; Unbound bazen çalışıyor görünüp sorgu yanıtlamayabiliyor, özellikle hafıza baskısı altında.

Failover Notify Scriptleri

Failover anında ne olduğunu loglamak ve gerekirse ek aksiyonlar almak için notify scriptleri kullanıyoruz:

cat > /usr/local/bin/notify-master.sh << 'EOF'
#!/bin/bash
LOGFILE="/var/log/keepalived-transitions.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

echo "$TIMESTAMP - Bu node MASTER oldu (VIP: 192.168.10.10)" >> $LOGFILE

# Unbound cache'i temizle - yeni master temiz başlasın
unbound-control flush_zone .

# İsteğe bağlı: Slack veya webhook bildirimi
# curl -s -X POST -H 'Content-type: application/json' 
#   --data '{"text":"DNS MASTER failover: dns1 -> aktif"}' 
#   https://hooks.slack.com/services/xxx/yyy/zzz

logger -t keepalived "TRANSITION: Bu node DNS MASTER oldu"
EOF

cat > /usr/local/bin/notify-backup.sh << 'EOF'
#!/bin/bash
LOGFILE="/var/log/keepalived-transitions.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

echo "$TIMESTAMP - Bu node BACKUP moduna geçti" >> $LOGFILE
logger -t keepalived "TRANSITION: Bu node DNS BACKUP moduna geçti"
EOF

chmod +x /usr/local/bin/notify-master.sh /usr/local/bin/notify-backup.sh

Firewall Kuralları

Keepalived’ın VRRP trafiği için IP protokol 112’yi açmanız gerekiyor. Bunu unutursanız node’lar birbirini göremez ve her ikisi de MASTER olmaya çalışır.

# UFW kullanıyorsanız
ufw allow from 192.168.10.11 to any proto 112
ufw allow from 192.168.10.12 to any proto 112
ufw allow 53/tcp
ufw allow 53/udp
ufw allow 8953/tcp  # Unbound control (sadece güvenli kaynaklardan)

# iptables kullanıyorsanız
iptables -A INPUT -p 112 -j ACCEPT
iptables -A OUTPUT -p 112 -j ACCEPT
iptables -A INPUT -p udp --dport 53 -j ACCEPT
iptables -A INPUT -p tcp --dport 53 -j ACCEPT

Servisleri Başlatma ve Doğrulama

Her şeyi sırayla ayağa kaldıralım:

# Her iki node'da Unbound'u başlat
systemctl enable unbound
systemctl start unbound

# Unbound'un doğru çalıştığını doğrula
unbound-control status
dig @127.0.0.1 google.com
dig @127.0.0.1 internal.example.com. dns1

# Keepalived'ı başlat
systemctl enable keepalived
systemctl start keepalived

# VIP'in dns1'de olduğunu doğrula
ip addr show dev eth0 | grep 192.168.10.10

# dns2'den kontrol - VIP burada olmamalı
ip addr show dev eth0 | grep 192.168.10.10

Failover testini elle yapın. Bu adımı atlama. Production’a almadan önce failover’ın gerçekten çalıştığını görmeniz lazım:

# dns1'de - Keepalived'ı durdur ve dns2'nin VIP'i alıp almadığını gör
systemctl stop keepalived

# dns2'de kontrol et
ip addr show dev eth0  # 192.168.10.10 burada görünmeli

# dns2'den VIP üzerinden sorgu yap
dig @192.168.10.10 google.com

# dns1'i geri getir
systemctl start keepalived
# Priority 110 olduğu için dns1 VIP'i geri alacak

İzleme ve Gözlemlenebilirlik

Kümeyi kurduktan sonra izlemeye almadan bırakmak olmaz. Unbound istatistiklerini Prometheus’a göndermek için küçük bir script işe yarıyor:

cat > /usr/local/bin/unbound-stats-exporter.sh << 'EOF'
#!/bin/bash
# Unbound istatistiklerini logla - cron ile her dakika çalıştır

STATS=$(unbound-control stats_noreset)
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

TOTAL_QUERIES=$(echo "$STATS" | grep "total.num.queries=" | cut -d= -f2)
CACHE_HITS=$(echo "$STATS" | grep "total.num.cachehits=" | cut -d= -f2)
CACHE_MISS=$(echo "$STATS" | grep "total.num.cachemiss=" | cut -d= -f2)
RECURSION=$(echo "$STATS" | grep "total.num.recursivereplies=" | cut -d= -f2)

echo "$TIMESTAMP queries=$TOTAL_QUERIES hits=$CACHE_HITS miss=$CACHE_MISS recursive=$RECURSION" 
    >> /var/log/unbound/stats.log

# Cache hit oranı düşükse uyar
if [ -n "$TOTAL_QUERIES" ] && [ "$TOTAL_QUERIES" -gt 100 ]; then
    HIT_RATE=$(echo "scale=2; $CACHE_HITS * 100 / $TOTAL_QUERIES" | bc)
    if (( $(echo "$HIT_RATE < 60" | bc -l) )); then
        logger -t unbound-monitor "UYARI: Cache hit oranı düşük: %$HIT_RATE"
    fi
fi
EOF

chmod +x /usr/local/bin/unbound-stats-exporter.sh
echo "* * * * * root /usr/local/bin/unbound-stats-exporter.sh" > /etc/cron.d/unbound-stats

Zabbix veya Nagios kullanıyorsanız harici DNS kontrolü de ekleyin. Sadece sunucunun kendi loopback’inden değil, dışarıdan VIP adresine atılan sorguları izlemek daha gerçekçi bir resim veriyor.

Yaygın Sorunlar ve Çözümleri

Split-brain durumu: Her iki node da VIP’e sahip görünüyorsa önce firewall kurallarını kontrol edin. VRRP paketleri geçemiyor olabilir. tcpdump -i eth0 proto 112 komutu ile VRRP trafiğini izleyin.

Unbound başlamıyor: Çoğunlukla port çakışması. ss -tlunp | grep :53 ile hangi process 53’ü tuttuğuna bakın. systemd-resolved sık suçludur.

Cache tutarsızlıkları: Her iki node bağımsız cache tutar. Failover sonrası yeni master’da cache boş olabilir ve kısa süreliğine performans düşüşü yaşanabilir. Notify scriptindeki flush_zone bunu temizlemez, tersine kasıtlı olarak temiz başlatır. Eğer cache sürekliliği kritikse, Unbound’un remote-control API’si üzerinden cache dump/restore yapan özel bir script yazılabilir ama çoğu durumda bu gerekli olmuyor.

Yüksek latency: unbound-control stats çıktısındaki histogram.* değerlerine bakın. num-threads değerini CPU çekirdek sayısına eşitleyin ve msg-cache-size ile rrset-cache-size değerlerini sunucunun RAM’ine göre artırın.

Zone Senkronizasyonu

İki node arasında zone dosyalarını senkronize tutmak için basit bir rsync + inotify çözümü kullanabilirsiniz. Daha kurumsal bir yaklaşım için Git reposu ve CI/CD pipeline da kurulabilir.

# dns1'de - zone değişikliklerini dns2'ye push et
cat > /usr/local/bin/sync-zones.sh << 'EOF'
#!/bin/bash
ZONES_DIR="/etc/unbound/zones"
REMOTE_HOST="192.168.10.12"
REMOTE_USER="root"

rsync -av --delete 
    -e "ssh -i /root/.ssh/dns-sync-key -o StrictHostKeyChecking=yes" 
    "$ZONES_DIR/" 
    "${REMOTE_USER}@${REMOTE_HOST}:${ZONES_DIR}/"

if [ $? -eq 0 ]; then
    # Uzak sunucuda Unbound konfigürasyonunu yeniden yükle
    ssh -i /root/.ssh/dns-sync-key 
        "${REMOTE_USER}@${REMOTE_HOST}" 
        "unbound-control reload"
    logger -t dns-sync "Zone senkronizasyonu başarılı"
else
    logger -t dns-sync "HATA: Zone senkronizasyonu başarısız"
fi
EOF

chmod +x /usr/local/bin/sync-zones.sh

SSH key’i parola olmadan çalışacak şekilde oluşturup yalnızca bu komutla kısıtlayın:

ssh-keygen -t ed25519 -f /root/.ssh/dns-sync-key -N ""
# dns2'de authorized_keys'e kısıtlı olarak ekle:
# command="rsync --server -av --delete . /etc/unbound/zones/" ssh-ed25519 AAAA...

Sonuç

Bu kurulumla elde ettiğiniz şey: istemcilerin tek bir IP adresine bağlandığı, arka planda iki bağımsız Unbound instance’ının çalıştığı ve herhangi biri düştüğünde otomatik olarak devir teslim yapan bir DNS altyapısı. Tek bir node arızasına karşı tam korumalı, önbellekleme ile hızlı ve iç alan adı desteğiyle eksiksiz.

Buradan sonraki adımlar altyapınıza göre değişebilir. Çok bölgeli bir yapınız varsa her bölgeye bir çift node koymak ve Anycast ile yönlendirmek düşünülebilir. Yoğun trafik altında num-threads ve önbellek boyutlarını profiling yaparak ayarlamak önemli. DNSSEC imzalama gerekiyorsa ayrı otoriter sunucular devreye girmeli; Unbound’u resolver rolünde tutmak daha sağlıklı.

DNS kümesinin aylarca kimse fark etmeden çalışması en iyi başarı göstergesi. İyi kurulmuş altyapı sessizdir.

Bir yanıt yazın

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