バックアップは取れていた。でも「戻せるか」は誰も確かめていなかった話

当ページのリンクには広告が含まれています。
この記事の結論
バックアップは「置いてあること」ではなく「そこから戻せること」に価値があるので、復元できるかどうかを定期的に自動で確かめるところまでを仕組みにすべきだと考えた話です。理由は、ファイルが存在していても中身が使えるとは限らず、その事実が分かるのが障害の当日になってしまうからです。実際に組んでみると、検証する側の作りにも「気づかないまま緑になり続ける」穴がいくつも残っていました。

お疲れ様です!IT業界で働くアライグマです!

個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、DBバックアップの自動リストア検証を追加しました。DevPickは、開発関連のニュースサイトをAIが横断してスコアリングし、その人の興味に合ったものだけを届けるWebアプリです。バックアップ自体はもともと毎日取れていたのですが、「取れていること」と「戻せること」は別だと気づいたのがきっかけでした。今回はその経緯と、検証を組む過程で見つかった不具合を書きます。

目次

「バックアップがある」までしか確認していなかった

DevPickのDBは、毎日1回バックアップを取っています。以前は本当にダンプを取って置いておくだけでしたが、そこから少しずつ手を入れて、圧縮ファイルが壊れていないかの検査、暗号化、別の場所への転送、転送後にハッシュ値を突き合わせる照合まで積み上げてきました。ここまでやっていれば、当時の自分はそれなりに安心していたのです。

ただ、この一連の確認が何を保証しているのかを改めて言葉にしてみると、「壊れていないファイルが、手元とは別の場所に置かれている」でしかありませんでした。ハッシュ値の照合は、転送の前後で中身が変わっていないことを示すだけです。転送前のファイルがそもそもサービスを立ち上げられる中身なのかは、この確認では何ひとつ分かりません。

たとえばダンプを取る時点で一部のテーブルが権限不足で抜けていたとしても、抜けたままのファイルが正しく圧縮され、正しく暗号化され、正しく転送され、ハッシュ値もぴったり一致します。全部緑になります。にもかかわらず、そこから復元したDBにはデータが足りていません。そして自分がその事実を知るのは、バックアップを使わなければならなくなった日、つまり一番余裕のない日です。

同じ「エラーを出さずに壊れている」という性質は、バックグラウンド処理が静かに消えていた話でも踏みました。表に出てこない壊れ方は、起きてから気づく形にしている限り、いつまでも気づけません。

IT女子 アラ美
ファイルはちゃんとあるのに中身が足りないって、一番タチが悪いパターンだよね。

ITアライグマ
しかも全部のチェックが緑のままなので、疑うきっかけすら無いんです。

隔離したDBへ実際に戻して、どこまで見るか

そこで、保存先にある最新の世代を取ってきて、隔離した一時的なDBへ実際に復元してみる仕組みを足しました。やっていることは、いざというときに自分が手でやる作業と同じです。それを毎回自動で走らせるだけです。

ここで大事なのは、「復元コマンドがエラーを出さずに終わった」で終わらせないことでした。ダンプの中身が半分しか無くても、その半分を流し込む処理自体は正常に完了してしまいます。復元が通ることと、戻ったDBが使えることは、やはり別なのです。

なので復元したあとに、次の4つを確認するようにしました。

  • スキーマの世代:DBの構造変更はマイグレーションツールで管理していて、どの世代まで適用済みかを示すリビジョンがDBの中に記録されています。想定より古い構造のまま戻っていないかが分かります。
  • 主要なテーブルが揃っているか:復元されるべきテーブルが欠けたまま終わっていないかを見ます。
  • それぞれに行が入っているか:テーブルはあるのに中身が空、という戻り方を弾きます。
  • 参照整合性:子テーブルの持つ参照先が親テーブルに実在するかを確認します。ここが崩れていると、画面上は表示されても操作した瞬間に落ちる戻り方になります。

置き場所についても1つ判断がありました。この検証を本番サーバーの上では動かさないことにしています。復元するには暗号化を解く秘密鍵が要るのですが、その鍵を本番ホストに置いてしまうと、「本番が侵害されてもバックアップの中身までは読めない」という暗号化の目的がその時点で消えてしまいます。なので検証は手元のセルフホストランナー側で行い、鍵はGitHubのシークレットに預けています。復元先もワークフローが立ち上げる使い捨てのDBに閉じているので、本番のデータには一切触れません。

そのうえで、検証用のDBは実行ごとに一意な名前で新規作成し、もし同じ名前が既にあれば中止するようにしました。削除するのはこの処理が自分で作成に成功したDBだけなので、万が一接続先を取り違えても既存のDBを消すことはありません。復号した平文のダンプと鍵の一時ファイルは、成功しても失敗しても消えるようにしてあります。

IT女子 アラ美
復元できたかどうかを見るのに、そこまで細かく確認する必要あるの。

ITアライグマ
復元処理自体は中身が半分でも通ってしまうので、戻った後を見るしかないんです。

「緑になりやすい検証」にしないための条件

組んでいて一番神経を使ったのは、検証の中身そのものより、検証が失敗しないほうへ倒れやすいという性質のほうでした。

チェックの仕組みは、条件が揃わないときに黙って成功側へ倒れがちです。値が取れなければ既定値を使う、比較できなければ素通りする、といった書き方はどれも自然に出てきますし、単体で見れば親切な作りにも見えます。ただ安全網としての検証では、これが致命的になります。何も確かめられなかった状態と、確かめて問題が無かった状態が、外から見て同じ緑になってしまうからです。

具体的に手を入れたのは3つです。1つ目は、復元する世代の鮮度を条件に含めたことです。既定では48時間以内の世代であることを要求しています。これが無いと、バックアップが何日も前から止まっていても、残っている古い世代の復元には成功するので緑が出続けます。「復元できる」と「守れている」がずれるわけです。

2つ目は、世代の日時が未来になっているケースを弾くようにしたことです。鮮度は現在時刻との差で見ているので、日時が未来だと差が負になり、しきい値の比較を無条件で通過します。時刻設定の狂いやファイル名の付け間違いで起こり得るのに、その場合だけ検査が実質的に無効化されるのは筋が悪いと感じました。

3つ目は、設定が空だったり数値でなかったりしたときに、既定値へ黙って戻さないようにしたことです。確認対象のテーブル一覧が空のまま渡ってきたら、それは「確認するものが無い」のではなく設定の書き間違いです。既定値で埋めて走らせると、書き間違えた本人が気づく機会を永久に失います。ここは設定漏れとして落とす扱いにしました。

IT女子 アラ美
チェックを足したのに安心できなくなるって、なんだか本末転倒に聞こえるんだけど。

ITアライグマ
確かめられなかったのを成功と区別できないと、安全網として機能しないんです。

検証スクリプト自体が、最新世代を取り違えていた

そうやって慎重に組んだつもりでしたが、動かしてみると検証する側にも不具合がありました。

バックアップの保存先には、毎日の世代を置く場所と、月初の世代を長期保管する場所を分けて用意しています。検証はそこから最新の世代を1つ選んで復元するのですが、この「最新」の選び方が、保存先の一覧出力をキー全体の文字列として並べ替えた末尾を取る、というものになっていました。

キー全体で比べると、置き場所を表す部分が先に来ます。日次の置き場所を表す文字列は月次の置き場所より辞書順で前に来るので、実際の日時とは無関係に、長期保管側の世代が常に「最新」として選ばれ続けることになります。しかも月初の世代は必ずそこへ残るので、運用を1ヶ月続けた時点でこの状態が確定します。結果として、直近の日次世代が一度も検証されないままになるか、あるいは鮮度の条件に引っかかって毎回失敗し続けるかのどちらかで、どちらに転んでもこの仕組みの目的が達成できません。

直し方はシンプルで、比較に使う部分をファイル名の日時を表す箇所だけに限定しました。この部分は桁数が固定なので、文字列として並べ替えても時系列の順序と一致します。あわせて、手で復元するときの手順書にも同じ読み方(一覧の最後の行を最新とみなす)が書かれていたので、そちらも日時で並べ替える形に直しました。

引っかかったのは、この不具合を既存のテストが捕まえられていなかったことです。長期保管側の世代を最新として選ぶケースのテストは書いてあったのですが、そのケースは「実際にも長期保管側のほうが新しい」状況を作っていました。つまり日時で見ても文字列で見ても答えが同じになるデータで、比較の仕方が間違っていても通ってしまいます。テストがあることと、間違いを検出できることは別だと改めて感じました。今回は日時と文字列の順序が食い違う向きのケースを足しています。

もう1件、確認対象のテーブル一覧と、参照整合性を見る子テーブルの一覧が食い違っている問題も見つかりました。子テーブル側にだけ入っているテーブルがあると、それが復元されていなかったときに「必須のテーブルがありません」ではなく参照整合性の検査そのものの実行エラーとして表に出るので、何が起きたのか読み取りにくくなります。ここは既定値を揃えるだけだと片方だけ直されたときに同じずれが再発するので、一方が他方に含まれていることを実行前に検査する形にしました。フィードのページ送りを作り直した話でも同じ種類の抜けに当たっていて、値を揃えるより仕組みで揃うようにするほうが結局は早いと感じています。

IT女子 アラ美
テストが通ってたのに間違ってたって、それが一番信じたくないやつ。

ITアライグマ
間違った実装でも通るデータでテストしていたので、通って当然だったんです。

検証が動く時間を、自分の生活リズムに合わせた

最後に、実行するタイミングでも設計をやり直しました。

この検証を動かしているのは、クラウド側の共有マシンではなく、自分で用意したセルフホストのランナーです。そしてその実体は常時稼働のサーバーではなく、普段の開発に使っている手元のマシンの上で動いています。にもかかわらず、当初は週に1回、月曜の午前4時に走らせる設定にしていました。その時間帯にマシンが起きているはずがありません。

厄介なのは、ランナーがオフラインでもジョブがすぐ失敗するわけではない点でした。実行を引き受ける相手が見つからない間、ジョブは待ち状態に入ります。そのまま一定時間拾われないと期限切れになるのですが、このとき失敗を知らせる処理もランナーの上で動く前提だったため、通知すら飛びません。つまり、検証が一度も走らないまま、誰にも何も伝わらずに終わります。バックアップの安全網として最も避けたい壊れ方が、実行時刻の設定ひとつで出来上がっていたわけです。

そこで、自分がマシンを使っている時間帯を実際のコミット履歴から調べました。直近200件の時間帯の分布を見ると、深夜から早朝にかけてはほぼゼロで、19時から23時のあいだが最も多くなっていました。稼働実績が一番高い21時台へ移し、あわせて週1回から毎日に変えています。1日くらい外しても翌日が拾ってくれるので、週1のままで1回外すよりずっと確実です。検証そのものは実測で数秒しかかからないので、毎日走らせても負担にはなりません。世代の鮮度を48時間で見ている条件とも噛み合います。

とはいえこれは緩和策で、構造的な解決はランナーを常時動いているマシンへ移すことです。GitHub Actionsの肥大化を防ぐCI/CD設計パターンで触れられているような実行基盤側の整理と同じで、今回は判断の経緯だけドキュメントに残して先送りにしました。

IT女子 アラ美
自分のPCの稼働時間に合わせてスケジュール決めるの、地味だけど現実的だね。

ITアライグマ
動かない時間に予約しても意味が無いので、実績から決めるのが確実でした。

まとめ

バックアップまわりで自分がやってきたことは、振り返ると「取ったものを守る」作業ばかりでした。壊れていないか調べ、暗号化し、別の場所へ運び、運んだ先で中身が変わっていないことを確かめる。どれも必要ですが、そのすべてを足しても「戻せる」の証明にはなりません。証明できるのは、実際に戻してみることだけです。

そして検証を仕組みにしてみると、今度は検証のほうが静かに機能を失う経路がいくつも出てきました。確かめられなかったのに緑になる、選ぶ対象を取り違える、そもそも実行されない。どれも共通しているのは、失敗が失敗の形で表に出てこないことです。バックアップ本体で困っていたのとまったく同じ性質を、安全網の側でもう一度踏んでいたことになります。

今の自分の結論は、確認する仕組みを足したときは「これは何を確かめていないか」を必ず一度書き出しておく、というところに落ち着いています。何を確かめているかは作った本人にはよく見えますが、確かめていない範囲は意識しないと視界に入りません。そして事故は、だいたいそちら側で起きます。

IT女子 アラ美
確かめてない範囲を書き出すって、地味に効きそうだけど面倒くさそう。

ITアライグマ
面倒ですが、書き出した時点で穴が半分見つかるので割に合っています。

作者が開発したサービス「DevPick」

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次