Dưới đây là hướng dẫn cấu hình và sử dụng Restic để backup web và gửi backup web qua Google Drive bằng Rclone để lưu trữ, phù hợp cho hosting DirectAdmin hoặc cPanel nếu bạn không muốn trả phí. Restic tương tự như Jetbackup, mặc dù có nhiều tính năng không thể bằng nhưng về cơ bản nó đáp ứng được các tính năng backup, restore và thuận tiện khi đẩy qua Google Drive.
I. Chuẩn bị môi trường
Cài python 3.8 và pip3
Cài python 3.8
sudo dnf module enable python38 sudo dnf install -y python38 python38-devel python38-libs python38-pip python3.8 --version
Chọn python 3.8 sử dụng chính
sudo alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1 sudo alternatives --config python3 Choose 3.8
Cài pip3 với python 3.8
python3.8 -m ensurepip --default-pip
Cài mudule requests
pip3 install requests
Cài mariadb client
yum install -y mariadb-client gzip
Cài Restic
Tải Restic
wget https://github.com/restic/restic/releases/download/v0.17.0/restic_0.17.0_linux_amd64.bz2 bzip2 -d restic_0.17.0_linux_amd64.bz2 sudo mv restic_0.17.0_linux_amd64 /usr/local/bin/restic sudo chmod +x /usr/local/bin/restic
Kiểm tra version
[root@directadmin-manhhc ~]# restic version restic 0.17.0 compiled with go1.22.5 on linux/amd64
Cài đặt Rclone và kết nối tới Google Drive
Tạo thư mục trên Google Drive, chuẩn bị key như khoanh đỏ
Cài rclone
yum -y install rclone
Tạo kết nối giữa rclone và Google Drive
[root@directadmin-manhhc ~]# rclone config 2025/02/23 00:43:33 NOTICE: Config file "/root/.config/rclone/rclone.conf" not found - using defaults No remotes found - make a new one n) New remote s) Set configuration password q) Quit config n/s/q> n name> gd Option Storage. Type of storage to configure. Enter a string value. Press Enter for the default (""). Choose a number from below, or type in your own value. 16 / Google Drive \ "drive" Storage> 16 Option client_id. Google Application Client Id Setting your own is recommended. See https://rclone.org/drive/#making-your-own-client-id for how to create your own. If you leave this blank, it will use an internal key which is low performance. Enter a string value. Press Enter for the default (""). client_id> -> Enter Option client_secret. OAuth Client Secret. Leave blank normally. Enter a string value. Press Enter for the default (""). client_secret> -> Enter Option scope. Scope that rclone should use when requesting access from drive. Enter a string value. Press Enter for the default (""). Choose a number from below, or type in your own value. 1 / Full access all files, excluding Application Data Folder. \ "drive" 2 / Read-only access to file metadata and file contents. \ "drive.readonly" / Access to files created by rclone only. 3 | These are visible in the drive website. | File authorization is revoked when the user deauthorizes the app. \ "drive.file" / Allows read and write access to the Application Data folder. 4 | This is not visible in the drive website. \ "drive.appfolder" / Allows read-only access to file metadata but 5 | does not allow any access to read or download file content. \ "drive.metadata.readonly" scope> 1 Option root_folder_id. ID of the root folder. Leave blank normally. Fill in to access "Computers" folders (see docs), or for rclone to use a non root folder as its starting point. Enter a string value. Press Enter for the default (""). root_folder_id> 15ygZ7eCD-oe_UGSDle0rrBW-ixNoo-IW Option service_account_file. Service Account Credentials JSON file path. Leave blank normally. Needed only if you want use SA instead of interactive login. Leading `~` will be expanded in the file name as will environment variables such as `${RCLONE_CONFIG_DIR}`. Enter a string value. Press Enter for the default (""). service_account_file> Edit advanced config? y) Yes n) No (default) y/n> n Use auto config? * Say Y if not sure * Say N if you are working on a remote or headless machine y) Yes (default) n) No y/n> y 2025/02/23 01:01:03 NOTICE: If your browser doesn't open automatically go to the following link: http://127.0.0.1:53682/auth?state=7PVkRig2emA2rTSZIilZ_g 2025/02/23 01:01:03 NOTICE: Log in and authorize rclone for access 2025/02/23 01:01:03 NOTICE: Waiting for code...
Ở bước này, chúng ta cần Create an SSH Tunnel trên máy tính của mình
ssh -L 53682:localhost:53682 -C -N -l <your_user> <your_remote_server_ip>
Sau khi tạo xong, chúng ta sử dụng trình duyệt và truy cập link bên dưới để xác thực
http://127.0.0.1:53682/auth?state=7PVkRig2emA2rTSZIilZ_g
Sau khi xác thực xong, chúng ta quay lại mục config rclone
2025/02/23 01:08:01 NOTICE: Got code Configure this as a Shared Drive (Team Drive)? y) Yes n) No (default) y/n> y No Shared Drives found in your account -------------------- [Google Drive] type = drive scope = drive root_folder_id = 15ygZ7eCD-oe_UGSDle0rrBW-ixNoo-IW token = {"access_token":"ya29.a0AXeO80RNvLKeJ6V8nct3xj2ba7DSULHF20yzU-iK2kuUToKpokvzOfIu70GcDvQ0_W5UzxuekZJjSPVaHENmXqH5FCp8QNNWUW8KuwtdjwDRsARJbvVjVc07WVIKjW2Sv0nRC4rNfO_F-R6vcMf4_sATQFXEvOFyyZ1meUHlaCgYKAQsSAQ8SFQHGX2MijIzXy4evckuO5Wlf58w90g0175","token_type":"Bearer","refresh_token":"1//0e0nzpbFD7TYICgYIARAAGA4SNwF-L9IrFN0iV18mT7jFMbX-oIQcKN27uPM85aBe9tgNgweWvuHYTUYS6cDJJHO4zQBtOUU3AjY","expiry":"2025-02-23T02:08:00.403968704+07:00"} -------------------- y) Yes this is OK (default) e) Edit this remote d) Delete this remote y/e/d> y Current remotes: Name Type ==== ==== gd drive e) Edit existing remote n) New remote d) Delete remote r) Rename remote c) Copy remote s) Set configuration password q) Quit config e/n/d/r/c/s/q> q
Khởi tạo Restic
Khởi tạo Restic repository
export RESTIC_REPOSITORY="rclone:gd:/restic-backups" export RESTIC_PASSWORD="str0ngPassword" [root@directadmin-manhhc ~]# restic init enter password for new repository: enter password again: created restic repository c20a319272 at rclone:gd:/restic-backups Please note that knowledge of your password is required to access the repository. Losing your password means that your data is irrecoverably lost.
Như vậy là mình đã hướng dẫn các bạn cách để kết nối Rclone với Google Drive, sau đó tạo thư mục để Restic backup và lưu trữ dữ liệu trên đó
II. Script backup
#!/usr/bin/env python3 import logging import os import subprocess import json import concurrent.futures import smtplib import requests from pathlib import Path from datetime import datetime, timedelta from email.mime.text import MIMEText from email.utils import formatdate import time CONFIG = { 'restic_bin': '/usr/local/bin/restic', 'rclone_bin': '/usr/local/bin/rclone', 'repository': 'rclone:gd:/restic-backups', 'password': 'i2bdi2oa(23J', 'exclude_patterns': [ '*.tmp', '*.cache', '*.log', '*.temp', '/home/*/tmp', '/home/*/cache' ], 'file_types': { 'config': ['.conf', '.ini', '.cfg'], 'website': ['.html', '.php', '.css', '.js'], 'database': ['.sql', '.dmp'], 'media': ['.jpg', '.png', '.mp4'] }, 'rclone_config': '/root/.config/rclone/rclone.conf', 'mysql': { 'enabled': True, 'user': 'da_admin', 'password': 'qcqw832hjasUancis', 'host': 'localhost', 'port': 3306, 'dump_dir': '/tmp/mysql_dumps', 'retention_days': 3, 'excluded_databases': [ 'information_schema', 'performance_schema', 'mysql', 'sys' ], 'options': [ '--single-transaction', '--quick', '--skip-lock-tables' ] }, 'telegram': { 'enabled': True, 'bot_token': 'CHATBOT_TOKEN', 'chat_id': 'CHAT_ID', 'api_timeout': 10 }, 'parallel_workers': 4, 'compression': 'auto', 'upload_limit': 204800, 'cache_dir': '/tmp/restic-cache', 'retention_policy': { 'keep_last': 7, 'keep_daily': 14, 'keep_weekly': 8, 'keep_monthly': 12, 'enable_prune': True }, 'date_tag_format': '%Y%m%d' } logging.basicConfig( filename='/var/log/file_level_backup.log', level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', filemode='a' ) class EnhancedBackup: def __init__(self): self.start_time = datetime.now() self.env = os.environ.copy() self.current_date_tag = datetime.now().strftime(CONFIG['date_tag_format']) self.env.update({ 'RESTIC_PASSWORD': CONFIG['password'], 'RCLONE_CONFIG': CONFIG['rclone_config'] }) self.db_success_count = 0 self.db_failure_count = 0 self.setup_path() self.clean_old_dumps() def setup_path(self): essential_paths = ['/usr/bin', '/usr/local/bin', '/bin'] self.env['PATH'] = ":".join(essential_paths + self.env.get('PATH', '').split(':')) def clean_old_dumps(self): if CONFIG['mysql']['enabled']: dump_dir = Path(CONFIG['mysql']['dump_dir']) cutoff = time.time() - (CONFIG['mysql']['retention_days'] * 86400) for f in dump_dir.glob("*.sql.gz"): if f.stat().st_mtime < cutoff: try: f.unlink() logging.info(f"Deleted old dump: {f}") except Exception as e: logging.error(f"Failed to delete {f}: {str(e)}") def mysql_backup(self): if not CONFIG['mysql']['enabled']: return True try: databases = self.get_mysql_databases() if not databases: logging.warning("No databases found for backup") return True logging.info(f"Starting MySQL backup for {len(databases)} databases") with concurrent.futures.ThreadPoolExecutor(max_workers=CONFIG['parallel_workers']) as executor: futures = {executor.submit(self.backup_single_database, db): db for db in databases} for future in concurrent.futures.as_completed(futures): db_name = futures[future] try: future.result() except Exception as e: logging.error(f"Database backup error: {db_name} - {str(e)}") return self.db_failure_count == 0 except Exception as e: logging.error(f"MySQL backup failed: {str(e)}") return False def get_mysql_databases(self): cmd = [ 'mysql', f"--user={CONFIG['mysql']['user']}", f"--password={CONFIG['mysql']['password']}", f"--host={CONFIG['mysql']['host']}", f"--port={CONFIG['mysql']['port']}", "-NBe", "SHOW DATABASES;" ] try: result = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, check=True, env=self.env ) databases = result.stdout.splitlines() return [db for db in databases if db not in CONFIG['mysql']['excluded_databases']] except subprocess.CalledProcessError as e: logging.error(f"Failed to get database list: {e.stderr}") raise Exception("MySQL connection failed") def backup_single_database(self, db_name): dump_dir = Path(CONFIG['mysql']['dump_dir']) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") dump_file = dump_dir / f"{db_name}_{timestamp}.sql.gz" try: cmd = [ 'mysqldump', f"--user={CONFIG['mysql']['user']}", f"--password={CONFIG['mysql']['password']}", f"--host={CONFIG['mysql']['host']}", f"--port={CONFIG['mysql']['port']}", *CONFIG['mysql']['options'], db_name, '| gzip' ] with open(dump_file, 'wb') as f: process = subprocess.run( ' '.join(cmd), shell=True, stdout=f, stderr=subprocess.PIPE, env=self.env ) if process.returncode != 0: raise Exception(f"mysqldump error: {process.stderr.decode()}") backup_cmd = [ CONFIG['restic_bin'], 'backup', str(dump_file), '--repo', CONFIG['repository'], '--tag', f"mysql,database:{db_name},date:{self.current_date_tag}", '--option', f"rclone.program={CONFIG['rclone_bin']}", '--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}", '--compression', CONFIG['compression'] ] if self.run_cmd(backup_cmd): logging.info(f"Backup successful for database: {db_name}") dump_file.unlink() self.db_success_count += 1 return True except Exception as e: logging.error(f"Failed to backup database {db_name}: {str(e)}") if dump_file.exists(): dump_file.unlink() self.db_failure_count += 1 return False def backup_user(self, user): home_dir = f"/home/{user}" cmd = [ CONFIG['restic_bin'], 'backup', home_dir, '--repo', CONFIG['repository'], '--exclude-file', self.generate_exclude_file(user), '--tag', f"user:{user},type:full,date:{self.current_date_tag}", '--option', f"rclone.program={CONFIG['rclone_bin']}", '--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}", '--compression', CONFIG['compression'], '--limit-upload', str(CONFIG['upload_limit']) ] return self.run_cmd(cmd) def generate_exclude_file(self, user): exclude_path = f"/tmp/exclude_{user}.txt" with open(exclude_path, 'w') as f: for pattern in CONFIG['exclude_patterns']: f.write(pattern.replace('*', user) + '\n') return exclude_path def run_cmd(self, cmd, context="Command"): logging.info(f"Executing: {' '.join(cmd)}") try: result = subprocess.run( cmd, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, check=True ) logging.info(f"{context} succeeded: {result.stdout[:200]}...") return True except subprocess.CalledProcessError as e: error_msg = f""" {context} failed: {' '.join(e.cmd)} Exit code: {e.returncode} Error output: {e.stderr[:1000]} """ logging.error(error_msg) return False def process_users(self): users_dir = '/usr/local/directadmin/data/users' users = [u for u in os.listdir(users_dir) if os.path.isdir(os.path.join(users_dir, u))] with concurrent.futures.ThreadPoolExecutor(max_workers=CONFIG['parallel_workers']) as executor: futures = {executor.submit(self.backup_user, user): user for user in users} for future in concurrent.futures.as_completed(futures): user = futures[future] try: if future.result(): logging.info(f"User {user} backup completed") else: logging.warning(f"User {user} backup had errors") except Exception as e: logging.error(f"User {user} backup failed: {str(e)}") def apply_retention_policy(self): try: cmd = [ CONFIG['restic_bin'], 'forget', '--repo', CONFIG['repository'], '--keep-last', str(CONFIG['retention_policy']['keep_last']), '--keep-daily', str(CONFIG['retention_policy']['keep_daily']), '--keep-weekly', str(CONFIG['retention_policy']['keep_weekly']), '--keep-monthly', str(CONFIG['retention_policy']['keep_monthly']), '--option', f"rclone.program={CONFIG['rclone_bin']}", '--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}" ] if CONFIG['retention_policy']['enable_prune']: cmd.append('--prune') return self.run_cmd(cmd, "Retention Policy") except Exception as e: logging.error(f"Retention policy failed: {str(e)}") return False def send_telegram_report(self, success=True, error_msg=None): if not CONFIG['telegram']['enabled']: return try: report = self.generate_report(success, error_msg) bot_token = CONFIG['telegram']['bot_token'] chat_id = CONFIG['telegram']['chat_id'] url = f"https://api.telegram.org/bot{bot_token}/sendMessage" payload = { 'chat_id': chat_id, 'text': report, 'parse_mode': 'HTML' } response = requests.post( url, data=payload, timeout=CONFIG['telegram']['api_timeout'] ) if response.status_code != 200: logging.error(f"Telegram API error: {response.text}") except Exception as e: logging.error(f"Telegram notification failed: {str(e)}") def generate_report(self, success, error_msg=None): status_emoji = "✅" if success else "❌" status_text = "THÀNH CÔNG" if success else "THẤT BẠI" duration = datetime.now() - self.start_time total_seconds = duration.total_seconds() hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) seconds = int(total_seconds % 60) duration_str = f"{hours}h {minutes}m {seconds}s" report = f""" <b>{status_emoji} BACKUP REPORT {status_emoji}</b> ━━━━━━━━━━━━━━━━━━━ <b>• Trạng thái:</b> {status_text} <b>• Thời gian bắt đầu:</b> {self.start_time.strftime('%Y-%m-%d %H:%M:%S')} <b>• Thời lượng:</b> {duration_str} <b>• Repository:</b> {CONFIG['repository']} <b>• Tag ngày:</b> {self.current_date_tag} <b>• Databases:</b> {self.db_success_count} thành công, {self.db_failure_count} thất bại """ if error_msg: report += f"\n<b>• Lỗi chính:</b> <code>{error_msg[:500]}</code>" return report def main(): start_time = datetime.now() error_message = None success = False try: backup = EnhancedBackup() # Backup MySQL if not backup.mysql_backup(): error_message = "MySQL backup failed" raise Exception(error_message) # Backup users backup.process_users() # Áp dụng retention policy if not backup.apply_retention_policy(): error_message = "Retention policy application failed" raise Exception(error_message) # Kiểm tra repository check_cmd = [ CONFIG['restic_bin'], 'check', '--repo', CONFIG['repository'], '--option', f"rclone.program={CONFIG['rclone_bin']}", '--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}" ] backup.run_cmd(check_cmd, "Repository Check") success = True except Exception as e: error_message = str(e) logging.critical(f"Critical failure: {error_message}") finally: # Gửi thông báo report = backup.generate_report(success, error_message) # Gửi qua Telegram backup.send_telegram_report(success, error_message) # Dọn dẹp file tạm for f in Path('/tmp').glob('exclude_*.txt'): try: f.unlink() except Exception as e: logging.error(f"Cleanup failed for {f}: {str(e)}") return 0 if success else 1 if __name__ == "__main__": exit(main())
Tổng quan
- Mục đích: Tự động hóa việc sao lưu dữ liệu (MySQL và thư mục người dùng), quản lý bản sao lưu, và thông báo kết quả.
- Telegram: Gửi thông báo.
- MySQL: Sao lưu cơ sở dữ liệu.
- Rclone: Dùng để kết nối với Google Drive làm nơi lưu trữ.
- Restic: Công cụ sao lưu chính.Công cụ sử dụng:
- Cấu hình: Được lưu trong biến CONFIG với các thông tin như đường dẫn, mật khẩu, chính sách giữ lại, v.v.
- Cơ chế: Sử dụng đa luồng (concurrent.futures) để xử lý song song, tăng hiệu suất.
Phân tích chi tiết
1. Cấu hình (CONFIG)
- Chính sách giữ lại: Số lượng bản sao lưu giữ lại theo ngày, tuần, tháng.
- Telegram: Token bot và chat ID để gửi thông báo.
- Cơ sở dữ liệu MySQL: Thông tin kết nối, thư mục lưu tạm, chính sách giữ lại dump.
- Loại trừ: exclude_patterns liệt kê các mẫu tệp/thư mục không cần sao lưu.
- Mật khẩu: password cho Restic.
- Kho lưu trữ: repository (Google Drive qua Rclone).
- Đường dẫn công cụ: restic_bin, rclone_bin.Đây là một từ điển chứa toàn bộ thông tin cấu hình:
2. Cấu hình logging
- Ghi log vào tệp /var/log/file_level_backup.log với định dạng bao gồm thời gian, mức độ (INFO, ERROR, v.v.), và thông điệp.
3. Lớp EnhancedBackup
Đây là lớp chính điều phối toàn bộ quy trình sao lưu:
- __init__: Khởi tạo môi trường, thiết lập biến môi trường (RESTIC_PASSWORD, RCLONE_CONFIG), làm sạch các dump cũ.
- setup_path: Đảm bảo các đường dẫn cần thiết có trong biến $PATH.
- clean_old_dumps: Xóa các tệp dump MySQL cũ hơn retention_days.
4. Sao lưu MySQL (mysql_backup)
- Kiểm tra: Nếu MySQL không được bật (enabled: False), bỏ qua.
- Lấy danh sách cơ sở dữ liệu: Dùng lệnh mysql SHOW DATABASES để lấy danh sách, loại bỏ các cơ sở dữ liệu bị loại trừ (excluded_databases).
Sao lưu từng cơ sở dữ liệu:
- Dùng mysqldump để tạo tệp .sql.gz.
- Sao lưu tệp này vào Restic với các thẻ (tags) như mysql, database:<tên>, date:<ngày>.
- Xóa tệp tạm sau khi sao lưu thành công.
- Song song: Sử dụng ThreadPoolExecutor để sao lưu nhiều cơ sở dữ liệu cùng lúc.
5. Sao lưu thư mục người dùng (backup_user)
- Đường dẫn: Sao lưu thư mục /home/<user>.
- Loại trừ: Tạo tệp loại trừ tạm thời dựa trên exclude_patterns.
- Lệnh Restic: Thêm các thẻ như user:<tên>, type:full, giới hạn băng thông upload.
6. Chạy lệnh (run_cmd)
- Hàm chung để thực thi lệnh shell, ghi log kết quả hoặc lỗi nếu có.
7. Xử lý người dùng (process_users)
- Lấy danh sách người dùng từ /usr/local/directadmin/data/users.
- Dùng ThreadPoolExecutor để sao lưu song song nhiều người dùng.
8. Áp dụng chính sách giữ lại (apply_retention_policy)
- Dùng lệnh restic forget để xóa các bản sao lưu cũ theo chính sách trong retention_policy.
- Nếu enable_prune được bật, thêm –prune để dọn dẹp không gian.
9. Gửi thông báo Telegram (send_telegram_report)
- Tạo báo cáo với trạng thái (thành công/thất bại), thời gian, số cơ sở dữ liệu sao lưu, lỗi (nếu có).
- Gửi qua API Telegram với định dạng HTML.
10. Hàm chính (main)
- Dọn dẹp tệp tạm (/tmp/exclude_*.txt).
- Gửi thông báo qua Telegram.
- Kiểm tra tính toàn vẹn của kho lưu trữ (restic check).
- Áp dụng chính sách giữ lại.
- Sao lưu người dùng.
- Sao lưu MySQL.Điều phối toàn bộ quy trình:
- Trả về mã thoát: 0 (thành công) hoặc 1 (thất bại).
Điểm mạnh
Tự động hóa toàn diện: Từ sao lưu, quản lý bản sao, đến thông báo.
- Hiệu suất cao: Sử dụng xử lý song song để tăng tốc độ.
- Linh hoạt: Cấu hình dễ dàng thay đổi qua CONFIG.
- Ghi log chi tiết: Dễ dàng theo dõi và debug nếu có lỗi.
- Thông báo tức thì: Telegram giúp quản trị viên nắm bắt tình hình nhanh chóng.
Kết luận
Script này là một giải pháp mạnh mẽ và hiệu quả để sao lưu dữ liệu lên Google Drive, phù hợp cho môi trường quản trị hệ thống như DirectAdmin. Tuy nhiên, cần cải thiện bảo mật và xử lý lỗi để tăng độ tin cậy. Nếu bạn muốn tối ưu hơn, hãy cân nhắc các gợi ý trên hoặc yêu cầu tôi chỉnh sửa cụ thể một phần nào đó!
III. Script Restore
#!/usr/bin/env python3 import subprocess import os import logging import json from pathlib import Path from datetime import datetime import requests import time import shutil # Cấu hình giống script backup CONFIG = { 'restic_bin': '/usr/local/bin/restic', 'rclone_bin': '/usr/local/bin/rclone', 'repository': 'rclone:gd:/restic-backups', 'password': 'i2bdi2oa(23J', 'rclone_config': '/root/.config/rclone/rclone.conf', 'mysql': { 'user': 'da_admin', 'password': 'qcqw832hjasUancis', 'host': 'localhost', 'port': 3306, 'dump_dir': '/tmp/mysql_restores' }, 'telegram': { 'enabled': True, 'bot_token': 'CHATBOT_TOKEN', 'chat_id': 'CHAT_ID', 'api_timeout': 10 } } logging.basicConfig( filename='/var/log/file_level_restore.log', level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', filemode='a' ) class EnhancedRestore: def __init__(self): self.start_time = datetime.now() self.env = os.environ.copy() self.env.update({ 'RESTIC_PASSWORD': CONFIG['password'], 'RCLONE_CONFIG': CONFIG['rclone_config'] }) self.restored_items = [] self.setup_path() self.ensure_restore_dir() def setup_path(self): essential_paths = ['/usr/bin', '/usr/local/bin', '/bin'] self.env['PATH'] = ":".join(essential_paths + self.env.get('PATH', '').split(':')) def ensure_restore_dir(self): restore_dir = Path(CONFIG['mysql']['dump_dir']) restore_dir.mkdir(parents=True, exist_ok=True) def run_cmd(self, cmd, context="Command"): logging.info(f"Executing: {' '.join(cmd)}") try: result = subprocess.run( cmd, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, check=True ) logging.info(f"{context} succeeded: {result.stdout[:200]}...") return result.stdout except subprocess.CalledProcessError as e: error_msg = f"{context} failed: {e.stderr}" logging.error(error_msg) raise Exception(error_msg) def list_snapshots(self, tag_filter=None): cmd = [ CONFIG['restic_bin'], 'snapshots', '--repo', CONFIG['repository'], '--json', '--option', f"rclone.program={CONFIG['rclone_bin']}", '--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}" ] if tag_filter: cmd.extend(['--tag', tag_filter]) output = self.run_cmd(cmd, "List Snapshots") return json.loads(output) def select_snapshot(self, snapshots): print("\nAvailable Snapshots:") for i, snapshot in enumerate(snapshots): tags = ",".join(snapshot.get('tags', [])) time = snapshot.get('time', 'N/A') print(f"{i}: ID={snapshot['short_id']}, Time={time}, Tags={tags}") while True: try: choice = int(input("\nSelect snapshot number to restore (0-{}): ".format(len(snapshots)-1))) if 0 <= choice < len(snapshots): return snapshots[choice]['short_id'] print("Invalid choice. Try again.") except ValueError: print("Please enter a valid number.") def restore_domain(self, user, domain): domain_path = Path(f"/home/{user}/domains/{domain}") domain_path.mkdir(parents=True, exist_ok=True) temp_restore_path = Path(f"/tmp/restore_{user}_{domain}_{int(time.time())}") temp_restore_path.mkdir(parents=True, exist_ok=True) snapshots = self.list_snapshots(f"user:{user},domain:{domain}") if not snapshots: logging.error(f"No snapshots found for user:{user}, domain:{domain}") print(f"No backups found for {domain}") return False snapshot_id = self.select_snapshot(snapshots) cmd = [ CONFIG['restic_bin'], 'restore', snapshot_id, '--repo', CONFIG['repository'], '--target', str(temp_restore_path), '--option', f"rclone.program={CONFIG['rclone_bin']}", '--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}" ] try: self.run_cmd(cmd, f"Restore domain {domain} for user {user} to temp dir") restored_domain_path = temp_restore_path / "home" / user / "domains" / domain if not restored_domain_path.exists(): raise Exception(f"Expected restored path {restored_domain_path} not found") for item in restored_domain_path.iterdir(): dest_path = domain_path / item.name if dest_path.exists(): if dest_path.is_symlink(): dest_path.unlink() elif dest_path.is_dir(): shutil.rmtree(dest_path) else: dest_path.unlink() shutil.move(str(item), str(domain_path)) self.set_permissions(domain_path, user) self.restored_items.append(f"Domain: {domain} (User: {user})") logging.info(f"Successfully restored domain {domain} for user {user}") print(f"Domain {domain} restored successfully.") return True except Exception as e: logging.error(f"Restore failed for domain {domain}: {str(e)}") print(f"Failed to restore domain {domain}: {str(e)}") return False finally: if temp_restore_path.exists(): shutil.rmtree(temp_restore_path, ignore_errors=True) def restore_database(self, db_name): snapshots = self.list_snapshots(f"mysql,database:{db_name}") if not snapshots: logging.error(f"No snapshots found for database:{db_name}") print(f"No backups found for database {db_name}") return False snapshot_id = self.select_snapshot(snapshots) temp_restore_path = Path(f"/tmp/restore_db_{db_name}_{int(time.time())}") temp_restore_path.mkdir(parents=True, exist_ok=True) dump_file = Path(CONFIG['mysql']['dump_dir']) / f"{db_name}_restore_{snapshot_id}.sql.gz" cmd = [ CONFIG['restic_bin'], 'restore', snapshot_id, '--repo', CONFIG['repository'], '--target', str(temp_restore_path), '--option', f"rclone.program={CONFIG['rclone_bin']}", '--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}" ] try: self.run_cmd(cmd, f"Restore dump for database {db_name}") gz_files = list(temp_restore_path.rglob(f"{db_name}_*.sql.gz")) if not gz_files: raise Exception(f"No .sql.gz file found in restored snapshot for {db_name}") if len(gz_files) > 1: logging.warning(f"Multiple .sql.gz files found, using the first one: {gz_files[0]}") restored_gz_file = gz_files[0] shutil.move(str(restored_gz_file), str(dump_file)) # Di chuyển file đến vị trí mong muốn with subprocess.Popen(['gunzip', str(dump_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) as gunzip: gunzip.wait() if gunzip.returncode != 0: raise Exception(f"Gunzip failed: {gunzip.stderr.read().decode()}") sql_file = dump_file.with_suffix('') self.import_database(db_name, sql_file) sql_file.unlink() # Dọn dẹp file SQL self.restored_items.append(f"Database: {db_name}") # Ghi nhận restore thành công logging.info(f"Successfully restored database {db_name}") print(f"Database {db_name} restored successfully.") return True except Exception as e: logging.error(f"Restore failed for database {db_name}: {str(e)}") print(f"Failed to restore database {db_name}: {str(e)}") return False finally: if temp_restore_path.exists(): shutil.rmtree(temp_restore_path, ignore_errors=True) def import_database(self, db_name, sql_file): drop_cmd = [ 'mysql', f"--user={CONFIG['mysql']['user']}", f"--password={CONFIG['mysql']['password']}", f"--host={CONFIG['mysql']['host']}", f"--port={CONFIG['mysql']['port']}", '-e', f"DROP DATABASE IF EXISTS {db_name}; CREATE DATABASE {db_name};" ] self.run_cmd(drop_cmd, f"Recreate database {db_name}") import_cmd = [ 'mysql', f"--user={CONFIG['mysql']['user']}", f"--password={CONFIG['mysql']['password']}", f"--host={CONFIG['mysql']['host']}", f"--port={CONFIG['mysql']['port']}", db_name ] with open(sql_file, 'r') as f: result = subprocess.run( import_cmd, stdin=f, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.env, universal_newlines=True ) if result.returncode != 0: raise Exception(f"Database import failed: {result.stderr}") def set_permissions(self, domain_path, user): import pwd import grp try: uid = pwd.getpwnam(user).pw_uid gid = grp.getgrnam(user).gr_gid os.chown(str(domain_path), uid, gid) os.chmod(str(domain_path), 0o751) for root, dirs, files in os.walk(str(domain_path)): for d in dirs: d_path = os.path.join(root, d) os.chown(d_path, uid, gid) os.chmod(d_path, 0o755) for f in files: f_path = os.path.join(root, f) os.chown(f_path, uid, gid) os.chmod(f_path, 0o644) logging.info(f"Permissions set successfully for {domain_path}") except Exception as e: logging.error(f"Failed to set permissions for {domain_path}: {str(e)}") raise def send_telegram_report(self, success=True, error_msg=None): if not CONFIG['telegram']['enabled']: return report = self.generate_report(success, error_msg) url = f"https://api.telegram.org/bot{CONFIG['telegram']['bot_token']}/sendMessage" payload = {'chat_id': CONFIG['telegram']['chat_id'], 'text': report, 'parse_mode': 'HTML'} requests.post(url, data=payload, timeout=CONFIG['telegram']['api_timeout']) def generate_report(self, success, error_msg=None): status_emoji = "✅" if success else "❌" status_text = "THÀNH CÔNG" if success else "THẤT BẠI" duration = datetime.now() - self.start_time duration_str = str(duration).split('.')[0] report = f""" <b>{status_emoji} RESTORE REPORT {status_emoji}</b> ━━━━━━━━━━━━━━━━━━━ <b>• Trạng thái:</b> {status_text} <b>• Thời gian bắt đầu:</b> {self.start_time.strftime('%Y-%m-%d %H:%M:%S')} <b>• Thời lượng:</b> {duration_str} <b>• Repository:</b> {CONFIG['repository']} """ if self.restored_items: # Thêm danh sách các đối tượng restore thành công report += "<b>• Restored successfully:</b>\n" + "\n".join([f" - {item}" for item in self.restored_items]) if error_msg: report += f"\n<b>• Lỗi chính:</b> <code>{error_msg[:500]}</code>" return report def main(): restore = EnhancedRestore() success = False error_message = None try: print("Restore Options:") print("1. Restore a domain") print("2. Restore a database") choice = input("Select an option (1-2): ") if choice == '1': user = input("Enter username: ") domain = input("Enter domain name (e.g., example.com): ") success = restore.restore_domain(user, domain) elif choice == '2': db_name = input("Enter database name: ") success = restore.restore_database(db_name) else: raise Exception("Invalid option selected") if not success: raise Exception("Restore process failed") except Exception as e: error_message = str(e) logging.critical(f"Restore failed: {error_message}") success = False finally: restore.send_telegram_report(success, error_message) restore_dir = Path(CONFIG['mysql']['dump_dir']) for f in restore_dir.glob("*.sql.gz"): f.unlink(missing_ok=True) return 0 if success else 1 if __name__ == "__main__": exit(main())
Tổng quan
- Mục đích: Phục hồi dữ liệu từ bản sao lưu trên Google Drive, bao gồm domain của người dùng và cơ sở dữ liệu MySQL.
Công cụ sử dụng:
- Restic: Công cụ phục hồi chính.
- Rclone: Kết nối với Google Drive.
- MySQL: Nhập lại dữ liệu cơ sở dữ liệu.
- Telegram: Gửi báo cáo kết quả.
- Cấu hình: Được định nghĩa trong biến CONFIG, tương tự script backup nhưng rút gọn cho mục đích phục hồi.
- Cơ chế: Tương tác với người dùng qua dòng lệnh để chọn snapshot, phục hồi dữ liệu vào thư mục tạm, sau đó di chuyển đến vị trí đích.
Phân tích chi tiết
1. Cấu hình (CONFIG)
Là một từ điển chứa thông tin cần thiết:
- Đường dẫn công cụ: restic_bin, rclone_bin.
- Kho lưu trữ: repository (Google Drive qua Rclone).
- Mật khẩu: password cho Restic.
- Rclone: Đường dẫn cấu hình rclone_config.
- MySQL: Thông tin kết nối và thư mục tạm để lưu file phục hồi.
- Telegram: Token bot và chat ID để gửi thông báo.
2. Cấu hình logging
- Ghi log vào /var/log/file_level_restore.log với định dạng gồm thời gian, mức độ (INFO, ERROR), và thông điệp.
3. Lớp EnhancedRestore
Đây là lớp chính điều phối quá trình phục hồi:
- __init__: Khởi tạo môi trường, thiết lập biến môi trường (RESTIC_PASSWORD, RCLONE_CONFIG), tạo thư mục tạm nếu cần.
- setup_path: Đảm bảo các đường dẫn cần thiết trong $PATH.
- ensure_restore_dir: Tạo thư mục tạm cho MySQL nếu chưa tồn tại (/tmp/mysql_restores).
4. Chạy lệnh (run_cmd)
- Hàm chung để thực thi lệnh shell với Restic hoặc MySQL, ghi log kết quả hoặc lỗi, trả về đầu ra stdout nếu thành công.
5. Liệt kê và chọn snapshot
- list_snapshots: Dùng lệnh restic snapshots để lấy danh sách snapshot từ kho lưu trữ, có thể lọc theo tag (ví dụ: user:<tên>, mysql).
- select_snapshot: Hiển thị danh sách snapshot (ID, thời gian, tags) và yêu cầu người dùng chọn một snapshot bằng số thứ tự.
6. Phục hồi domain (restore_domain)
Quy trình:
- Tạo thư mục đích (/home/<user>/domains/<domain>) và thư mục tạm (/tmp/restore_<user>_<domain>_<timestamp>).
- Lấy danh sách snapshot liên quan đến user:<user>,domain:<domain>.
- Phục hồi snapshot được chọn vào thư mục tạm bằng restic restore.
- Di chuyển nội dung từ thư mục tạm về thư mục đích, xóa nội dung cũ nếu tồn tại (hỗ trợ symlink, thư mục, file).
- Thiết lập quyền sở hữu và phân quyền (set_permissions).
- Ghi nhận thành công vào restored_items và dọn dẹp thư mục tạm.
- Xử lý lỗi: Nếu không tìm thấy snapshot hoặc quá trình thất bại, ghi log lỗi và trả về False.
7. Phục hồi cơ sở dữ liệu (restore_database)
Quy trình:
- Lấy danh sách snapshot liên quan đến mysql,database:<db_name>.
- Phục hồi snapshot vào thư mục tạm (/tmp/restore_db_<db_name>_<timestamp>).
- Tìm file .sql.gz trong thư mục tạm, di chuyển đến /tmp/mysql_restores.
- Giải nén file bằng gunzip để lấy .sql.
- Nhập dữ liệu vào MySQL bằng hàm import_database.
- Dọn dẹp file tạm và ghi nhận thành công.
- Xử lý lỗi: Nếu không tìm thấy snapshot hoặc file .sql.gz, hoặc giải nén/nhập dữ liệu thất bại, ghi log lỗi và trả về False.
8. Nhập cơ sở dữ liệu (import_database)
- Xóa và tạo lại cơ sở dữ liệu bằng lệnh mysql -e.
- Nhập dữ liệu từ file .sql vào cơ sở dữ liệu đích.
9. Thiết lập quyền (set_permissions)
Đặt quyền sở hữu (chown) và phân quyền (chmod) cho domain:
- Thư mục gốc: 751 (rwxr-x–x).
- Thư mục con: 755 (rwxr-xr-x).
- File: 644 (rw-r–r–).
- Dùng UID/GID của người dùng để đảm bảo tính chính xác.
10. Gửi thông báo Telegram (send_telegram_report)
- Tạo báo cáo với trạng thái, thời gian, danh sách item phục hồi thành công, và lỗi (nếu có).
- Gửi qua API Telegram với định dạng HTML.
11. Hàm chính (main)
Quy trình:
- Hiển thị menu chọn phục hồi domain (1) hoặc cơ sở dữ liệu (2).
- Thu thập thông tin từ người dùng (username, domain, hoặc tên database).
- Gọi hàm tương ứng (restore_domain hoặc restore_database).
- Gửi báo cáo qua Telegram và dọn dẹp file .sql.gz trong thư mục tạm.
- Trả về: Mã thoát 0 (thành công) hoặc 1 (thất bại).
Điểm mạnh
- Tương tác tốt: Người dùng có thể chọn snapshot cụ thể để phục hồi.
- Quản lý quyền: Đảm bảo quyền sở hữu và phân quyền phù hợp sau khi phục hồi domain.
- Thông báo chi tiết: Báo cáo Telegram cung cấp thông tin đầy đủ về quá trình phục hồi.
- Dọn dẹp cẩn thận: Xóa thư mục tạm và file .sql.gz sau khi hoàn tất.
Kết luận
Script này là một công cụ phục hồi hiệu quả, phù hợp với hệ thống sử dụng DirectAdmin và Google Drive để lưu trữ sao lưu. Nó cung cấp khả năng tương tác tốt và xử lý đầy đủ các bước từ khôi phục dữ liệu đến thiết lập quyền. Tuy nhiên, để tăng độ tin cậy và bảo mật, bạn nên xem xét các cải tiến tôi đã đề xuất.