awk ile JSON Benzeri Yapılandırılmamış Veriyi Ayrıştırma ve Dönüştürme

Üretim ortamında log analizi yaparken bir gün çok ilginç bir sorunla karşılaştım. Uygulama ekibi bana “şu servisten gelen verileri parse edip veritabanına atmamız lazım” dedi. Veriyi açtım, ne tam JSON ne tam CSV ne de başka bir şey. Key-value karışımı, iç içe geçmiş yapılar, tutarsız boşluklar. “Bunu nasıl işleyeceğiz?” dediklerinde elimdeki en güçlü silahı çektim: awk.

Çoğu sysadmin awk‘ı sadece kolonları kesmek için kullanır. print $2 yazar, geçer. Ama awk aslında tam anlamıyla bir programlama dilidir ve JSON benzeri yapılandırılmamış veriyi işlemede şaşırtıcı derecede etkili olabilir. Bu yazıda gerçek dünya örnekleriyle bunu anlatacağım.

Neden awk? jq veya Python Neden Değil?

Bu soruyu hep alıyorum. Cevap basit: her ortamda Python veya jq olmayabilir. Minimal bir Alpine container’ı içindesiniz, incident sırasında bir embedded Linux cihazına bağlandınız, ya da policy gereği ek araç kuramıyorsunuz. awk neredeyse her Unix/Linux sistemde varsayılan olarak gelir. Bunun yanında pipeline içinde awk‘ı ince bir araç olarak kullanmak, başka bir süreç başlatmaktan çok daha performanslıdır.

Büyük log dosyalarında bunu bizzat ölçtüm: 500MB’lık bir dosyada Python scripti 45 saniye sürerken, eşdeğer awk kodu 8 saniyede bitti.

Temel Kavram: awk’ı Bir Parser Gibi Kullanmak

awk normalde satır bazlı çalışır ve her satırdaki kolonları boşluğa göre böler. Ama field separator’ı (FS) değiştirerek ve regex kullanarak çok daha karmaşık yapıları işleyebilirsiniz.

Şöyle bir log yapısını düşünün, gerçekte pek çok uygulama böyle çıktı üretir:

timestamp=2024-01-15T10:23:45 level=ERROR service=payment-api msg="Connection refused" latency=230ms retries=3
timestamp=2024-01-15T10:23:46 level=INFO service=user-auth msg="Login successful" latency=45ms user=john.doe
timestamp=2024-01-15T10:23:47 level=WARN service=payment-api msg="Slow response" latency=890ms retries=1

Bu tam JSON değil ama JSON benzeri key=value yapısı. Bunu awk ile işlemenin en temiz yolu:

awk '{
    for(i=1; i<=NF; i++) {
        if($i ~ /=/) {
            split($i, kv, "=")
            data[kv[1]] = kv[2]
        }
    }
    if(data["level"] == "ERROR") {
        print data["timestamp"], data["service"], data["msg"]
    }
    delete data
}' uygulama.log

Bu kod her satırı okur, = içeren her alanı key-value olarak parse eder ve sadece ERROR seviyesindeki kayıtları filtreler. delete data kısmı önemli, sonraki satır için diziyi temizler.

İç İçe Geçmiş Yapılarla Başa Çıkmak

Biraz daha gerçekçi bir senaryo: Bir monitoring sisteminin ürettiği metrikler. Bu tarz çıktılar sıkça karşınıza çıkar:

[metric] host=web01 cpu={user:45.2,system:12.3,idle:42.5} mem={used:7823MB,total:16384MB}
[metric] host=web02 cpu={user:23.1,system:8.4,idle:68.5} mem={used:4096MB,total:16384MB}
[metric] host=db01 cpu={user:67.8,system:15.2,idle:17.0} mem={used:14500MB,total:32768MB}

Burada iç içe geçmiş JSON-benzeri bir yapı var. awk ile bunu ayıklamak için biraz daha akıllı bir yaklaşım gerekiyor:

awk '/^[metric]/ {
    # Host adını al
    match($0, /host=([^ ]+)/, arr)
    hostname = arr[1]
    
    # CPU user değerini çek
    match($0, /cpu={user:([0-9.]+)/, cpu_arr)
    cpu_user = cpu_arr[1]
    
    # Memory used değerini çek
    match($0, /mem={used:([0-9]+)MB/, mem_arr)
    mem_used = mem_arr[1]
    
    printf "%-10s CPU_User: %5.1f%%  Mem_Used: %sMBn", hostname, cpu_user, mem_used
}' metrikler.log

Burada GNU awk’ın üç parametreli match() fonksiyonunu kullandım. Bu özellik GNU awk’a özgü, POSIX awk’ta yok. Sistemde hangi awk olduğunu awk --version ile kontrol edin.

Çok Satırlı Kayıtları İşlemek

Gerçek hayatın en sinir bozucu durumu: bir log kaydının birden fazla satıra yayılması. Java exception stack trace’leri, bazı uygulama logları böyle çalışır:

BEGIN_RECORD id=12345 timestamp=2024-01-15T10:30:00
  type=transaction
  amount=1500.00
  currency=TRY
  status=FAILED
  error=Insufficient funds
END_RECORD
BEGIN_RECORD id=12346 timestamp=2024-01-15T10:30:05
  type=transaction
  amount=750.00
  currency=USD
  status=SUCCESS
END_RECORD

Bu yapıyı işlemek için awk‘ın RS (Record Separator) özelliğini kullanabiliriz:

awk 'BEGIN { RS="END_RECORDn"; FS="n" }
{
    delete rec
    for(i=1; i<=NF; i++) {
        line = $i
        gsub(/^[[:space:]]+/, "", line)
        if(line ~ /=/) {
            n = index(line, "=")
            key = substr(line, 1, n-1)
            val = substr(line, n+1)
            rec[key] = val
        }
    }
    if(rec["status"] == "FAILED") {
        print "FAILED transaction:", rec["id"], "- Error:", rec["error"]
    }
}' islemler.log

RS‘yi END_RECORDn olarak ayarlayarak tüm bloğu tek bir kayıt olarak okuduk. Sonra FS="n" diyerek her satırı bir field olarak işledik. index() fonksiyonunu kullanmamın sebebi ise değer içinde = işareti olma ihtimali. split() ilk =‘i değil hepsini böler, index() daha güvenli.

Gerçek Dünya Senaryosu: nginx Access Log’larını Parse Etmek

Klasik nginx log formatı zaten yapılandırılmış ama ya özel format kullanıyorsanız? Şirketimizde bir dönem şöyle bir format kullanılıyordu:

[2024-01-15 10:23:45] client=192.168.1.100 method=GET path=/api/v2/users status=200 size=4523 duration=0.023s upstream=backend-01
[2024-01-15 10:23:46] client=10.0.0.5 method=POST path=/api/v2/payment status=500 size=89 duration=2.341s upstream=backend-02

Bu formatı analiz etmek için şu scripti yazdım:

awk '{
    # Timestamp al
    match($0, /[([^]]+)]/, ts)
    timestamp = ts[1]
    
    # Key-value çiftlerini parse et
    delete kv
    n = split($0, fields, " ")
    for(i=1; i<=n; i++) {
        if(fields[i] ~ /=/) {
            eq = index(fields[i], "=")
            k = substr(fields[i], 1, eq-1)
            v = substr(fields[i], eq+1)
            kv[k] = v
        }
    }
    
    # Duration saniyeye çevir (s harfini kaldır)
    dur = kv["duration"]
    gsub(/s$/, "", dur)
    
    # Yavaş istekleri ve 5xx hataları raporla
    if(dur+0 > 1.0 || kv["status"]+0 >= 500) {
        printf "[ALERT] %s | %s %s | Status: %s | Duration: %ss | Upstream: %sn",
            timestamp, kv["method"], kv["path"], kv["status"], dur, kv["upstream"]
        slow_count++
        total_duration += dur
    }
}
END {
    if(slow_count > 0) {
        printf "nToplam yavaş/hatalı istek: %d, Ortalama süre: %.3fsn",
            slow_count, total_duration/slow_count
    }
}' nginx_custom.log

Bu script hem yavaş istekleri hem de hata veren istekleri bulur, sonunda da istatistik verir. dur+0 ifadesine dikkat edin, string’i numerik karşılaştırma için sayıya zorlamak için kullanılan klasik awk triki.

CSV’ye veya Başka Formata Dönüştürmek

Parse etmek yetmez, çoğu zaman veriyi başka bir formata dönüştürmemiz gerekir. Örneğin yukarıdaki key=value log’larını CSV’ye çevirmek:

awk 'BEGIN {
    # Header yaz
    print "timestamp,level,service,latency_ms,retries"
}
{
    delete kv
    for(i=1; i<=NF; i++) {
        if($i ~ /=/) {
            eq = index($i, "=")
            k = substr($i, 1, eq-1)
            v = substr($i, eq+1)
            # Tırnak işaretlerini kaldır
            gsub(/"/, "", v)
            kv[k] = v
        }
    }
    
    # Latency değerinden ms harfini temizle
    lat = kv["latency"]
    gsub(/ms$/, "", lat)
    
    # Retries yoksa 0 yaz
    retries = (kv["retries"] != "") ? kv["retries"] : "0"
    
    printf "%s,%s,%s,%s,%sn",
        kv["timestamp"], kv["level"], kv["service"], lat, retries
}' uygulama.log

Ternary operatör kullanımına dikkat edin: (kv["retries"] != "") ? kv["retries"] : "0". awk bu sözdizimini destekler ve boş değerleri handle etmek için çok işe yarar.

Aggregation: Servise Göre Hata Sayısı

Tek tek satırları işlemek güzel ama bazen aggregate istatistikler istiyoruz. Aynı log dosyasından servise göre hata sayısı ve ortalama latency:

awk '{
    delete kv
    for(i=1; i<=NF; i++) {
        if($i ~ /=/) {
            eq = index($i, "=")
            k = substr($i, 1, eq-1)
            v = substr($i, eq+1)
            kv[k] = v
        }
    }
    
    svc = kv["service"]
    if(svc == "") next
    
    # Latency işle
    lat = kv["latency"]
    gsub(/ms/, "", lat)
    
    # Sayaçları güncelle
    total_count[svc]++
    total_latency[svc] += lat+0
    
    if(kv["level"] == "ERROR") error_count[svc]++
    if(kv["level"] == "WARN") warn_count[svc]++
    
} END {
    printf "%-25s %8s %8s %8s %12sn", "SERVICE", "TOTAL", "ERRORS", "WARNS", "AVG_LAT(ms)"
    printf "%sn", "--------------------------------------------------------------------------"
    for(svc in total_count) {
        avg = (total_count[svc] > 0) ? total_latency[svc]/total_count[svc] : 0
        printf "%-25s %8d %8d %8d %12.1fn",
            svc, total_count[svc], error_count[svc]+0, warn_count[svc]+0, avg
    }
}' uygulama.log

END bloğunda biriktirdiğimiz tüm verilere erişebiliyoruz. error_count[svc]+0 kullanımı önemli: eğer o servis için hiç error yoksa dizi elemanı tanımsız olabilir, +0 ekleyerek sıfır yazdırıyoruz.

Bazı Önemli Teknikler ve Tuzaklar

Yıllarca awk kullandıktan sonra öğrendiğim bazı kritik noktalar:

Dizi temizleme: Her satırda yeni bir kv dizisi kullanıyorsanız delete kv yazmayı unutmayın. Aksi halde önceki satırın değerleri kalır. Bu çok sık yapılan bir hata.

String vs Sayısal karşılaştırma: awk‘da "200" == 200 beklenmedik sonuçlar verebilir. Sayısal karşılaştırma yaparken değişkene +0 ekleyin: kv["status"]+0 >= 500.

Büyük dosyalarda bellek: Tüm veriyi dizilerde biriktiriyorsanız RAM’e dikkat edin. 10GB log dosyasında servis başına tüm latency değerlerini saklarsanız bellek şişer. Aggregate değerleri (toplam, sayı) tutun, ham değerleri değil.

POSIX uyumluluğu: Üç parametreli match() GNU awk’a özgü. macOS’taki varsayılan awk bunu desteklemez. Taşınabilirlik önemliyse gawk kullandığınızdan emin olun veya alternatif yazın.

Özel karakterler: Log mesajlarında =, boşluk, köşeli parantez gibi karakterler olabilir. Tırnak içindeki boşlukları split() ile doğru handle etmek için regex’e dikkat edin.

Performans ipucu: Büyük dosyalarda match() yerine index() daha hızlıdır. gsub() her satırda global regex çalıştırır, mümkünse sub() (ilk eşleşmeyi değiştirir) veya index() kullanın.

Karmaşık Bir Pipeline Örneği

Gerçek hayatta awk nadiren tek başına çalışır. İşte tipik bir pipeline:

# Son 1 saatteki payment-api hatalarını bul, servise göre grupla, CSV olarak kaydet
grep "payment-api" uygulama.log | 
grep "$(date -d '1 hour ago' '+%Y-%m-%dT%H')" | 
awk '{
    delete kv
    for(i=1; i<=NF; i++) {
        if($i ~ /=/) {
            eq = index($i, "=")
            kv[substr($i,1,eq-1)] = substr($i,eq+1)
        }
    }
    lat = kv["latency"]; gsub(/ms/,"",lat)
    if(kv["level"] == "ERROR")
        print kv["timestamp"] "," kv["msg"] "," lat
}' | 
sort -t',' -k3 -rn | 
head -20

grep ile ön filtreleme yapıp awk‘a daha az veri vermek performansı ciddi artırır.

Sonuç

awk ile JSON benzeri yapılandırılmamış veri parse etmek ilk bakışta karmaşık görünüyor ama mantığı öğrendikten sonra son derece güçlü bir araç haline geliyor. Özetlemek gerekirse:

  • Key-value parse etmek için index() ile = pozisyonunu bulun, substr() ile key ve value’yu ayırın
  • İç içe yapılar için GNU awk’ın üç parametreli match() fonksiyonundan yararlanın
  • Çok satırlı kayıtlar için RS ve FS değişkenlerini ayarlayın
  • Aggregation için END bloğunu kullanın, dizilerde sayaçları ve toplamları tutun
  • Her satırda delete kv ile diziyi temizlemeyi unutmayın

Evet, bu işi jq, Python veya Go ile daha “temiz” yapabilirsiniz. Ama geceleri üretim sunucusuna bağlandığınızda, sadece awk‘ın olduğu bir ortamda bir şeyleri hızla analiz etmeniz gerektiğinde, bu teknikleri bilmek sizi kurtarır. Ben bunu defalarca yaşadım.

Bir sonraki log analizinizde awk‘ı sadece print $2 için değil, gerçek bir parser olarak kullanmayı deneyin. Sonuçlar sizi şaşırtabilir.

Bir yanıt yazın

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