Python ile Otomatik Yedekleme Scripti Nasıl Yazılır

Yedekleme işi “bir gün yapacağım” listesinde en uzun kalan görevlerden biridir. Ta ki bir şeyler ters gidene kadar. Disk çöker, yanlışlıkla rm -rf çalışır ya da bir uygulama güncellemesi her şeyi berbat eder ve o an geldiğinde fark edersiniz ki son yedek iki ay önceymiş. Bu yazıda Python ile sağlam, esnek ve gerçekten işe yarayan bir otomatik yedekleme scripti yazacağız. Hem Linux hem Windows ortamlarını göz önünde bulundurarak, production’da kullanabileceğiniz seviyede bir çözüm geliştireceğiz.

Neden Python ile Yedekleme?

Bash scripti de yazabilirsiniz, cron job da kurabilirsiniz ama Python size çok daha fazlasını verir. Hata yönetimi, loglama, e-posta bildirimi, cross-platform çalışma ve karmaşık mantık kurma konularında Python açık ara öndedir. Üstüne üstlük standart kütüphaneler ile çoğu işi dış bağımlılık olmadan halledebilirsiniz.

Yazacağımız script şunları yapacak:

  • Belirtilen dizinleri tarball veya zip olarak sıkıştırıp yedekleyecek
  • Tarih damgalı dosya adları oluşturacak
  • Eski yedekleri otomatik temizleyecek (retention policy)
  • Tüm işlemleri log dosyasına yazacak
  • Hata durumunda e-posta gönderecek
  • Konfigürasyonu ayrı bir dosyadan okuyacak

Ortam Hazırlığı

Önce Python versiyonumuzu kontrol edelim ve gerekli yapıyı oluşturalım:

python3 --version
# Python 3.8+ olması yeterli, ekstra paket gerekmez

mkdir -p ~/backup_scripts
cd ~/backup_scripts
touch backup.py
touch backup_config.json
touch backup.log

Eğer e-posta bildirimi için daha gelişmiş bir kütüphane kullanmak isterseniz sadece şu paketi kurmanız yeterli:

pip3 install schedule
# Periyodik çalıştırma için, cron yerine Python içinde zamanlama yapmak isteyenler için

Standart kütüphaneler olan os, shutil, tarfile, zipfile, logging, smtplib ve json modülleri Python ile zaten geliyor.

Konfigürasyon Dosyası

Her şeyi script içine gömmek yerine ayrı bir JSON konfigürasyon dosyası kullanmak çok daha temiz bir yaklaşım. Böylece script’e dokunmadan ayarları değiştirebilirsiniz:

cat > backup_config.json << 'EOF'
{
    "backup_sources": [
        "/var/www/html",
        "/etc/nginx",
        "/home/deploy/app",
        "/var/lib/postgresql/data"
    ],
    "backup_destination": "/mnt/backup",
    "backup_format": "tar.gz",
    "retention_days": 30,
    "max_backup_count": 10,
    "log_file": "/var/log/backup.log",
    "compression_level": 9,
    "email_notifications": {
        "enabled": true,
        "smtp_server": "smtp.gmail.com",
        "smtp_port": 587,
        "sender": "[email protected]",
        "recipients": ["[email protected]", "[email protected]"],
        "on_success": false,
        "on_failure": true
    },
    "exclude_patterns": [
        "*.tmp",
        "*.log",
        "__pycache__",
        "node_modules",
        ".git"
    ]
}
EOF

on_success: false ayarı dikkat çekici. Her başarılı yedekte mail almak istemezsiniz, posta kutunuz çöp olur. Ama hata olduğunda anında haberdar olmak şart.

Temel Yedekleme Fonksiyonları

Şimdi asıl scripti yazmaya başlayalım. Önce temel yapıyı ve yardımcı fonksiyonları oluşturalım:

cat > backup.py << 'PYEOF'
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import sys
import json
import shutil
import tarfile
import zipfile
import logging
import smtplib
import hashlib
import fnmatch
from datetime import datetime, timedelta
from pathlib import Path
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart


def load_config(config_path: str) -> dict:
    """Konfigürasyon dosyasını yükler ve doğrular."""
    try:
        with open(config_path, 'r', encoding='utf-8') as f:
            config = json.load(f)
        
        # Zorunlu alanları kontrol et
        required_keys = ['backup_sources', 'backup_destination']
        for key in required_keys:
            if key not in config:
                raise ValueError(f"Konfigürasyonda eksik alan: {key}")
        
        return config
    except FileNotFoundError:
        print(f"HATA: Konfigürasyon dosyası bulunamadı: {config_path}")
        sys.exit(1)
    except json.JSONDecodeError as e:
        print(f"HATA: Konfigürasyon dosyası geçersiz JSON: {e}")
        sys.exit(1)


def setup_logging(log_file: str) -> logging.Logger:
    """Logging sistemini yapılandırır."""
    logger = logging.getLogger('BackupScript')
    logger.setLevel(logging.DEBUG)
    
    formatter = logging.Formatter(
        '%(asctime)s | %(levelname)-8s | %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    
    # Dosyaya yaz
    file_handler = logging.FileHandler(log_file, encoding='utf-8')
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(formatter)
    
    # Konsola yaz
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)
    console_handler.setFormatter(formatter)
    
    logger.addHandler(file_handler)
    logger.addHandler(console_handler)
    
    return logger
PYEOF

Sıkıştırma ve Yedek Oluşturma

Birden fazla format desteği ekleyerek hem Linux hem Windows uyumlu hale getirelim:

cat >> backup.py << 'PYEOF'

def should_exclude(path: str, exclude_patterns: list) -> bool:
    """Dosyanın exclude listesinde olup olmadığını kontrol eder."""
    filename = os.path.basename(path)
    for pattern in exclude_patterns:
        if fnmatch.fnmatch(filename, pattern):
            return True
        # Tam yol eşleşmesi de kontrol et
        if pattern in path:
            return True
    return False


def create_tar_backup(source_path: str, dest_file: str, 
                      exclude_patterns: list, compression: int,
                      logger: logging.Logger) -> bool:
    """TAR.GZ formatında yedek oluşturur."""
    try:
        mode = 'w:gz'
        
        def exclude_filter(tarinfo):
            if should_exclude(tarinfo.name, exclude_patterns):
                logger.debug(f"Atlandı: {tarinfo.name}")
                return None
            return tarinfo
        
        with tarfile.open(dest_file, mode, 
                         compresslevel=compression) as tar:
            tar.add(source_path, 
                   arcname=os.path.basename(source_path),
                   filter=exclude_filter)
        
        return True
    except tarfile.TarError as e:
        logger.error(f"TAR oluşturma hatası: {e}")
        return False
    except PermissionError as e:
        logger.error(f"Yetki hatası: {e}")
        return False


def create_zip_backup(source_path: str, dest_file: str,
                      exclude_patterns: list,
                      logger: logging.Logger) -> bool:
    """ZIP formatında yedek oluşturur (Windows uyumlu)."""
    try:
        with zipfile.ZipFile(dest_file, 'w', 
                            zipfile.ZIP_DEFLATED) as zipf:
            if os.path.isfile(source_path):
                zipf.write(source_path, os.path.basename(source_path))
            else:
                for root, dirs, files in os.walk(source_path):
                    # Exclude listesindeki dizinleri atla
                    dirs[:] = [d for d in dirs 
                               if not should_exclude(d, exclude_patterns)]
                    
                    for file in files:
                        if should_exclude(file, exclude_patterns):
                            logger.debug(f"Atlandı: {file}")
                            continue
                        
                        file_path = os.path.join(root, file)
                        arcname = os.path.relpath(file_path, 
                                                  os.path.dirname(source_path))
                        zipf.write(file_path, arcname)
        return True
    except Exception as e:
        logger.error(f"ZIP oluşturma hatası: {e}")
        return False


def calculate_checksum(file_path: str) -> str:
    """SHA256 checksum hesaplar."""
    sha256 = hashlib.sha256()
    with open(file_path, 'rb') as f:
        for chunk in iter(lambda: f.read(8192), b''):
            sha256.update(chunk)
    return sha256.hexdigest()
PYEOF

Checksum hesaplama kritik bir detay. Yedek dosyası oluştu ama bozuk mu? Checksum olmadan bilemezsiniz. Yedek aldınız ama restore edemiyorsanız hiç almamıştan farksız.

Ana Yedekleme Mantığı

cat >> backup.py << 'PYEOF'

def run_backup(config: dict, logger: logging.Logger) -> dict:
    """Ana yedekleme işlemini çalıştırır."""
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    backup_dest = config['backup_destination']
    backup_format = config.get('backup_format', 'tar.gz')
    exclude_patterns = config.get('exclude_patterns', [])
    compression = config.get('compression_level', 6)
    
    # Hedef dizini oluştur
    os.makedirs(backup_dest, exist_ok=True)
    
    results = {
        'timestamp': timestamp,
        'successful': [],
        'failed': [],
        'total_size': 0
    }
    
    for source in config['backup_sources']:
        if not os.path.exists(source):
            logger.warning(f"Kaynak bulunamadı, atlanıyor: {source}")
            results['failed'].append({
                'source': source,
                'error': 'Kaynak dizin/dosya bulunamadı'
            })
            continue
        
        # Dosya adını oluştur
        source_name = source.strip('/').replace('/', '_')
        if backup_format == 'zip':
            filename = f"{source_name}_{timestamp}.zip"
        else:
            filename = f"{source_name}_{timestamp}.tar.gz"
        
        dest_file = os.path.join(backup_dest, filename)
        
        logger.info(f"Yedekleniyor: {source} --> {dest_file}")
        start_time = datetime.now()
        
        # Yedek oluştur
        success = False
        if backup_format == 'zip':
            success = create_zip_backup(source, dest_file, 
                                       exclude_patterns, logger)
        else:
            success = create_tar_backup(source, dest_file,
                                       exclude_patterns, compression, logger)
        
        if success:
            elapsed = (datetime.now() - start_time).seconds
            file_size = os.path.getsize(dest_file)
            checksum = calculate_checksum(dest_file)
            
            # Checksum dosyası oluştur
            checksum_file = dest_file + '.sha256'
            with open(checksum_file, 'w') as f:
                f.write(f"{checksum}  {filename}n")
            
            results['successful'].append({
                'source': source,
                'dest': dest_file,
                'size_mb': round(file_size / (1024 * 1024), 2),
                'duration_sec': elapsed,
                'checksum': checksum
            })
            results['total_size'] += file_size
            
            logger.info(f"Tamamlandı: {filename} | "
                       f"Boyut: {file_size/(1024*1024):.2f} MB | "
                       f"Süre: {elapsed}s")
        else:
            results['failed'].append({
                'source': source,
                'error': 'Yedek oluşturma başarısız'
            })
            logger.error(f"Başarısız: {source}")
    
    return results
PYEOF

Eski Yedekleri Temizleme (Retention Policy)

Yedek alıp temizlememek en klasik hatalardan biridir. Disk dolar, sistem çöker, günah başa döner:

cat >> backup.py << 'PYEOF'

def cleanup_old_backups(config: dict, logger: logging.Logger) -> int:
    """Retention policy'e göre eski yedekleri temizler."""
    backup_dest = config['backup_destination']
    retention_days = config.get('retention_days', 30)
    max_count = config.get('max_backup_count', 0)
    
    if not os.path.exists(backup_dest):
        return 0
    
    deleted_count = 0
    cutoff_date = datetime.now() - timedelta(days=retention_days)
    
    # Tüm yedek dosyalarını bul
    backup_files = []
    for f in Path(backup_dest).glob('*.tar.gz'):
        backup_files.append(f)
    for f in Path(backup_dest).glob('*.zip'):
        backup_files.append(f)
    
    # Tarih bazlı temizlik
    for backup_file in backup_files:
        file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
        if file_mtime < cutoff_date:
            try:
                backup_file.unlink()
                # Checksum dosyasını da sil
                checksum_file = Path(str(backup_file) + '.sha256')
                if checksum_file.exists():
                    checksum_file.unlink()
                
                logger.info(f"Eski yedek silindi: {backup_file.name} "
                           f"({file_mtime.strftime('%Y-%m-%d')})")
                deleted_count += 1
            except Exception as e:
                logger.error(f"Silme hatası: {backup_file.name}: {e}")
    
    # Maksimum sayı bazlı temizlik
    if max_count > 0:
        remaining_files = sorted(
            [f for f in Path(backup_dest).glob('*.tar.gz')] +
            [f for f in Path(backup_dest).glob('*.zip')],
            key=lambda x: x.stat().st_mtime,
            reverse=True
        )
        
        if len(remaining_files) > max_count:
            for old_file in remaining_files[max_count:]:
                try:
                    old_file.unlink()
                    checksum_file = Path(str(old_file) + '.sha256')
                    if checksum_file.exists():
                        checksum_file.unlink()
                    logger.info(f"Limit aşımı, silindi: {old_file.name}")
                    deleted_count += 1
                except Exception as e:
                    logger.error(f"Silme hatası: {e}")
    
    return deleted_count
PYEOF

E-posta Bildirimi

cat >> backup.py << 'PYEOF'

def send_notification(config: dict, results: dict, 
                      logger: logging.Logger) -> None:
    """Yedekleme sonucunu e-posta ile bildirir."""
    email_config = config.get('email_notifications', {})
    
    if not email_config.get('enabled', False):
        return
    
    has_failure = len(results['failed']) > 0
    
    # Ne zaman mail gönderileceğini kontrol et
    if has_failure and not email_config.get('on_failure', True):
        return
    if not has_failure and not email_config.get('on_success', False):
        return
    
    status = "BASARISIZ" if has_failure else "BASARILI"
    hostname = os.uname().nodename if hasattr(os, 'uname') else os.environ.get('COMPUTERNAME', 'unknown')
    
    subject = f"[{status}] Yedekleme Raporu - {hostname} - {results['timestamp']}"
    
    # Mail içeriği oluştur
    body_lines = [
        f"Sunucu: {hostname}",
        f"Tarih/Saat: {results['timestamp']}",
        f"Toplam boyut: {results['total_size']/(1024*1024):.2f} MB",
        "",
        f"Basarili: {len(results['successful'])} kaynak",
        f"Basarisiz: {len(results['failed'])} kaynak",
        ""
    ]
    
    if results['successful']:
        body_lines.append("=== BASARILI YEDEKLER ===")
        for item in results['successful']:
            body_lines.append(
                f"  + {item['source']} -> {item['size_mb']} MB ({item['duration_sec']}s)"
            )
    
    if results['failed']:
        body_lines.append("")
        body_lines.append("=== BASARISIZ YEDEKLER ===")
        for item in results['failed']:
            body_lines.append(f"  - {item['source']}: {item['error']}")
    
    body = "n".join(body_lines)
    
    try:
        msg = MIMEMultipart()
        msg['From'] = email_config['sender']
        msg['To'] = ', '.join(email_config['recipients'])
        msg['Subject'] = subject
        msg.attach(MIMEText(body, 'plain', 'utf-8'))
        
        with smtplib.SMTP(email_config['smtp_server'], 
                         email_config['smtp_port']) as server:
            server.ehlo()
            server.starttls()
            server.login(
                email_config.get('username', email_config['sender']),
                email_config.get('password', '')
            )
            server.sendmail(
                email_config['sender'],
                email_config['recipients'],
                msg.as_string()
            )
        
        logger.info("Bildirim maili gönderildi")
    except Exception as e:
        logger.error(f"Mail gönderilemedi: {e}")
PYEOF

Main Fonksiyonu ve Çalıştırma

cat >> backup.py << 'PYEOF'

def main():
    """Ana giriş noktası."""
    import argparse
    
    parser = argparse.ArgumentParser(description='Otomatik Yedekleme Scripti')
    parser.add_argument('--config', default='backup_config.json',
                       help='Konfigürasyon dosyası yolu')
    parser.add_argument('--dry-run', action='store_true',
                       help='Gerçekte yedek almadan simüle et')
    parser.add_argument('--cleanup-only', action='store_true',
                       help='Sadece eski yedekleri temizle')
    args = parser.parse_args()
    
    # Konfigürasyonu yükle
    config = load_config(args.config)
    
    # Logging'i başlat
    log_file = config.get('log_file', '/var/log/backup.log')
    logger = setup_logging(log_file)
    
    logger.info("=" * 60)
    logger.info("Yedekleme scripti başlatıldı")
    
    if args.dry_run:
        logger.info("DRY-RUN modu: Gerçek yedek alınmayacak")
        for source in config['backup_sources']:
            exists = "VAR" if os.path.exists(source) else "YOK"
            logger.info(f"  [{exists}] {source}")
        return
    
    if args.cleanup_only:
        deleted = cleanup_old_backups(config, logger)
        logger.info(f"Temizlik tamamlandı: {deleted} dosya silindi")
        return
    
    # Yedekleme çalıştır
    results = run_backup(config, logger)
    
    # Eski yedekleri temizle
    deleted = cleanup_old_backups(config, logger)
    logger.info(f"Temizlenen eski yedek: {deleted}")
    
    # Özet
    total_mb = results['total_size'] / (1024 * 1024)
    logger.info(f"Tamamlandi | Basarili: {len(results['successful'])} | "
               f"Basarisiz: {len(results['failed'])} | "
               f"Toplam: {total_mb:.2f} MB")
    
    # Bildirim gönder
    send_notification(config, results, logger)
    
    # Başarısız yedek varsa hata kodu ile çık
    if results['failed']:
        sys.exit(1)


if __name__ == '__main__':
    main()
PYEOF

Cron Job ile Otomatik Zamanlama

Script hazır, şimdi bunu düzenli çalışacak hale getirelim:

# Scripti çalıştırılabilir yap
chmod +x /opt/backup/backup.py

# Crontab'a ekle
crontab -e

# Her gece 02:00'de çalıştır
0 2 * * * /usr/bin/python3 /opt/backup/backup.py --config /opt/backup/backup_config.json >> /var/log/backup_cron.log 2>&1

# Pazar günleri haftalık tam yedek (farklı konfigürasyon ile)
0 3 * * 0 /usr/bin/python3 /opt/backup/backup.py --config /opt/backup/backup_weekly.json >> /var/log/backup_weekly.log 2>&1

# Her ayın 1'inde eski yedekleri temizle
0 4 1 * * /usr/bin/python3 /opt/backup/backup.py --cleanup-only --config /opt/backup/backup_config.json

Test ve Doğrulama

Scripti production’a almadan önce test etmek kritik:

# Dry-run ile test et
python3 backup.py --config backup_config.json --dry-run

# Gerçek yedek al (terminal'de izleyerek)
python3 backup.py --config backup_config.json

# Oluşan yedekleri listele
ls -lh /mnt/backup/

# Checksum'ları doğrula
cd /mnt/backup
sha256sum -c *.sha256

# TAR dosyasının bütünlüğünü kontrol et
tar -tzf var_www_html_20241215_020001.tar.gz | head -20

# Test restore (geçici dizine)
mkdir /tmp/test_restore
tar -xzf var_www_html_20241215_020001.tar.gz -C /tmp/test_restore
ls -la /tmp/test_restore/

Önemli kural: Restore etmediğiniz yedek, yedek değildir. Ayda bir kez test restore yapın.

Gerçek Dünya Senaryosu: PostgreSQL Yedekleme

Veritabanını tarball ile yedeklemek doğru değil. Önce dump alıp sonra sıkıştırmak gerekiyor. Bunu konfigürasyona eklemek için script’e pre-backup hook ekleyebilirsiniz:

# PostgreSQL dump'ını önceden al
cat > /opt/backup/pre_backup.sh << 'EOF'
#!/bin/bash
DUMP_DIR="/var/backup/db_dumps"
mkdir -p $DUMP_DIR
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# Tüm veritabanlarını dump al
pg_dumpall -U postgres > "$DUMP_DIR/all_databases_${TIMESTAMP}.sql"
gzip "$DUMP_DIR/all_databases_${TIMESTAMP}.sql"

echo "DB dump tamamlandı: $DUMP_DIR"
EOF

chmod +x /opt/backup/pre_backup.sh

# Crontab'da önce pre-backup, sonra Python scripti çalıştır
# 55 1 * * * /opt/backup/pre_backup.sh
# 0  2 * * * python3 /opt/backup/backup.py --config /opt/backup/backup_config.json

/var/backup/db_dumps dizinini backup_sources listesine eklediğinizde hem uygulama dosyaları hem de veritabanı yedeği tek scriptle yönetilmiş olur.

Uzak Sunucuya Yedek Kopyalama

Yerel disk yedeği tek başına yeterli değil. Aynı sunucu yanarsa ne olacak? Basit bir rsync entegrasyonu ekleyelim:

# Yedekleri uzak sunucuya kopyala
cat > /opt/backup/sync_to_remote.sh << 'EOF'
#!/bin/bash
LOCAL_BACKUP="/mnt/backup"
REMOTE_USER="backup_user"
REMOTE_HOST="backup-server.sirketim.com"
REMOTE_PATH="/backup/$(hostname)"

# SSH key ile kimlik doğrulama kullan (parola KULLANMA)
rsync -avz --delete 
      --exclude='*.tmp' 
      -e "ssh -i /home/deploy/.ssh/backup_key -p 2222" 
      "$LOCAL_BACKUP/" 
      "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"

if [ $? -eq 0 ]; then
    echo "$(date): Uzak senkronizasyon basarili" >> /var/log/backup_sync.log
else
    echo "$(date): Uzak senkronizasyon BASARISIZ" >> /var/log/backup_sync.log
fi
EOF

chmod +x /opt/backup/sync_to_remote.sh

Sonuç

Bu yazıda sıfırdan başlayıp production’a hazır bir yedekleme sistemi inşa ettik. Script’in en güçlü yanları şunlar: konfigürasyon bazlı esnek yapı, checksum doğrulaması, retention policy ve e-posta bildirimi. Ama şunu unutmayın: bu script çalışıyor diye işiniz bitti değil.

Yapmanız gerekenler:

  • Ayda bir restore testi yapın, gerçekten geri dönebildiğinizi doğrulayın
  • Log dosyalarını düzenli gözden geçirin, sessiz hataları yakalayın
  • Disk doluluk oranını izleyin, yedek birikmesi sistemi çökertebilir
  • Birden fazla lokasyon kullanın, 3-2-1 kuralını (3 kopya, 2 farklı medya, 1 uzak lokasyon) takip edin
  • SMTP parolalarını config dosyasına düz metin yazmak yerine environment variable kullanın

Script’i kendi ortamınıza göre uyarlamaktan çekinmeyin. İçinde bulunduğumuz kaynak kod yapısı modüler tasarlandığı için yeni özellik eklemek oldukça kolay. Bir sonraki adım olarak AWS S3 veya Minio’ya yükleme, Telegram bildirimi ya da Prometheus metriği ekleme gibi geliştirmeler yapabilirsiniz. Kod GitHub’da tutun, versiyonlayın ve değişiklikleri takip edin. Yedekleme scriptiniz de verileriniz kadar değerli.

Yorum yapın