FreshRSSを環境移行用にバックアップ・復元

セルフホスト出来るRSSリーダーとしてはかなり使いやすいFreshRSS。頻繁にフィードが追加されるわけでもないので、たまにフォード一覧をエクスポートしておき、移行時もエクスポートしたフィードをインポートすればよいのですが、設定まで含めてもっと手軽に復元出来るように、バックアップ・復元スクリプトを作ってみました。
復元時は以下のような動作をします。

  • 既存コンテナ前提docker stop → ボリューム上書き → docker compose up -d の流れで、セットアップ済み環境に対して安全に上書きします
  • docker-compose.yml は任意復元 — 復元先の設定を活かしたい場合はスキップできます
  • tailscale serve を復元先ドメインで再設定 — 元ホストのドメインではなく、復元先の tailscale status から取得したドメインで設定し直します
  • FreshRSSのURL設定の確認を促す — 復元後、管理画面で「FreshRSSのURL」が復元先ドメインになっているか確認するよう案内します
  • /tmp に作業ディレクトリを作って各ファイルをまとめ、最後に freshrss_backup_<timestamp>.tar.gz 1ファイルとして出力 latest.tar.gz シンボリックリンクも更新

バックアップ

/opt/lxd-data/freshrss/20250524_153000/.tar.gz のようなタイムスタンプ付き1ファイルで保存されます。バックアップ中はコンテナを一時停止して整合性を確保します。

mkdir -p /opt/lxd-data/freshrss
cd /opt/lxd-data/freshrss
nano freshrss-backup.sh
#!/bin/bash
set -euo pipefail
# =============================================================
#  FreshRSS バックアップスクリプト
#
#  バックアップ対象:
#   - Dockerボリューム <project>_freshrss_data
#   - Dockerボリューム <project>_freshrss_extensions
#   - docker-compose.yml
#
#  出力:
#   /opt/lxd-data/freshrss/freshrss_backup_<timestamp>.tar.gz
#    └── freshrss_backup_<timestamp>/
#         ├── freshrss_data.tar.gz
#         ├── freshrss_extensions.tar.gz
#         ├── docker-compose.yml
#         └── backup-info.txt
#
#  実行環境: LXDコンテナ内 (root または sudo)
# =============================================================

FRESHRSS_DIR="/opt/docker/freshrss"
BACKUP_BASE="/opt/lxd-data/freshrss"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_NAME="freshrss_backup_${TIMESTAMP}"
BACKUP_ARCHIVE="${BACKUP_BASE}/${BACKUP_NAME}.tar.gz"
WORK_DIR="/tmp/${BACKUP_NAME}"

# ── カラー出力 ────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'

echo ""
echo "════════════════════════════════════════"
echo "  FreshRSS バックアップ"
echo "════════════════════════════════════════"
echo ""

# ── 前提確認 ──────────────────────────────────────
if ! command -v docker &>/dev/null; then
    echo -e "${RED}ERROR: Docker がインストールされていません${NC}"
    exit 1
fi

if ! docker inspect freshrss &>/dev/null; then
    echo -e "${RED}ERROR: freshrss コンテナが存在しません${NC}"
    exit 1
fi

# ── ボリューム名の自動検出 ────────────────────────
_detect_volume() {
    local suffix="$1"
    local project
    project=$(cd "${FRESHRSS_DIR}" && docker compose config --format json 2>/dev/null \
        | python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))" 2>/dev/null) || project=""
    if [ -n "$project" ] && docker volume inspect "${project}_${suffix}" &>/dev/null; then
        echo "${project}_${suffix}"
    elif docker volume inspect "${suffix}" &>/dev/null; then
        echo "${suffix}"
    else
        echo ""
    fi
}

VOL_DATA=$(_detect_volume "freshrss_data")
VOL_EXTENSIONS=$(_detect_volume "freshrss_extensions")

if [ -z "$VOL_DATA" ] || [ -z "$VOL_EXTENSIONS" ]; then
    echo -e "${RED}ERROR: Dockerボリュームが見つかりません${NC}"
    echo "  現在のボリューム一覧:"
    docker volume ls | grep -i freshrss || echo "    (freshrss関連ボリュームなし)"
    exit 1
fi

echo -e "  ボリューム検出:"
echo -e "    data       : ${VOL_DATA}"
echo -e "    extensions : ${VOL_EXTENSIONS}"

# ── 作業ディレクトリ準備 ──────────────────────────
echo ""
echo "==> [1/4] 作業ディレクトリを準備..."
mkdir -p "${WORK_DIR}"
mkdir -p "${BACKUP_BASE}"
echo -e "  ${GREEN}✓ ${WORK_DIR}${NC}"

# クリーンアップトラップ
trap 'rm -rf "${WORK_DIR}"' EXIT

# ── docker-compose.yml のバックアップ ────────────
echo ""
echo "==> [2/4] docker-compose.yml をバックアップ..."
if [ -f "${FRESHRSS_DIR}/docker-compose.yml" ]; then
    cp "${FRESHRSS_DIR}/docker-compose.yml" "${WORK_DIR}/docker-compose.yml"
    echo -e "  ${GREEN}✓ docker-compose.yml${NC}"
else
    echo -e "  ${YELLOW}⚠ ${FRESHRSS_DIR}/docker-compose.yml が見つかりません(スキップ)${NC}"
fi

# ── Dockerボリュームのバックアップ ───────────────
echo ""
echo "==> [3/4] Dockerボリュームをバックアップ..."

CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' freshrss 2>/dev/null || echo "unknown")
echo -e "  コンテナ状態: ${CONTAINER_STATUS}"

if [ "$CONTAINER_STATUS" = "running" ]; then
    echo -e "  ${YELLOW}⚠ バックアップのためコンテナを一時停止します${NC}"
    docker stop freshrss
    STOPPED_BY_SCRIPT=true
else
    STOPPED_BY_SCRIPT=false
fi

echo -n "  ${VOL_DATA} ... "
docker run --rm \
    -v "${VOL_DATA}":/source:ro \
    -v "${WORK_DIR}":/backup \
    alpine \
    tar czf /backup/freshrss_data.tar.gz -C /source .
echo -e "${GREEN}✓${NC}"

echo -n "  ${VOL_EXTENSIONS} ... "
docker run --rm \
    -v "${VOL_EXTENSIONS}":/source:ro \
    -v "${WORK_DIR}":/backup \
    alpine \
    tar czf /backup/freshrss_extensions.tar.gz -C /source .
echo -e "${GREEN}✓${NC}"

if [ "$STOPPED_BY_SCRIPT" = "true" ]; then
    docker start freshrss
    echo -e "  ${GREEN}✓ コンテナを再起動しました${NC}"
fi

# ── メタデータ保存 ────────────────────────────────
TAILSCALE_DOMAIN=""
if command -v tailscale &>/dev/null && tailscale status &>/dev/null 2>&1; then
    TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('Self', {}).get('DNSName', '').rstrip('.'))
" 2>/dev/null) || TAILSCALE_DOMAIN=""
fi

cat > "${WORK_DIR}/backup-info.txt" <<EOF
backup_timestamp=${TIMESTAMP}
backup_date=$(date "+%Y-%m-%d %H:%M:%S")
source_host=$(hostname)
tailscale_domain=${TAILSCALE_DOMAIN}
freshrss_dir=${FRESHRSS_DIR}
container_status_at_backup=${CONTAINER_STATUS}
vol_data=${VOL_DATA}
vol_extensions=${VOL_EXTENSIONS}
files=docker-compose.yml,freshrss_data.tar.gz,freshrss_extensions.tar.gz
EOF

# ── tar.gz にまとめる ─────────────────────────────
echo ""
echo "==> [4/4] アーカイブを作成..."
echo -n "  ${BACKUP_ARCHIVE} ... "
tar czf "${BACKUP_ARCHIVE}" -C "/tmp" "${BACKUP_NAME}"
echo -e "${GREEN}✓${NC}"

# ── latest シンボリックリンク更新 ─────────────────
ln -sfn "${BACKUP_ARCHIVE}" "${BACKUP_BASE}/latest.tar.gz"
echo -e "  ${GREEN}✓ ${BACKUP_BASE}/latest.tar.gz -> ${BACKUP_ARCHIVE}${NC}"

# ── 完了 ─────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo -e "  ${GREEN}✅  バックアップ完了!${NC}"
echo "════════════════════════════════════════"
echo ""
echo "  📦 アーカイブ: ${BACKUP_ARCHIVE}"
echo ""
echo "  📊 内容:"
tar tzf "${BACKUP_ARCHIVE}" | while read f; do
    echo "    ${f}"
done
echo ""
echo "  💾 サイズ: $(du -sh "${BACKUP_ARCHIVE}" | cut -f1)"
echo ""

BACKUP_COUNT=$(find "${BACKUP_BASE}" -maxdepth 1 -name "freshrss_backup_*.tar.gz" | wc -l)
echo "  🗂  保存済みバックアップ数: ${BACKUP_COUNT}"
echo ""
if [ "$BACKUP_COUNT" -gt 5 ]; then
    echo -e "  ${YELLOW}ヒント: バックアップが5件を超えています。古いものの削除を検討してください:${NC}"
    echo "    ls ${BACKUP_BASE}/*.tar.gz"
    echo "    rm ${BACKUP_BASE}/freshrss_backup_20XXXXXX_XXXXXX.tar.gz"
    echo ""
fi
echo "════════════════════════════════════════"
echo ""

バックアップを実行。

sudo bash freshrss-backup.sh

復元

下記でスクリプトを作成し、バックアップしたファイルを入れて復元します。

mkdir -p /opt/lxd-data/freshrss
cd /opt/lxd-data/freshrss
nano freshrss-restore.sh
#!/bin/bash
set -euo pipefail
# =============================================================
#  FreshRSS 復元スクリプト
#
#  前提条件:
#   - 復元先に添付セットアップスクリプトでFreshRSSが導入済み
#
#  使い方:
#   bash freshrss-restore.sh                          # /opt/lxd-data/freshrss/ から対話選択
#   bash freshrss-restore.sh <ファイル or ディレクトリ>  # 直接指定
#     例) bash freshrss-restore.sh /opt/lxd-data/freshrss/freshrss_backup_20260524_002810.tar.gz
#     例) bash freshrss-restore.sh /mnt/usb/freshrss_backup_20260524_002810.tar.gz
#
#  実行環境: LXDコンテナ内 (root または sudo)
# =============================================================

FRESHRSS_DIR="/opt/docker/freshrss"
BACKUP_BASE="/opt/lxd-data/freshrss"
TAILSCALE_PORT=3309
PORT=6060

# ── カラー出力 ────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'

echo ""
echo "════════════════════════════════════════"
echo "  FreshRSS 復元スクリプト"
echo "════════════════════════════════════════"
echo ""

# ── 前提確認 ──────────────────────────────────────
if ! command -v docker &>/dev/null; then
    echo -e "${RED}ERROR: Docker がインストールされていません${NC}"
    exit 1
fi
if ! command -v tailscale &>/dev/null; then
    echo -e "${RED}ERROR: tailscale がインストールされていません${NC}"
    exit 1
fi
if ! tailscale status &>/dev/null 2>&1; then
    echo -e "${RED}ERROR: tailscale が接続されていません。tailscale up を実行してください${NC}"
    exit 1
fi
if ! docker inspect freshrss &>/dev/null; then
    echo -e "${RED}ERROR: freshrss コンテナが存在しません${NC}"
    echo "先にセットアップスクリプトを実行してください"
    exit 1
fi

# ── 復元先ボリューム名の自動検出 ──────────────────
_detect_volume() {
    local suffix="$1"
    local project
    project=$(cd "${FRESHRSS_DIR}" && docker compose config --format json 2>/dev/null \
        | python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))" 2>/dev/null) || project=""
    if [ -n "$project" ] && docker volume inspect "${project}_${suffix}" &>/dev/null; then
        echo "${project}_${suffix}"
    elif docker volume inspect "${suffix}" &>/dev/null; then
        echo "${suffix}"
    else
        echo ""
    fi
}

VOL_DATA=$(_detect_volume "freshrss_data")
VOL_EXTENSIONS=$(_detect_volume "freshrss_extensions")

if [ -z "$VOL_DATA" ] || [ -z "$VOL_EXTENSIONS" ]; then
    echo -e "${RED}ERROR: 復元先のDockerボリュームが見つかりません${NC}"
    docker volume ls | grep -i freshrss || echo "    (freshrss関連ボリュームなし)"
    exit 1
fi

echo -e "  復元先ボリューム:"
echo -e "    data       : ${VOL_DATA}"
echo -e "    extensions : ${VOL_EXTENSIONS}"

# ── バックアップ選択 ──────────────────────────────
ARCHIVE=""

if [ "${1:-}" != "" ]; then
    # 引数で直接指定
    ARCHIVE="$1"
    if [ ! -f "$ARCHIVE" ]; then
        echo -e "${RED}ERROR: 指定されたファイルが見つかりません: ${ARCHIVE}${NC}"
        exit 1
    fi
else
    # /opt/lxd-data/freshrss/ から対話選択
    mapfile -t ARCHIVE_LIST < <(find "${BACKUP_BASE}" -maxdepth 1 -name "freshrss_backup_*.tar.gz" | sort -r)

    if [ ${#ARCHIVE_LIST[@]} -eq 0 ]; then
        echo -e "${RED}ERROR: バックアップが見つかりません: ${BACKUP_BASE}/*.tar.gz${NC}"
        exit 1
    fi

    echo ""
    echo "利用可能なバックアップ:"
    echo ""
    for i in "${!ARCHIVE_LIST[@]}"; do
        FNAME=$(basename "${ARCHIVE_LIST[$i]}")
        SIZE=$(du -sh "${ARCHIVE_LIST[$i]}" 2>/dev/null | cut -f1)
        # アーカイブ内の backup-info.txt からホスト名を取得
        SOURCE_HOST=$(tar xzOf "${ARCHIVE_LIST[$i]}" --wildcards "*/backup-info.txt" 2>/dev/null \
            | grep "^source_host=" | cut -d= -f2) || SOURCE_HOST=""
        LATEST_MARK=""
        if [ "$(readlink -f ${BACKUP_BASE}/latest.tar.gz 2>/dev/null)" = "${ARCHIVE_LIST[$i]}" ]; then
            LATEST_MARK=" ${CYAN}[latest]${NC}"
        fi
        printf "  %2d) %s  %-8s  元ホスト: %s%b\n" \
            "$((i+1))" "$FNAME" "$SIZE" "$SOURCE_HOST" "$LATEST_MARK"
    done

    echo ""
    read -rp "復元するバックアップ番号を入力 [1-${#ARCHIVE_LIST[@]}]: " SEL

    if ! [[ "$SEL" =~ ^[0-9]+$ ]] || [ "$SEL" -lt 1 ] || [ "$SEL" -gt ${#ARCHIVE_LIST[@]} ]; then
        echo -e "${RED}ERROR: 無効な選択です${NC}"
        exit 1
    fi

    ARCHIVE="${ARCHIVE_LIST[$((SEL-1))]}"
fi

echo ""
echo -e "  復元元: ${CYAN}${ARCHIVE}${NC}"

# ── アーカイブを展開して情報表示 ─────────────────
WORK_DIR=$(mktemp -d)
trap 'rm -rf "${WORK_DIR}"' EXIT

echo -n "  アーカイブを展開中 ... "
tar xzf "${ARCHIVE}" -C "${WORK_DIR}"
INNER_DIR=$(find "${WORK_DIR}" -mindepth 1 -maxdepth 1 -type d | head -1)
echo -e "${GREEN}✓${NC}"

# 必要ファイルの確認
for f in freshrss_data.tar.gz freshrss_extensions.tar.gz; do
    if [ ! -f "${INNER_DIR}/${f}" ]; then
        echo -e "${RED}ERROR: アーカイブ内に ${f} が見つかりません${NC}"
        exit 1
    fi
done

INFO_FILE="${INNER_DIR}/backup-info.txt"
if [ -f "$INFO_FILE" ]; then
    SOURCE_HOST=$(grep "^source_host=" "$INFO_FILE" | cut -d= -f2)
    BACKUP_DATE=$(grep "^backup_date=" "$INFO_FILE" | cut -d= -f2-)
    SOURCE_DOMAIN=$(grep "^tailscale_domain=" "$INFO_FILE" | cut -d= -f2)
    SRC_VOL_DATA=$(grep "^vol_data=" "$INFO_FILE" | cut -d= -f2)
    echo ""
    echo "  📋 バックアップ情報:"
    echo "    取得日時    : ${BACKUP_DATE}"
    echo "    元ホスト    : ${SOURCE_HOST}"
    echo "    元ドメイン  : ${SOURCE_DOMAIN}"
    echo "    元ボリューム: ${SRC_VOL_DATA}"
fi

# ── 復元先ドメイン取得 ────────────────────────────
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('Self', {}).get('DNSName', '').rstrip('.'))
" 2>/dev/null)

if [ -z "$TAILSCALE_DOMAIN" ]; then
    echo -e "${RED}ERROR: Tailscaleドメインを取得できませんでした${NC}"
    exit 1
fi

echo ""
echo "  🌐 復元先URL: https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
echo ""

# ── 最終確認 ──────────────────────────────────────
echo -e "${YELLOW}⚠️  警告: ${VOL_DATA} / ${VOL_EXTENSIONS} は上書きされます${NC}"
read -rp "続行しますか? [y/N]: " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
    echo "キャンセルしました"
    exit 0
fi

# ── [1/5] コンテナ停止 ───────────────────────────
echo ""
echo "==> [1/5] コンテナを停止..."
docker stop freshrss
echo -e "  ${GREEN}✓ freshrss 停止${NC}"

# ── [2/5] ボリューム復元 ─────────────────────────
echo ""
echo "==> [2/5] Dockerボリュームを復元..."

echo -n "  ${VOL_DATA} ... "
docker run --rm \
    -v "${VOL_DATA}":/target \
    -v "${INNER_DIR}":/backup:ro \
    alpine \
    sh -c "rm -rf /target/* /target/..?* /target/.[!.]* 2>/dev/null; tar xzf /backup/freshrss_data.tar.gz -C /target"
echo -e "${GREEN}✓${NC}"

echo -n "  ${VOL_EXTENSIONS} ... "
docker run --rm \
    -v "${VOL_EXTENSIONS}":/target \
    -v "${INNER_DIR}":/backup:ro \
    alpine \
    sh -c "rm -rf /target/* /target/..?* /target/.[!.]* 2>/dev/null; tar xzf /backup/freshrss_extensions.tar.gz -C /target"
echo -e "${GREEN}✓${NC}"

# ── [3/5] docker-compose.yml 復元(任意) ────────
echo ""
echo "==> [3/5] docker-compose.yml の処理..."
if [ -f "${INNER_DIR}/docker-compose.yml" ]; then
    read -rp "  docker-compose.yml も復元しますか? [y/N]: " RESTORE_COMPOSE
    if [[ "$RESTORE_COMPOSE" =~ ^[Yy]$ ]]; then
        cp "${INNER_DIR}/docker-compose.yml" "${FRESHRSS_DIR}/docker-compose.yml"
        echo -e "  ${GREEN}✓ docker-compose.yml を復元${NC}"
    else
        echo -e "  ${YELLOW}スキップ(現在の docker-compose.yml を維持)${NC}"
    fi
else
    echo -e "  ${YELLOW}バックアップに docker-compose.yml がありません(スキップ)${NC}"
fi

# ── [4/5] コンテナ再起動 ─────────────────────────
echo ""
echo "==> [4/5] コンテナを起動..."
cd "${FRESHRSS_DIR}"
docker compose up -d

echo "  ⏳ FreshRSSの起動を待機中(最大60秒)..."
for i in $(seq 1 12); do
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null) || true
    if [ -n "$HTTP_CODE" ] && [ "$HTTP_CODE" != "000" ]; then
        echo -e "\n  ${GREEN}✓ FreshRSS 起動完了 (HTTP ${HTTP_CODE})${NC}"
        break
    fi
    if [ "$i" -eq 12 ]; then
        echo -e "\n${RED}ERROR: FreshRSSがタイムアウトしました${NC}"
        docker logs freshrss --tail=20
        exit 1
    fi
    sleep 5
    echo -n "."
done

# ── [5/5] tailscale serve 再設定 ─────────────────
echo ""
echo "==> [5/5] tailscale serve を再設定..."
tailscale serve --https=${TAILSCALE_PORT} off 2>/dev/null || true
tailscale serve --bg --https=${TAILSCALE_PORT} "http://localhost:${PORT}" || {
    echo -e "${RED}ERROR: tailscale serve の設定に失敗しました${NC}"
    exit 1
}
echo -e "  ${GREEN}✓ tailscale serve 設定完了${NC}"

echo ""
tailscale serve status

# ── 完了 ─────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo -e "  ${GREEN}✅  復元完了!${NC}"
echo "════════════════════════════════════════"
echo ""
echo "  🌐 URL: https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
echo ""
echo -e "  ${YELLOW}⚠️  FreshRSSの設定確認事項${NC}"
echo "  ブラウザでログイン後、以下を確認してください:"
echo "  設定 > 認証 > FreshRSSのURL"
echo "    → https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
echo "       (元ホストのURLのままの場合は更新が必要)"
echo ""
echo "════════════════════════════════════════"
echo ""

対話式で、バックアップ一覧から番号選択して復元するには下記です。

sudo bash freshrss-restore.sh

もし、タイムスタンプを直接指定するなら下記です。引数にパスを直接渡せるので、USBや別ディレクトリに置いたアーカイブも指定可能です。

bash freshrss-restore.sh /mnt/usb/freshrss_backup_20260524_002810.tar.gz
タイトルとURLをコピーしました