홈랩 AI 서버 구축 가이드 2편: 철통 보안 아키텍처 및 3-2-1 백업전략

편수2편 / 5편
난이도⭐⭐ 중급
다루는 내용Linux 기본설정 · UFW · Nginx · Cloudflare · Tailscale · 백업 · DR
섹션 수13개 섹션
🐧 Linux 초기설정 🔒 보안 아키텍처 🌐 Nginx · Cloudflare 🛡️ Tailscale VPN 💾 3-2-1 백업 재해복구 · HA · 서버이전
2편은 서버를 외부 위협으로부터 지키고, 장애로부터 빠르게 복구하는 방법을 다룹니다. Ubuntu 서버 첫 부팅 직후 해야 할 Linux 기본 설정과 홈랩 추천 설정부터 시작해, UFW 방화벽·Nginx SSL·Cloudflare Tunnel·Tailscale VPN으로 보안 레이어를 쌓습니다. 이어서 3-2-1 백업 전략, Docker 볼륨 자동 백업, PostgreSQL·Qdrant 핫 백업, 재해복구 플레이북까지 — 보안과 안정성을 한 편에 완전히 정복합니다.
SECTION01

🐧 Linux 기본 설정 & 홈랩 추천 설정

① 서버 첫 부팅 후 필수 초기 설정

Ubuntu 24.04 초기 설정 (root 또는 sudo)
# 1. 시스템 업데이트 & 필수 패키지
apt update && apt upgrade -y
apt install -y curl wget git vim htop tmux tree net-tools   build-essential ca-certificates gnupg lsb-release   software-properties-common apt-transport-https   unzip p7zip-full rsync ncdu duf

# 2. 타임존 설정
timedatectl set-timezone Asia/Seoul
timedatectl status  # 확인

# 3. 로케일 설정
locale-gen ko_KR.UTF-8
update-locale LANG=ko_KR.UTF-8
echo 'LANG=ko_KR.UTF-8' >> /etc/environment

# 4. 스왑 설정 (RAM 128GB면 불필요하지만 안전망으로)
fallocate -l 8G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

# 5. 커널 파라미터 최적화 (AI 서버 권장)
cat >> /etc/sysctl.conf << 'EOF'
# 네트워크 최적화
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.core.netdev_max_backlog = 5000
net.ipv4.tcp_congestion_control = bbr
# 파일 디스크립터
fs.file-max = 2097152
# 메모리 과할당 허용 (Docker 컨테이너용)
vm.overcommit_memory = 1
vm.max_map_count = 262144
EOF
sysctl -p

② 사용자 & SSH 보안 초기 설정

관리자 계정 & SSH 키 설정
# 1. 관리자 계정 생성 (root 직접 사용 금지)
adduser evan                    # 비밀번호 설정
usermod -aG sudo evan
usermod -aG docker evan         # Docker 그룹 추가

# 2. SSH 키 기반 인증 설정 (클라이언트 PC에서)
ssh-keygen -t ed25519 -C "homelab-r730"
ssh-copy-id [email protected]  # 서버 IP

# 3. SSH 보안 강화 (/etc/ssh/sshd_config)
sed -i 's/#PermitRootLogin.*/PermitRootLogin no/'       /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/#PubkeyAuthentication.*/PubkeyAuthentication yes/'    /etc/ssh/sshd_config
# SSH 포트 변경 권장 (기본 22 → 비표준 포트)
sed -i 's/#Port 22/Port 22222/' /etc/ssh/sshd_config
systemctl restart sshd

# 4. sudo 타임아웃 조정 (기본 15분 → 30분)
echo 'Defaults timestamp_timeout=30' >> /etc/sudoers.d/custom

③ 스토리지 마운트 설정 (/mnt/data)

추가 디스크 마운트 & 디렉토리 구조 생성
# 추가 HDD/SSD 파티션 생성 (예: /dev/sdb)
fdisk /dev/sdb   # n → p → Enter × 3 → w
mkfs.ext4 /dev/sdb1
blkid /dev/sdb1  # UUID 확인

# /etc/fstab에 영구 마운트 등록
echo 'UUID=xxxx-xxxx /mnt/data ext4 defaults,noatime 0 2' >> /etc/fstab
mount -a
df -h /mnt/data   # 확인

# AI 서버 표준 디렉토리 구조
mkdir -p /mnt/data/{01_ai,02_automation,03_database,04_web,05_monitor}
mkdir -p /mnt/data/01_ai/{ollama,openwebui,comfyui,dify,whisper,kokoro}
mkdir -p /mnt/data/02_automation/{n8n,n8n-db}
mkdir -p /mnt/data/03_database/{qdrant,postgres,redis,meilisearch}
mkdir -p /mnt/data/05_monitor/{grafana,prometheus}

# 소유권 설정
chown -R evan:evan /mnt/data
chmod -R 755 /mnt/data

④ 홈랩 추천 툴 설치

생산성 향상 도구 모음
# lazydocker — 터미널 Docker TUI
curl https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_lzd.sh | bash

# ctop — 컨테이너 리소스 모니터링
wget https://github.com/bcicen/ctop/releases/download/v0.7.7/ctop-0.7.7-linux-amd64 -O /usr/local/bin/ctop
chmod +x /usr/local/bin/ctop

# duf — 디스크 사용량 시각화
apt install duf -y

# btop — htop 대체 리소스 모니터
apt install btop -y

# bat — cat 대체 (신택스 하이라이팅)
apt install bat -y && ln -s /usr/bin/batcat /usr/local/bin/bat

# fd — find 대체
apt install fd-find -y && ln -s /usr/bin/fdfind /usr/local/bin/fd

# ripgrep — grep 대체
apt install ripgrep -y

# tmux 설정 (~/.tmux.conf)
cat > ~/.tmux.conf << 'EOF'
set -g default-terminal "screen-256color"
set -g mouse on
set -g history-limit 10000
bind r source-file ~/.tmux.conf \; display "Reloaded!"
set -g prefix C-a
unbind C-b
bind C-a send-prefix
EOF

⑤ 시스템 자동화 & 유지보수 스크립트

Cron 기반 자동 유지보수 설정
# /usr/local/bin/homelab-maintenance.sh
cat > /usr/local/bin/homelab-maintenance.sh << 'EOF'
#!/bin/bash
LOG=/var/log/homelab-maintenance.log
echo "=== $(date) ===" >> $LOG

# Docker 이미지·컨테이너 정리
docker system prune -f >> $LOG 2>&1

# apt 자동 업데이트 (보안 패치만)
apt-get update -qq && apt-get upgrade -y --only-upgrade >> $LOG 2>&1

# 디스크 사용량 리포트
df -h /mnt/data >> $LOG

# 컨테이너 상태 확인
docker ps --format "table {{.Names}}\t{{.Status}}" >> $LOG
echo "유지보수 완료" >> $LOG
EOF
chmod +x /usr/local/bin/homelab-maintenance.sh

# Cron 등록 — 매주 일요일 새벽 3시
echo '0 3 * * 0 /usr/local/bin/homelab-maintenance.sh' | crontab -

# 로그 로테이션 설정
cat > /etc/logrotate.d/homelab << 'EOF'
/var/log/homelab-*.log {
  weekly
  rotate 4
  compress
  missingok
  notifempty
}
EOF
💡
홈랩 서버 운영 핵심 원칙

root 직접 로그인 금지 — sudo 계정만 사용
SSH 키 인증만 허용 — 비밀번호 인증 비활성화
/mnt/data/ 아래 모든 데이터 집중 — 디스크 교체 시 재마운트만으로 복구
tmux 세션 유지 — SSH 끊겨도 작업 유지
lazydocker로 컨테이너 상태 빠른 확인

SECTION02

AI 서버 보안 아키텍처 설계 원칙

AI 서버는 LLM API, 이미지 생성 엔진, 자동화 파이프라인 등 고가의 연산 자원이 집중된 시스템입니다. 보안 레이어를 단계별로 설계하지 않으면 한 번의 침입으로 모든 서비스가 무력화됩니다. 핵심 원칙은 외부에는 최소한만 노출하고, 내부에서는 서비스 간 격리를 유지하는 것입니다.

🏗️ AI 서버 보안 레이어 구조

레이어도구역할위치
L1. 네트워크 경계UFW / iptables포트 기반 접근 제어OS 수준
L2. 리버스 프록시Nginx / CaddySSL 종료, 도메인 라우팅, Rate LimitDocker 컨테이너
L3. CDN / 터널Cloudflare TunnelDDoS 방어, IP 숨김, 글로벌 캐시클라우드 엣지
L4. VPNTailscale / WireGuard신뢰 디바이스 전용 내부망OS 수준
L5. 애플리케이션Open WebUI Auth사용자 인증, 역할 분리앱 수준
L6. 침입 탐지Fail2ban브루트포스 자동 차단OS 수준
L7. 감사 로그auditd / syslog이상 행동 추적OS 수준

🌐 접근 방식별 보안 설계 선택

시나리오추천 구성특징
개인 전용 (내부망만)Tailscale VPN만공인 IP 불필요. 가장 안전. 외부 접근은 VPN으로만
팀 공유 (소규모)Cloudflare Tunnel + Zero TrustGoogle/Microsoft 계정 인증. 포트포워딩 불필요
도메인 공개 서비스Nginx + Let's Encrypt + CloudflareSSL 자동 갱신. DDoS 방어. Rate Limiting 필수
기업/멀티유저Nginx + LDAP/SSO + Cloudflare + VPN전사 계정 연동. 완전한 감사 로그
SECTION03

UFW 방화벽 완전 설정

UFW(Uncomplicated Firewall)는 Ubuntu에서 가장 많이 사용하는 방화벽 관리 도구입니다. AI 서버에서 주의해야 할 점은 Docker가 UFW를 우회해 직접 iptables를 수정한다는 것입니다. 이를 방지하지 않으면 UFW에서 포트를 막아도 Docker 서비스는 외부에 노출됩니다.

🚨
Docker + UFW 치명적 보안 구멍 — 반드시 패치 필요

기본 설정에서 docker run -p 8080:8080을 실행하면 UFW가 포트 8080을 막아도 외부에서 접근됩니다. Docker 데몬이 iptables를 직접 수정하기 때문입니다. 아래 설정으로 반드시 차단해야 합니다.

bash — Docker UFW 우회 차단 + 방화벽 완전 설정
## ── Step 1: Docker UFW 우회 차단 ─────────────────
# /etc/docker/daemon.json 수정
sudo tee /etc/docker/daemon.json << 'EOF'
{
  "iptables": false,
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3"
  }
}
EOF

# Docker 재시작
sudo systemctl restart docker

## ── Step 2: UFW 기본 정책 설정 ───────────────────
sudo ufw default deny incoming
sudo ufw default allow outgoing

## ── Step 3: 허용 포트 설정 ───────────────────────
# SSH (변경한 포트로)
sudo ufw allow 2222/tcp comment 'SSH'

# HTTP/HTTPS (Nginx 리버스 프록시)
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'

# 내부 네트워크에서만 직접 접근 허용 (예: 192.168.1.0/24)
sudo ufw allow from 192.168.1.0/24 to any port 3000 comment 'Open WebUI LAN'
sudo ufw allow from 192.168.1.0/24 to any port 11434 comment 'Ollama API LAN'
sudo ufw allow from 192.168.1.0/24 to any port 5678 comment 'n8n LAN'
sudo ufw allow from 192.168.1.0/24 to any port 8188 comment 'ComfyUI LAN'

# Tailscale VPN 네트워크에서 전체 허용
sudo ufw allow in on tailscale0

## ── Step 4: UFW 활성화 ───────────────────────────
sudo ufw enable
sudo ufw status verbose

## ── Step 5: Docker 네트워크용 iptables 규칙 추가 ─
# /etc/ufw/after.rules 파일 끝에 추가
sudo tee -a /etc/ufw/after.rules << 'EOF'

# Docker 컨테이너 간 통신 허용
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i docker0 -j ACCEPT
-A DOCKER-USER -o docker0 -j ACCEPT
-A DOCKER-USER -i br-+ -j ACCEPT
-A DOCKER-USER -o br-+ -j ACCEPT
COMMIT
EOF

sudo ufw reload

📊 AI 서버 포트 보안 정책표

포트서비스외부 허용LAN 허용VPN 허용
2222SSH✅ (Key 인증만)
80/443Nginx HTTPS
3000Open WebUI❌ (Nginx 통해서만)
11434Ollama API
5678n8n
8188ComfyUI
6333Qdrant✅ (관리자만)
5432PostgreSQL✅ (관리자만)
SECTION04

Nginx 리버스 프록시 + SSL 완전 설정

Nginx 리버스 프록시는 모든 외부 요청을 443(HTTPS)으로 받아 내부 Docker 서비스로 라우팅합니다. 이 구조의 핵심 장점은 내부 포트를 완전히 숨기면서 서비스별 서브도메인, Rate Limiting, 인증 레이어를 중앙에서 관리할 수 있다는 것입니다.

📁 ~/ai-server/security/docker-compose.yml
yaml — Nginx Proxy Manager (노코드 관리 UI)
services:
  nginx-proxy-manager:
    image: jc21/nginx-proxy-manager:latest
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - "80:80"       # HTTP → HTTPS 리다이렉트
      - "443:443"     # HTTPS
      - "81:81"       # 관리 UI (내부망만 허용)
    volumes:
      - nginx_data:/data
      - nginx_letsencrypt:/etc/letsencrypt
    networks:
      - ai_net

volumes:
  nginx_data:
  nginx_letsencrypt:

networks:
  ai_net:
    external: true
    name: ai_network

⚙️ 수동 Nginx 설정 (고급 — Rate Limiting 포함)

nginx — /etc/nginx/conf.d/ai-server.conf
# Rate Limiting 정의
limit_req_zone $binary_remote_addr zone=ai_api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=webui:10m rate=60r/m;

# Open WebUI (ai.yourdomain.com)
server {
    listen 443 ssl http2;
    server_name ai.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;

    # 보안 헤더
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header Strict-Transport-Security "max-age=31536000" always;

    # Rate Limiting 적용
    limit_req zone=webui burst=20 nodelay;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;     # WebSocket 지원
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;                    # LLM 긴 응답 타임아웃
        proxy_send_timeout 300s;
        client_max_body_size 50M;                   # 파일 업로드 크기
    }
}

# n8n (n8n.yourdomain.com)
server {
    listen 443 ssl http2;
    server_name n8n.yourdomain.com;
    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://localhost:5678;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400s;  # 웹훅 롱폴링
    }
}

# HTTP → HTTPS 리다이렉트
server {
    listen 80;
    server_name _;
    return 301 https://$host$request_uri;
}

🔑 Let's Encrypt SSL 자동 발급 & 갱신

bash — Certbot SSL 인증서 발급
# Certbot 설치
sudo apt install -y certbot python3-certbot-nginx

# 와일드카드 인증서 발급 (Cloudflare DNS 사용 시)
sudo apt install -y python3-certbot-dns-cloudflare

# Cloudflare API 토큰 설정
sudo tee /root/.cloudflare.ini << 'EOF'
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
sudo chmod 600 /root/.cloudflare.ini

# 와일드카드 인증서 발급 (*.yourdomain.com)
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /root/.cloudflare.ini \
  -d yourdomain.com \
  -d '*.yourdomain.com'

# 자동 갱신 확인
sudo certbot renew --dry-run

# cron 자동 갱신 등록 (이미 자동 설정됨, 확인만)
sudo systemctl status certbot.timer
SECTION05

Cloudflare Tunnel — 공인 IP 없이 HTTPS 공개

Cloudflare Tunnel은 포트 포워딩도, 공인 IP도 필요 없이 AI 서버를 안전하게 인터넷에 공개하는 가장 강력한 방법입니다. 서버가 Cloudflare 엣지와 아웃바운드 터널을 유지하기 때문에 DDoS 공격으로부터 서버 IP가 완전히 숨겨집니다.

bash — Cloudflare Tunnel 설치 및 설정
# cloudflared 설치
curl -L --output cloudflared.deb \
  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb

# Cloudflare 계정 인증
cloudflared tunnel login

# 터널 생성
cloudflared tunnel create ai-server
# → UUID가 출력됩니다. 예: a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# 터널 설정 파일 작성
mkdir -p ~/.cloudflared
tee ~/.cloudflared/config.yml << 'EOF'
tunnel: a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx
credentials-file: /root/.cloudflared/a1b2c3d4-xxxx.json

ingress:
  - hostname: ai.yourdomain.com
    service: http://localhost:3000
    originRequest:
      connectTimeout: 30s
      noTLSVerify: false

  - hostname: n8n.yourdomain.com
    service: http://localhost:5678

  - hostname: comfy.yourdomain.com
    service: http://localhost:8188

  - service: http_status:404
EOF

# DNS 레코드 자동 등록
cloudflared tunnel route dns ai-server ai.yourdomain.com
cloudflared tunnel route dns ai-server n8n.yourdomain.com

# 시스템 서비스로 등록 (부팅 시 자동 시작)
sudo cloudflared service install
sudo systemctl start cloudflared
sudo systemctl status cloudflared

🔐 Cloudflare Zero Trust Access — 추가 인증 레이어

Cloudflare Zero Trust를 사용하면 도메인에 접근하기 전에 Google/Microsoft/GitHub 계정 인증을 추가할 수 있습니다. Open WebUI의 자체 로그인 전에 한 번 더 인증하는 구조로 최강의 보안을 제공합니다.

1
Cloudflare Zero Trust 대시보드 접속
one.dash.cloudflare.com → Access → Applications → Add an Application
2
Self-hosted Application 선택
Application domain: ai.yourdomain.com 입력
3
Policy 설정
Allow → Emails → 허용할 이메일 주소 추가. 또는 Google Workspace 도메인 전체 허용.
4
Identity Provider 연결
Settings → Authentication → Add new → Google 또는 GitHub 선택 후 OAuth 설정.
SECTION06

Tailscale VPN — 프라이빗 AI 전용 내부망

Tailscale은 WireGuard 기반의 메시 VPN으로, 어디서든 내 AI 서버에 안전하게 접근할 수 있는 가장 간단한 솔루션입니다. 설정 시간 5분, 공인 IP 불필요, 포트 포워딩 불필요. 특히 신뢰하는 디바이스만 AI 서버 내부 포트에 직접 접근해야 할 때 Cloudflare Tunnel과 병행해서 사용합니다.

bash — Tailscale 설치 및 AI 서버 전용 네트워크 설정
# Tailscale 설치
curl -fsSL https://tailscale.com/install.sh | sh

# 로그인 (브라우저 인증 링크 출력)
sudo tailscale up --advertise-exit-node

# Tailscale IP 확인 (100.x.x.x 형태)
tailscale ip -4

# Docker 컨테이너에서 Tailscale 통해 접근 허용
# UFW에 tailscale0 인터페이스 전체 허용
sudo ufw allow in on tailscale0
sudo ufw allow out on tailscale0

# 상태 확인
tailscale status

📱 모바일 접근 설정

  • iOS/Android에 Tailscale 앱 설치 → 같은 계정으로 로그인
  • 앱에서 AI 서버 Tailscale IP(100.x.x.x)로 접속 → 내부망과 동일하게 동작
  • 모바일 브라우저에서 http://100.x.x.x:3000 → Open WebUI 접속
  • iTailscale Magic DNS 활성화 시 http://ai-server로도 접속 가능
✅ Cloudflare Tunnel vs Tailscale — 사용 구분
  • Cloudflare Tunnel — 팀원·외부 협력사에게 AI 서비스 공개 시. 브라우저만 있으면 접근 가능
  • Tailscale — 본인만의 완전한 프라이빗 접근. Qdrant, PostgreSQL 같은 DB 직접 접근 시
  • 두 가지 병행 — Open WebUI는 Cloudflare로 팀 공개, Ollama API는 Tailscale로 개발자 전용
SECTION07

Fail2ban · SSH 강화 · 서버 해딩 보안

🛡️ SSH 키 인증 강제화 (비밀번호 로그인 차단)

bash — SSH 보안 강화 완전 설정
# SSH 키 생성 (클라이언트에서 실행)
ssh-keygen -t ed25519 -C "ai-server-access"
ssh-copy-id -p 2222 user@ai-server-ip

# SSH 보안 설정 강화 (/etc/ssh/sshd_config)
sudo tee -a /etc/ssh/sshd_config << 'EOF'

# 비밀번호 인증 완전 차단
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no

# 연결 타임아웃
ClientAliveInterval 300
ClientAliveCountMax 3
LoginGraceTime 30

# MaxAuth 시도 제한
MaxAuthTries 3
MaxSessions 5

# 특정 사용자만 허용
AllowUsers your_username
EOF

sudo systemctl restart ssh

# 설정 테스트 (반드시 새 터미널에서 확인 후 기존 세션 닫기)
ssh -p 2222 your_username@ai-server-ip

🚫 Fail2ban 설치 & AI 서버 특화 설정

bash — Fail2ban 완전 설정
sudo apt install -y fail2ban

# AI 서버 특화 Fail2ban 설정
sudo tee /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime  = 3600        # 차단 시간 (초)
findtime = 600         # 탐지 시간 윈도우
maxretry = 5           # 허용 실패 횟수
backend  = systemd
banaction = ufw

[sshd]
enabled  = true
port     = 2222
logpath  = /var/log/auth.log
maxretry = 3
bantime  = 86400       # SSH는 24시간 차단

[nginx-http-auth]
enabled  = true
logpath  = /var/log/nginx/error.log

[nginx-limit-req]
enabled  = true
logpath  = /var/log/nginx/error.log

[open-webui]
enabled  = true
port     = 3000,443
logpath  = /var/lib/docker/containers/*/open-webui*.log
maxretry = 10
bantime  = 1800
filter   = open-webui
EOF

sudo systemctl enable fail2ban
sudo systemctl start fail2ban
sudo fail2ban-client status

🔄 자동 보안 업데이트 설정

bash — unattended-upgrades 보안 패치 자동화
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

# 보안 업데이트만 자동 적용 설정
sudo tee /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
EOF

# 업데이트 로그 확인
sudo cat /var/log/unattended-upgrades/unattended-upgrades.log

✅ 보안 설정 최종 체크리스트

  • UFW 활성화 + Docker UFW 우회 차단 완료
  • SSH 포트 변경 + 키 인증만 허용 + 비밀번호 로그인 차단
  • Nginx HTTPS + WebSocket + Rate Limiting 설정 완료
  • Let's Encrypt SSL 와일드카드 인증서 발급 + 자동 갱신
  • Cloudflare Tunnel 설정 + Zero Trust Access 인증 추가
  • Tailscale VPN 설치 + 내부 서비스 전용 접근 구성
  • Fail2ban 브루트포스 자동 차단 활성화
  • 자동 보안 업데이트 활성화
  • !Open WebUI Admin Panel → Default User Role → Pending 설정 (외부 공개 시 필수)
  • !정기적으로 sudo fail2ban-client status sshd로 차단 현황 모니터링

글 2 완료! AI 서버 보안 레이어가 완성됐습니다. 글 3에서는 ComfyUI와 Stable Diffusion A1111을 설치해 무제한 AI 이미지 생성 환경을 구축합니다. FLUX.1, SDXL, Lora, ControlNet까지 전부 다룹니다.

SECTIONF1

3-2-1 백업 전략 설계 & 복구 목표 설정

백업은 "있으면 좋은 것"이 아니라 반드시 테스트된 복구 계획이어야 합니다. "백업이 있다"는 것과 "복구가 된다"는 것은 다릅니다. 많은 팀이 백업은 하지만 복구 테스트를 하지 않아 실제 장애 시 데이터를 잃습니다.

📐 RTO & RPO — 복구 목표 설정

지표의미개인 홈랩팀 운영기업 서비스
RTO
(Recovery Time Objective)
장애 발생 후 서비스 재개까지 허용 시간24시간4시간1시간 이하
RPO
(Recovery Point Objective)
데이터 손실을 허용하는 최대 시간 범위24시간6시간1시간 이하

📦 3-2-1 백업 원칙 적용

원칙의미AI 서버 적용 방법
3개의 복사본원본 + 백업 2개운영 데이터 + 로컬 NAS + 클라우드
2가지 미디어서로 다른 저장 매체NVMe SSD (운영) + HDD NAS (백업)
1개 오프사이트물리적으로 분리된 장소Cloudflare R2 / Backblaze B2 (클라우드)

📊 AI 서버 데이터 중요도 분류

데이터위치중요도백업 주기보존 기간
Qdrant 벡터 DBqdrant_storage 볼륨최고6시간마다30일
Open WebUI 대화·설정open_webui_data 볼륨최고매일90일
PostgreSQL (n8n·Dify)postgres_data 볼륨최고매일 (WAL 연속)30일
Ollama 모델 파일ollama_data 볼륨높음주간영구
n8n 워크플로우 JSONn8n_data 볼륨높음매일90일
ComfyUI 워크플로우comfyui_models 볼륨보통주간90일
이미지 생성 결과물comfyui_output 볼륨낮음월간1년
SECTIONF2

Docker 볼륨 자동 백업 완전 구현

bash — ~/ai-server/scripts/backup.sh (완전 자동 백업 시스템)
#!/bin/bash
# AI 서버 완전 자동 백업 스크립트
# cron: 0 2 * * * /home/ubuntu/ai-server/scripts/backup.sh >> /var/log/ai-backup.log 2>&1

set -euo pipefail

BACKUP_BASE="/mnt/nas/ai-server-backup"   # NAS 마운트 경로
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="$BACKUP_BASE/$DATE"
LOG_FILE="/var/log/ai-backup.log"
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK"
RETENTION_DAYS=30                          # 30일 이전 백업 자동 삭제

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"; }
notify_slack() {
    curl -s -X POST "$SLACK_WEBHOOK" \
        -H 'Content-type: application/json' \
        -d "{\"text\": \"$1\"}" > /dev/null
}

log "=== AI 서버 백업 시작 ==="
mkdir -p "$BACKUP_DIR"

## ── 핵심 볼륨 목록 ───────────────────────────────
declare -A CRITICAL_VOLUMES=(
    ["qdrant_storage"]="Qdrant 벡터 DB"
    ["open_webui_data"]="Open WebUI 데이터"
    ["postgres_data"]="PostgreSQL"
    ["n8n_data"]="n8n 워크플로우"
)

declare -A NORMAL_VOLUMES=(
    ["ollama_data"]="Ollama 모델"
    ["grafana_data"]="Grafana 대시보드"
    ["pipelines_data"]="Open WebUI 파이프라인"
)

TOTAL_SIZE=0
BACKUP_COUNT=0
ERRORS=0

backup_volume() {
    local VOL_NAME=$1
    local VOL_DESC=$2
    local COMPRESS=${3:-"gz"}   # gz=빠름, bz2=작음

    log "  백업: $VOL_DESC ($VOL_NAME)..."

    # 볼륨 존재 확인
    if ! docker volume inspect "$VOL_NAME" &>/dev/null; then
        log "  ⚠️  볼륨 없음, 건너뜀: $VOL_NAME"
        return 0
    fi

    local OUTPUT_FILE="$BACKUP_DIR/${VOL_NAME}_${DATE}.tar.${COMPRESS}"

    # Alpine 컨테이너로 볼륨 압축 백업
    docker run --rm \
        -v "${VOL_NAME}:/data:ro" \
        -v "${BACKUP_DIR}:/backup" \
        alpine:latest \
        sh -c "cd /data && tar c${COMPRESS}f /backup/$(basename $OUTPUT_FILE) . 2>/dev/null" \
    && {
        local SIZE=$(du -sh "$OUTPUT_FILE" | cut -f1)
        log "  ✅ 완료: $VOL_DESC → $SIZE"
        BACKUP_COUNT=$((BACKUP_COUNT + 1))
    } || {
        log "  ❌ 실패: $VOL_DESC"
        ERRORS=$((ERRORS + 1))
    }
}

## ── PostgreSQL 핫 덤프 (서비스 중단 없이) ────────
backup_postgres() {
    log "  백업: PostgreSQL (pg_dump)..."
    local PG_BACKUP="$BACKUP_DIR/postgres_dump_${DATE}.sql.gz"

    docker exec n8n_postgres pg_dumpall \
        -U aiserver 2>/dev/null \
    | gzip > "$PG_BACKUP" \
    && log "  ✅ PostgreSQL 덤프 완료: $(du -sh $PG_BACKUP | cut -f1)" \
    || { log "  ❌ PostgreSQL 덤프 실패"; ERRORS=$((ERRORS + 1)); }
}

## ── Qdrant 스냅샷 (API 사용) ─────────────────────
backup_qdrant_snapshots() {
    log "  백업: Qdrant 스냅샷..."
    local QDRANT_URL="http://localhost:6333"
    local SNAP_DIR="$BACKUP_DIR/qdrant_snapshots"
    mkdir -p "$SNAP_DIR"

    # 컬렉션 목록 조회
    COLLECTIONS=$(curl -s "${QDRANT_URL}/collections" \
        -H "api-key: your_qdrant_key" \
    | python3 -c "import sys,json; d=json.load(sys.stdin); print('\n'.join([c['name'] for c in d['result']['collections']]))" 2>/dev/null)

    for COL in $COLLECTIONS; do
        # 스냅샷 생성
        SNAP_INFO=$(curl -s -X POST \
            "${QDRANT_URL}/collections/${COL}/snapshots" \
            -H "api-key: your_qdrant_key" \
            -H "Content-Type: application/json")

        SNAP_NAME=$(echo "$SNAP_INFO" | python3 -c \
            "import sys,json; d=json.load(sys.stdin); print(d['result']['name'])" 2>/dev/null)

        if [ -n "$SNAP_NAME" ]; then
            # 스냅샷 파일 다운로드
            curl -s "${QDRANT_URL}/collections/${COL}/snapshots/${SNAP_NAME}" \
                -H "api-key: your_qdrant_key" \
                -o "$SNAP_DIR/${COL}_${DATE}.snapshot" \
            && log "  ✅ Qdrant $COL 스냅샷 완료"
        fi
    done
}

## ── 설정 파일 백업 ────────────────────────────────
backup_configs() {
    log "  백업: 설정 파일..."
    local CONFIG_DIR="$BACKUP_DIR/configs"
    mkdir -p "$CONFIG_DIR"

    # 중요 설정 파일 모음
    for f in \
        ~/ai-server/core/docker-compose.yml \
        ~/ai-server/security/docker-compose.yml \
        ~/ai-server/monitoring/docker-compose.yml \
        ~/ai-server/monitoring/prometheus.yml \
        ~/ai-server/monitoring/alert_rules.yml \
        /etc/nginx/conf.d/ \
        ~/.cloudflared/config.yml; do
        [ -e "$f" ] && cp -r "$f" "$CONFIG_DIR/" 2>/dev/null
    done
    log "  ✅ 설정 파일 백업 완료"
}

## ── 실행 ──────────────────────────────────────────
log "핵심 볼륨 백업..."
for VOL in "${!CRITICAL_VOLUMES[@]}"; do
    backup_volume "$VOL" "${CRITICAL_VOLUMES[$VOL]}" "gz"
done

# 일요일만 일반 볼륨 백업 (대용량 절약)
if [ "$(date +%u)" = "7" ]; then
    log "일반 볼륨 백업 (주간)..."
    for VOL in "${!NORMAL_VOLUMES[@]}"; do
        backup_volume "$VOL" "${NORMAL_VOLUMES[$VOL]}" "gz"
    done
fi

backup_postgres
backup_qdrant_snapshots
backup_configs

## ── 백업 검증 ─────────────────────────────────────
log "백업 파일 검증..."
for f in "$BACKUP_DIR"/*.tar.gz; do
    [ -f "$f" ] || continue
    if ! gzip -t "$f" 2>/dev/null; then
        log "  ❌ 손상된 백업: $(basename $f)"
        ERRORS=$((ERRORS + 1))
    fi
done

## ── 오래된 백업 삭제 ──────────────────────────────
log "오래된 백업 정리 (${RETENTION_DAYS}일 이전)..."
find "$BACKUP_BASE" -maxdepth 1 -type d -mtime "+${RETENTION_DAYS}" -exec rm -rf {} \; 2>/dev/null || true

## ── 클라우드 오프사이트 동기화 ───────────────────
if command -v rclone &>/dev/null; then
    log "클라우드 동기화 (Backblaze B2)..."
    rclone sync "$BACKUP_DIR" "b2:ai-server-backup/$DATE" \
        --transfers=4 --quiet \
    && log "  ✅ 클라우드 동기화 완료" \
    || log "  ⚠️  클라우드 동기화 실패 (로컬 백업은 유지)"
fi

## ── 완료 알림 ─────────────────────────────────────
TOTAL_SIZE=$(du -sh "$BACKUP_DIR" | cut -f1)
STATUS_EMOJI=$( [ "$ERRORS" -eq 0 ] && echo "✅" || echo "⚠️" )
MSG="${STATUS_EMOJI} AI 서버 백업 완료\n날짜: ${DATE}\n성공: ${BACKUP_COUNT}건\n오류: ${ERRORS}건\n크기: ${TOTAL_SIZE}\n경로: ${BACKUP_DIR}"

notify_slack "$MSG"
log "=== 백업 완료: ${BACKUP_COUNT}건 성공, ${ERRORS}건 오류, ${TOTAL_SIZE} ==="
[ "$ERRORS" -gt 0 ] && exit 1 || exit 0
SECTIONF3

데이터베이스 핫 백업 — PostgreSQL & Qdrant

Docker 볼륨 전체를 복사하는 방법은 DB가 실행 중일 때 파일이 불완전 상태일 수 있습니다. 데이터베이스는 반드시 DB 자체 백업 도구를 사용해야 데이터 무결성이 보장됩니다.

bash — PostgreSQL WAL 스트리밍 복제 설정 (실시간 HA)
## ── Primary DB 설정 (주 서버) ─────────────────────
# docker-compose 환경변수에 추가
services:
  postgresql:
    image: pgvector/pgvector:pg16
    command: >
      postgres
      -c wal_level=replica
      -c max_wal_senders=3
      -c wal_keep_size=1GB
      -c archive_mode=on
      -c archive_command='cp %p /var/lib/postgresql/wal_archive/%f'
    environment:
      - POSTGRES_USER=aiserver
      - POSTGRES_PASSWORD=secure_password
      - POSTGRES_DB=aiserver_main

# 복제 사용자 생성 (컨테이너 내부에서 실행)
docker exec -it postgresql psql -U aiserver -c "
    CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD 'repl_password';
"

## ── 간단한 Point-in-Time 복구 설정 ───────────────
# 기본 백업 (베이스라인)
docker exec postgresql pg_basebackup \
    -U aiserver \
    -D /var/lib/postgresql/base_backup \
    -Ft -z -P

# 특정 시점으로 복구 (장애 발생 시)
docker exec postgresql psql -U aiserver -c "
    SELECT pg_wal_replay_resume();
"

## ── Barman으로 PostgreSQL 자동 백업 관리 ─────────
docker run -d \
    --name barman \
    --network ai_network \
    -v barman_data:/var/lib/barman \
    -e BARMAN_SERVER_NAME=aiserver_pg \
    -e BARMAN_CONNSTRING="host=postgresql user=barman" \
    ubazzio/barman:latest
python — Qdrant 자동 스냅샷 & 복구 완전 스크립트
import httpx, asyncio, os
from datetime import datetime
from pathlib import Path

QDRANT_URL = "http://192.168.1.253:6333"
QDRANT_KEY = "your_qdrant_key"
SNAP_DIR   = Path("/mnt/nas/ai-server-backup/qdrant")

async def create_all_snapshots() -> dict:
    """모든 컬렉션 스냅샷 생성"""
    headers = {"api-key": QDRANT_KEY}
    async with httpx.AsyncClient(timeout=300) as client:
        # 컬렉션 목록
        cols = await client.get(f"{QDRANT_URL}/collections", headers=headers)
        collections = [c["name"] for c in cols.json()["result"]["collections"]]

        results = {}
        for col in collections:
            print(f"  스냅샷 생성: {col}")
            snap = await client.post(
                f"{QDRANT_URL}/collections/{col}/snapshots",
                headers=headers
            )
            snap_name = snap.json()["result"]["name"]

            # 스냅샷 다운로드
            SNAP_DIR.mkdir(parents=True, exist_ok=True)
            output = SNAP_DIR / f"{col}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.snapshot"

            download = await client.get(
                f"{QDRANT_URL}/collections/{col}/snapshots/{snap_name}",
                headers=headers
            )
            output.write_bytes(download.content)

            file_size = output.stat().st_size / 1024 / 1024
            results[col] = {"file": str(output), "size_mb": round(file_size, 2)}
            print(f"  ✅ {col}: {file_size:.1f}MB")

        return results

async def restore_from_snapshot(collection: str, snapshot_file: str):
    """스냅샷에서 컬렉션 복구"""
    headers = {"api-key": QDRANT_KEY}
    async with httpx.AsyncClient(timeout=600) as client:
        # 기존 컬렉션 삭제 (있을 경우)
        await client.delete(
            f"{QDRANT_URL}/collections/{collection}",
            headers=headers
        )

        # 스냅샷 업로드 및 복구
        with open(snapshot_file, "rb") as f:
            resp = await client.post(
                f"{QDRANT_URL}/collections/{collection}/snapshots/upload",
                headers=headers,
                files={"snapshot": f}
            )

        if resp.status_code == 200:
            print(f"✅ {collection} 복구 완료")
        else:
            print(f"❌ 복구 실패: {resp.text}")

# 전체 스냅샷 생성
results = asyncio.run(create_all_snapshots())
print(f"\n✅ 총 {len(results)}개 컬렉션 백업 완료")

# 복구 예시
# asyncio.run(restore_from_snapshot("ai_knowledge_base", "/mnt/nas/.../snapshot"))
SECTIONF4

재해복구 플레이북 & 자동 복구 스크립트

🚨 장애 시나리오별 대응 타임라인

T+0
장애 감지 — Grafana 알림 수신
GPU 온도 급등 / 서비스 다운 / 디스크 오류 알림 → 즉시 확인 시작. ssh admin@ai-server 접속 시도
T+5분
초기 진단
~/ai-server/scripts/health_check.sh 실행 → 증상 파악. 하드웨어 vs 소프트웨어 vs 데이터 문제 구분
T+15분
격리 & 영향 범위 확인
어떤 서비스가 영향받는가? 데이터 손실 여부? 최신 백업 타임스탬프 확인 → 복구 계획 결정
T+30분
복구 실행
시나리오별 플레이북 실행. 소프트웨어 문제는 컨테이너 재시작. 데이터 문제는 백업 복구 스크립트 실행
T+1~4시간
서비스 검증 & 모니터링 강화
헬스체크 통과 확인 → 사용자 통보 → 1시간 집중 모니터링 → 원인 분석 보고서 작성
bash — ~/ai-server/scripts/disaster_recovery.sh (원클릭 복구)
#!/bin/bash
# AI 서버 재해복구 스크립트
# 사용법: ./disaster_recovery.sh [scenario]
# scenario: full | services | database | qdrant | configs

BACKUP_BASE="/mnt/nas/ai-server-backup"
LOG_FILE="/var/log/ai-recovery.log"

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

## 최신 백업 자동 탐색
find_latest_backup() {
    ls -td "$BACKUP_BASE"/[0-9]* 2>/dev/null | head -1
}

LATEST_BACKUP=$(find_latest_backup)
if [ -z "$LATEST_BACKUP" ]; then
    log "❌ 백업을 찾을 수 없습니다: $BACKUP_BASE"
    exit 1
fi
log "사용할 백업: $LATEST_BACKUP"

## 시나리오별 복구 함수

recover_services() {
    log "🔄 서비스 재시작 복구..."
    # 모든 AI 서버 서비스 중지 후 재시작
    for svc in core security automation database monitoring; do
        COMPOSE="$HOME/ai-server/$svc/docker-compose.yml"
        [ -f "$COMPOSE" ] && {
            docker compose -f "$COMPOSE" down --remove-orphans
            docker compose -f "$COMPOSE" up -d
            log "  ✅ $svc 재시작"
        }
    done
}

recover_volume() {
    local VOL_NAME=$1
    local BACKUP_FILE=$(ls "$LATEST_BACKUP"/${VOL_NAME}_*.tar.gz 2>/dev/null | head -1)

    [ -z "$BACKUP_FILE" ] && { log "  ❌ 백업 없음: $VOL_NAME"; return 1; }

    log "  복구: $VOL_NAME from $(basename $BACKUP_FILE)"

    # 볼륨 재생성
    docker volume rm "$VOL_NAME" 2>/dev/null || true
    docker volume create "$VOL_NAME"

    # 압축 복구
    docker run --rm \
        -v "${VOL_NAME}:/data" \
        -v "${LATEST_BACKUP}:/backup:ro" \
        alpine:latest \
        sh -c "cd /data && tar xzf /backup/$(basename $BACKUP_FILE) ." \
    && log "  ✅ $VOL_NAME 복구 완료" \
    || { log "  ❌ $VOL_NAME 복구 실패"; return 1; }
}

recover_database() {
    log "🔄 PostgreSQL 복구..."
    DUMP_FILE=$(ls "$LATEST_BACKUP"/postgres_dump_*.sql.gz 2>/dev/null | head -1)
    [ -z "$DUMP_FILE" ] && { log "  ❌ DB 덤프 없음"; return 1; }

    recover_volume "postgres_data"

    # 컨테이너 재시작 후 덤프 복구
    docker compose -f ~/ai-server/automation/docker-compose.yml up -d postgresql
    sleep 10

    zcat "$DUMP_FILE" | docker exec -i postgresql psql -U aiserver
    log "  ✅ PostgreSQL 복구 완료"
}

## 복구 시나리오 실행
SCENARIO=${1:-"full"}
log "=== 재해복구 시작: $SCENARIO ==="

case "$SCENARIO" in
    "services")
        recover_services
        ;;
    "database")
        recover_database
        ;;
    "qdrant")
        recover_volume "qdrant_storage"
        docker compose -f ~/ai-server/database/docker-compose.yml restart qdrant
        ;;
    "full")
        log "전체 복구 시작..."
        for VOL in open_webui_data n8n_data postgres_data qdrant_storage grafana_data; do
            recover_volume "$VOL" || true
        done
        recover_services
        log "✅ 전체 복구 완료"
        ;;
    *)
        log "알 수 없는 시나리오: $SCENARIO"
        echo "사용법: $0 [full|services|database|qdrant|configs]"
        exit 1
        ;;
esac

# 복구 후 헬스체크
log "헬스체크 실행..."
sleep 30
~/ai-server/scripts/health_check.sh
log "=== 재해복구 완료 ==="
SECTIONF5

고가용성 구성 — Nginx HA & 자동 페일오버

단일 서버 장애 시 서비스 중단 없이 자동으로 백업 서버로 전환하는 구조입니다. 두 대의 서버에 Keepalived와 Nginx를 설정해 가상 IP(VIP)가 항상 살아있는 서버로 자동 이동합니다.

bash — Keepalived 설치 및 HA 설정 (Primary 서버)
sudo apt install -y keepalived

# Primary 서버 설정
sudo tee /etc/keepalived/keepalived.conf << 'EOF'
global_defs {
    router_id AI_SERVER_PRIMARY
    enable_script_security
}

vrrp_script check_nginx {
    script "/bin/curl -sf http://localhost:3000/health || exit 1"
    interval 5         # 5초마다 헬스체크
    weight -20         # 실패 시 우선순위 20 감소
    fall    2          # 2번 실패 시 FAULT
    rise    1          # 1번 성공 시 복구
}

vrrp_instance VI_1 {
    state  MASTER                    # 백업 서버는 BACKUP으로 변경
    interface ens3                   # 네트워크 인터페이스명
    virtual_router_id 51             # 두 서버 동일 값
    priority 110                     # 백업 서버는 100으로 낮게
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass ai_server_ha_secret
    }

    virtual_ipaddress {
        192.168.1.200/24             # 가상 IP (어느 서버가 살아도 이 IP로 접속)
    }

    track_script {
        check_nginx
    }

    notify_master  "/etc/keepalived/notify.sh MASTER"
    notify_backup  "/etc/keepalived/notify.sh BACKUP"
    notify_fault   "/etc/keepalived/notify.sh FAULT"
}
EOF

# 페일오버 알림 스크립트
sudo tee /etc/keepalived/notify.sh << 'SCRIPT'
#!/bin/bash
STATE=$1
WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK"
curl -s -X POST "$WEBHOOK" \
    -H 'Content-type: application/json' \
    -d "{\"text\": \"🔄 AI 서버 HA 상태 변경: $STATE\n서버: $(hostname)\n시간: $(date)\"}"
SCRIPT

sudo chmod +x /etc/keepalived/notify.sh
sudo systemctl enable keepalived
sudo systemctl start keepalived
💡
HA 구성 팁 — 어디까지 투자할 것인가

완전한 HA는 하드웨어 2배 비용이 듭니다. 현실적 대안: 주 서버는 단일 구성, 백업 서버는 NAS나 저사양 미니PC로 Open WebUI + 외부 API 연결만 유지하는 Warm Standby 구성이 비용 효율적입니다. 주 서버 장애 시 Cloudflare Tunnel 대상을 백업으로 변경하면 10분 이내 서비스 재개 가능합니다.

SECTIONF6

서버 이전 & 마이그레이션 완전 가이드

GPU 업그레이드, 새 서버 도입, 클라우드 이전 등 서버를 바꿔야 하는 상황을 위한 무중단 마이그레이션 절차입니다. 올바른 순서를 지키면 데이터 손실 없이 수 시간 이내 이전이 완료됩니다.

bash — 서버 이전 완전 절차 스크립트
#!/bin/bash
# AI 서버 이전 절차 스크립트
# 기존 서버: OLD_SERVER=192.168.1.100
# 신규 서버: NEW_SERVER=192.168.1.200

OLD_SERVER="192.168.1.100"
NEW_SERVER="192.168.1.200"
TRANSFER_DIR="/tmp/ai_migration_$(date +%Y%m%d)"
SSH_KEY="~/.ssh/ai_server_key"

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

## ── Phase 1: 신규 서버 준비 확인 ─────────────────
log "=== Phase 1: 신규 서버 사전 확인 ==="
ssh -i "$SSH_KEY" "ubuntu@$NEW_SERVER" << 'REMOTE'
    docker --version || { echo "Docker 미설치"; exit 1; }
    nvidia-smi || echo "GPU 없음 (정상이면 계속)"
    df -h /
    free -h
    echo "✅ 신규 서버 준비 확인"
REMOTE

## ── Phase 2: 기존 서버에서 최종 백업 ────────────
log "=== Phase 2: 최종 전체 백업 ==="
ssh -i "$SSH_KEY" "ubuntu@$OLD_SERVER" << 'REMOTE'
    mkdir -p /tmp/ai_migration
    
    # 핵심 볼륨 내보내기
    for VOL in open_webui_data n8n_data postgres_data qdrant_storage ollama_data grafana_data; do
        if docker volume inspect "$VOL" &>/dev/null; then
            echo "내보내기: $VOL"
            docker run --rm \
                -v "${VOL}:/data:ro" \
                -v "/tmp/ai_migration:/backup" \
                alpine:latest \
                tar czf "/backup/${VOL}.tar.gz" -C /data .
            echo "  완료: $(du -sh /tmp/ai_migration/${VOL}.tar.gz | cut -f1)"
        fi
    done
    
    # 설정 파일 패키징
    tar czf /tmp/ai_migration/configs.tar.gz \
        ~/ai-server \
        ~/.cloudflared 2>/dev/null || true
    
    echo "✅ 백업 완료: $(du -sh /tmp/ai_migration)"
REMOTE

## ── Phase 3: 신규 서버로 데이터 전송 ────────────
log "=== Phase 3: 데이터 전송 (rsync) ==="
rsync -avzP --progress \
    -e "ssh -i $SSH_KEY" \
    "ubuntu@${OLD_SERVER}:/tmp/ai_migration/" \
    "ubuntu@${NEW_SERVER}:${TRANSFER_DIR}/"

## ── Phase 4: 신규 서버에서 복원 ─────────────────
log "=== Phase 4: 신규 서버 복원 ==="
ssh -i "$SSH_KEY" "ubuntu@$NEW_SERVER" << REMOTE
    # 설정 파일 복원
    mkdir -p ~/ai-server ~/.cloudflared
    tar xzf ${TRANSFER_DIR}/configs.tar.gz -C / 2>/dev/null || true
    
    # Docker 볼륨 복원
    for ARCHIVE in ${TRANSFER_DIR}/*.tar.gz; do
        VOL_NAME=\$(basename "\$ARCHIVE" .tar.gz)
        [[ "\$VOL_NAME" == "configs" ]] && continue
        
        echo "복원: \$VOL_NAME"
        docker volume create "\$VOL_NAME"
        docker run --rm \
            -v "\${VOL_NAME}:/data" \
            -v "${TRANSFER_DIR}:/backup:ro" \
            alpine:latest \
            tar xzf "/backup/\${VOL_NAME}.tar.gz" -C /data
        echo "  완료"
    done
    
    # 서비스 시작
    cd ~/ai-server
    for svc in core security automation database monitoring; do
        [ -f "\$svc/docker-compose.yml" ] && \
            docker compose -f "\$svc/docker-compose.yml" up -d
        sleep 3
    done
    
    echo "✅ 복원 완료"
REMOTE

## ── Phase 5: 검증 ─────────────────────────────────
log "=== Phase 5: 서비스 검증 ==="
sleep 30
ssh -i "$SSH_KEY" "ubuntu@$NEW_SERVER" \
    "~/ai-server/scripts/health_check.sh"

## ── Phase 6: Cloudflare Tunnel 대상 변경 ────────
log "=== Phase 6: DNS/Tunnel 전환 ==="
log "⚡ 수동 작업 필요:"
log "  Cloudflare Zero Trust → Networks → Tunnels"
log "  기존 터널 비활성화 → 신규 서버 터널 활성화"
log "  또는 Tailscale IP 업데이트"

log "=== ✅ 마이그레이션 완료! ==="
log "다음 작업:"
log "  1. 신규 서버에서 모든 서비스 정상 확인"
log "  2. 사용자 테스트 완료 후 기존 서버 종료"
log "  3. 기존 서버 데이터 30일 후 삭제"
⚠️ 이전 시 절대 놓치면 안 되는 체크포인트
  • Ollama 모델 재다운로드 vs 이전 — 모델 파일이 수십 GB라 전송보다 신규 서버에서 다시 pull이 빠를 수 있음. 인터넷 속도와 전송 속도 비교 후 결정.
  • 환경변수 및 시크릿 분리 확인 — docker-compose.yml에 API Key나 비밀번호가 하드코딩되어 있으면 이전 과정에서 노출 위험. .env 파일로 분리 관리 필수.
  • Qdrant 인덱스 재구축 필요 — 볼륨 복원 후 대용량 컬렉션은 최적화 필요: POST /collections/{name}/index
  • TailScale/Cloudflare 설정 갱신 — 신규 서버의 새 IP나 터널 ID로 반드시 업데이트. 기존 클라이언트의 접속 설정도 함께 변경.
✅ 추가편 F 적용으로 달성하는 운영 안정성
  • 매일 새벽 2시 자동 백업 → NAS + Backblaze B2 오프사이트 동시 저장
  • 장애 발생 시 30분 이내 원클릭 복구 스크립트로 서비스 재개
  • PostgreSQL WAL 연속 아카이빙으로 어느 시점으로도 복구 가능
  • Qdrant 컬렉션별 스냅샷으로 벡터 DB 안전하게 보호
  • Keepalived HA 구성으로 주 서버 장애 시 자동 페일오버
  • 마이그레이션 스크립트로 새 서버 이전 시 4시간 이내 완료

🎉 추가편 F까지 완성으로 AI 서버 시리즈 전체 완결!
기본 5편 + 추가 6편 총 11편의 콘텐츠로 최종 5편으로 통합하여 AI 서버 구축·운영·활용·최적화·보안·백업의 모든 것을 커버했습니다. 각 글을 시리즈로 연결하고 실제 URL을 업데이트하면 완성된 마스터 가이드가 됩니다.

← 이전 편
1편 — 인프라·OS·Docker·LLM 최적화 구축
다음 편 →
3편 — 이미지·음성·비전 AI 완전 구현

Related Stories

Leave A Reply

Please enter your comment!
Please enter your name here

Stay on op - Ge the daily news in your inbox