PrometheusによるBorgBackupとResticのバックアップ監視:サイレントエラーの検知と期限切れアラート

Monitoring tutorial - IT technology blog
Monitoring tutorial - IT technology blog

背景:バックアップが「理論上」しか存在しなかった夜

午前2時15分、スマートフォンのバイブレーションが激しく鳴り響いた。PagerDutyからの通知によると、本番環境のPostgreSQLが稼働するNVMeパーティションで物理セクタ障害が発生したという。慌てて起き上がりターミナルを開き、待機系ノードへSSHでログインした。BorgBackupのcronジョブは毎朝1時に定期実行されているはずなので、データは安全だと確信していた。しかし、最新のアーカイブを確認すべくborg listを実行した瞬間、背筋が凍りついた。画面に返ってきた最新のスナップショットは……23日前のものだった。

システムログを遡ってようやく真相が判明した。NAS/SANからマウントしていたNFSディレクトリがファームウェア更新のメンテナンス対象となり、その後リードオンリー権限で再マウントされていたのだ。borg createコマンドは即座にエラーを吐いて終了コード2を返していた。しかし無情にも、crontabの設定行の末尾には> /dev/null 2>&1という致命的なリダイレクトが記述されていた。バックアップジョブは3週間以上もの間、誰にも気づかれず静かに失敗し続けていた(サイレントエラー)。アラートメールも飛ばず、誰も気づく術がなかったのだ。

BorgBackupもResticも、重複排除(deduplication)やデータ圧縮に極めて優れたツールだ。しかし本質的にこれらは、実行後にプロセスが終了するバッチジョブである。一方で、Prometheusは定期的にメトリクスを取得(pull)する仕組みを採用している。cronジョブが終了してしまえば、Prometheusがスクレイプするためのエンドポイントはどこにも残らない。

Linux環境で最も堅牢な解決策は、Node ExporterのTextfile Collectorを活用することだ。仕組みは極めてシンプルである。バックアップスクリプトの実行完了時に、Prometheusのフォーマットに準拠した静的テキストファイルへ結果を出力する。Node Exporterがそのファイルを自動的に読み込み、Prometheusサーバーがスクレイプできるようにメトリクスを公開するのだ。

Textfile Collectorのセットアップ

この構成では、追加の常駐デーモンや複雑なPushgatewayを導入する必要はない。バックアップ対象ノードで既に稼働しているnode_exporterをそのまま活用できる。

1. メトリクス配置用ディレクトリの作成と権限設定

メトリクスファイルを一時保存するための専用ディレクトリを作成し、Node Exporterを実行するユーザーに対して適切なアクセス権限を付与する:

sudo mkdir -p /var/lib/node_exporter/textfile_collector
sudo chown -R prometheus:prometheus /var/lib/node_exporter/textfile_collector
sudo chmod 775 /var/lib/node_exporter/textfile_collector

Node Exporterのsystemdサービス定義ファイル(通常は/etc/systemd/system/node_exporter.service)を開き、--collector.textfile.directoryフラグを追加する:

ExecStart=/usr/local/bin/node_exporter \
  --collector.textfile.directory=/var/lib/node_exporter/textfile_collector

デーモンをリロードしてサービスを再起動する:

sudo systemctl daemon-reload
sudo systemctl restart node_exporter

詳細な構成手順

1. アトミック書き込みを行うラッパースクリプトの作成

テキストファイル形式でメトリクスを書き出す際の鉄則:>リダイレクト演算子を使って.promファイルへ直接書き込んではならない。必ず一時ファイルへ出力した上で、mvコマンドを用いてアトミックに上書きする必要がある。もしPrometheusがファイル書き込み中の空の状態や中途半端なタイミングでスクレイプしてしまうと、パースエラーが発生してそのジョブの全メトリクスが破棄されてしまうためだ。

以下は、BorgBackup向けの完全なラッパースクリプトの例である:

#!/usr/bin/env bash
set -uo pipefail

PROM_DIR="/var/lib/node_exporter/textfile_collector"
TARGET_NAME="db_production_borg"
PROM_FILE="${PROM_DIR}/backup_${TARGET_NAME}.prom"
TEMP_FILE="${PROM_FILE}.$$.tmp"

START_TIME=$(date +%s)
EXIT_CODE=0

# 認証情報とターゲットの設定
export BORG_PASSPHRASE="YourSecretPassphraseHere"
export BORG_REPO="/mnt/backup_storage/borg_repo"

# 実際のバックアップ処理を実行
borg create --stats \
  ::"backup-{now:%Y-%m-%d_%H-%M-%S}" \
  /var/lib/postgresql/data 2>/tmp/borg_err.log || EXIT_CODE=$?

END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))

# Borgの終了コード仕様: 0 = 成功, 1 = 警告, 2 = 致命的エラー
SUCCESS=1
if [ "$EXIT_CODE" -ne 0 ]; then
  SUCCESS=0
fi

# jqを使用して実際のアーカイブサイズを抽出
ORIGINAL_SIZE=0
DEDUP_SIZE=0
if [ "$SUCCESS" -eq 1 ]; then
  ORIGINAL_SIZE=$(borg info --last 1 --json | jq '.archives[0].stats.original_size' 2>/dev/null || echo 0)
  DEDUP_SIZE=$(borg info --last 1 --json | jq '.archives[0].stats.deduplicated_size' 2>/dev/null || echo 0)
fi

# 一時ファイルへメトリクスを書き出し
cat <<EOF > "${TEMP_FILE}"
# HELP backup_last_run_timestamp_seconds 最後に実行されたバックアップの終了時刻 (UNIX秒)
# TYPE backup_last_run_timestamp_seconds gauge
backup_last_run_timestamp_seconds{target="${TARGET_NAME}"} ${END_TIME}

# HELP backup_status_success バックアップの実行ステータス (1 = 成功, 0 = 失敗)
# TYPE backup_status_success gauge
backup_status_success{target="${TARGET_NAME}",exit_code="${EXIT_CODE}"} ${SUCCESS}

# HELP backup_duration_seconds バックアップの所要時間 (秒)
# TYPE backup_duration_seconds gauge
backup_duration_seconds{target="${TARGET_NAME}"} ${DURATION}

# HELP backup_original_bytes 圧縮前の元データサイズ (バイト)
# TYPE backup_original_bytes gauge
backup_original_bytes{target="${TARGET_NAME}"} ${ORIGINAL_SIZE}

# HELP backup_dedup_bytes 重複排除後にリポジトリへ実際に書き込まれたサイズ (バイト)
# TYPE backup_dedup_bytes gauge
backup_dedup_bytes{target="${TARGET_NAME}"} ${DEDUP_SIZE}
EOF

# アトミックにリネームしてprometheusユーザーに読み取り権限を付与
mv "${TEMP_FILE}" "${PROM_FILE}"
chmod 644 "${PROM_FILE}"

exit "$EXIT_CODE"

Resticを使用している場合は、コマンド実行部分を以下のように差し替えるだけでよい:

# Resticを設定してS3 / MinIOへ直接バックアップ
export RESTIC_PASSWORD="SecretPass123"
export RESTIC_REPOSITORY="s3:https://s3.ap-southeast-1.amazonaws.com/company-backup/restic"
export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

restic backup /var/lib/postgresql/data --json 2>/tmp/restic_err.log || EXIT_CODE=$?

2. Prometheusのアラートルール設定

アラートルールを/etc/prometheus/rules/backup_alerts.ymlに保存する:

groups:
  - name: backup_monitoring_alerts
    rules:
      - alert: BackupJobFailed
        expr: backup_status_success == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "ターゲット {{ $labels.target }} でバックアップが失敗しました"
          description: "バックアップジョブ {{ $labels.target }} が終了コード {{ $labels.exit_code }} で終了しました。直ちにサーバーのログを確認してください。"

      - alert: BackupJobOverdue
        expr: (time() - backup_last_run_timestamp_seconds) > 97200
        for: 15m
        labels:
          severity: critical
        annotations:
          summary: "ターゲット {{ $labels.target }} のバックアップが期限切れです"
          description: "ターゲット {{ $labels.target }} で27時間以上新しいスナップショットが取得されていません。cronジョブが停止している可能性があります。"

      - alert: BackupDurationTooLong
        expr: backup_duration_seconds > 14400
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "ターゲット {{ $labels.target }} のバックアップ所要時間が異常に長延化しています"
          description: "バックアップ処理に4時間以上要しています。ネットワーク帯域の逼迫またはディスクI/Oの過負荷が疑われます。"

なぜ24時間ではなく97,200秒(27時間)という数値を設定するのか?運用開始当初、毎朝1時実行のジョブに対して86,400秒(24時間)きっかりの閾値を設定していたことがある。しかし、データベースの書き込み負荷が高くスナップショット処理が普段より20分延びただけで、深夜にAlertmanagerから警報が鳴り響いてしまった。27時間という設定は3時間の安全マージン(バッファ)を持たせることで、多少のバックアップ遅延があっても誤報(アラート疲れ)を起こさないようにするための実践的な知恵である。

動作確認と本番運用

1. ローカルノードでのメトリクス確認

root権限でスクリプトを1度実行し、ファイル生成フローを確認する:

sudo /usr/local/bin/run_borg_backup.sh

Node Exporterの9100番ポートへリクエストを送り、メトリクスが正しく読み取られているか確認する:

curl -s http://localhost:9100/metrics | grep backup_

正しく設定されていれば、以下のような形式でデータが返される:

backup_dedup_bytes{target="db_production_borg"} 482344960
backup_duration_seconds{target="db_production_borg"} 184
backup_last_run_timestamp_seconds{target="db_production_borg"} 1730282400
backup_original_bytes{target="db_production_borg"} 12884901888
backup_status_success{exit_code="0",target="db_production_borg"} 1

2. Prometheus Expression Browserでの検証

PrometheusのWeb UI上で以下のPromQL式を入力し、バックアップの経過時間をリアルタイムで監視する:

(time() - backup_last_run_timestamp_seconds) / 3600

正常に動作していれば、グラフは0から24の間を規則正しく上下するノコギリ波を描く。グラフが27のラインを超えた瞬間、オンコール担当チームのSlackやTelegramへ期限切れアラートが即座に通知される。

エラーシナリオをテストしたい場合は、.promファイルを開いてbackup_status_successの値を0に書き換えるだけでよい。設定通り1分後には、PrometheusのAlertsタブが黄色(Pending)から赤色(Firing)へと切り替わる。

3. ジョブの競合を防ぐためCronからSystemd Timerへ移行する

従来のcrontabには、前回のジョブがまだ実行中であっても次の新しいジョブを容赦なく起動してしまうという致命的な弱点がある。データベースが500GBや1TB規模に達すると、バックアップ処理に数時間かかることも珍しくない。複数のジョブが重複実行されると、I/Oが枯渇してシステムがハングアップし、Borgのロックファイルがロックされたままになってしまう。

最も確実なベストプラクティスは、Systemd ServiceとSystemd Timerのペアに移行することだ。Systemdであれば、前のサービス実行が完了していない場合、新しいトリガーを自動的にスキップしてくれる。

/etc/systemd/system/borg-backup.serviceにサービスユニットを作成する:

[Unit]
Description=Borg Backup Service with Prometheus Textfile Metrics
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/run_borg_backup.sh
User=root
TimeoutSec=21600

/etc/systemd/system/borg-backup.timerで実行スケジュールを定義する:

[Unit]
Description=Run Borg Backup daily at 1:00 AM

[Timer]
OnCalendar=*-*-* 01:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

タイマーを有効化して起動する:

sudo systemctl daemon-reload
sudo systemctl enable --now borg-backup.timer
sudo systemctl list-timers borg-backup.timer

Persistent=trueを指定しておくことで、サーバーが午前1時のスケジュール実行時に万が一再起動していた場合でも、システム起動後すぐにバックアップが補完実行され、スケジュールが完全にスキップされる事態を防ぐことができる。

Share: