
はじめに
日次バックアップのCIジョブが緑色になっていても、それは「本当に復元できる」ことを意味しているとは限りません。
多くの現場では「ダンプファイルが空でない」「アーカイブのTOC(目次)が読める」といったチェックだけで成功扱いにしていないでしょうか。これらはあくまで「ファイルとして壊れていない」ことの確認であり、「そのファイルから実際にデータを戻せる」ことの証明にはなっていません。
この記事では、暗号化前のダンプを同一ジョブ内の使い捨てDBへ実際にリストアし、主要テーブルの行数を前回成功時の結果と比較する仕組みを紹介します。「存在確認」から「実証済みの復元可能性」へと、バックアップ検証の精度を一段引き上げる設計パターンです。
こんな人におすすめ
- バックアップCIジョブを運用しているが、終了コード0だけを見て安心している方
- 「ダンプが壊れていないこと」と「復元できること」を混同してしまっている方
- 判定ロジックをワークフローのシェルスクリプトに直接書いていて、テストしづらいと感じている方
- 障害対応の前に、本当に戻せるかどうかを事前に確かめておきたい方
「非空チェック」は復元可能性の証明にならない
バックアップ検証でありがちな失敗は、ファイルの存在や構造だけを見て「検証済み」と判断してしまうことです。
ダンプファイルのサイズが0でない、アーカイブのTOCが正常に読み込める。これらは必要条件ではありますが、十分条件ではありません。実際にリストアを走らせて、初めて「検証した」と言えます。
そこで、CIジョブの中に使い捨てのデータベースコンテナを立て、暗号化前のダンプをそこへ実際にリストアする工程を追加します。ポイントは、サービスコンテナに使うイメージを本番相当のものにすることです。素の公式DBイメージではなく、実運用と同じ拡張機能・スキーマ構成を持つイメージを使うことで、無関係な依存不足による偽陽性の失敗を避けられます。
リストアツールの終了コードを鵜呑みにしない
実際にリストアを組み込んでみると、次の罠にぶつかります。リストアツールの終了コード単体は、合否判定として信頼できるシグナルにならないというものです。
管理用スキーマなど、「そもそも新規DBには存在しない前提のオブジェクト」に対する操作でエラーになるのは想定内のノイズです。ここでいちいちジョブを失敗させていては、本当に危険な失敗を見逃しかねません。
実際の合否判定は、自前テーブルの行数取得クエリが成功するかどうかに置くのが安全です。
set +e
restore_tool --no-owner --no-acl -h 127.0.0.1 -U postgres -d restore_check "$dump_file"
restore_exit=$?
set -e
if [ "$restore_exit" -ne 0 ]; then
echo "::notice::リストアツールが ${restore_exit} で終了したが、新規DBに存在しない管理用オブジェクト由来の想定内ノイズ。以降の行数取得クエリが本当のゲート。"
fi
# ここで失敗する(= 主要テーブルの行数が取得できない)場合のみ、
# 暗号化・アップロード前にジョブをfailさせる
counts_json="$(query_row_counts_for_key_tables)"
fail-fastにする条件と警告に留める条件を分ける
行数を取得できたら、次は「その数字をどう評価するか」という問題が出てきます。
復元自体が失敗した場合、つまり行数取得クエリそのものが失敗した場合は、暗号化・アップロードの前にジョブをfailさせて止めます。一方で、行数が前回比で大きく減少しているケースは「意図した削除かもしれない」ため、通知のみに留めてジョブは継続させます。
この2つを同じ扱いにしてしまうと、正当な削除操作のたびにアラートが誤爆したり、逆に本当の異常が「よくある削減」として見過ごされたりします。
判定ロジックは、ワークフローのYAMLやシェルスクリプトに埋め込まず、独立したスクリプトに切り出しておくのがおすすめです。境界値のテストがずっと書きやすくなります。
// 前回run比で50%以下まで落ち込んだテーブルを「データ消失の疑いあり」として検出する
const DEGRADED_RATIO = 0.5
function evaluateTableCount({ table, current, previous }) {
if (previous === null || previous <= 0) {
// 比較対象がない(初回run、または前回が0件)場合は判定しない
return { table, current, previous, degraded: false, pctChange: null }
}
const pctChange = ((current - previous) / previous) * 100
const degraded = current <= previous * DEGRADED_RATIO
return { table, current, previous, degraded, pctChange }
}
「ちょうど閾値ぴったり」のような境界を含む扱いにするか除く扱いにするかは、こうして独立させておくとレビューもしやすくなります。
「前回の結果」をどう引き継ぐか
前回比較を行うには、前回runの判定結果をどこかに保持しておく必要があります。
ここでは、判定結果を固定名のartifactとして毎回アップロードし、直前の成功run一覧から取得する設計にしています。こうしておくと、初回runやartifact取得に失敗した場合でも「比較なし=成功」という安全側の挙動に自然にフォールバックできます。
特別な状態管理サービスを用意しなくても、CIの標準機能だけでこの引き継ぎが完結する点が扱いやすいところです。
つまづきやすいポイント
- サービスコンテナのイメージ選定: 素の公式イメージを使うと、実運用にある拡張機能やスキーマの違いから、無関係な理由でリストアが失敗することがあります
- 終了コードと合否判定の混同: リストアツールが非ゼロで終了しても、それだけで異常とは限りません。本当のゲートは行数取得クエリの成否です
- CI内検証だけでは足りない範囲がある: CI内の検証はオフラインの復号鍵を扱えないという制約があります。実際の復号〜復元〜アプリ疎通までの経路は、別途、定期的に人手で確認するリマインダーの仕組みで補う必要があります
まとめ
「ダンプが空でない」「TOCが読める」といったチェックは、復元可能性の証明にはなりません。
同一ジョブ内で実際にリストアし、行数取得クエリの成否をゲートにすることで、検証の精度を一段引き上げられます。fail-fastにする条件と警告に留める条件を分け、判定ロジックを独立したスクリプトに切り出してテストを書いておくと、運用しながら安心して育てていける仕組みになります。
CIによる自動検証と、四半期ごとの人手による復旧ドリルは役割が異なります。両方を組み合わせて、初めて「本当に戻せるバックアップ」と言えるのではないでしょうか。

