Tự động hóa backup với cron job trên Linux: Script thực tế từ production

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

Lúc 2 giờ sáng và câu hỏi “backup ở đâu rồi?”

Chuyện xảy ra với mình năm ngoái. Database production bị corrupt sau một lần update package không cẩn thận. Tay run run mở terminal, gõ lệnh kiểm tra backup — và nhận ra lần cuối backup là… 3 tuần trước. Ba tuần transaction logs, đơn hàng, dữ liệu user — biến mất sạch. Không recovery, không rollback.

Từ đó mình nghiệm ra: backup thủ công là backup không tồn tại. Con người hay quên, máy móc thì không. Cron job sinh ra để giải quyết đúng vấn đề này.

Sau 3 năm quản lý hơn 10 VPS chạy Linux, bài học đắt nhất mình học được là: luôn chạy thử script thủ công trước khi giao cho cron. Đặc biệt với backup — một script lỗi chạy lúc 3 giờ sáng có thể fail âm thầm cả tháng mà không ai hay, cho đến khi cần restore thật.

Bối cảnh & Tại sao cần tự động hóa

Cái bẫy của backup thủ công

Nhiều team có quy trình backup rõ ràng trên giấy: “Backup mỗi tối thứ Sáu”. Nhưng thực tế? Người phụ trách nghỉ phép → quên backup. Deadline gấp → skip. Lễ Tết → không ai nhớ.

Cron job chấm dứt hoàn toàn sự phụ thuộc vào con người trong quy trình này. Config xong một lần, nó chạy đúng giờ, mỗi ngày, không cần nhắc nhở.

Những thứ cần backup trên một server thực tế

  • Database: MySQL/PostgreSQL dump — quan trọng nhất, mất là mất thật
  • Config files: /etc/nginx/, /etc/apache2/, application configs
  • User data: /var/www/html/uploads, media files
  • SSL certificates: /etc/letsencrypt/ — mất cert là website down ngay

Cài đặt

Kiểm tra cron daemon đã chạy chưa

Cron daemon có sẵn trên hầu hết mọi distro Linux hiện đại — thường không cần cài thêm gì. Xác nhận nhanh:

# Ubuntu/Debian
systemctl status cron

# RHEL/AlmaLinux/CentOS
systemctl status crond

# Nếu chưa chạy thì bật lên:
systemctl enable --now cron    # Ubuntu/Debian
systemctl enable --now crond   # RHEL-based

Cài đặt công cụ cần thiết

# Ubuntu/Debian
apt install -y mysql-client postgresql-client rsync gzip tar curl

# AlmaLinux/CentOS
dnf install -y mysql postgresql rsync gzip tar curl

# Nếu cần backup lên cloud (S3, Backblaze, Google Drive...)
curl https://rclone.org/install.sh | sudo bash

Cấu hình chi tiết

Hiểu cú pháp cron một lần cho xong

Nhiều người ngại cron vì cú pháp trông lạ. Thực ra chỉ có 5 trường:

* * * * * command_to_run
│ │ │ │ │
│ │ │ │ └── Thứ trong tuần (0-7, 0 và 7 đều là Chủ nhật)
│ │ │ └──── Tháng (1-12)
│ │ └────── Ngày trong tháng (1-31)
│ └──────── Giờ (0-23)
└────────── Phút (0-59)

Một vài pattern hay dùng:

0 2 * * *     # Chạy lúc 2:00 sáng mỗi ngày
0 2 * * 0     # Chạy lúc 2:00 sáng mỗi Chủ nhật
*/30 * * * *  # Chạy mỗi 30 phút
0 0 1 * *     # Chạy lúc 0:00 ngày 1 hàng tháng
0 2,14 * * *  # Chạy lúc 2:00 và 14:00 mỗi ngày

Không chắc expression đúng không? Paste vào crontab.guru để test ngay trong browser — tiết kiệm khối thời gian debug.

Tạo MySQL user chỉ có quyền backup

Đừng dùng root MySQL trong backup script. User riêng với quyền tối thiểu — nếu script bị lộ, attacker cũng chỉ đọc được data chứ không xóa hay sửa được gì:

-- Chạy trong MySQL console
CREATE USER 'backup_user'@'localhost' IDENTIFIED BY 'strong_password_here';
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON myapp_db.*
    TO 'backup_user'@'localhost';
FLUSH PRIVILEGES;

Viết backup script hoàn chỉnh

Script dưới đây là bản mình đang chạy thực tế — đã tinh chỉnh qua nhiều lần học từ lỗi:

#!/bin/bash
# /usr/local/bin/backup.sh

set -euo pipefail  # Dừng ngay nếu có lỗi, biến undefined, hoặc pipe fail

# ===== CONFIG =====
BACKUP_DIR="/var/backups/myapp"
MYSQL_USER="backup_user"
MYSQL_PASS="your_password_here"
MYSQL_DB="myapp_db"
REMOTE_DEST="myaws:mybucket/backups"  # rclone remote (bỏ qua nếu không dùng)
KEEP_DAYS=7
DATE=$(date +%Y%m%d_%H%M%S)
LOG_FILE="/var/log/backup.log"

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

# ===== MAIN =====
mkdir -p "$BACKUP_DIR"
log "=== Bắt đầu backup: $DATE ==="

# 1. Dump MySQL
log "Đang dump MySQL..."
mysqldump \
    -u "$MYSQL_USER" \
    -p"$MYSQL_PASS" \
    --single-transaction \
    --routines \
    --triggers \
    "$MYSQL_DB" | gzip > "$BACKUP_DIR/db_${DATE}.sql.gz"
log "✓ MySQL xong: db_${DATE}.sql.gz ($(du -sh "$BACKUP_DIR/db_${DATE}.sql.gz" | cut -f1))"

# 2. Backup config + SSL
log "Đang backup configs..."
tar -czf "$BACKUP_DIR/config_${DATE}.tar.gz" \
    /etc/nginx/ \
    /etc/letsencrypt/ \
    /var/www/html/config/ \
    2>/dev/null || true   # Không dừng nếu 1 path không tồn tại
log "✓ Config xong"

# 3. Sync uploads
log "Đang sync uploads..."
rsync -az --delete \
    /var/www/html/uploads/ \
    "$BACKUP_DIR/uploads_latest/"
log "✓ Uploads sync xong"

# 4. Upload remote (nếu rclone đã cài)
if command -v rclone &>/dev/null; then
    log "Đang upload lên remote..."
    rclone copy "$BACKUP_DIR" "$REMOTE_DEST" \
        --include "*${DATE}*" \
        --log-file "$LOG_FILE"
    log "✓ Remote upload xong"
fi

# 5. Dọn backup cũ
log "Xóa backup cũ hơn ${KEEP_DAYS} ngày..."
find "$BACKUP_DIR" -name "*.gz" -mtime +$KEEP_DAYS -delete
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +$KEEP_DAYS -delete
log "✓ Cleanup xong"

log "=== Backup hoàn tất ==="

Phân quyền và test chạy thủ công trước:

chmod +x /usr/local/bin/backup.sh
# Luôn chạy thủ công 1 lần trước khi thêm vào cron
/usr/local/bin/backup.sh

Thêm vào crontab

sudo crontab -e

Thêm vào các dòng sau:

# Backup đầy đủ lúc 2:30 sáng mỗi ngày
# (offset 30 phút để tránh đụng job khác chạy đúng giờ chẵn)
30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

# Backup nhanh DB mỗi 6 tiếng (chỉ DB, không cần backup file)
0 */6 * * * mysqldump -u backup_user -p'your_pass' myapp_db | gzip > /var/backups/myapp/db_quick_$(date +\%H).sql.gz 2>>/var/log/backup.log

Chú ý quan trọng: Trong crontab, ký tự % có nghĩa đặc biệt (xuống dòng), nên phải escape thành \% khi dùng trong lệnh date.

Kiểm tra & Monitoring

Xác nhận cron job đã chạy

# Xem crontab của root
sudo crontab -l

# Kiểm tra file backup vừa tạo
ls -lht /var/backups/myapp/ | head -10

# Theo dõi log realtime
tail -f /var/log/backup.log

Script verify tính toàn vẹn backup

Backup xong không có nghĩa là backup tốt — file có thể bị corrupt hoặc truncate giữa chừng. Mình thêm bước verify sau mỗi lần chạy. File DB hợp lệ thường từ vài MB đến vài trăm MB tùy size database; nếu chỉ có vài KB thì dump gần như chắc chắn đã fail:

#!/bin/bash
# /usr/local/bin/verify-backup.sh

BACKUP_DIR="/var/backups/myapp"
LATEST_DB=$(ls -t "$BACKUP_DIR"/db_*.sql.gz 2>/dev/null | head -1)

if [ -z "$LATEST_DB" ]; then
    echo "CRITICAL: Không tìm thấy file backup database!"
    exit 2
fi

# Kiểm tra file .gz có valid không
if gzip -t "$LATEST_DB" 2>/dev/null; then
    echo "OK: Backup hợp lệ: $LATEST_DB ($(du -sh "$LATEST_DB" | cut -f1))"
else
    echo "CRITICAL: Backup bị corrupt: $LATEST_DB"
    exit 2
fi

# Cảnh báo nếu file quá nhỏ — dump thất bại thường tạo file rỗng hoặc chỉ có header
SIZE=$(stat -c%s "$LATEST_DB")
if [ "$SIZE" -lt 10240 ]; then
    echo "WARNING: File backup chỉ ${SIZE} bytes — có thể dump thất bại"
    exit 1
fi

echo "OK: Tất cả checks passed"

Alert khi backup thất bại

Cron chạy nhưng không ai nhìn log — vẫn có thể fail mà không hay. Đơn giản nhất là gửi Telegram notification vào cuối script, kèm luôn size backup để dễ phát hiện bất thường:

# Thêm vào cuối backup.sh
TELEGRAM_TOKEN="your_bot_token"
TELEGRAM_CHAT_ID="your_chat_id"
BACKUP_SIZE=$(du -sh "$BACKUP_DIR" | cut -f1)

curl -s -X POST \
    "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
    -d "chat_id=${TELEGRAM_CHAT_ID}" \
    -d "text=✅ Backup $(hostname) xong lúc $(date '+%H:%M %d/%m/%Y') | Size: ${BACKUP_SIZE}" > /dev/null

Test restore — bước hay bị bỏ qua nhất

Backup mà không test restore thì cũng như không có backup. Lịch mình đang làm: mỗi tháng restore 1 lần vào DB test, đếm số bảng để xác nhận dump đầy đủ.

# Restore MySQL từ file backup
gunzip -c /var/backups/myapp/db_20260301_023000.sql.gz | \
    mysql -u root -p test_restore_db

# Xác nhận số tables restore được
mysql -u root -p -e \
    "SELECT COUNT(*) as table_count FROM information_schema.tables \
     WHERE table_schema='test_restore_db';"

Thêm cron monthly để auto test restore:

# Ngày 1 hàng tháng lúc 3:00 sáng
0 3 1 * * /usr/local/bin/test-restore.sh >> /var/log/backup-verify.log 2>&1

Checklist trước khi đưa vào production

  • ☑ Script đã chạy thủ công thành công ít nhất 1 lần
  • ☑ Log output rõ ràng, có timestamp, dễ đọc khi debug
  • ☑ File backup được verify tính toàn vẹn sau khi tạo
  • ☑ Alert gửi về khi backup thất bại
  • ☑ Có rotation — tự xóa file cũ để không đầy disk
  • ☑ Backup được copy ra remote storage (offsite)
  • ☑ Đã test restore ít nhất 1 lần

Setup xong cron không phải là xong việc — đó mới chỉ là bước đầu. Monitoring mới là thứ quyết định backup có thực sự hoạt động không. Mình từng có script chạy đúng lịch nhưng fail âm thầm 2 tuần vì MySQL password đổi mà không update script. Đến khi cần restore mới biết. Nếu hồi đó có Telegram alert, mình đã biết ngay hôm đó — không phải 2 tuần sau.

Share: