Sinyal Yakalama: trap ile Temiz Çıkış Yönetimi

Bir bash scripti yazıyorsunuz, her şey güzel gidiyor, sonra kullanıcı Ctrl+C’ye basıyor. Script yarım kalıyor, geçici dosyalar ortalıkta duruyor, lock dosyası silinmiyor, yarım kalan işlemler sistem kaynaklarını tüketmeye devam ediyor. Tanıdık geldi mi? İşte trap komutu tam olarak bu tür kaosları önlemek için var.

trap, bash scriptlerinizde sinyal ve olay yakalamayı sağlayan bir yerleşik komuttur. Script beklenmedik şekilde sonlandığında, bir sinyal aldığında ya da normal çıkış yaptığında ne olacağını siz belirlersiniz. Bu yazıda trap mekanizmasını derinlemesine inceleyecek, gerçek dünya senaryolarıyla nasıl kullanacağınızı göreceğiz.

Sinyal Nedir, Neden Önemlidir?

Linux’ta sinyal, bir prosese gönderilen asenkron bildirimdir. Kullanıcı Ctrl+C’ye bastığında SIGINT gönderilir, kill komutu varsayılan olarak SIGTERM gönderir, sistem kapanırken SIGHUP gelir. Bash scriptleri bu sinyalleri varsayılan davranışla işler, yani çoğunlukla hemen sonlanır.

Sysadmin olarak yazdığınız scriptler genellikle:

  • Geçici dosya ve dizinler oluşturur
  • Lock dosyaları tutar
  • Veritabanı bağlantıları açar
  • Yarım kalan işlemler başlatır
  • Log dosyalarına yazar

Bunların temizlenmesi gerekir. trap olmadan bu temizlik işlemi şansa bırakılmış demektir.

trap Sözdizimi

trap 'komut_veya_fonksiyon' SINYAL [SINYAL2 ...]

Temel kullanım bu kadar basit. Ama pratikte genellikle bir fonksiyon çağrısı yapılır:

trap temizle EXIT
trap 'temizle; exit 1' INT TERM

Yaygın sinyal isimleri şunlardır:

  • EXIT: Script herhangi bir şekilde çıkış yaptığında (en kullanışlı olanı)
  • INT: Ctrl+C ile gelen SIGINT sinyali
  • TERM: kill komutunun gönderdiği SIGTERM
  • HUP: Terminal kapandığında veya SIGHUP alındığında
  • ERR: Herhangi bir komut hata ile dönüş yaptığında (set -e ile birlikte kullanışlı)
  • DEBUG: Her komut çalışmadan önce tetiklenir
  • QUIT: Ctrl+ ile gelen SIGQUIT

trap ile mevcut tanımlamaları görmek için:

trap -p

Belirli bir sinyalin tanımını kaldırmak için:

trap - EXIT

Sinyali yok saymak için (boş string):

trap '' INT

En Temel Kullanım: EXIT ile Temizlik

EXIT yakalama noktası, trap kullanımının en temel ve en pratik şeklidir. Script nasıl çıkarsa çıksın, normal çıkış, exit komutu, sinyal, hata, her durumda tetiklenir.

#!/bin/bash

GECICI_DIR=$(mktemp -d)
LOCK_DOSYASI="/var/run/benim_scriptim.lock"

temizle() {
    echo "Temizlik yapılıyor..."
    rm -rf "$GECICI_DIR"
    rm -f "$LOCK_DOSYASI"
    echo "Temizlik tamamlandı."
}

trap temizle EXIT

# Lock dosyası oluştur
touch "$LOCK_DOSYASI"

echo "Geçici dizin: $GECICI_DIR"
echo "İşlemler başlıyor..."

# Burada asıl iş yapılır
cp -r /etc/nginx "$GECICI_DIR/"
tar -czf /backup/nginx_config_$(date +%Y%m%d).tar.gz -C "$GECICI_DIR" .

echo "Yedekleme tamamlandı."

Bu örnekte script Ctrl+C ile kesilse de, hata verse de, normal tamamlansa da temizle fonksiyonu her zaman çalışır. Geçici dizin ve lock dosyası kesinlikle temizlenir.

INT ve TERM ile Graceful Shutdown

Production ortamında servis benzeri çalışan scriptlerde INT ve TERM sinyallerini ayrı ele almak gerekir:

#!/bin/bash

DEVAM=true
ISLENEN=0
LOG_DOSYASI="/var/log/toplu_islem.log"

baslik_logla() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_DOSYASI"
}

kapatma_islemi() {
    baslik_logla "Durdurma sinyali alındı. Mevcut işlem tamamlanıyor..."
    DEVAM=false
}

cikis_raporu() {
    baslik_logla "Script sonlandı. Toplam işlenen: $ISLENEN kayıt"
}

trap kapatma_islemi INT TERM
trap cikis_raporu EXIT

baslik_logla "Toplu işlem başladı. PID: $$"

# Veri dosyasından satır satır işle
while IFS= read -r satir && $DEVAM; do
    # Simüle edilmiş işlem
    sleep 0.1
    ISLENEN=$((ISLENEN + 1))
    
    if (( ISLENEN % 100 == 0 )); then
        baslik_logla "$ISLENEN kayıt işlendi"
    fi
done < /data/islenecek_liste.txt

if $DEVAM; then
    baslik_logla "Tüm kayıtlar başarıyla işlendi."
else
    baslik_logla "Script erken sonlandırıldı. Kaldığı yer kaydedildi."
fi

Buradaki kritik nokta DEVAM değişkeni. Sinyal geldiğinde hemen exit yapmak yerine döngünün mevcut iterasyonu tamamlanmasına izin veriyoruz. Bu, veri tutarlılığı açısından çok önemli.

ERR Sinyali ile Hata Yönetimi

ERR sinyali, sıfırdan farklı çıkış kodu döndüren her komutta tetiklenir. set -e ile birlikte kullanıldığında güçlü bir hata yönetimi sağlar:

#!/bin/bash

set -euo pipefail

HATA_SATIRI=""
HATA_KODU=""

hata_isle() {
    HATA_KODU=$?
    HATA_SATIRI=$BASH_LINENO
    echo "HATA: Satır $HATA_SATIRI'de komut başarısız oldu (çıkış kodu: $HATA_KODU)" >&2
    echo "Komut: $BASH_COMMAND" >&2
}

temizle() {
    local cikis_kodu=$?
    if [ $cikis_kodu -ne 0 ]; then
        echo "Script hata ile sonlandı. Çıkış kodu: $cikis_kodu" >&2
        # Acil durum temizliği
        rm -f /tmp/islem_kilidi
    fi
}

trap hata_isle ERR
trap temizle EXIT

echo "Deployment başlıyor..."

# Bu komutlardan biri başarısız olursa hata_isle tetiklenir
systemctl stop uygulamam
cp /deploy/yeni_surum /opt/uygulamam/
systemctl start uygulamam
systemctl is-active uygulamam

echo "Deployment tamamlandı."

BASH_LINENO, BASH_COMMAND gibi özel değişkenler hata ayıklamada altın değerindedir.

Gerçek Dünya Senaryosu: Veritabanı Yedekleme Scripti

Şimdi daha kapsamlı bir örnek. Production’da kullandığım türde bir yedekleme scripti:

#!/bin/bash

set -euo pipefail

# Konfigürasyon
DB_HOST="localhost"
DB_USER="backup_user"
DB_PASS="gizli_sifre"
YEDEK_DIZINI="/backup/mysql"
GECICI_DIZIN=""
LOCK_DOSYASI="/var/run/mysql_yedek.lock"
BILDIRIM_EMAIL="[email protected]"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$1] $2" | tee -a /var/log/mysql_yedek.log
}

bildirim_gonder() {
    local konu=$1
    local mesaj=$2
    echo "$mesaj" | mail -s "$konu" "$BILDIRIM_EMAIL" 2>/dev/null || true
}

temizle() {
    local cikis_kodu=$?
    
    log "INFO" "Temizlik başlıyor..."
    
    # Geçici dizini temizle
    if [ -n "$GECICI_DIZIN" ] && [ -d "$GECICI_DIZIN" ]; then
        rm -rf "$GECICI_DIZIN"
        log "INFO" "Geçici dizin silindi: $GECICI_DIZIN"
    fi
    
    # Lock dosyasını kaldır
    if [ -f "$LOCK_DOSYASI" ]; then
        rm -f "$LOCK_DOSYASI"
        log "INFO" "Lock dosyası kaldırıldı"
    fi
    
    # Hata durumunda bildirim gönder
    if [ $cikis_kodu -ne 0 ]; then
        log "ERROR" "Script $cikis_kodu koduyla sonlandı"
        bildirim_gonder 
            "HATA: MySQL Yedekleme Başarısız - $(hostname)" 
            "$(date): Yedekleme scripti başarısız oldu. Çıkış kodu: $cikis_kodu"
    fi
}

kesinti_isle() {
    log "WARN" "Kesinti sinyali alındı, temizlik yapılıyor..."
    exit 130
}

trap temizle EXIT
trap kesinti_isle INT TERM

# Çakışma kontrolü
if [ -f "$LOCK_DOSYASI" ]; then
    PID=$(cat "$LOCK_DOSYASI")
    if kill -0 "$PID" 2>/dev/null; then
        log "ERROR" "Script zaten çalışıyor (PID: $PID)"
        exit 1
    else
        log "WARN" "Eski lock dosyası bulundu, temizleniyor"
        rm -f "$LOCK_DOSYASI"
    fi
fi

# Lock oluştur
echo $$ > "$LOCK_DOSYASI"
log "INFO" "Yedekleme başladı. PID: $$"

# Geçici dizin oluştur
GECICI_DIZIN=$(mktemp -d /tmp/mysql_yedek_XXXXXX)

# Veritabanlarını listele ve yedekle
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" 
    -e "SHOW DATABASES;" --skip-column-names 2>/dev/null | 
    grep -Ev "^(information_schema|performance_schema|sys)$" | 
while read -r db; do
    log "INFO" "Yedekleniyor: $db"
    
    mysqldump -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" 
        --single-transaction 
        --routines 
        --triggers 
        "$db" > "$GECICI_DIZIN/$db.sql"
    
    gzip "$GECICI_DIZIN/$db.sql"
    log "INFO" "$db yedeği tamamlandı: $(du -sh "$GECICI_DIZIN/$db.sql.gz" | cut -f1)"
done

# Yedeği asıl dizine taşı
TARIH=$(date '+%Y%m%d_%H%M%S')
FINAL_DOSYA="$YEDEK_DIZINI/yedek_$TARIH.tar.gz"
tar -czf "$FINAL_DOSYA" -C "$GECICI_DIZIN" .

log "INFO" "Yedekleme tamamlandı: $FINAL_DOSYA ($(du -sh "$FINAL_DOSYA" | cut -f1))"

# 30 günden eski yedekleri temizle
find "$YEDEK_DIZINI" -name "yedek_*.tar.gz" -mtime +30 -delete
log "INFO" "Eski yedekler temizlendi"

Bu script, birden fazla senaryo için trap kullanıyor: normal çıkışta temizlik, sinyal durumunda zarif kapanış, hata durumunda bildirim.

DEBUG Sinyali ile Script Takibi

DEBUG sinyali her komuttan önce tetiklenir. Üretimde pek kullanılmaz ama hata ayıklama ve audit log oluşturmak için çok işe yarar:

#!/bin/bash

AUDIT_LOG="/var/log/script_audit.log"

komut_takip() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S.%N')] [PID:$$] KOMUT: $BASH_COMMAND" >> "$AUDIT_LOG"
}

# Sadece belirli kritik bölümlerde aktif et
trap komut_takip DEBUG

# Takip edilmesini istediğimiz kritik bölüm
echo "Kritik bölüm başlıyor"
useradd -m yeni_kullanici
passwd yeni_kullanici
usermod -aG sudo yeni_kullanici
echo "Kritik bölüm bitti"

# DEBUG trapini kaldır
trap - DEBUG

echo "Bu komut artık takip edilmiyor"

İç İçe trap Kullanımı ve Alt Scriptlerde Dikkat Edilecekler

Alt scriptler (subshell) üst scriptin trap tanımlarını miras almaz. Sinyal davranışını kontrol etmek istiyorsanız her scriptte ayrı tanımlamanız gerekir:

#!/bin/bash

PARCA_SCRIPTLERI=()
PARCA_PIDLERI=()

tum_parcelari_durdur() {
    echo "Ana script sonlanıyor, parçalar durduruluyor..."
    for pid in "${PARCA_PIDLERI[@]}"; do
        if kill -0 "$pid" 2>/dev/null; then
            kill -TERM "$pid"
            echo "PID $pid durduruldu"
        fi
    done
    wait
    echo "Tüm parçalar durduruldu"
}

trap tum_parcelari_durdur EXIT INT TERM

# Alt işleri arka planda başlat
for i in 1 2 3; do
    (
        # Alt scriptte de kendi trap tanımı
        trap 'echo "Parça $i temizlendi"; rm -f /tmp/parca_$i.pid' EXIT
        
        echo $$ > "/tmp/parca_$i.pid"
        while true; do
            echo "Parça $i çalışıyor..."
            sleep 5
        done
    ) &
    
    PARCA_PIDLERI+=($!)
    echo "Parça $i başlatıldı, PID: $!"
done

echo "Tüm parçalar çalışıyor. Durdurmak için Ctrl+C"
wait

trap ile Rollback Mekanizması

Deployment scriptlerinde yanlış giden bir şey olduğunda önceki duruma dönmek hayat kurtarır:

#!/bin/bash

set -euo pipefail

UYGULAMA_DIZINI="/opt/uygulamam"
YEDEK_DIZINI="/opt/uygulamam_yedek"
SERVIS_ADI="uygulamam"
ROLLBACK_GEREKLI=false

log() { echo "[$(date '+%H:%M:%S')] $*"; }

rollback() {
    local cikis=$?
    
    if $ROLLBACK_GEREKLI && [ $cikis -ne 0 ]; then
        log "HATA ALINDI! Rollback başlatılıyor..."
        
        systemctl stop "$SERVIS_ADI" 2>/dev/null || true
        
        if [ -d "$YEDEK_DIZINI" ]; then
            rm -rf "$UYGULAMA_DIZINI"
            mv "$YEDEK_DIZINI" "$UYGULAMA_DIZINI"
            systemctl start "$SERVIS_ADI"
            log "Rollback tamamlandı, eski sürüm geri yüklendi"
        else
            log "KRITIK: Yedek dizin bulunamadı, manuel müdahale gerekli!"
        fi
    elif [ -d "$YEDEK_DIZINI" ] && [ $cikis -eq 0 ]; then
        # Başarılı deployment, yedeği temizle
        rm -rf "$YEDEK_DIZINI"
        log "Deployment başarılı, yedek temizlendi"
    fi
}

trap rollback EXIT

log "Deployment başlıyor..."

# Mevcut versiyonu yedekle
cp -r "$UYGULAMA_DIZINI" "$YEDEK_DIZINI"
log "Mevcut versiyon yedeklendi"

# Artık rollback aktif
ROLLBACK_GEREKLI=true

log "Servis durduruluyor..."
systemctl stop "$SERVIS_ADI"

log "Yeni dosyalar kopyalanıyor..."
rsync -av --delete /deploy/yeni_surum/ "$UYGULAMA_DIZINI/"

log "Bağımlılıklar güncelleniyor..."
cd "$UYGULAMA_DIZINI"
pip install -r requirements.txt -q

log "Veritabanı migrasyonu çalıştırılıyor..."
python manage.py migrate --noinput

log "Servis başlatılıyor..."
systemctl start "$SERVIS_ADI"

sleep 3

log "Sağlık kontrolü yapılıyor..."
systemctl is-active "$SERVIS_ADI"
curl -sf http://localhost:8080/health > /dev/null

log "Deployment başarıyla tamamlandı!"

Bu örnekte ROLLBACK_GEREKLI bayrağını kullanarak sadece kısmen tamamlanmış deployment’larda rollback yapmayı sağlıyoruz. Yedekleme henüz tamamlanmamışken script çökerse rollback yapılmaz.

Sinyal Maskeleme ve Kritik Bölümler

Bazı durumlarda kodun belirli bir bölümünün kesinlikle kesintisiz çalışmasını isteyebilirsiniz. Sinyal maskeleyerek bunu sağlayabilirsiniz:

#!/bin/bash

# Sinyal maskeleme: kritik bölümde kesintisiz çalış
kritik_islem() {
    local geciktirilmis_sinyal=""
    
    # Kritik bölümde INT ve TERM'i geciktir
    trap 'geciktirilmis_sinyal=INT' INT
    trap 'geciktirilmis_sinyal=TERM' TERM
    
    echo "Kritik işlem başlıyor, kesilmeyecek..."
    
    # Kritik veritabanı işlemi - bu bloğun ortada kesilmesi veri kaybına yol açar
    mysql -e "BEGIN;"
    mysql -e "UPDATE hesaplar SET bakiye = bakiye - 100 WHERE id = 1;"
    mysql -e "UPDATE hesaplar SET bakiye = bakiye + 100 WHERE id = 2;"
    mysql -e "COMMIT;"
    
    echo "Kritik işlem tamamlandı"
    
    # Normal sinyal işleyicilerini geri yükle
    trap - INT TERM
    
    # Geciktirilmiş sinyal varsa şimdi işle
    if [ -n "$geciktirilmis_sinyal" ]; then
        echo "Geciktirilmiş sinyal işleniyor: $geciktirilmis_sinyal"
        kill -"$geciktirilmis_sinyal" $$
    fi
}

trap 'echo "INT alındı, kritik işlem sonrası çıkılacak"; exit 130' INT
trap 'echo "TERM alındı"; exit 143' TERM

echo "Script başladı"
kritik_islem
echo "Script devam ediyor..."

Pratik İpuçları ve Sık Yapılan Hatalar

trap kullanırken dikkat edilmesi gereken bazı noktalar var:

  • Değişken expansion zamanlaması: trap 'echo $DEGISKEN' EXIT ifadesinde $DEGISKEN, trap tanımlanırken değil çalışırken genişletilir. Bu genellikle istenen davranıştır ama farkında olun.
  • trap bir fonksiyona atanırsa: Fonksiyon içindeki return kodu değil, fonksiyonu tetikleyen olayın çıkış kodu $? olarak gelir. Bunu düzgün yakalamak için fonksiyonun başında local cikis=$? yapın.
  • set -e ve trap ERR birlikteliği: set -e aktifken bir komut başarısız olduğunda hem ERR hem EXIT tetiklenir. Bunu hesaba katın.
  • Subshell’lerde trap: Parantez içindeki komutlar (komutlar) alt proseste çalışır ve üst scriptin trap’lerini miras almaz. Pipe içindeki komutlar da öyle.
  • exit kodunu korumak: EXIT handler’ı içinde yeni komutlar çalıştırırsanız $? değeri değişir. Başta kaydedin:
temizle() {
    local CIKIS_KODU=$?
    
    # Temizlik komutları $? değiştirse de orijinal kodu koruduk
    rm -rf "$GECICI_DIR"
    
    # Orijinal çıkış kodu ile çık
    exit $CIKIS_KODU
}

trap temizle EXIT
  • İnteraktif ve non-interaktif davranış farkı: Cron’dan çalışan scriptlerde terminal sinyalleri gelmez ama SIGTERM process sonlandırma için hala kullanılır.

Sonuç

trap, bash scriptlerinizi gerçek production kalitesine taşıyan en önemli araçlardan biri. Temizlik işlemleri, rollback mekanizmaları, graceful shutdown, hata bildirimleri, hepsi trap ile tutarlı ve güvenilir hale gelir.

Önerdiğim temel kurallar şunlar: Her script başında en azından trap temizle EXIT koyun, kritik scriptlerde INT ve TERM sinyallerini ayrı ele alın, set -euo pipefail ile birlikte ERR trapini kullanmayı düşünün. Lock dosyaları, geçici dizinler, açık bağlantılar ne kadar önemli bir kaynaksa o kadar dikkatli bir temizlik stratejisi kurun.

En önemli nokta şu: Script’in NASIL sonlandığı değil, sonlandıktan sonra sistemi hangi durumda bıraktığı önemlidir. trap size bu kontrolü verir. Kullanın.

Yorum yapın