awk ile Apache ve Nginx Log Formatları Arasında Otomatik Dönüşüm ve Normalizasyon

Farklı log analiz araçlarıyla çalışırken en çok başım ağrıyan konulardan biri buydu: bir sunucuyu Apache’den Nginx’e taşıyorsunuz, log pipeline’ınız var, Elasticsearch’e ya da başka bir yere besliyorsunuz, ve birdenbire her şey çöküyor. Format farklı, alan sırası farklı, timestamp formatı farklı. Ya da tam tersi, legacy bir Apache sunucusundan Nginx’e geçiş yapıyorsunuz ve eski loglarla yeni logları aynı formatta analiz etmeniz gerekiyor. İşte bu noktada awk hayat kurtarıcı oluyor.

Bu yazıda Apache Combined Log Format ile Nginx’in varsayılan log formatı arasında birebir dönüşüm yapan, production ortamında kullanılabilir düzeyde awk scriptleri geliştireceğiz.

Log Formatlarını Tanıyalım

Önce neyle uğraştığımızı netleştirelim.

Apache Combined Log Format şöyle görünür:

192.168.1.100 - frank [10/Oct/2024:13:55:36 -0700] "GET /index.html HTTP/1.1" 200 2326 "http://example.com/start.html" "Mozilla/5.0 (Windows NT 10.0)"

Alanlar sırasıyla şöyle:

  • %h: Remote host (IP adresi)
  • %l: Remote logname (genellikle tire)
  • %u: Remote user
  • %t: Zaman damgası (köşeli parantez içinde)
  • %r: İstek satırı (tırnak içinde)
  • %>s: HTTP durum kodu
  • %b: Gönderilen byte miktarı
  • %{Referer}i: Referer header (tırnak içinde)
  • %{User-Agent}i: User-Agent header (tırnak içinde)

Nginx varsayılan log_format ise şöyle:

192.168.1.100 - frank [10/Oct/2024:13:55:36 +0300] "GET /index.html HTTP/1.1" 200 2326 "http://example.com/start.html" "Mozilla/5.0 (Windows NT 10.0)"

Yüzeysel bakıldığında benzer görünüyorlar, ama şeytanın ayrıntıda gizlendiğini bir kez daha hatırlayalım. Timezone offset farkları, bazı Nginx kurulumlarında $body_bytes_sent yerine $bytes_sent kullanımı, özel Nginx log formatları, tırnak karakterleri içindeki escape farkları… Bunların hepsi parse ederken kafanızı şişirebilir.

Ayrıca pratikte sıklıkla karşılaşılan bir başka Nginx formatı daha var: JSON formatı. Modern kurulumların çoğu artık buna geçiyor. Onu da ele alacağız.

Temel Parse Mantığı

awk‘ın log parsing için bu kadar uygun olmasının nedeni, alan ayırıcıları konusunda esnek olması. Ama Apache/Nginx logları için standart boşluk ayırıcısı yetmiyor, çünkü tırnak içindeki alanlar boşluk içerebilir. Bunu aşmak için FPAT (Field Pattern) yaklaşımını kullanacağız.

# FPAT ile Apache log parse etme - temel örnek
awk 'BEGIN {
    FPAT = "([^ ]+)|(\[[^\]]+\])|("[^"]+")"
}
{
    ip        = $1
    ident     = $2
    user      = $3
    timestamp = $4
    request   = $5
    status    = $6
    bytes     = $7
    referer   = $8
    useragent = $9
    
    print ip, status, bytes
}' /var/log/apache2/access.log

Bu FPAT kalıbı üç alternatifi kapsar:

  • ([^ ]+): Boşluk içermeyen normal alanlar
  • (\[[^\]]+\]): Köşeli parantez içindeki alanlar (timestamp)
  • (“[^”]+”): Çift tırnak içindeki alanlar (request, referer, user-agent)

Apache’den Nginx’e Dönüşüm

Şimdi gerçek dönüşüm scriptine geçelim. Diyelim ki Apache sunucunuzu Nginx’e taşıdınız ama eski Apache loglarınızı Nginx formatında normalize etmeniz gerekiyor. Belki merkezi bir log analiz sistemine besliyorsunuz ve her şeyin aynı formatta olmasını istiyorsunuz.

#!/usr/bin/awk -f
# apache_to_nginx.awk
# Kullanim: awk -f apache_to_nginx.awk apache_access.log > nginx_format.log

BEGIN {
    FPAT = "([^ ]+)|(\[[^\]]+\])|("[^"]+")"
    OFS = " "
}

{
    # Alan kontrolu - bozuk satirlari atla
    if (NF < 9) {
        print "PARSE_ERROR:", $0 > "/dev/stderr"
        next
    }
    
    ip        = $1
    ident     = $2
    user      = $3
    timestamp = $4
    request   = $5
    status    = $6
    bytes     = $7
    referer   = $8
    useragent = $9
    
    # Apache timestamp: [10/Oct/2024:13:55:36 -0700]
    # Nginx timestamp ayni formati kullanir, sadece timezone normalize et
    # Koseeli parantezleri temizle ve UTC'ye donustur (opsiyonel)
    gsub(/^[|]$/, "", timestamp)
    
    # Nginx combined formatina uygun cikti
    # $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"
    printf "%s - %s [%s] %s %s %s %s %sn", 
        ip, user, timestamp, request, status, bytes, referer, useragent
}

Nginx’ten Apache’ye Ters Dönüşüm

Ters yönde de işlem yapmamız gerekebilir. Özellikle Apache’ye özgü analiz araçlarını (AWStats, GoAccess Apache modu gibi) kullanmak istediğinizde:

#!/usr/bin/awk -f
# nginx_to_apache.awk
# Nginx combined log'u Apache Combined Log Format'a donusturur

BEGIN {
    FPAT = "([^ ]+)|(\[[^\]]+\])|("[^"]*")"
    months["Jan"]="01"; months["Feb"]="02"; months["Mar"]="03"
    months["Apr"]="04"; months["May"]="05"; months["Jun"]="06"
    months["Jul"]="07"; months["Aug"]="08"; months["Sep"]="09"
    months["Oct"]="10"; months["Nov"]="11"; months["Dec"]="12"
}

function normalize_timestamp(ts,    parts, day, mon, year, time, tz) {
    # Giris: 10/Oct/2024:13:55:36 +0300
    # Cikis: 10/Oct/2024:13:55:36 +0300 (Apache zaten bu formati bekliyor)
    return ts
}

{
    if (NF < 8) next
    
    ip        = $1
    # $2 tire (-)
    user      = $3
    timestamp = $4
    request   = $5
    status    = $6
    bytes     = $7
    referer   = $8
    useragent = (NF >= 9) ? $9 : ""-""
    
    # Koseeli parantezleri temizle
    gsub(/^[|]$/, "", timestamp)
    
    # bytes alani - tire ise 0 yaz (Apache bazi durumlarda tire kullanir)
    if (bytes == "-") bytes = "0"
    
    # Apache Combined Format ciktisi
    printf "%s - %s [%s] %s %s %s %s %sn", 
        ip, user, timestamp, request, status, bytes, referer, useragent
}

Nginx JSON Log Formatına Dönüşüm

Modern log aggregation pipeline’larında (Filebeat, Fluentd, Logstash) JSON formatı çok daha kullanışlı. Eğer Nginx’inizde henüz JSON log formatı yoksa ve eski logları JSON’a çevirmek istiyorsanız:

#!/usr/bin/awk -f
# apache_to_json.awk
# Apache Combined Log'u JSON formatina donusturur
# Filebeat ya da Logstash ile kullanim icin idealdir

BEGIN {
    FPAT = "([^ ]+)|(\[[^\]]+\])|("[^"]*")"
}

function json_escape(str) {
    gsub(/\/, "\\", str)
    gsub(/"/, "\"", str)
    gsub(/t/, "\t", str)
    gsub(/r/, "\r", str)
    return str
}

function strip_quotes(str) {
    gsub(/^"|"$/, "", str)
    return str
}

function parse_request(req,    parts, method, path, proto) {
    req = strip_quotes(req)
    if (split(req, parts, " ") >= 3) {
        method = parts[1]
        path   = parts[2]
        proto  = parts[3]
    } else {
        method = req
        path   = "-"
        proto  = "-"
    }
    return method SUBSEP path SUBSEP proto
}

{
    if (NF < 7) next
    
    ip        = $1
    user      = $3
    timestamp = $4
    request   = $5
    status    = $6
    bytes     = $7
    referer   = (NF >= 8) ? $8 : ""-""
    useragent = (NF >= 9) ? $9 : ""-""
    
    gsub(/^[|]$/, "", timestamp)
    
    # Request'i parcala
    n = split(parse_request(request), req_parts, SUBSEP)
    http_method = req_parts[1]
    http_path   = req_parts[2]
    http_proto  = req_parts[3]
    
    # Temizle
    referer_clean   = json_escape(strip_quotes(referer))
    useragent_clean = json_escape(strip_quotes(useragent))
    
    if (user == "-") user = ""
    
    printf "{"remote_addr":"%s","remote_user":"%s","time_local":"%s"," 
           ""method":"%s","path":"%s","protocol":"%s"," 
           ""status":%s,"bytes_sent":%s," 
           ""http_referer":"%s","http_user_agent":"%s"}n", 
        ip, user, timestamp, 
        http_method, json_escape(http_path), http_proto, 
        status, bytes, 
        referer_clean, useragent_clean
}

Bu scriptle çıktınız şuna benzer:

{"remote_addr":"192.168.1.100","remote_user":"","time_local":"10/Oct/2024:13:55:36 +0300","method":"GET","path":"/index.html","protocol":"HTTP/1.1","status":200,"bytes_sent":2326,"http_referer":"http://example.com/","http_user_agent":"Mozilla/5.0"}

Hatalı Satırları Ayıklama ve Raporlama

Production ortamında loglar her zaman temiz gelmiyor. Bot trafiği, kötü encode edilmiş istekler, log rotation sırasında kesilen satırlar… Bunları filtreleyen bir wrapper yazalım:

#!/bin/bash
# log_normalize.sh
# Kullanim: ./log_normalize.sh input.log output.log [apache|nginx]

INPUT="$1"
OUTPUT="$2"
FORMAT="${3:-apache}"
ERRORS="/tmp/parse_errors_$(date +%Y%m%d_%H%M%S).log"

if [[ ! -f "$INPUT" ]]; then
    echo "Hata: $INPUT dosyasi bulunamadi" >&2
    exit 1
fi

awk -v format="$format" '
BEGIN {
    FPAT = "([^ ]+)|(\[[^\]]+\])|("[^"]*")"
    ok_count    = 0
    err_count   = 0
    skip_count  = 0
}

{
    # Minimum alan sayisi kontrolu
    if (NF < 7) {
        print NR": Alan sayisi yetersiz ("NF"): "$0 > "/dev/stderr"
        err_count++
        next
    }
    
    # Status kod sayisal mi?
    if ($6 !~ /^[0-9]{3}$/) {
        print NR": Gecersiz status kodu: "$6 > "/dev/stderr"
        err_count++
        next
    }
    
    # IP adresi gecerli mi? (basit kontrol)
    if ($1 !~ /^[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$/ && 
        $1 !~ /^[0-9a-fA-F:]+$/) {
        skip_count++
        next
    }
    
    print $0
    ok_count++
}

END {
    print "=== Normalizasyon Raporu ===" > "/dev/stderr"
    print "Basarili: "ok_count > "/dev/stderr"
    print "Hata: "err_count > "/dev/stderr"
    print "Atlanan: "skip_count > "/dev/stderr"
    print "Toplam: "NR > "/dev/stderr"
}
' "$INPUT" > "$OUTPUT" 2>"$ERRORS"

echo "Cikti: $OUTPUT"
echo "Hata raporu: $ERRORS"

Çoklu Dosya ve Tarih Aralığı Normalizasyonu

Gerçek senaryolarda log rotation sonucu oluşan onlarca dosyayı işlemeniz gerekebilir. Üstelik belirli bir tarih aralığındaki logları çekmek de gerekebilir:

#!/usr/bin/awk -f
# filtered_normalize.awk
# Kullanim: awk -v start_date="2024/10/01" -v end_date="2024/10/31" 
#               -f filtered_normalize.awk /var/log/nginx/access.log.* 

BEGIN {
    FPAT = "([^ ]+)|(\[[^\]]+\])|("[^"]*")"
    
    # Ay numaralarini hazirla
    split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", 
          month_names, " ")
    for (i = 1; i <= 12; i++) {
        month_num[month_names[i]] = sprintf("%02d", i)
    }
}

function apache_date_to_sortable(ts,    parts, day, mon, year, time) {
    # Giris: 10/Oct/2024:13:55:36 +0300
    # Cikis: 2024/10/10 (karsilastirma icin)
    if (split(ts, parts, /[/:]/) >= 4) {
        day  = parts[1]
        mon  = month_num[parts[2]]
        year = parts[3]
        return year"/"mon"/"day
    }
    return "0000/00/00"
}

{
    if (NF < 7) next
    
    ts_raw = $4
    gsub(/^[|]$/, "", ts_raw)
    
    sortable = apache_date_to_sortable(ts_raw)
    
    # Tarih filtresi
    if (start_date != "" && sortable < start_date) next
    if (end_date   != "" && sortable > end_date)   next
    
    print $0
}

Bunu birden fazla log dosyasıyla kullanmak için:

# Tum rotate edilmis loglari birlestirip filtrele
awk -v start_date="2024/10/01" -v end_date="2024/10/15" 
    -f filtered_normalize.awk 
    /var/log/nginx/access.log 
    /var/log/nginx/access.log.1 
    /var/log/nginx/access.log.2.gz  # Bu direkt calismaz, once zcat gerekir

# Gzip'li dosyalar icin:
zcat /var/log/nginx/access.log.*.gz | 
    awk -v start_date="2024/10/01" -v end_date="2024/10/15" 
    -f filtered_normalize.awk -

Performans: Büyük Log Dosyalarında Optimizasyon

Gigabyte’larca log işlerken awk‘ı verimli kullanmak kritik. Birkaç pratik nokta:

OFMT ve CONVFMT ayarları: Sayısal dönüşümler yapıyorsanız bunları başta tanımlayın.

Gereksiz gsub’dan kaçının: Her satırda çalışan gsub çağrıları birikince ciddi yavaşlama yaratır.

Pipe ve process substitution: Çok büyük dosyalar için paralel işleme düşünün.

# Buyuk log dosyasi icin paralel isleme
# 4 parcaya bol, paralel isle, birlestir
LOGFILE="/var/log/nginx/access.log"
LINES=$(wc -l < "$LOGFILE")
CHUNK=$((LINES / 4))

for i in 1 2 3 4; do
    start=$(( (i-1) * CHUNK + 1 ))
    end=$(( i * CHUNK ))
    sed -n "${start},${end}p" "$LOGFILE" | 
        awk -f apache_to_json.awk > "/tmp/chunk_${i}.json" &
done

wait
cat /tmp/chunk_*.json > output.json
rm /tmp/chunk_*.json

Bir de şunu söyleyelim: eğer düzenli olarak 10GB+ log işleyecekseniz, saf awk yerine awk‘ı ön filtreleme aşamasında kullanıp asıl ağır işi jq, clickhouse-local ya da DuckDB gibi araçlara bırakmanızı öneririm. Ama script hızlıca bir şeyler yapmak için awk hala rakipsiz.

Normalizasyon Kalitesini Doğrulama

Dönüşüm yaptıktan sonra doğrulama yapmak şart. Satır sayısı eşleşiyor mu, status code dağılımı korunmuş mu, IP adresleri tutarlı mı diye bakmak gerekiyor:

# Orijinal ve donusturulmus loglari karsilastir
compare_logs() {
    local original="$1"
    local converted="$2"
    
    echo "=== Log Karsilastirma ==="
    
    orig_lines=$(wc -l < "$original")
    conv_lines=$(wc -l < "$converted")
    echo "Orijinal satir: $orig_lines"
    echo "Donusturulen satir: $conv_lines"
    
    if [[ "$orig_lines" -ne "$conv_lines" ]]; then
        echo "UYARI: Satir sayilari eslesmıyor!"
    fi
    
    echo ""
    echo "--- Orijinal Status Kod Dagilimi ---"
    awk 'BEGIN{FPAT="([^ ]+)|(\[[^\]]+\])|("[^"]*")"} {print $6}' 
        "$original" | sort | uniq -c | sort -rn | head -10
    
    echo ""
    echo "--- Donusturulen Status Kod Dagilimi ---"
    awk 'BEGIN{FPAT="([^ ]+)|(\[[^\]]+\])|("[^"]*")"} {print $6}' 
        "$converted" | sort | uniq -c | sort -rn | head -10
    
    echo ""
    echo "--- En Cok Trafik Alan IP (Orijinal) ---"
    awk 'BEGIN{FPAT="([^ ]+)|(\[[^\]]+\])|("[^"]*")"} {print $1}' 
        "$original" | sort | uniq -c | sort -rn | head -5
}

compare_logs /var/log/apache2/access.log /tmp/nginx_converted.log

Sonuç

awk ile log normalizasyonu yapmak ilk bakışta göz korkutucu gelebilir, ama bir kez FPAT mekanizmasını kavradıktan sonra gerisi neredeyse kendiliğinden geliyor. Burada anlattığım yaklaşımı özetlemek gerekirse:

  • FPAT kullanın: Standart FS yerine FPAT ile tırnak içi alanları doğru parse edin.
  • Hata yönetimini ihmal etmeyin: Production logları kirlidir, her satırın düzgün geleceğini varsaymayın.
  • Doğrulama yapın: Dönüşüm sonrası satır sayısı ve alan dağılımlarını karşılaştırın.
  • JSON’u düşünün: Eğer downstream pipeline’ınız destekliyorsa, düz text yerine JSON çıktısı çok daha kolay tüketilir.
  • Büyük dosyalarda paralel işleme: awk tek thread çalışır, büyük dosyalar için paralel yaklaşım gerekir.

Bu scriptlerin hepsini bir git reposunda toplamanızı, her ortama uygun konfigürasyonu değişken olarak dışarıya almanızı ve cron’a ya da log pipeline’ınıza entegre etmenizi öneririm. Log normalizasyonu bir kerelik iş değil, sürekli akan bir süreç. Bunu ne kadar otomatikleştirirseniz, gece 2’de “neden loglar gelmiyor” sorusunu o kadar az duyarsınız.

Bir yanıt yazın

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