auto backup sqlite
This commit is contained in:
1 parent
1672de549a
commit
58f8a49e24
3 files changed
+175
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import logging
|
||||
import shutil
|
||||
import fcntl
|
||||
import signal
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger('django.production.backup')
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Enterprise-grade, Chunked, URI Read-Only SQLite WAL backup for Docker/Swarm.'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# اتصال سیگنالهای توقف داکر به متد خروج امن
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
self.lock_file = None
|
||||
self.local_backup_path = None
|
||||
self._shutdown_requested = False
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""
|
||||
فقط فلگ را تغییر میدهیم. خروج ناگهانی (sys.exit) باعث خرابی میشود.
|
||||
انجین در چرخه بعدی متوجه این فلگ شده و با احترام خارج میشود.
|
||||
"""
|
||||
logger.warning(f"OS Signal {signum} received from Coolify/Docker. Initiating graceful abort...")
|
||||
self._shutdown_requested = True
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# ۱. قفل انحصاری (محل قفل میتواند در /tmp باشد چون حجمش صفر است)
|
||||
lock_file_path = "/tmp/django_sqlite_backup.lock"
|
||||
self.lock_file = open(lock_file_path, "w")
|
||||
|
||||
try:
|
||||
fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except IOError:
|
||||
self.stderr.write(self.style.WARNING("ABORTED: Another backup cronjob is currently running."))
|
||||
return
|
||||
|
||||
required_envs = ['S3_ACCESS_KEY', 'S3_SECRET_KEY', 'S3_ENDPOINT', 'S3_BUCKET']
|
||||
missing_envs = [env for env in required_envs if not os.getenv(env)]
|
||||
if missing_envs:
|
||||
self.handle_critical_failure(f"Missing S3 Configuration ENVs: {', '.join(missing_envs)}")
|
||||
return
|
||||
|
||||
db_path = Path(settings.DATABASES['default']['NAME']).absolute()
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f"db_backup_{timestamp}.sqlite3"
|
||||
|
||||
# مهم: ذخیره فایل موقت در کنار دیتابیس اصلی برای جلوگیری از افتادن در تله tmpfs (RAM)
|
||||
self.local_backup_path = db_path.parent / backup_filename
|
||||
|
||||
src_conn = None
|
||||
dst_conn = None
|
||||
|
||||
try:
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"Source database not found at {db_path}")
|
||||
|
||||
db_size = db_path.stat().st_size
|
||||
_, _, free_space = shutil.disk_usage(db_path.parent)
|
||||
|
||||
if free_space < (db_size * 1.5):
|
||||
raise IOError(f"Insufficient volume space. Req: {db_size*1.5}B, Free: {free_space}B")
|
||||
|
||||
logger.info("Initializing Zero-Impact Read-Only connection to production DB...")
|
||||
db_uri = f"file:{db_path}?mode=ro"
|
||||
src_conn = sqlite3.connect(db_uri, uri=True, timeout=30)
|
||||
dst_conn = sqlite3.connect(self.local_backup_path)
|
||||
|
||||
logger.info("Executing native SQLite C-Level chunked backup (Preventing WAL Starvation)...")
|
||||
|
||||
# کالبک هوشمند برای مانیتورینگ وضعیت کپی و بررسی سیگنال توقف
|
||||
def progress_callback(status, remaining, total):
|
||||
if self._shutdown_requested:
|
||||
# با پرتاب این اکسپشن، انجین C کپی را متوقف کرده و کنترل را به پایتون پس میدهد
|
||||
raise InterruptedError("Graceful abort: Container shutdown requested.")
|
||||
return None # ادامه عملیات
|
||||
|
||||
with dst_conn:
|
||||
# استفاده از قابلیتهای نیتیو: کپی ۲۵۰ صفحه، ۵۰ میلیثانیه تنفس به دیتابیس زنده
|
||||
# این کار حلقه While را حذف کرده و مدیریت را به سطح کرنل/انجین C میسپارد
|
||||
src_conn.backup(
|
||||
dst_conn,
|
||||
pages=250,
|
||||
sleep=0.05,
|
||||
progress=progress_callback
|
||||
)
|
||||
|
||||
dst_conn.close()
|
||||
src_conn.close()
|
||||
src_conn = dst_conn = None
|
||||
|
||||
# اگر توقف به دلیل ریاستارت سرور بوده، فایل ناقص را آپلود نمیکنیم
|
||||
if self._shutdown_requested:
|
||||
logger.info("Backup safely aborted before S3 upload.")
|
||||
return
|
||||
|
||||
if not self.local_backup_path.exists() or self.local_backup_path.stat().st_size == 0:
|
||||
raise ValueError("Generated snapshot is missing or 0 bytes.")
|
||||
|
||||
# ۲. انتقال امن به S3
|
||||
s3_config = Config(
|
||||
retries={'max_attempts': 5, 'mode': 'standard'},
|
||||
connect_timeout=15,
|
||||
read_timeout=60 # افزایش زمان برای فایلهای بزرگ
|
||||
)
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=os.getenv('S3_ACCESS_KEY'),
|
||||
aws_secret_access_key=os.getenv('S3_SECRET_KEY'),
|
||||
endpoint_url=os.getenv('S3_ENDPOINT'),
|
||||
config=s3_config
|
||||
)
|
||||
|
||||
bucket_name = os.getenv('S3_BUCKET')
|
||||
s3_key = f"backups/{backup_filename}"
|
||||
|
||||
logger.info("Transferring encrypted snapshot to S3 Object Storage...")
|
||||
# Boto3 به صورت خودکار برای فایلهای بزرگ از Multipart Upload استفاده میکند
|
||||
s3_client.upload_file(Filename=str(self.local_backup_path), Bucket=bucket_name, Key=s3_key)
|
||||
|
||||
# ۳. اعتبارسنجی نهایی
|
||||
s3_metadata = s3_client.head_object(Bucket=bucket_name, Key=s3_key)
|
||||
if s3_metadata['ContentLength'] != self.local_backup_path.stat().st_size:
|
||||
raise ValueError("Integrity breach: Size mismatch inside S3 bucket!")
|
||||
|
||||
success_msg = f"SUCCESS: Production backup {backup_filename} verified and secured."
|
||||
logger.info(success_msg)
|
||||
self.stdout.write(self.style.SUCCESS(success_msg))
|
||||
|
||||
except InterruptedError as e:
|
||||
# هندل کردن لاگ توقف امن (این یک فاجعه نیست، رفتار سیستم است)
|
||||
logger.warning(str(e))
|
||||
except Exception as e:
|
||||
self.handle_critical_failure(f"CRITICAL BACKUP FAILURE: {str(e)}")
|
||||
|
||||
finally:
|
||||
# تضمین آزادسازی منابع تحت هر شرایطی
|
||||
if src_conn:
|
||||
try: src_conn.close()
|
||||
except: pass
|
||||
if dst_conn:
|
||||
try: dst_conn.close()
|
||||
except: pass
|
||||
|
||||
self.cleanup_resources()
|
||||
|
||||
def cleanup_resources(self):
|
||||
"""تضمین پاکسازی Volume های داکر در هر شرایطی"""
|
||||
if self.local_backup_path and self.local_backup_path.exists():
|
||||
try:
|
||||
os.remove(self.local_backup_path)
|
||||
logger.info("Ephemeral local snapshot purged from volume.")
|
||||
except Exception as e:
|
||||
logger.error(f"Leak Alert: Failed to purge temp file: {str(e)}")
|
||||
|
||||
if self.lock_file:
|
||||
try:
|
||||
fcntl.flock(self.lock_file, fcntl.LOCK_UN)
|
||||
self.lock_file.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
def handle_critical_failure(self, message):
|
||||
logger.critical(message, exc_info=True)
|
||||
self.stderr.write(self.style.ERROR(message))
|
||||
Reference in new issue
Block a user