Bash’te Hata Yönetimi: Exit Kodları ve trap Kullanımı

Bir script yazıyorsunuz, her şey güzel gidiyor, test ortamında mükemmel çalışıyor. Sonra production’a alıyorsunuz ve bir gece yarısı alarm geliyor: script yarıda kesilmiş, veriler yarım kalmış, sistem tutarsız bir durumda. Üstelik hiçbir log yok, hiçbir hata mesajı yok. Ne olduğunu anlamak için saatler harcıyorsunuz. İşte bu senaryo, Bash’te hata yönetimini neden ciddiye almanız gerektiğini tek başına açıklıyor.

Bu yazıda exit kodlarını, trap mekanizmasını ve production kalitesinde script yazmanın temel prensiplerini ele alacağız. Bunlar “ileri seviye” konular değil, aslında her script yazan kişinin bilmesi gereken temel pratikler.

Exit Kodları Nedir ve Neden Önemli?

Her Unix/Linux komutu çalışmasını tamamladıktan sonra bir çıkış kodu döndürür. Bu kod, işlemin başarılı mı yoksa hatalı mı tamamlandığını belirtir. 0 başarıyı, 0 dışındaki her değer bir hatayı ifade eder.

ls /var/log
echo $?   # 0 döner, başarılı

ls /olmayan/dizin
echo $?   # 2 döner, dosya/dizin bulunamadı

$? değişkeni, her zaman en son çalıştırılan komutun exit kodunu tutar. Bu kadar basit ama pek çok scriptte bu bilgi tamamen görmezden geliniyor.

Yaygın Exit Kod Değerleri

  • 0: Başarılı çalışma
  • 1: Genel hata (birçok uygulama tarafından kullanılır)
  • 2: Yanlış kullanım, komut satırı hatası
  • 126: Komut bulundu ama çalıştırılamadı (izin hatası)
  • 127: Komut bulunamadı
  • 128: Geçersiz exit argümanı
  • 128+n: n sinyali ile sonlandırıldı (örneğin 130 = CTRL+C yani SIGINT)
  • 130: Script, CTRL+C ile sonlandırıldı
  • 137: SIGKILL ile öldürüldü (128 + 9)
  • 255: Exit kodu aralık dışı

Kendi Exit Kodlarınızı Tanımlayın

Script yazarken anlamlı exit kodları kullanmak, hem debugging’i kolaylaştırır hem de scripti çağıran diğer sistemlerin (cron, CI/CD, monitoring) ne olduğunu anlamasını sağlar.

#!/bin/bash

# Exit kodlarını sabit olarak tanımla
readonly EXIT_SUCCESS=0
readonly EXIT_GENERAL_ERROR=1
readonly EXIT_INVALID_ARGS=2
readonly EXIT_FILE_NOT_FOUND=3
readonly EXIT_PERMISSION_DENIED=4
readonly EXIT_NETWORK_ERROR=5

backup_database() {
    local db_name="$1"
    local backup_dir="$2"

    if [[ -z "$db_name" || -z "$backup_dir" ]]; then
        echo "HATA: Veritabanı adı ve backup dizini gerekli." >&2
        exit $EXIT_INVALID_ARGS
    fi

    if [[ ! -d "$backup_dir" ]]; then
        echo "HATA: Backup dizini bulunamadı: $backup_dir" >&2
        exit $EXIT_FILE_NOT_FOUND
    fi

    if [[ ! -w "$backup_dir" ]]; then
        echo "HATA: Backup dizinine yazma izni yok: $backup_dir" >&2
        exit $EXIT_PERMISSION_DENIED
    fi

    # Backup işlemi
    pg_dump "$db_name" > "$backup_dir/${db_name}_$(date +%Y%m%d_%H%M%S).sql" || {
        echo "HATA: Veritabanı backup alınamadı." >&2
        exit $EXIT_GENERAL_ERROR
    }

    echo "Backup başarıyla tamamlandı."
    exit $EXIT_SUCCESS
}

backup_database "$1" "$2"

set -e, set -u ve set -o pipefail

Bash scriptlerinde hata yönetiminin ilk katmanı bu üç ayardır. Çoğu kişi bunların ne işe yaradığını bilir ama farkında olmadan hatalı kullanır.

#!/bin/bash
set -euo pipefail
  • set -e: Herhangi bir komut sıfır dışı exit kodu döndürürse script durur
  • set -u: Tanımlanmamış değişken kullanılırsa hata verir
  • set -o pipefail: Pipe içindeki herhangi bir komut başarısız olursa tüm pipe başarısız sayılır

pipefail olmadan şu senaryo sessizce geçer:

# pipefail olmadan bu başarılı görünür!
cat /olmayan/dosya | grep "bir şey" | wc -l
echo $?   # 0 döner çünkü wc -l başarılı

# pipefail ile
set -o pipefail
cat /olmayan/dosya | grep "bir şey" | wc -l
echo $?   # 1 döner, cat başarısız olduğu için

set -e kullanırken dikkat etmeniz gereken bir nokta var: bazı komutların başarısız olması normaldir ve bunu bekliyorsunuzdur. Bu durumda || veya if kullanın:

#!/bin/bash
set -euo pipefail

# Bu script'i patlatır çünkü grep bulamazsa 1 döner
grep "pattern" /var/log/syslog

# Bunun yerine:
if grep "pattern" /var/log/syslog; then
    echo "Pattern bulundu"
else
    echo "Pattern bulunamadı, bu normal"
fi

# Ya da:
grep "pattern" /var/log/syslog || true

trap Komutu: Gerçek Güç Burada

trap, belirli sinyaller veya olaylar gerçekleştiğinde çalışacak komutları veya fonksiyonları tanımlamanızı sağlar. Bu, hata yönetiminin en güçlü aracıdır.

Temel sözdizimi şöyle: trap 'komut' SINYAL

Temel trap Kullanımı

#!/bin/bash
set -euo pipefail

# Script sonlandığında temizlik yap
trap 'echo "Script sonlandı"' EXIT

# CTRL+C yakalandığında
trap 'echo "Kullanıcı tarafından iptal edildi"; exit 130' INT

# Hata yakalandığında
trap 'echo "Satır $LINENO: Hata oluştu"' ERR

echo "İşlem başlıyor..."
sleep 5
echo "İşlem tamamlandı."

Cleanup Fonksiyonu: Production’da Olmazsa Olmaz

Gerçek dünyada trap en çok geçici dosyaları, lock dosyalarını ve yarım kalan işlemleri temizlemek için kullanılır:

#!/bin/bash
set -euo pipefail

# Geçici dosya ve dizinler için değişkenler
TEMP_DIR=""
LOCK_FILE="/var/run/myapp.lock"
LOG_FILE="/var/log/myapp/deploy_$(date +%Y%m%d_%H%M%S).log"

cleanup() {
    local exit_code=$?

    echo "Temizlik yapılıyor..." | tee -a "$LOG_FILE"

    # Geçici dizini sil
    if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
        rm -rf "$TEMP_DIR"
        echo "Geçici dizin silindi: $TEMP_DIR" | tee -a "$LOG_FILE"
    fi

    # Lock dosyasını kaldır
    if [[ -f "$LOCK_FILE" ]]; then
        rm -f "$LOCK_FILE"
        echo "Lock dosyası kaldırıldı." | tee -a "$LOG_FILE"
    fi

    # Hata durumunda bildirim gönder
    if [[ $exit_code -ne 0 ]]; then
        echo "HATA: Script $exit_code kodu ile sonlandı." | tee -a "$LOG_FILE"
        # Burada mail veya Slack bildirimi gönderilebilir
        # send_alert "Deploy scripti başarısız: exit code $exit_code"
    fi

    exit $exit_code
}

# trap'i en başta tanımla
trap cleanup EXIT INT TERM

# Lock dosyası kontrolü
if [[ -f "$LOCK_FILE" ]]; then
    echo "HATA: Script zaten çalışıyor (lock dosyası mevcut: $LOCK_FILE)" >&2
    exit 1
fi

# Lock oluştur
echo $$ > "$LOCK_FILE"

# Geçici dizin oluştur
TEMP_DIR=$(mktemp -d)
echo "Geçici dizin oluşturuldu: $TEMP_DIR"

# Asıl iş burada yapılır
echo "Deploy işlemi başlıyor..."
# ... işlemler ...
echo "Deploy tamamlandı."

Hata Satırını Yakalamak

Debugging için altın değerinde bir teknik: hata oluştuğunda hangi satırda olduğunu loglamak.

#!/bin/bash
set -euo pipefail

error_handler() {
    local exit_code=$?
    local line_number=$1
    local command="$2"

    echo "========================================" >&2
    echo "HATA DETAYI:" >&2
    echo "  Satır numarası : $line_number" >&2
    echo "  Başarısız komut: $command" >&2
    echo "  Exit kodu      : $exit_code" >&2
    echo "  Zaman          : $(date '+%Y-%m-%d %H:%M:%S')" >&2
    echo "  Script         : $0" >&2
    echo "========================================" >&2
}

trap 'error_handler $LINENO "$BASH_COMMAND"' ERR

# Test
echo "Adım 1 başlıyor..."
cp /olmayan/dosya /tmp/hedef  # Bu başarısız olacak
echo "Buraya ulaşılamaz"

Bu çıktı size tam olarak nerede, ne başarısız olduğunu söyler. Gece yarısı prodction’da hata ayıklarken bunu ne kadar çok değerlediğinizi anlarsınız.

Gerçek Dünya Senaryosu: Database Backup Script

Şimdi tüm bu kavramları birleştiren, production’da kullanılabilecek gerçekçi bir örnek görelim:

#!/bin/bash
set -euo pipefail

###############################################
# PostgreSQL Backup Script
# Kullanım: ./pg_backup.sh <db_name> <backup_dir>
###############################################

# Sabitler
readonly SCRIPT_NAME=$(basename "$0")
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)
readonly LOG_FILE="/var/log/pg_backup/${TIMESTAMP}.log"
readonly LOCK_FILE="/var/run/pg_backup.lock"
readonly RETENTION_DAYS=7

# Exit kodları
readonly E_SUCCESS=0
readonly E_ARGS=2
readonly E_LOCK=3
readonly E_DIRS=4
readonly E_BACKUP=5
readonly E_COMPRESS=6

# Genel değişkenler
TEMP_DIR=""
DB_NAME=""
BACKUP_DIR=""

log() {
    local level="$1"
    shift
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
}

cleanup() {
    local exit_code=$?

    log "INFO" "Temizlik başlıyor..."

    [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
    [[ -f "$LOCK_FILE" ]] && rm -f "$LOCK_FILE"

    if [[ $exit_code -eq 0 ]]; then
        log "INFO" "Backup başarıyla tamamlandı."
    else
        log "ERROR" "Backup BAŞARISIZ oldu. Exit kodu: $exit_code"
        # Burada alerting sisteminizi tetikleyebilirsiniz
    fi
}

error_handler() {
    log "ERROR" "Satır $1'de hata: $2"
}

trap cleanup EXIT
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
trap 'log "WARN" "Script kullanıcı tarafından durduruldu"; exit 130' INT TERM

check_args() {
    if [[ $# -ne 2 ]]; then
        log "ERROR" "Kullanım: $SCRIPT_NAME <db_name> <backup_dir>"
        exit $E_ARGS
    fi
    DB_NAME="$1"
    BACKUP_DIR="$2"
}

check_prerequisites() {
    # Lock kontrolü
    if [[ -f "$LOCK_FILE" ]]; then
        local pid
        pid=$(cat "$LOCK_FILE")
        if kill -0 "$pid" 2>/dev/null; then
            log "ERROR" "Backup zaten çalışıyor (PID: $pid)"
            exit $E_LOCK
        else
            log "WARN" "Eski lock dosyası bulundu, temizleniyor..."
            rm -f "$LOCK_FILE"
        fi
    fi

    echo $$ > "$LOCK_FILE"

    # Dizin kontrolleri
    if [[ ! -d "$BACKUP_DIR" ]]; then
        log "INFO" "Backup dizini oluşturuluyor: $BACKUP_DIR"
        mkdir -p "$BACKUP_DIR" || { log "ERROR" "Dizin oluşturulamadı"; exit $E_DIRS; }
    fi

    # pg_dump kontrolü
    if ! command -v pg_dump &>/dev/null; then
        log "ERROR" "pg_dump bulunamadı"
        exit $E_BACKUP
    fi
}

run_backup() {
    TEMP_DIR=$(mktemp -d)
    local backup_file="${TEMP_DIR}/${DB_NAME}_${TIMESTAMP}.sql"
    local final_file="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"

    log "INFO" "Backup başlıyor: $DB_NAME"

    if ! pg_dump "$DB_NAME" > "$backup_file"; then
        log "ERROR" "pg_dump başarısız oldu"
        exit $E_BACKUP
    fi

    log "INFO" "Sıkıştırılıyor..."
    if ! gzip -c "$backup_file" > "$final_file"; then
        log "ERROR" "Sıkıştırma başarısız oldu"
        exit $E_COMPRESS
    fi

    local size
    size=$(du -sh "$final_file" | cut -f1)
    log "INFO" "Backup tamamlandı: $final_file ($size)"
}

cleanup_old_backups() {
    log "INFO" "${RETENTION_DAYS} günden eski backuplar temizleniyor..."
    find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" -mtime +"$RETENTION_DAYS" -delete
    log "INFO" "Eski backup temizliği tamamlandı."
}

main() {
    mkdir -p "$(dirname "$LOG_FILE")"
    check_args "$@"
    check_prerequisites
    run_backup
    cleanup_old_backups
}

main "$@"

Subshell’lerde Hata Yönetimi

set -e ve trap ERR subshell’lerde farklı davranabilir. Bunu bilmek önemli:

#!/bin/bash
set -euo pipefail

trap 'echo "Ana shell hatası: satır $LINENO"' ERR

riskli_islem() {
    # Bu fonksiyon içindeki hata ERR trap'i tetikler
    cp /olmayan/dosya /tmp/hedef
}

# Direkt çağrı - ERR trap tetiklenir
riskli_islem

# Subshell - ERR trap ana shell'de tetiklenmez
(
    cp /olmayan/dosya /tmp/hedef
) || {
    echo "Subshell hatası yakalandı: $?"
}

# Command substitution
sonuc=$(riskli_islem) || {
    echo "Command substitution hatası: $?"
}

Sinyal Yönetimi ve Graceful Shutdown

Özellikle uzun süren scriptlerde graceful shutdown kritik öneme sahiptir:

#!/bin/bash
set -euo pipefail

PROCESSING=false
SHUTDOWN_REQUESTED=false

shutdown_handler() {
    echo ""
    echo "Kapatma sinyali alındı..."
    SHUTDOWN_REQUESTED=true

    if [[ "$PROCESSING" == "true" ]]; then
        echo "Mevcut işlem tamamlanana kadar bekleniyor..."
        # İşlemin bitmesini bekle
        while [[ "$PROCESSING" == "true" ]]; do
            sleep 1
        done
    fi

    echo "Güvenli kapatma tamamlandı."
    exit 0
}

trap shutdown_handler SIGTERM SIGINT

process_item() {
    local item="$1"
    PROCESSING=true
    echo "İşleniyor: $item"
    sleep 2  # Simüle edilmiş işlem
    echo "Tamamlandı: $item"
    PROCESSING=false
}

# Ana işlem döngüsü
items=("dosya1" "dosya2" "dosya3" "dosya4" "dosya5")

for item in "${items[@]}"; do
    if [[ "$SHUTDOWN_REQUESTED" == "true" ]]; then
        echo "Kapatma istendi, yeni işlem başlatılmıyor."
        break
    fi
    process_item "$item"
done

Hata Yönetimi Kütüphanesi Oluşturmak

Birden fazla scriptte aynı hata yönetimi kodunu tekrarlamak yerine, kaynak alınabilir bir kütüphane oluşturun:

#!/bin/bash
# /usr/local/lib/bash/error_lib.sh
# Kullanım: source /usr/local/lib/bash/error_lib.sh

# Renkli çıktı
readonly RED='33[0;31m'
readonly YELLOW='33[1;33m'
readonly GREEN='33[0;32m'
readonly NC='33[0m'

log_info()  { echo -e "${GREEN}[INFO]${NC}  $(date '+%H:%M:%S') $*"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC}  $(date '+%H:%M:%S') $*" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%H:%M:%S') $*" >&2; }

die() {
    local message="${1:-Bilinmeyen hata}"
    local exit_code="${2:-1}"
    log_error "$message"
    exit "$exit_code"
}

require_root() {
    [[ $EUID -eq 0 ]] || die "Bu script root yetkisi gerektiriyor." 1
}

require_command() {
    local cmd="$1"
    command -v "$cmd" &>/dev/null || die "'$cmd' komutu bulunamadı. Lütfen yükleyin." 127
}

require_file() {
    local file="$1"
    [[ -f "$file" ]] || die "Dosya bulunamadı: $file" 3
}

require_dir() {
    local dir="$1"
    [[ -d "$dir" ]] || die "Dizin bulunamadı: $dir" 3
}

setup_error_handling() {
    set -euo pipefail
    trap 'log_error "Satır $LINENO: '''$BASH_COMMAND''' başarısız (exit: $?)"' ERR
}

Bu kütüphaneyi kullanan bir script:

#!/bin/bash
source /usr/local/lib/bash/error_lib.sh

setup_error_handling

require_root
require_command "rsync"
require_command "pg_dump"
require_dir "/backup"

log_info "Yedekleme başlıyor..."

rsync -av /var/www/ /backup/www/ || die "rsync başarısız oldu" 5

log_info "Yedekleme tamamlandı."

Sık Yapılan Hatalar ve Kaçınma Yolları

Hata yönetimi yaparken en çok rastlanan yanlışlar şunlar:

  • set -e sonrası test komutlarını unutmak: grep, diff, test gibi komutlar başarısızlıkta 1 döner. Bunları if bloğuna alın ya da || true ekleyin.
  • $?‘yi geciktirmek: $? her komut çalıştırıldığında sıfırlanır. Kontrol etmeniz gereken exit kodunu hemen yakalayın.
  • Stderr’i görmezden gelmek: Hata mesajlarını her zaman >&2 ile stderr’e yönlendirin. Çağıran sistemler stdout’u veri olarak, stderr’i hata olarak değerlendirir.
  • trap’i geç tanımlamak: trap tanımlanmadan önce oluşan hatalar yakalanmaz. Script’in en başında tanımlayın.
  • Cleanup’ta exit kodunu kaybetmek: Cleanup fonksiyonu içinde başka komutlar çalıştırırsanız $? değişir. İlk satırda local exit_code=$? ile saklayın.

Sonuç

Exit kodları ve trap mekanizması, güvenilir Bash scriptlerinin temel taşlarıdır. Sadece “hata olursa ne yapalım” değil, “nasıl temiz bir şekilde kapanırız ve sistemi tutarlı bırakırız” sorusunun cevabıdır.

Özetleyecek olursak: Her scripte set -euo pipefail ekleyin, trap cleanup EXIT ile her zaman temizlik yapın, anlamlı exit kodları kullanın ve hataları her zaman stderr’e yazın. Bu dört pratik bile script kaliteniizi dramatik şekilde artırır.

Production’da çalışan scriptler bir gün mutlaka beklenmedik bir durumla karşılaşır. O an geldiğinde sisteminizin nerede durduğunu bilmek, ne kadar veri kaybettiğinizi anlamak ve olayı hızla toplamak; hepsi bu temellere bağlıdır. Gece yarısı alarm geldiğinde “en azından log var” diyebilmek, paha biçilmez bir rahatlıktır.

Yorum yapın