From ba026842f78ec61de473bd7faacd369be5079fb5 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 12 Nov 2025 18:19:54 +0900 Subject: [PATCH] feat: Add database backup system - Add Dockerfile.backup for backup container - Add backup.py script with PostgreSQL backup functionality - Add backup service to docker-compose.prod.yml - Update env.production.example with backup configuration - Add db/README.md with backup system documentation Features: - Automated daily backups (07:30, 18:00) - Local and FTP remote backup support - 7-day retention policy - PostgreSQL 16 client for waceplm database --- Dockerfile.backup | 54 +++++ db/README.md | 111 ++++++++++ db/backup.py | 463 ++++++++++++++++++++++++++++++++++++++++ docker-compose.prod.yml | 14 ++ env.production.example | 24 ++- 5 files changed, 661 insertions(+), 5 deletions(-) create mode 100644 Dockerfile.backup create mode 100644 db/README.md create mode 100644 db/backup.py diff --git a/Dockerfile.backup b/Dockerfile.backup new file mode 100644 index 0000000..b99d232 --- /dev/null +++ b/Dockerfile.backup @@ -0,0 +1,54 @@ +# Use an official Python runtime as a parent image +FROM dockerhub.wace.me/python:3.10-slim.linux + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV TZ=Asia/Seoul + +# Install system dependencies including PostgreSQL client 16 +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + gnupg \ + lsb-release \ + wget \ + ca-certificates \ + tzdata \ + gpg \ + lftp && \ + # Add PostgreSQL Apt Repository + # Download the key + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | \ + # Dearmor the key and save to the keyring directory + gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg && \ + # Set correct permissions for the keyring file + chmod 644 /usr/share/keyrings/postgresql-archive-keyring.gpg && \ + # Add the repository source, signed by the keyring + sh -c 'echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \ + # Update again after adding repo + apt-get update && \ + # Install specific client version + apt-get install -y --no-install-recommends \ + postgresql-client-16 && \ + # Configure timezone + ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \ + # Clean up (remove build dependencies) + apt-get purge -y --auto-remove wget gnupg lsb-release ca-certificates gpg && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +# Using requirements.txt is generally better, but for a simple script: +RUN pip install --no-cache-dir schedule pytz + +# Set the working directory in the container +WORKDIR /app + +# Copy the Python script into the container +COPY db/backup.py . + +# Ensure .ssh directory exists (still good practice, though key might not be used) +# RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh # No longer needed for SSH keys + +# Command to run the application +CMD ["python", "backup.py"] \ No newline at end of file diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..8b299e9 --- /dev/null +++ b/db/README.md @@ -0,0 +1,111 @@ +# 데이터베이스 백업 시스템 + +## 개요 +PostgreSQL 데이터베이스를 자동으로 백업하는 시스템입니다. +- 하루 2회 자동 백업 (오전 7:30, 오후 6:00) +- 로컬 백업 및 FTP 원격 백업 지원 +- 7일 이상 된 백업 자동 삭제 + +## 파일 구성 +- `backup.py`: 백업 스크립트 +- `00-create-roles.sh`: 데이터베이스 역할 생성 스크립트 +- `dbexport.pgsql`: 데이터베이스 덤프 파일 + +## 설정 방법 + +### 1. 환경 변수 설정 +`.env.production` 파일에 다음 설정을 추가: + +```bash +# 필수 설정 +POSTGRES_HOST=wace-plm-db +POSTGRES_DOCKER_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=your_password +POSTGRES_DB=waceplm +LOCAL_BACKUP_PATH=/backups/local +BACKUP_RETENTION_DAYS=7 +BACKUP_TIME_AM=07:30 +BACKUP_TIME_PM=18:00 + +# 선택 설정 (FTP 원격 백업) +FTP_HOST=ftp.example.com +FTP_USER=backup_user +FTP_PASSWORD=ftp_password +FTP_PATH=/backups +FTP_PORT=2122 +``` + +### 2. Docker Compose 실행 +```bash +# 백업 서비스 포함하여 실행 +docker-compose -f docker-compose.prod.yml up -d + +# 백업 서비스만 재시작 +docker-compose -f docker-compose.prod.yml restart wace-plm-backup +``` + +### 3. 백업 확인 +```bash +# 백업 로그 확인 +docker logs -f wace-plm-backup + +# 백업 파일 확인 +ls -lh /home/wace-plm/backups/ +``` + +## 수동 백업 실행 + +### 즉시 백업 실행 +```bash +docker exec wace-plm-backup python -c "from backup import backup_databases; backup_databases()" +``` + +### 백업 파일 복원 +```bash +# 백업 파일 확인 +ls /home/wace-plm/backups/ + +# 복원 실행 +docker exec -i wace-plm-db psql -U postgres -d waceplm < /home/wace-plm/backups/waceplm_backup_YYYYMMDD_HHMMSS.sql +``` + +## 백업 스케줄 +- **오전 백업**: 매일 07:30 +- **오후 백업**: 매일 18:00 +- **자동 삭제**: 7일 이상 된 백업 파일 + +## 트러블슈팅 + +### 백업이 실행되지 않는 경우 +```bash +# 컨테이너 상태 확인 +docker ps | grep backup + +# 로그 확인 +docker logs wace-plm-backup + +# 환경 변수 확인 +docker exec wace-plm-backup env | grep POSTGRES +``` + +### 디스크 공간 부족 +```bash +# 백업 파일 크기 확인 +du -sh /home/wace-plm/backups/ + +# 오래된 백업 수동 삭제 +find /home/wace-plm/backups/ -name "*.sql" -mtime +7 -delete +``` + +### FTP 업로드 실패 +- FTP 서버 접속 정보 확인 +- 방화벽 설정 확인 +- FTP 경로 권한 확인 + +## 주의사항 +1. 백업 파일은 민감한 정보를 포함하므로 접근 권한 관리 필요 +2. 충분한 디스크 공간 확보 필요 (데이터베이스 크기의 최소 10배 권장) +3. FTP 비밀번호는 반드시 환경 변수로 관리 +4. 정기적으로 복원 테스트 수행 권장 + diff --git a/db/backup.py b/db/backup.py new file mode 100644 index 0000000..f0aa615 --- /dev/null +++ b/db/backup.py @@ -0,0 +1,463 @@ +import os +import schedule +import time +import subprocess +from datetime import datetime, timedelta +import logging +import sys +import ftplib # Use the built-in FTP library +from io import BytesIO # Needed for reading file content for FTP upload + +# --- Configuration (from environment variables) --- +POSTGRES_HOST = os.getenv('POSTGRES_HOST', 'wace-plm-db') +POSTGRES_PORT = os.getenv('POSTGRES_DOCKER_PORT', '5432') +POSTGRES_USER = os.getenv('POSTGRES_USER') +POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD') +POSTGRES_DB = os.getenv('POSTGRES_DB') +LOCAL_BACKUP_PATH = os.getenv('LOCAL_BACKUP_PATH', '/backups/local') + +# FTP Configuration +FTP_HOST = os.getenv('FTP_HOST') +FTP_USER = os.getenv('FTP_USER') +FTP_PASSWORD = os.getenv('FTP_PASSWORD') +FTP_PATH = os.getenv('FTP_PATH', '/') # Default to root FTP directory if not specified +FTP_PORT = int(os.getenv('FTP_PORT', 2122)) # Default FTP port is 21 + +BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 7)) +BACKUP_TIME_AM = os.getenv('BACKUP_TIME_AM', "07:30") +BACKUP_TIME_PM = os.getenv('BACKUP_TIME_PM', "18:00") +# --- End Configuration --- + +# --- Logging Setup --- +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + stream=sys.stdout # Log to stdout to be captured by Docker logs +) +# --- End Logging Setup --- + +def check_env_vars(): + """Check if required environment variables are set.""" + required_vars = ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_DB'] + + # Add FTP vars only if host/user/password/path are provided + if FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH: + required_vars.extend(['FTP_HOST', 'FTP_USER', 'FTP_PASSWORD', 'FTP_PATH']) + logging.info("FTP configuration found in environment variables.") + else: + logging.warning("FTP configuration not fully provided (HOST, USER, PASSWORD, PATH). Remote backups will be skipped.") + + # Check database vars + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + logging.error(f"Missing required environment variables: {', '.join(missing_vars)}") + sys.exit(1) + + logging.info("All required environment variables checked.") + +def create_backup_dirs(): + """Create local backup directory if it doesn't exist.""" + try: + os.makedirs(LOCAL_BACKUP_PATH, exist_ok=True) + logging.info(f"Ensured local backup directory exists: {LOCAL_BACKUP_PATH}") + except OSError as e: + logging.error(f"Error creating local backup directory: {e}") + sys.exit(1) # Stop if local backup isn't possible + + # Note: We will try to create the remote FTP directory if it doesn't exist during upload/cleanup + +def ensure_ftp_dir(ftp, path): + """Ensures the specified directory exists on the FTP server.""" + parts = path.strip('/').split('/') + current_path = '' + for part in parts: + if not part: continue + current_path += '/' + part + try: + ftp.cwd(current_path) + except ftplib.error_perm as e: + if str(e).startswith('550'): # 550: Directory not found or permission denied + try: + ftp.mkd(current_path) + logging.info(f"Created remote FTP directory: {current_path}") + ftp.cwd(current_path) # Go into the newly created dir + except ftplib.error_perm as mkd_e: + logging.error(f"Failed to create or access FTP directory {current_path}: {mkd_e}") + raise # Re-raise the exception to signal failure + else: + logging.error(f"FTP error accessing {current_path}: {e}") + raise + # Ensure we are in the final target directory + ftp.cwd(path) + logging.info(f"Ensured remote FTP directory exists and CWD set to: {path}") + +def perform_database_backup(db_config, backup_prefix): + """Performs PostgreSQL database backup for a specific database configuration.""" + timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S') + backup_filename = f"{backup_prefix}_{timestamp}.sql" # SQL 파일로 변경 (.gz 제거) + local_filepath = os.path.join(LOCAL_BACKUP_PATH, backup_filename) + + logging.info(f"Starting backup for database '{db_config['db_name']}' with prefix '{backup_prefix}'...") + logging.info(f"Local target: {local_filepath}") + + # 1. Create local backup using pg_dump with plain SQL format (no compression) + pg_dump_command = [ + 'pg_dump', + f'--host={db_config["host"]}', + f'--port={db_config["port"]}', + f'--username={db_config["user"]}', + f'--dbname={db_config["db_name"]}', + '--format=plain', # 일반 SQL 텍스트 형식 사용 + '--no-owner', # 소유자 정보 제외 (복원 시 유연성 향상) + '--no-privileges', # 권한 정보 제외 (복원 시 유연성 향상) + f'--file={local_filepath}' + ] + env = os.environ.copy() + env['PGPASSWORD'] = db_config['password'] + + try: + process = subprocess.run( + pg_dump_command, + env=env, + check=True, + capture_output=True, + text=True + ) + logging.info(f"Successfully created local backup: {local_filepath}") + logging.debug(f"pg_dump stdout: {process.stdout}") + logging.debug(f"pg_dump stderr: {process.stderr}") + + # 2. Upload to Remote via FTP if configured + if FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH: + remote_target_path = FTP_PATH.rstrip("/") + "/" + backup_filename + # Ensure log path starts with / for clarity + log_full_path = f"/{remote_target_path.lstrip('/')}" + logging.info(f"Attempting to upload backup via FTP to: ftp://{FTP_HOST}:{FTP_PORT}{log_full_path}") + ftp = None # Initialize ftp to None + try: + ftp = ftplib.FTP() + ftp.connect(FTP_HOST, FTP_PORT, timeout=60) # Increased timeout to 60 seconds + ftp.login(FTP_USER, FTP_PASSWORD) + ftp.set_pasv(True) # Use passive mode, usually necessary + + # --- Simplified directory change (mimicking lftp) --- + try: + if FTP_PATH: # Only change directory if FTP_PATH is set + logging.info(f"Changing remote directory to: {FTP_PATH}") + ftp.cwd(FTP_PATH) + logging.info(f"Successfully changed remote directory to: {ftp.pwd()}") # Log current dir + else: + logging.info("FTP_PATH is not set, uploading to user's home directory.") + + # Upload the file to the current directory + logging.info(f"Attempting to upload {backup_filename} to current remote directory.") + with open(local_filepath, 'rb') as local_file: + ftp.storbinary(f'STOR {backup_filename}', local_file) + logging.info(f"Successfully uploaded backup via FTP to remote path: {FTP_PATH}/{backup_filename}") # Adjust log message slightly + + except ftplib.error_perm as ftp_cwd_err: + logging.error(f"Failed to change FTP directory to '{FTP_PATH}': {ftp_cwd_err}") + except ftplib.all_errors as ftp_err: + logging.error(f"FTP operation failed during/after CWD or during STOR: {ftp_err}") + # --- End Simplified directory change --- + + except ftplib.all_errors as ftp_err: + logging.error(f"FTP connection/login failed: {ftp_err}") # Adjusted error scope + # Potentially retry or raise an error to indicate failure + except FileNotFoundError: + logging.error(f"Local backup file not found for FTP upload: {local_filepath}") + except Exception as ftp_e: + logging.error(f"An unexpected error occurred during FTP upload: {ftp_e}") + finally: + if ftp: + try: + ftp.quit() + except ftplib.all_errors: + logging.debug("FTP quit command failed, closing connection.") + ftp.close() # Force close if quit fails + + else: + logging.warning("FTP configuration not provided. Skipping remote upload.") + + except subprocess.CalledProcessError as e: + logging.error(f"pg_dump failed with exit code {e.returncode} for database '{db_config['db_name']}'") + logging.error(f"pg_dump stderr: {e.stderr}") + if os.path.exists(local_filepath): + try: + os.remove(local_filepath) + logging.info(f"Removed incomplete local backup file: {local_filepath}") + except OSError as remove_err: + logging.error(f"Error removing incomplete local backup file {local_filepath}: {remove_err}") + except Exception as e: + logging.error(f"An unexpected error occurred during backup for '{db_config['db_name']}': {e}") + +def perform_backup(): + """Performs the PostgreSQL database backup.""" + logging.info("=== Starting backup process ===") + + # Database configuration + db_config = { + 'host': POSTGRES_HOST, + 'port': POSTGRES_PORT, + 'user': POSTGRES_USER, + 'password': POSTGRES_PASSWORD, + 'db_name': POSTGRES_DB + } + + # Perform backup + try: + perform_database_backup(db_config, POSTGRES_DB) + logging.info(f"Completed backup for database: {POSTGRES_DB}") + except Exception as e: + logging.error(f"Failed to backup database: {e}") + + logging.info("=== Backup process completed ===") + +# Legacy function kept for compatibility (now calls the new generic function) +def perform_backup_legacy(): + """Legacy backup function - kept for backward compatibility.""" + return perform_backup() + +def cleanup_local_backups(backup_dir): + """Removes local backups older than BACKUP_RETENTION_DAYS.""" + if not os.path.isdir(backup_dir): + logging.warning(f"Local cleanup skipped: Directory not found or inaccessible: {backup_dir}") + return + + logging.info(f"Starting cleanup of old local backups in: {backup_dir}") + cutoff_date = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS) + files_deleted = 0 + files_checked = 0 + + try: + for filename in os.listdir(backup_dir): + # Match the filename pattern + is_db_backup = filename.startswith(f"{POSTGRES_DB}_") and filename.endswith(".sql") + + if is_db_backup: + files_checked += 1 + filepath = os.path.join(backup_dir, filename) + try: + # Use file modification time for age check + file_mod_time_ts = os.path.getmtime(filepath) + file_mod_time = datetime.fromtimestamp(file_mod_time_ts) + + if file_mod_time < cutoff_date: + os.remove(filepath) + logging.info(f"Deleted old local backup: {filepath} (modified: {file_mod_time})") + files_deleted += 1 + except OSError as e: + logging.error(f"Error processing or deleting local file {filepath}: {e}") + except ValueError: # Should not happen with getmtime + logging.warning(f"Could not get modification time for local file: {filename}. Skipping.") + + logging.info(f"Local cleanup finished for {backup_dir}. Checked: {files_checked}, Deleted: {files_deleted}.") + except OSError as e: + logging.error(f"Error listing directory {backup_dir} during local cleanup: {e}") + +def parse_mlsd_time(timestr): + """Parses the timestamp from MLSD command output (YYYYMMDDHHMMSS).""" + try: + return datetime.strptime(timestr, '%Y%m%d%H%M%S') + except ValueError: + logging.warning(f"Could not parse MLSD time string: {timestr}") + return None + +def cleanup_remote_backups(): + """Removes remote backups older than BACKUP_RETENTION_DAYS using FTP.""" + if not (FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH): + logging.warning("FTP configuration not provided. Skipping remote cleanup.") + return + + remote_dir = FTP_PATH.rstrip("/") + # Correct the logging message to avoid double slash if FTP_PATH starts with / + log_path = f"/{remote_dir.lstrip('/')}" if remote_dir else "/" + logging.info(f"Starting cleanup of old remote backups in: ftp://{FTP_HOST}:{FTP_PORT}{log_path}") + cutoff_date = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS) + files_deleted = 0 + files_checked = 0 + ftp = None + + try: + ftp = ftplib.FTP() + ftp.connect(FTP_HOST, FTP_PORT, timeout=60) # Increased timeout to 60 seconds + ftp.login(FTP_USER, FTP_PASSWORD) + ftp.set_pasv(True) + + # --- Simplified directory change (similar to upload) --- + try: + if remote_dir: # Only change directory if remote_dir (derived from FTP_PATH) is set + logging.info(f"Changing remote directory for cleanup to: {remote_dir}") + ftp.cwd(remote_dir) + logging.info(f"Successfully changed remote directory for cleanup to: {ftp.pwd()}") # Log current dir + else: + logging.info("FTP_PATH is not set, performing cleanup in user's home directory.") + + # --- Proceed with listing and deletion in the CURRENT directory --- + # Use MLSD for modern servers, fallback needed if not supported + try: + lines = [] + ftp.retrlines('MLSD', lines.append) + logging.debug(f"MLSD output for current directory ({ftp.pwd()}):\n" + "\n".join(lines)) + + for line in lines: + parts = line.split(';') + facts = {} + for part in parts: + if '=' in part: + key, value = part.split('=', 1) + facts[key.strip().lower()] = value.strip() + + filename = facts.get('') # Filename is the part without key=value + filetype = facts.get('type') + modify_time_str = facts.get('modify') + + # Process files matching database pattern + is_db_backup = filename and filename.startswith(f"{POSTGRES_DB}_") and filename.endswith(".sql") + + if filetype == 'file' and is_db_backup: + files_checked += 1 + if modify_time_str: + file_mod_time = parse_mlsd_time(modify_time_str) + if file_mod_time and file_mod_time < cutoff_date: + try: + ftp.delete(filename) + logging.info(f"Deleted old remote backup: {filename} (modified: {file_mod_time})") + files_deleted += 1 + except ftplib.error_perm as del_err: + logging.error(f"Failed to delete remote file {filename}: {del_err}") + elif not file_mod_time: + logging.warning(f"Skipping remote file due to unparseable time: {filename}") + else: + logging.warning(f"Could not get modification time for remote file: {filename}. Skipping deletion check.") + + logging.info(f"Remote cleanup finished using MLSD for {remote_dir}. Checked: {files_checked}, Deleted: {files_deleted}.") + + except ftplib.error_perm as mlsd_err: + logging.warning(f"MLSD command failed (server might not support it): {mlsd_err}. Falling back to LIST/MDTM (less reliable).") + # Fallback to LIST and MDTM (less efficient and parsing can be fragile) + files_deleted = 0 # Reset counter for fallback + files_checked = 0 + try: + filenames = ftp.nlst() + logging.debug(f"NLST output for {remote_dir}: {filenames}") + for filename in filenames: + # Check for database backup pattern + is_db_backup = filename.startswith(f"{POSTGRES_DB}_") and filename.endswith(".sql") + + if is_db_backup: + files_checked += 1 + try: + # Attempt to get modification time + mdtm_str = ftp.voidcmd(f"MDTM {filename}") + # Response format is usually "213 YYYYMMDDHHMMSS" + if mdtm_str.startswith("213 "): + file_mod_time = parse_mlsd_time(mdtm_str[4:].strip()) + if file_mod_time and file_mod_time < cutoff_date: + try: + ftp.delete(filename) + logging.info(f"Deleted old remote backup (fallback): {filename} (modified: {file_mod_time})") + files_deleted += 1 + except ftplib.error_perm as del_err_fb: + logging.error(f"Failed to delete remote file {filename} (fallback): {del_err_fb}") + elif not file_mod_time: + logging.warning(f"Skipping remote file (fallback) due to unparseable time: {filename}") + else: + logging.warning(f"Could not get MDTM for remote file {filename}: {mdtm_str}. Skipping deletion check.") + except ftplib.error_perm as mdtm_err: + logging.warning(f"MDTM command failed for {filename}: {mdtm_err}. Skipping deletion check.") + except Exception as fb_err: + logging.warning(f"Error processing file {filename} in fallback: {fb_err}. Skipping.") + logging.info(f"Remote cleanup finished using LIST/MDTM fallback for {remote_dir}. Checked: {files_checked}, Deleted: {files_deleted}.") + except ftplib.error_perm as list_err: + logging.error(f"Failed to list files using NLST in fallback: {list_err}") + except Exception as fallback_list_err: + logging.error(f"An unexpected error occurred during FTP fallback cleanup: {fallback_list_err}") + + except ftplib.error_perm as ftp_cwd_err: + logging.error(f"Failed to change FTP directory for cleanup to '{remote_dir}': {ftp_cwd_err}") + # If we can't change directory, we can't clean it. + return # Exit cleanup function + except ftplib.all_errors as ftp_err: + logging.error(f"FTP connection/login failed during cleanup: {ftp_err}") + return # Exit cleanup function + # --- End Simplified directory change --- + + except ftplib.all_errors as ftp_err: + logging.error(f"FTP cleanup failed: {ftp_err}") + except Exception as ftp_clean_e: + logging.error(f"An unexpected error occurred during FTP cleanup: {ftp_clean_e}") + finally: + if ftp: + try: + ftp.quit() + except ftplib.all_errors: + logging.debug("FTP quit command failed during cleanup, closing connection.") + ftp.close() + + +def run_cleanup(): + """Runs cleanup for both local and remote directories.""" + logging.info("Running scheduled cleanup job.") + cleanup_local_backups(LOCAL_BACKUP_PATH) + cleanup_remote_backups() + +def run_backup_job(): + """Runs the backup job.""" + logging.info("Running scheduled backup job.") + perform_backup() + # Cleanup is handled by a separate schedule + +if __name__ == "__main__": + check_env_vars() + create_backup_dirs() + + logging.info("Backup script starting.") + logging.info(f"Scheduling backups for {BACKUP_TIME_AM} and {BACKUP_TIME_PM} KST (Asia/Seoul).") + logging.info(f"Backup retention: {BACKUP_RETENTION_DAYS} days.") + logging.info(f"Local backup path: {LOCAL_BACKUP_PATH}") + + # Log database configuration + logging.info(f"Database: {POSTGRES_DB} at {POSTGRES_HOST}:{POSTGRES_PORT}") + + if FTP_HOST and FTP_USER and FTP_PASSWORD and FTP_PATH: + # Ensure log path starts with / for clarity + log_ftp_path = f"/{FTP_PATH.lstrip('/')}" + logging.info(f"FTP Target: ftp://{FTP_USER}:****@{FTP_HOST}:{FTP_PORT}{log_ftp_path}") + else: + logging.info("FTP Target: Not configured.") + + # --- Initial Run --- + logging.info("--- Performing initial backup and cleanup run on startup ---") + try: + run_backup_job() # Perform the backup job first + run_cleanup() # Then run cleanup + logging.info("--- Initial run complete. Proceeding to scheduled runs. ---") + except Exception as initial_run_error: + logging.error(f"Error during initial backup/cleanup run: {initial_run_error}") + # Log the error and continue to the scheduler. + # --- End Initial Run --- + + # --- Scheduling --- + schedule.every().day.at(BACKUP_TIME_AM, "Asia/Seoul").do(run_backup_job) + schedule.every().day.at(BACKUP_TIME_PM, "Asia/Seoul").do(run_backup_job) + + # Schedule cleanup (e.g., once daily, shortly after the first backup) + # Ensure the time parsing and addition handles potential day rollovers if needed, + # but adding 15 minutes should be safe. + try: + cleanup_dt = datetime.strptime(BACKUP_TIME_AM, "%H:%M") + timedelta(minutes=15) + cleanup_time = cleanup_dt.strftime("%H:%M") + logging.info(f"Scheduling daily cleanup job for {cleanup_time} KST (Asia/Seoul).") + schedule.every().day.at(cleanup_time, "Asia/Seoul").do(run_cleanup) + except ValueError: + logging.error(f"Invalid BACKUP_TIME_AM format: {BACKUP_TIME_AM}. Cannot schedule cleanup accurately.") + # Fallback: Schedule cleanup at a fixed time like 08:00 + logging.warning("Scheduling cleanup for 08:00 KST as fallback.") + schedule.every().day.at("08:00", "Asia/Seoul").do(run_cleanup) + # --- End Scheduling --- + + logging.info("Scheduler started. Waiting for scheduled jobs...") + while True: + schedule.run_pending() + time.sleep(60) # Check every 60 seconds \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5412e78..79e9008 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -46,6 +46,20 @@ services: timeout: 5s retries: 5 + wace-plm-backup: + build: + context: . + dockerfile: Dockerfile.backup + container_name: wace-plm-backup + restart: always + env_file: + - .env.production + depends_on: + wace-plm-db: + condition: service_healthy + volumes: + - /home/wace-plm/backups:/backups/local + networks: default: external: diff --git a/env.production.example b/env.production.example index 7d1e0de..212cbef 100644 --- a/env.production.example +++ b/env.production.example @@ -1,15 +1,15 @@ -# PLM ILSHIN 운영환경 설정 +# WACE PLM 운영환경 설정 # 애플리케이션 환경 NODE_ENV=production # 데이터베이스 설정 -DB_URL=jdbc:postgresql://localhost:5432/ilshin +DB_URL=jdbc:postgresql://wace-plm-db:5432/waceplm DB_USERNAME=postgres DB_PASSWORD=your_production_password # PostgreSQL 환경 변수 -POSTGRES_DB=ilshin +POSTGRES_DB=waceplm POSTGRES_USER=postgres POSTGRES_PASSWORD=your_production_password @@ -26,5 +26,19 @@ DEBUG=false SSL_ENABLED=true # 도메인 설정 -DOMAIN=ilshin.esgrin.com -ALT_DOMAIN=autoclave.co.kr \ No newline at end of file +DOMAIN=waceplm.esgrin.com + +# 백업 설정 +POSTGRES_HOST=wace-plm-db +POSTGRES_DOCKER_PORT=5432 +LOCAL_BACKUP_PATH=/backups/local +BACKUP_RETENTION_DAYS=7 +BACKUP_TIME_AM=07:30 +BACKUP_TIME_PM=18:00 + +# FTP 백업 설정 (NAS Backup SMB Configuration) +FTP_HOST=effectsno1.synology.me +FTP_USER=esgrin-mes-backup +FTP_PASSWORD=UyD12#11YHnn +FTP_PATH=esgrin-mes-backup +FTP_PORT=2112 \ No newline at end of file