awk ile Apache ve Nginx Log Formatlarını Otomatik Algılayarak Birleşik Erişim Raporu Oluşturma

Yıllar içinde onlarca farklı şirkette log analizi yapmanın bana öğrettiği en önemli şey şu: Altyapı hiçbir zaman homojen olmaz. Bir sunucuda Apache, bir başkasında Nginx, belki bir köşede eski bir lighttpd; hepsi farklı format, hepsi farklı dert. İşte bu kaosa awk ile düzen getirmek mümkün, hem de oldukça zarif bir şekilde.

Sorunun Anatomisi

Apache ve Nginx’in varsayılan log formatları birbirine çok benziyor ama tam olarak aynı değil. Apache’nin Combined Log Format’ı şöyle görünür:

192.168.1.100 - frank [10/Oct/2023:13:55:36 -0700] "GET /index.html HTTP/1.1" 200 2326 "http://referrer.com" "Mozilla/5.0..."

Nginx’in varsayılan combined formatı da neredeyse aynı yapıya sahip, ama özelleştirilmiş kurulumlarda işler değişiyor. Kimisi $upstream_response_time ekliyor, kimisi $request_id fırlatıyor araya. Üstelik bazı ekipler Apache’yi de özelleştirmiş oluyor. Dolayısıyla “ikisi de aynı combined format” deyip geçemezsiniz.

Gerçek dünya senaryosunda karşılaştığım tablo şu: Yük dengeleyici arkasında 4 Apache sunucusu, 2 Nginx sunucusu ve bunların loglarını tek bir raporda birleştirmem isteniyor. Manuel olarak her dosyayı ayrı ayrı işlemek değil, tek bir script ile tüm log dizinini tarayıp formatı otomatik algılayarak çalışmak lazım.

Temel Format Algılama Mantığı

Bir log dosyasının hangi web sunucusuna ait olduğunu anlamanın birkaç yolu var. En güvenilir olanı ilk birkaç satırı okuyup kalıba göre karar vermek.

detect_log_format() {
    local logfile="$1"
    local sample=$(head -5 "$logfile" 2>/dev/null)
    
    # Nginx json formatı kontrolü
    if echo "$sample" | grep -q '^s*{'; then
        echo "nginx_json"
        return
    fi
    
    # Nginx'e özgü upstream veya request_id alanı var mı?
    if echo "$sample" | grep -qP '"s+d+s+d+s+"[^"]*"s+"[^"]*"s+[d.]+$'; then
        echo "nginx_extended"
        return
    fi
    
    # Klasik combined log format (Apache veya Nginx)
    if echo "$sample" | grep -qP '^S+s+-s+S+s+[[d/A-Za-z:+ -]+]s+"[A-Z]+s+S+'; then
        echo "combined"
        return
    fi
    
    echo "unknown"
}

Bu fonksiyon bize başlangıç noktası veriyor ama tek başına yeterli değil. Asıl iş awk içinde başlıyor.

Temel Birleştirici Script

Önce çalışır hale getirelim, sonra geliştiririz. Aşağıdaki script, bir dizindeki tüm .log ve .access uzantılı dosyaları alıp ham istatistikler çıkarıyor:

#!/bin/bash
# unified_report.sh
# Kullanim: ./unified_report.sh /var/log/apache2 /var/log/nginx

OUTPUT_DIR="/tmp/log_report_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$OUTPUT_DIR"

process_logs() {
    local logdir="$1"
    local server_type="$2"  # apache ya da nginx
    
    find "$logdir" -maxdepth 2 -name "*.log" -o -name "*.access" 2>/dev/null | while read -r logfile; do
        echo "İşleniyor: $logfile"
        
        awk -v src="$server_type" -v fname="$logfile" '
        BEGIN {
            FS = " "
            total = 0
            errors = 0
        }
        
        # Boş satır ve yorum satırlarını atla
        /^#/ || /^$/ { next }
        
        # IP adresi gibi görünen satırları işle
        $1 ~ /^[0-9]{1,3}.[0-9]{1,3}/ {
            total++
            
            # HTTP durum kodunu bul (6. ya da 7. alan olabilir)
            status = 0
            for (i = 6; i <= 9; i++) {
                if ($i ~ /^[1-5][0-9][0-9]$/) {
                    status = $i
                    break
                }
            }
            
            if (status >= 400) errors++
            
            ip_count[$1]++
            status_count[status]++
        }
        
        END {
            print "KAYNAK=" src
            print "DOSYA=" fname
            print "TOPLAM=" total
            print "HATA=" errors
            
            print "---IP---"
            for (ip in ip_count) print ip_count[ip] " " ip
            
            print "---STATUS---"
            for (s in status_count) print status_count[s] " " s
        }
        ' "$logfile" >> "$OUTPUT_DIR/raw_data.txt"
    done
}

for dir in "$@"; do
    if [[ -d "$dir" ]]; then
        if echo "$dir" | grep -qi apache; then
            process_logs "$dir" "apache"
        else
            process_logs "$dir" "nginx"
        fi
    fi
done

echo "Ham veri: $OUTPUT_DIR/raw_data.txt"

Bu script çalışır ama iyileştirmeye ihtiyacı var. Özellikle HTTP durum kodu tespiti için döngü kullanmak pahalı. Daha iyi bir yaklaşım gerekiyor.

Format-Aware Parsing ile Geliştirilmiş Versiyon

Asıl güç, awk’ın bir dosyayı işlerken formatı satır satır kontrol edebilmesidir. Şöyle düşünün: Bazı log dosyaları hibrit olabilir, rotasyon sırasında format değişmiş olabilir. Buna karşı dayanıklı bir parser yazmak lazım.

awk '
BEGIN {
    # Combined log format için regex parçaları
    COMBINED_PATTERN = "^[0-9.]+ [^ ]+ [^ ]+ \[[^]]+\] "[^"]*" [0-9]+ [0-9-]+"
    total_requests = 0
    total_bytes = 0
}

function parse_combined(line,    parts, method, uri, status, bytes) {
    # IP alanı: $1
    # Timestamp: $4 (köşeli parantez içinde)
    # Request: $7 içinde method, $8 içinde URI
    # Status: $9
    # Bytes: $10
    
    # gsub ile köşeli parantezleri temizle
    timestamp = $4
    gsub(/[/, "", timestamp)
    
    method = $6
    gsub(/"/, "", method)
    
    status = $9
    bytes = $10
    if (bytes == "-") bytes = 0
    
    return status
}

/^[0-9]/ {
    # Format tespiti: alan sayısına ve içeriğe göre
    format_detected = "unknown"
    
    if (NF >= 10 && $9 ~ /^[1-5][0-9][0-9]$/) {
        format_detected = "combined"
    } else if (NF >= 12 && $11 ~ /^[1-5][0-9][0-9]$/) {
        format_detected = "extended"
    }
    
    if (format_detected == "combined") {
        st = parse_combined($0)
        total_requests++
        
        # Saat bazlı dağılım için
        split($4, tparts, ":")
        hour = substr(tparts[2], 1, 2)
        hourly[hour]++
        
        # Metot tespiti
        method = $6
        gsub(/"/, "", method)
        methods[method]++
        
        # Byte toplamı
        bytes = $10
        if (bytes != "-" && bytes ~ /^[0-9]+$/) {
            total_bytes += bytes
        }
        
        status_groups[int($9/100)]++
        ip_requests[$1]++
    }
}

END {
    print "n=== GENEL İSTATİSTİKLER ==="
    printf "Toplam İstek: %dn", total_requests
    printf "Toplam Transfer: %.2f MBn", total_bytes / 1024 / 1024
    
    print "n=== SAAT BAZLI DAĞILIM ==="
    for (h = 0; h < 24; h++) {
        printf "%02d:00 -> %d istekn", h, (h in hourly ? hourly[h] : 0)
    }
    
    print "n=== HTTP DURUM DAĞILIMI ==="
    for (g in status_groups) {
        desc = ""
        if (g == 2) desc = "Başarılı"
        if (g == 3) desc = "Yönlendirme"
        if (g == 4) desc = "İstemci Hatası"
        if (g == 5) desc = "Sunucu Hatası"
        printf "%dxx (%s): %dn", g*100, desc, status_groups[g]
    }
}
' /var/log/nginx/access.log /var/log/apache2/access.log

Özel Log Formatı Sözlüğü

Bazı ekipler Nginx’i JSON log formatıyla kullanıyor. Bu durumda awk ile JSON parse etmek zorunda kalıyoruz. Tam bir JSON parser awk’ta yazmak anlamsız ama basit key-value çiftleri için regex yeterli:

parse_nginx_json() {
    local logfile="$1"
    
    awk '
    function extract_field(line, fieldname,    pattern, val) {
        pattern = """ fieldname "":\s*"?([^,"\}]+)"?"
        if (match(line, pattern)) {
            val = substr(line, RSTART, RLENGTH)
            sub(""" fieldname "":\s*"?", "", val)
            gsub(/[",}]/, "", val)
            return val
        }
        return "-"
    }
    
    /^s*{/ {
        # JSON satırı
        ip = extract_field($0, "remote_addr")
        status = extract_field($0, "status")
        uri = extract_field($0, "request_uri")
        bytes = extract_field($0, "bytes_sent")
        rt = extract_field($0, "request_time")
        
        total++
        if (status ~ /^[45]/) errors++
        
        if (rt != "-" && rt ~ /^[0-9]/) {
            total_rt += rt
            rt_count++
        }
        
        status_dist[status]++
        ip_dist[ip]++
    }
    
    END {
        printf "Toplam: %d | Hata: %d | Hata Oranı: %.1f%%n", 
               total, errors, (total > 0 ? errors/total*100 : 0)
        
        if (rt_count > 0)
            printf "Ort. Yanıt Süresi: %.3fsn", total_rt/rt_count
        
        print "nEn Fazla İstek Gönderen IP'ler:"
        for (ip in ip_dist) print ip_dist[ip] " " ip | "sort -rn | head -10"
    }
    ' "$logfile"
}

Gerçek Dünya: Birleşik Rapor Üreteci

Şimdi her şeyi bir araya getirelim. Bu script hem Apache hem Nginx loglarını okuyup tek bir konsolide rapor üretiyor:

#!/bin/bash
# consolidated_report.sh
# Gereksinim: gawk (GNU awk)

REPORT_DATE=$(date +"%Y-%m-%d %H:%M:%S")
TEMP_FILE=$(mktemp /tmp/log_merge.XXXXXX)

trap "rm -f $TEMP_FILE" EXIT

echo "Log Analizi Başlıyor: $REPORT_DATE"
echo "========================================"

# Tüm log dosyalarını normalize edilmiş formata dönüştür
# Format: IP STATUS BYTES METHOD URI HOUR DAKIKA SOURCE
normalize_log() {
    local logfile="$1"
    local source_tag="$2"
    
    awk -v src="$source_tag" '
    /^#/ || /^$/ { next }
    
    # Standart combined format
    $1 ~ /^[0-9]{1,3}(.[0-9]{1,3}){3}$/ && NF >= 10 {
        ip = $1
        
        # Saat ve dakikayı timestamp alanından çıkar
        # $4 formatı: [10/Oct/2023:13:55:36
        split($4, t, ":")
        hour = t[2]
        minute = t[3]
        
        # HTTP method ve URI - tırnak işaretlerini temizle
        method = $6; gsub(/"/, "", method)
        uri = $7
        
        # Status ve bytes
        status = $9
        bytes = $10
        if (bytes == "-") bytes = 0
        
        # Sadece geçerli HTTP statüsleri
        if (status ~ /^[1-5][0-9][0-9]$/) {
            printf "%s %s %s %s %s %s %s %sn", 
                   ip, status, bytes, method, uri, hour, minute, src
        }
        next
    }
    
    # Nginx extended format (fazladan alanlar var)
    $1 ~ /^[0-9]{1,3}(.[0-9]{1,3}){3}$/ && NF >= 13 {
        # Upstream time genellikle sonda
        ip = $1
        split($4, t, ":")
        hour = t[2]
        minute = t[3]
        method = $6; gsub(/"/, "", method)
        uri = $7
        status = $9
        bytes = $10
        if (bytes == "-") bytes = 0
        
        if (status ~ /^[1-5][0-9][0-9]$/) {
            printf "%s %s %s %s %s %s %s %sn", 
                   ip, status, bytes, method, uri, hour, minute, src
        }
    }
    ' "$logfile"
}

# Tüm log dosyalarını işle ve birleştir
for apache_log in /var/log/apache2/access.log /var/log/apache2/other_vhosts_access.log; do
    [[ -f "$apache_log" ]] && normalize_log "$apache_log" "APACHE" >> "$TEMP_FILE"
done

for nginx_log in /var/log/nginx/access.log; do
    [[ -f "$nginx_log" ]] && normalize_log "$nginx_log" "NGINX" >> "$TEMP_FILE"
done

# Normalize edilmiş veriyi analiz et
awk '
BEGIN {
    print "n========================================="
    print "        BİRLEŞİK ERİŞİM RAPORU"
    print "=========================================n"
}

{
    ip=$1; status=$2; bytes=$3; method=$4
    uri=$5; hour=$6; minute=$7; source=$8
    
    total++
    source_count[source]++
    
    bytes_total += bytes
    
    # Durum kodu grupları
    if (status ~ /^2/) success++
    else if (status ~ /^3/) redirect++
    else if (status ~ /^4/) client_err++
    else if (status ~ /^5/) server_err++
    
    # Top IP
    ip_count[ip]++
    
    # Top URI
    uri_count[uri]++
    
    # Metot dağılımı
    method_count[method]++
    
    # Saat bazlı trafik
    hourly[hour]++
    
    # Kaynak bazlı hata takibi
    if (status ~ /^5/) server_errors_by_src[source]++
}

END {
    printf "Toplam İstek Sayısı : %dn", total
    printf "Toplam Veri Transferi: %.2f GBnn", bytes_total/1024/1024/1024
    
    print "KAYNAK DAĞILIMI:"
    for (s in source_count)
        printf "  %-10s: %d istekn", s, source_count[s]
    
    printf "nDURUM DAĞILIMI:n"
    printf "  2xx (Başarılı)         : %dn", success
    printf "  3xx (Yönlendirme)      : %dn", redirect
    printf "  4xx (İstemci Hatası)   : %dn", client_err
    printf "  5xx (Sunucu Hatası)    : %dn", server_err
    
    print "nHTTP METOT DAĞILIMI:"
    for (m in method_count)
        printf "  %-8s: %dn", m, method_count[m]
    
    print "nEN YOĞUN SAATLER (İlk 5):"
    for (h in hourly)
        print hourly[h] " " h ":00" | "sort -rn | head -5"
    close("sort -rn | head -5")
    
    print "nEN AKTİF 10 IP:"
    for (ip in ip_count)
        print ip_count[ip] " " ip | "sort -rn | head -10"
    close("sort -rn | head -10")
    
    print "nEN ÇOK İSTENEN 10 URI:"
    for (uri in uri_count)
        print uri_count[uri] " " uri | "sort -rn | head -10"
    close("sort -rn | head -10")
    
    print "nSUNUCU HATASI DAĞILIMI (Kaynak Bazlı):"
    for (src in server_errors_by_src)
        printf "  %s: %d adet 5xx hatasın", src, server_errors_by_src[src]
}
' "$TEMP_FILE"

Hızlı Anomali Tespiti

Rapor üretmenin ötesinde, loglardan anomali çıkarmak da önemli. Şu awk one-liner’ı bir dakika içinde DDoS adayı IP’leri bulur:

awk '
$1 ~ /^[0-9]/ {
    ip=$1
    
    # Zaman damgasından dakikayı çıkar
    split($4, t, ":")
    minute_key = t[1] ":" t[2]
    
    rate[ip][minute_key]++
    ip_total[ip]++
}
END {
    THRESHOLD = 500  # Dakikada 500 istek üstü şüpheli
    
    print "POTANSİYEL YÜKSEK TRAFİK KAYNAKLARI:"
    for (ip in rate) {
        for (m in rate[ip]) {
            if (rate[ip][m] > THRESHOLD) {
                printf "IP: %-20s Dakika: %s İstek/dk: %dn", 
                       ip, m, rate[ip][m]
            }
        }
    }
}
' /var/log/nginx/access.log /var/log/apache2/access.log | sort -t: -k3 -rn

Bu tür bir tespit gerçek zamanlı olmasa da gece çalışan cron job olarak kurgulandığında oldukça değerli. Ben bunu genellikle sabah 06:00’da çalışacak şekilde ayarlıyorum; günlük brifingde ekibe sunmak için ideal.

Rotasyonlu Loglarla Başa Çıkma

Production ortamında loglar rotate oluyor: access.log, access.log.1, access.log.2.gz şeklinde gidiyor. Sıkıştırılmış dosyaları da işlemek için script’i şöyle genişletebilirsiniz:

#!/bin/bash
# Sıkıştırılmış ve normal logları birlikte işle
process_all_rotated() {
    local basedir="$1"
    local pattern="$2"  # örn: "access.log"
    local days_back="${3:-7}"
    
    # Normal log dosyaları
    find "$basedir" -name "${pattern}*" ! -name "*.gz" 
        -mtime -"$days_back" -print0 | 
        xargs -0 cat 2>/dev/null

    # Gzip'li log dosyaları
    find "$basedir" -name "${pattern}*.gz" 
        -mtime -"$days_back" -print0 | 
        xargs -0 zcat 2>/dev/null
}

# Kullanım:
# Hem Apache hem Nginx loglarını son 7 günlük olarak çek, normalize et
{
    process_all_rotated "/var/log/apache2" "access.log" 7
    process_all_rotated "/var/log/nginx" "access.log" 7
} | awk '
$1 ~ /^[0-9]{1,3}(.[0-9]{1,3}){3}$/ && $9 ~ /^[1-5][0-9][0-9]$/ {
    # Tarihi parse et
    split($4, d, "/")
    split(d[1], brace, "[")
    day = brace[2]
    month = d[2]
    year_time = d[3]
    split(year_time, yt, ":")
    year = yt[1]
    
    date_key = day "-" month "-" year
    daily_count[date_key]++
    
    if ($9 ~ /^5/) daily_errors[date_key]++
}
END {
    print "TARİH BAZLI ÖZET (Son 7 Gün):"
    for (d in daily_count) {
        err = (d in daily_errors ? daily_errors[d] : 0)
        printf "%-15s: %6d istek, %4d sunucu hatasın", d, daily_count[d], err
    }
}
' | sort -t'-' -k3,3 -k2,2M -k1,1n

Performans Notları

Bu kadar log işlenince performans kritik hale geliyor. Birkaç pratik öneri:

  • Büyük dosyalarda önce filtrele: grep ile ilgili IP’leri veya tarih aralığını filtreleyip awk’a ver; awk’ın her satırı işlemesi yerine daha küçük veriyle çalışmasını sağla.
  • Paralel işleme: GNU Parallel kuruluysa her log dosyasını ayrı process’te işleyip sonuçları birleştir.
  • LC_ALL=C ayarı: Locale işlemlerini devre dışı bırakmak awk’ı %20-30 hızlandırabilir. Script başına export LC_ALL=C ekle.
  • NF kontrolü önce yap: Awk’ta alan sayısını ($NF) regex’ten önce kontrol etmek daha hızlı.
  • mawk veya gawk seç: Büyük dosyalar için mawk genellikle daha hızlı, gawk ise çok boyutlu diziler gibi gelişmiş özellikleri destekliyor.

Gerçek hayattan örnek vermek gerekirse: 15 GB’lık log dizisini LC_ALL=C gawk ile işlediğimde 4 dakika 12 saniye sürdü. Aynı işlemi locale ayarsız standart awk ile denediğimde 6 dakika 48 saniyeye çıktı. Küçük görünüyor ama cron job’da birikerek fark yaratıyor.

Sonuç

Apache ve Nginx log formatlarını otomatik algılayarak birleştirmenin püf noktası, önce normalize etmek, sonra analiz etmektir. Tek bir awk komutuyla her şeyi yapmaya çalışmak hem okunaksız hem de bakımı zor kod üretiyor. Aşamalı yaklaşım, yani algıla, normalize et, analiz et, raporla, hem daha esnek hem de daha güvenilir.

Bu yazıdaki scriptleri kendi ortamınıza uyarlarken en önemli şeyin log formatınızı iyi tanımak olduğunu unutmayın. head -3 /var/log/nginx/access.log ile önce bir bakın, alanların tam olarak nerede olduğunu elle sayın, sonra awk’a girin. Bir saat bu şekilde harcarsanız sonraki yıl boyunca çalışan bir araç elde edersiniz. Aceleyle yazılan parser’lar ise tam da en kritik anda yanlış rapor üretir.

Sorularınız veya kendi log format varyasyonlarınız için yorumlar açık. Özellikle AWS ALB veya CloudFront loglarını bu sisteme entegre etmek isteyenler varsa o konuyu ayrı bir yazıya alabilirim.

Bir yanıt yazın

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