0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

バックアップが動いているからといって、データが安全なわけではない — MySQL 復旧リハーサル

0
Posted at

はじめに

現在、リダイレクトサービス kurl.me を運営しています。

このサービスをローンチするにあたり、負荷テストを行おうと考えました。
しかしその前に、もし崩れてしまった場合に復旧できるのかどうかを、まず検証しなければなりませんでした。
現在、バックアップは毎日午前4時に実行されており、一度も失敗することなく成功していました。
ここまで見れば安心できるかもしれませんが、「バックアップが動いている」ことと「データが安全である」ことは別の問題だと考えています。

現在のバックアップの問題

穴は2つあります。

  1. バックアップが自分のサーバーのディスクにだけ溜まっており、サーバーが死ねばバックアップも一緒に死ぬということ
  2. そして、そのバックアップを使って復元をしてみたことが一度もないということ
    そこで今回の記事は、負荷テストではなく、その一つ前の段階である DB の安定性についての話です。

安定性は3つの軸で判断しました

最初は「安定性を高めたい」という漠然とした状態でしたが、災害復旧(ディザスタリカバリ)の分野の用語を借りることで、3つの軸に具体化することができました。

  • RPO (Recovery Point Objective)
    • 事故が起きたとき、データを何分ぶんまで失ってもよいのか?
  • RTO (Recovery Time Objective)
    • 落ちてから何分以内に戻ってこなければならないのか?
  • お金 (Money)
    • この安定性のために、資本をどの程度まで投入できるのか?

RPO と RTO はもともとどちらも「目標値」を意味する言葉ですが、この記事では、今の構成で実際にどれくらいの値が出るのかを測る物差しとして使います。
私は収益のない個人サービスを一人で運営している立場なので、大規模サービスのような華やかな構成にお金を注ぎ込むことはできません。ですからこの問いは「どうすれば安全になるのか」ではなく、「月0円あたりで RPO と RTO をそれぞれどこまで確保できるのか?」 です。
現在の状態を見てみると、こうなります。

RPO : 最大24時間 (午前4時のダンプ以降はすべて消失)
RTO : 未知数 (復元テストを一度も行っていない)

あらかじめ宣言しておくトレードオフ

結論から言うと、私は可用性を買う代わりに、現在の構成のままデータ耐久性を先に補強することにしました。
テーブルの上には3枚のカードがありました。

現在の構成を維持し、バックアップをきちんと作る

アプリと DB を1つのサーバーで運用します。
サーバーが消えてしまった場合は新しい環境を自分で構築してデータを復元しなければならないため、即時復旧は諦めます。
その代わりバックアップをサーバーの外に保管し、実際に復元と時点復旧(PITR)が可能かどうかを検証することで、データ消失の範囲を測定可能な上限の中に閉じ込めます。
コストは最も少ないですが、運用責任はすべて自分の担当になります。

RDS Single-AZ に移す

DB プロセスの管理をはじめとする運用負担のかなりの部分を、マネージドサービスに任せることができます。

その代わり固定費が発生します。

ただし、マネージド DB に移したからといって、すべての復旧の問題がなくなるわけではありません。事故が起きたときにどの時点まで戻すのかを判断し、検証し、接続する手順は、依然として自分が管理しなければなりません。

ホスト障害への対応は楽になるでしょうが、自動フェイルオーバーを購入する選択ではありません。

RDS Multi-AZ に移す

マネージド DB の運用面の利便性に加えて、ホスト障害が発生したときに自動フェイルオーバーする構成まで買います。

最も可用性が高いですが、最もコストも高くなります。
そこで私は1番を選びました。

今のサービスで最も重要なのは、数分の停止よりも、復旧する手段がないままデータが消えてしまうことでした。そしてコストの問題も考慮しないわけにはいきませんでした。

目標は無損失ではなく、事故のタイプごとにどこまで失いうるのかという管理範囲を確認し、その上限を確定することです。

なぜ Multi-AZ のような可用性構成がこの目標の答えではないのかは、次の節で障害をタイプ別に分けてみると明らかになります。

障害という言葉を再定義する

勉強していくうちに、「安定性を高める」という言葉が漠然としていた理由が分かりました。

障害を1つの出来事として一括りにしていたからです。
私には、大きく5つの意味のある障害が発生しうると考えました。

# 障害のタイプ 何が起きるのか 対応手段
1 プロセスの死 アプリや DB のプロセスがクラッシュ。データは無事 自動再起動
2 人間または AI のミス・バグ 誤って DELETE、マイグレーション事故。マシンは無事なのにデータが壊れる バックアップ + 時点復旧(binlog)
3 ディスク・サーバーの消滅 インスタンスが丸ごと消える。そのディスクにあったものは全部一緒に消える 別の場所にあるバックアップ
4 ランサムウェア・乗っ取り 攻撃者がサーバーの権限を取得。消せるものは全部消されると想定しなければならない サーバー権限でも消せないバックアップ
5 バックアップが静かに死ぬこと ある日からバックアップが失敗しているのに誰も気づかない。事故当日になって発見する 失敗したときに自分を起こす通知

よく言われる 3-2-1 ルール(コピー3つ・媒体2種・オフサイト1つ)に照らしてみると、私が今持っているものは事実上 1-1-0 でした。

データバックアップの 3-2-1 ルール
image.png

  • 重要なデータは3つのコピーで保管します
  • 2種類の異なる保存媒体を使用します
  • そのうち1つは別の場所に保管します

表を作ってみて、2つのことが見えてきました。

1つ目、可用性を高めることと、データをよみがえらせられるようにすることは別の問題でした

RDS Multi-AZ のように自動フェイルオーバーが可能な構成は、プロセスやホストが死んだときにサービスの停止時間を減らしてくれます。

しかし、人が誤った DELETE を実行したり、マイグレーションのコードがデータを誤って変更したりした場合、これは DB から見れば正常なリクエストであるため、他のインスタンスにもそのまま反映されます。

つまり、自動フェイルオーバーは現在の状態を提供し続けるための手段であり、バックアップと時点復旧は過去の正常な状態に戻るための手段です。この2つは、解決する障害そのものが異なります。

2つ目、復旧手段が障害と同じ障害ドメインにあってはいけませんでした

現在のサーバーの binlog と既存のバックアップファイルは、DB と同じディスクにあります。

人や AI が誤ったクエリを実行したタイプ2では復旧に使えますが、サーバーのディスクが丸ごと消えるタイプ3では、復旧手段まで一緒に消えてしまいます。

バックアップをサーバーの外に移すだけでも、ディスクの消滅には対応できます。しかしタイプ4のようにサーバーが乗っ取られた場合には、条件がもう1つ必要です。

侵害されたサーバーの権限でリモートのバックアップまで削除できるのであれば、物理的に別の場所にあったとしても、安全なバックアップとは言えません。

したがって、タイプ3を防ぐにはバックアップが別の障害ドメインになければならず、タイプ4まで防ぐにはバックアップがサーバーの削除権限の外になければなりません。

そしてタイプ5は、性格が少し違いました。データやサーバーが死ぬのではなく、復旧手段そのものが静かに死ぬ障害でした。

バックアップスクリプトが成功ログを残しているという事実は、失敗したときに自分が気づけるという意味ではありません。cron が実行されなかったり、ディスクがいっぱいになったり、アップロードの段階で止まったりしても、ログを自分で確認しなければ、事故が起きるまで気づかない可能性があります。

「バックアップが実行された」と「バックアップが失敗したら自分が気づく」は、別の条件だからです。

この5つの評価基準を立てたので、今の kurl がこれらにきちんと備えられているのかを照らし合わせてみました。

다섯개의 장애 유형을 점검 하다

  • 감사는 명령어 몇 줄 이면 됐다
  • 오래 걸린건 조사가 아니라, "결과를 이미 있는것" 과 "없는것" 으로 정직히 가르는 일이었다

タイプ1、プロセスの死 - クリア

5つのコンテナすべてに再起動ポリシーが設定されています。

/kurl-md-app-1     restart=unless-stopped/kurl-md-mysql-1   restart=unless-stopped/kurl-md-nginx-1   restart=unless-stopped/kurl-md-redis-1   restart=unless-stopped/kurl-md-certbot-1 restart=unless-stopped

プロセスが死んだら、Docker が再び立ち上げてくれます。

タイプ2、人や AI のミス - 条件付き

ここで、今回の点検における最大の発見が出てきました。

log_bin                       ONbinlog_expire_logs_seconds    2592000   -- 30日

binlog には、コミットされたデータの変更とテーブル構造の変更が、復旧に再利用できるイベントの形で記録されます。

これは MySQL 8 のデフォルトのオプションなので最初から動いており、私はその存在を知りませんでした。

つまり、自分で設定したことはないものの、binlog が有効になっており、有効期限は30日に設定されていました。

サーバーが生きている間は、時点復旧に使う変更履歴が最大30日間保存される構成でした。

image.png

タイプ3、ディスクの消滅 - 穴

Filesystem      Size  Used Avail Use% Mounted on
/dev/root        29G  9.2G   19G  33% /
/dev/root        29G  9.2G   19G  33% /

これが現在の問題です。

バックアップは8つ、きれいに積み上がっていました。すべて自分のサーバーのディスクに。

このディスクが消える日、データも一度に消えてしまいます。

タイプ4、ランサムウェア - 穴

バックアップのディレクトリは、サーバーの権限で削除できるようになっています。

わざわざハッキングされなくても、最近は AI がどんな余計なことをするか分からないので、これは危険です。

タイプ5、静かな失敗 - 穴

冒頭で書いた「連続した成功の証拠」とは、このログです。

[Fri Jul 17 04:00:05 UTC 2026] backed up short_link-20260717-040001.sql.gz (2088032 bytes)[Sat Jul 18 04:00:04 UTC 2026] backed up short_link-20260718-040001.sql.gz (2116032 bytes)...[Thu Jul 23 04:00:04 UTC 2026] backed up short_link-20260723-040001.sql.gz (2250718 bytes)[Fri Jul 24 04:00:04 UTC 2026] backed up short_link-20260724-040001.sql.gz (2280734 bytes)

ファイルが毎日2〜3万バイトずつ積み上がっていることは確認できましたが、問題は、このように直接開いてみなければ成功を確認できないということです。

もし失敗していたとしたら、復元が必要になった日にその失敗を知ることになるでしょう。

点検結果

障害のタイプ 判定 一行の根拠
1 プロセスの死 クリア 全コンテナが自動再起動
2 人・AI のミス 条件付き binlog 有効・保存期間30日に設定、復旧手順は未検証
3 ディスクの消滅 バックアップがすべて同じディスク
4 ランサムウェア サーバー権限でバックアップを削除可能
5 静かな失敗 成功ログだけがあり、失敗の通知がない

ダンプファイルがあるので、復元はできるだろうとは思います。しかし「できるだろう」と「できる」は違い、これが序論で RTO を未知数のまま残しておいた理由です。

穴を埋めるのはその次であり、順序として真っ先に確認すべきなのは「復元ができるのか」でした。

1回目のリハーサル - 復元はできるのか?

リハーサルの条件を先に決めました。

本番環境には触れません。検証用に別途作ったダンプではなく、cron が実際に作っておいた今日の午前4時のファイルを使います — 検証しなければならないのは「バックアップができるのか」ではなく「毎日作られているまさにそのファイルがよみがえるのか」だからです。そしてバージョンを合わせます。本番が MySQL 8.0.33 なら、リハーサルも 8.0.33 です。

まずダンプをサーバーから自分の Mac にコピーしました。この瞬間はささいなことですが記録しておく価値があります。このファイルが、このサービスの歴史上はじめてのオフサイトバックアップです。 サーバーの外に出た最初のコピーが、リハーサルの準備物として生まれました。

Mac で同じバージョンの使い捨てコンテナを立ち上げて、流し込みました。

docker run -d --name restore-rehearsal -e MYSQL_DATABASE=short_link \  -p 3307:3306 mysql:8.0.33
gunzip -c short_link-20260724-040001.sql.gz \  | docker exec -i restore-rehearsal mysql -uroot -p... short_link

復元には3.7秒かかり、エラーなく58個のテーブルが作成されました。

そしてマイグレーションの履歴もすべて無事でした。

しかし、復元コマンドが成功したということと、データが正常に戻ってきたということは別の問題です。

そこで、復元したものと実サーバーの行数もチェックしてみました。

テーブル 復元したもの (午前4時のダンプ) 実サーバー (当日の夕方) 差分
users 18 18 0
link 67 67 0
posts 136 136 0
click_event 2,062 2,075 +13

差分があるのはクリックイベントだけで、すべて「午前4時以降に増えたぶん」です。

復元したもののクリックの最終時刻も、ダンプ直前である 7月23日15時55分 で、食い違うデータはありません。

リハーサルの成果物はコンテナではなく、「できる」という事実と、Mac に残ったオフサイトのコピー1つです。

これで序論の未知数が1つ消えました。

DB ダンプの投入時間を直接確認しました

もちろん、正直に付けておくべき注釈が2つあります。

  1. 3.7秒というのは 2.2MB の DB での数字です。データが大きくなればこの数字も大きくなるでしょう。ですから実際に価値があるのは数字ではなく、「手順が検証済みであり、いつでも測り直せる」ということです。
  2. この時間は DB のデータを投入するのにかかった時間にすぎません。サーバーが丸ごと消えたときに新しいサーバーを作り、アプリケーションを立ち上げ直す全体の RTO は、まだ測定していません。

残っているのは障害タイプの穴3つです。

復元ができると分かったので、次はバックアップをサーバーの外へ、そしてサーバーの権限が届かない場所へ分離する番です。

バックアップの責任分離

障害タイプの穴の3番と4番は、実は一文で整理できます。

バックアップはサーバーの外になければならず(ディスク・サーバーの消滅)、サーバーに付与された権限では削除できないようになっていなければならない(サーバーの乗っ取り)。

前半は簡単です。どこでもいいので外に送ればいいのです。難しいのは後半です。バックアップを送るにはサーバーにストレージへのアクセス権限を与えなければなりませんが、その権限は侵入者にもそのまま渡ってしまいます。

サーバーが「書き込める」場所はたいてい「消すこともできる」場所であり、そうなるとタイプ4で元どおり崩れてしまいます。

権限を半分だけ発行することが、この問題の答えになりうると考えました。S3 バケットを作り、サーバーには入れること(s3:PutObject)だけをたった1つ許可するポリシーを付けました。

金庫というよりは郵便ポストに近いです。サーバーはオブジェクトを入れることはできますが、一覧を取得したり、ダウンロードしたり、削除したりはできません。ただし同じキーで再びアップロードすることは可能であり、この問題は後ほどバージョン管理で補います。

image.png

s3:PutObject だけを与えた状態で、これを EC2 インスタンスロールとして付与しました。こうすることで、長期キーのファイルがディスク上に存在せず、取り出せる一時キーですら権限は「入れる」の1つだけになります。

そして毎日未明のバックアップスクリプトの最後に、アップロードの1ステップを付け足しました。

# Offsite copy — instance role is put-only, so a compromised box can't delete these
if command -v aws >/dev/null 2>&1; then
  if aws s3api put-object --bucket kurl-db-backup \
      --key "$(basename "$OUT")" --body "$OUT" \
      --region ap-northeast-2 >/dev/null 2>>"$LOG"; then
    echo "[$(date)] offsite uploaded $(basename "$OUT")" >> "$LOG"
  else
    echo "[$(date)] OFFSITE UPLOAD FAILED $(basename "$OUT")" >> "$LOG"
    curl -fsS -m 10 --retry 3 https://hc-ping.com/*****/fail >/dev/null 2>&1 || true
  fi
fi

そして、実際に消せるのかを試してみました。

$ aws s3 rm s3://kurl-db-backup/short_link-20260724-040001.sql.gz
delete failed: s3://kurl-db-backup/short_link-20260724-040001.sql.gz
An error occurred (AccessDenied) when calling the DeleteObject operation:
User: arn:aws:sts::**********:assumed-role/kurl-db-backup/i-******* is not authorized
to perform: s3:DeleteObject on resource:
"arn:aws:s3:::kurl-db-backup/short_link-20260724-040001.sql.gz"
because no identity-based policy allows the s3:DeleteObject action

この拒否メッセージは、現在のインスタンスロールにバックアップの削除権限がないという証拠です。このロールだけを奪った攻撃者は、既存のバックアップオブジェクトを直接削除することはできません。

もう1つ確認しておくことがありました。このサーバーには AWS の鍵がもう1つ住んでいます。

アプリで使う画像(プロフィール、記事の写真など)をアップロードするときに使う静的キーです。この鍵が広範囲な S3 権限を持っていると、いま作ったばかりの防御が裏口から破られてしまいます。

確認してみると、画像バケット1か所に限定されていました。これで一文が完成します。

点検した範囲において、サーバー内部のアプリケーション認証情報とインスタンスロールのどちらにも、バックアップバケットのオブジェクトを参照したり削除したりする権限はありませんでした。

そしてもう1つ。削除はできなくても、同じ名前で上書きすることは可能です(それも PutObject ですから)。そこでバケットにバージョニングを有効にしました。上書きすると、元のファイルが以前のバージョンとして残ります。

7A024317-CE4E-4486-9B1A-103D7A33C665_1_105_c.jpeg

平常時はファイル名に日付が入っているため上書き自体が起こらず、保存はライフサイクルルールが管理します。

ですから正確な表現は「消せないバックアップ」ではなく「30日以内に気づけば生きているバックアップ」です。攻撃者がこのサーバーでできる最悪のことは、汚染されたダンプを正常なバックアップのふりをして送り続けることですが、その場合でも既存のコピーは、ライフサイクルが期限切れにするまでそのまま残ります。そしてここで防ぐ脅威は、あくまでアプリサーバーとその中の認証情報が奪われる場合です。AWS アカウント自体が乗っ取られるシナリオまで防ぐには、Object Lock で書き込み後は変更不可(WORM)そのものを買わなければなりませんが、今の規模では「入れる専用 + バージョニング」までが適正なラインだと考えました。

最後に、上限そのものを減らしました。ダンプが1日1回であれば、サーバーが消滅したときの消失の上限は24時間のまま残ります。そこで cron を1日1回から6時間間隔で4回(UTC 4・10・16・22時)に増やしました。これで、サーバーやディスクが突然消滅するタイプ3のデータ消失の上限は最大6時間です。

タイプ4のようにサーバーが乗っ取られ、汚染されたバックアップがアップロードされ続ける場合には、固定された6時間の RPO を保証することはできません。その代わり、バージョニングと30日の保存期間の中で侵害を発見できれば、攻撃以前の最後の正常なバックアップに戻ることができます。

障害のタイプ 判定
1 プロセスの死 クリア
2 人のミス 条件付き — binlog はあり、リプレイのリハーサルが残っている
3 ディスクの消滅 クリアに変更 — 6時間ごとにサーバーの外へ
4 ランサムウェア 条件付きクリア — サーバーの認証情報では既存のバックアップを削除できず、バージョニングと30日保存を適用
5 静かな失敗 穴 — 依然として残っている

残っているのは2つです。バックアップが死んだときに自分を起こす通知(タイプ5)、そして binlog のリプレイが本当にできるのか(タイプ2)。これが次の順番です。

2回目のリハーサル - binlog は本当に時間を巻き戻してくれるのか

障害タイプの2番目は「条件付き」のまま残っていました。

binlog が有効になっていて保存期間も30日に設定されていますが、ダンプに続けてリプレイする手順は、まだ検証していません。

2回目のリハーサルのシナリオはこうです

  1. 午前4時のダンプに戻ります
  2. binlog をリプレイして、今この瞬間まで巻き戻してきます
  3. 成功すれば、サーバーとローカルに binlog が残っている条件のもとで、誤った DELETE のような論理障害によるデータ消失の範囲を、1日単位ではなく秒単位にまで縮められるという意味になります

ローカルで午前4時の状態を復元し(1回目のリハーサルと同じ)、サーバーから binlog ファイルをコピーしてきたうえで、午前4時以降のイベントだけを選んでリプレイしました。

mysqlbinlog --start-datetime="2026-07-24 04:00:05" \  --database=short_link binlog.000005 | mysql short_link

そして、同じ瞬間に取っておいた実サーバーのスナップショットと照らし合わせました。

項目 リプレイ後 (ローカル) 実サーバー (同じ瞬間)
click_event 2,078 2,078
request_metrics 123,946 123,946

検証対象とした click_event と request_metrics テーブルの行数が、実サーバーのスナップショットと正確に一致しました。

この結果によって、ダンプ以降17時間のあいだに発生した変更分を binlog でリプレイできることを確認しました。

ただし、このリハーサルの本当の収穫は成功ではなく、

成功までにつまずいた落とし穴3つです。

事故当日の夜にはじめて出会っていたら、それぞれ問題になっていたはずの要素でした。

落とし穴1 復旧ツールがなかった

  • 公式 MySQL の Docker イメージに mysqlbinlog がありませんでした。スリムなイメージなので、復旧に必要なツールが抜けています。リハーサルのおかげで今のうちに気づけたので、復旧用のクライアントを別の場所に用意しておきました

落とし穴2、3 時刻で開始点を決めること自体が危険だった

  • タイムゾーンの問題です。リプレイ後の最終クリック時刻が9時間ずれて見えました。実際にデータが飛んだのではなく、サーバーは KST、ローカルは UTC で表示していただけでした
  • ダンプに binlog の座標がありませんでした。リプレイの開始点を「午前4時5秒あたり」と推測しなければなりませんでした。今回は行数の完全一致で無損失が証明されましたが、午前4時の数秒のあいだには書き込みがありませんでした。もし昼の時間帯だったら、その推測が消失や二重適用になっていたかもしれません。その日のうちにバックアップスクリプトに --source-data=2 フラグを追加しました。これでダンプの先頭にしおりが挟まれます
- CHANGE MASTER TO MASTER_LOG_FILE='binlog.000005', MASTER_LOG_POS=48060873;

次の復旧からは、推測ではなく座標から始めます。

バックアップが死んだら、実際に通知が来る

最後の穴、障害タイプ5です。

ここで必要だったのは技術ではなく、方向転換でした。

これまでバックアップは成功を記録していました。

自分から見に行かなければならないログとして。これをひっくり返して、生存信号が途切れたら警報が鳴るようにしました。

healthchecks.io を使って、6時間に1回(バックアップの周期に合わせて、猶予1時間)信号が来なければならないというチェックを1つ作り、バックアップスクリプトの一番最後で ping を送るようにしました。バックアップが正常に終わったときだけ ping が出ていくので、cron が止まろうと、ディスクがいっぱいになろうと、スクリプトが死のうと、

理由が何であれ ping は途切れ、猶予時間が過ぎるとメールが飛んできます。

healthchecks.io

失敗を検知するのではなく生存を確認する仕組みなので、自分が想像もできなかった死に方まで捕まえられます。オフサイトへのアップロードだけが失敗する場合には、翌日まで待たずに即座に失敗信号を別途送ります。

3回目のリハーサル - サーバーが丸ごと消えたことにしよう

1回目のリハーサルで付けておいた注釈が、ずっと引っかかっていました。「これはデータをよみがえらせる時間であって、サーバーを建て直す時間ではない。」今回はそれを消す番です。

シナリオはこうです。ライブサーバーが丸ごと消えました。残っているのは、サーバーの外にあるものだけです。

  • S3 のダンプ、GitHub のコードとコンテナイメージ

条件は1回目のリハーサルと同じ原則です。ライブには触れません。

同じリージョンに同じスペック(t4g.small、Ubuntu 24.04 Arm)の新しいインスタンスを1つ立ち上げました。

ここに付ける鍵をもう1つ作りました。

サーバーの鍵が「入れるだけ」だったので、復旧する側の鍵は「取り出すだけ」です。

s3:GetObject + s3:ListBucket だけを持つロールを新しいインスタンスに付与しました。

これでバックアップバケットを挟んで、書く側と読む側の権限が完全に分かれました。

ストップウォッチはインスタンスの起動時刻からです。

区間 所要
デプロイ構成の偵察 (コード・compose の把握) 3分12秒
Docker・基本ツールのインストール 3分07秒
コードのクローン (GitHub) + AWS CLI 16秒
.env の再構成 — シークレットをすべて再発行 1秒
アプリイメージの pull (GHCR) 21秒
S3 からダンプをダウンロード 5秒 (転送1.2秒)
MySQL の起動 + ダンプの投入 44秒 (投入11秒)
アプリの起動 → healthy 45秒
合計: 起動 → アプリが正常応答するまで 9分49秒 (その他の時間を含む)
  • 「.env の再構成 — シークレットをすべて再発行」の1秒は、コマンドが準備できている前提での数字であり、何をどう埋めるかを考えた時間は偵察の3分に含まれています

S3 から取り出す瞬間がこのリハーサルの核心となる場面ですが、レスポンスにはレシートが2枚重なって出てきました。ダウンロードしたファイルに expire-30d のライフサイクルが付いているということ、そしてダンプの先頭に、2回目のリハーサルで仕込んでおいた binlog のしおりが実際に挟まっているということです。

"Expiration": "expiry-date=\"Fri, 28 Aug 2026 00:00:00 GMT\", rule-id=\"expire-30d\""
-- CHANGE MASTER TO MASTER_LOG_FILE='binlog.000006', MASTER_LOG_POS=1701946;

復元後の照合は、今回も行数で行いました。同じ瞬間のライブサーバーと比べると:

テーブル 復元したもの (新サーバー) ライブ (同じ瞬間) 差分
users 19 20 +1
link 72 72 0
posts 136 137 +1
click_event 2,273 2,273 0

差分は、ダンプ(10時)以降に増えたぶんだけです。

アプリは起動しながら、復元されたスキーマ上のマイグレーション履歴118件を検証し、それによって復元された DB が現在のコードと正確にかみ合うという Flyway のバージョニング機能を確認しました。

Successfully validated 118 migrations (execution time 00:00.188s)
Schema `short_link` is up to date. No migration necessary.

最後に、復元されたリンクを1つ選び、新しいボックスのアプリに直接リクエストを投げました。

7D28EFC0-75C7-4108-B9B7-57F93A46B951.png

302、元のアドレスへ弾き返しました。データが戻ってきたのではなく、サービスが戻ってきたのです。

今回、また別の問題を見つけました。

env のバックアップがない問題

コードにも(当然ながら)、S3 にもありません。サーバーが消えれば、シークレットも一緒に消えます。新しく作ればいいのではないかと思いましたが、埋めていくうちに、シークレットには3つの等級があることが分かりました。

  • 新しく作ればそれで済むもの
    • DB・Redis のパスワード。新しいボックスなのだから、新しいパスワードでかまいません
  • 新しく作ると代償があるもの — JWT の署名キー。再発行すると全ユーザーがログアウトされます。災害時であれば受け入れられる代償です
  • 新しく作るとデータが死ぬもの — 2FA のシークレットを暗号化する AES キー。このキーが消えると、DB に保存された 2FA のシークレットを永遠に復号できません。バックアップが無事でも、この32バイトがなければ 2FA ユーザーは締め出されます

3つ目の等級が、このリハーサルの最大の収穫です。「データは S3 にあるから安全だ」と思っていたのに、そのデータを読むための鍵が1つ、バックアップの外に住んでいたのです。

オフサイトは S3 以外にもあった

  • コードは GitHub に、コンテナイメージは GHCR に、デプロイ用のシークレットの一部は GitHub Actions に、すでに住んでいました。
  • サーバーの消滅に耐える軸は、実は3つあったのです — データは S3、コードとイメージは GitHub、トラフィックは Cloudflare。今回やったことは、そのうち空いていた1つ目の軸を埋めることでした

スコープに正直であること — 9分49秒がすべてではない

  • この数字は「データ + アプリが正常応答する」までです。一般のユーザーが kurl.me に再びアクセスできるようにするには、DNS を新しいボックスに向け(オリジン IP の変更)、TLS 証明書を再発行してもらう区間が残っています。証明書ボリュームが空の新しいボックスでは nginx を立ち上げておらず、この区間は測っていません
  • それでも、未知数の大きさが違います
    • 昨日の RTO は「すべてが未知数」でしたが、今残っている未知数は DNS の伝播と certbot 1回ぶんです

決算 - 月0円あたりで買ったもの

序論の問いに戻ります。

月0円あたりで、RPO と RTO を何分まで買えるのか。

かかった費用: S3 の保管料(月あたり、事実上0円) + 無料の通知サービス。

項目 以前 以後
RPO — 人・AI のミスおよびアプリケーションのバグ 最大24時間と考えていた。binlog はあったが復旧は未検証 サーバーとローカルの binlog が保存されている場合、秒単位の時点復旧を実証
RPO — サーバー・ディスクの消滅 バックアップがサーバーと同じディスクにあり、復旧できない可能性があった オフサイトのダンプ基準で最大6時間
サーバー乗っ取り時の復旧可能範囲 サーバー権限でローカルのバックアップを削除可能 サーバーの認証情報では S3 のバックアップを削除できない。保存された正常なバージョンがあれば、その時点へ復旧可能
バックアップ失敗の検知時間 失敗した事実を自分でログから確認しなければならなかった バックアップ周期6時間と猶予1時間を基準に、最大7時間以内に警報
DB ダンプの投入時間 未測定 2.2MB のダンプ基準で3.7秒
サーバー復旧の区間 未測定 新サーバーの起動からデータ・アプリの正常応答まで9分49秒。DNS・TLS の切り替えは除く

1-1-0 だった構成からオフサイトのコピーを確保し、3-2-1 に近づきました。ただし、オリジナルとローカルのダンプは同じ障害ドメインを共有しています。

残った宿題4つ

  1. 再生成できないシークレットのオフサイトバックアップ
    • 3回目のリハーサルが見つけ出した穴。2FA の AES キーのように、失えばデータの一部が死んでしまうシークレットは、バックアップの隣ではなくバックアップの中になければなりません
  2. binlog のオフサイトアップロード
    • 今はダンプだけが外に出ています。binlog を1時間単位で安全にオフサイトへ保管し、ダンプの座標からリプレイする手順まで検証すれば、サーバー消滅時の RPO を6時間から最大1時間程度にまで縮められます
  3. DNS・TLS の切り替え区間
    • 3回目のリハーサルが測らなかった残り半分。オリジン IP の変更と certbot の再発行までつなげれば、RTO の全区間が実測になります
  4. 復元リハーサルの定期化
    • 一度できたということは、これからもできるという意味ではありません。この記事が「バックアップが動いている ≠ 安全である」で始まった以上、同じ論理を自分自身にも適用しなければなりません

推敲しているあいだに、タイプ5が実際に起きました

この記事を整えている途中でバックアップのログを再確認したところ、オフサイトへのアップロードが3日間ずっと失敗していました。

インスタンスに紐づいた IAM ロールが別のロールに変わったことで PutObject の権限が消えており、そのあいだに実行された12回のアップロードがすべて AccessDenied で失敗していました。

ローカルのバックアップは正常に作られていたため、私は問題に気づけませんでした。3日のあいだ、システムはふたたび「バックアップがサーバーと同じ障害ドメインにしか存在しない状態」に戻っていたのです。

ところで、なぜ警報を受け取れなかったのでしょうか?

正確に言えば、受け取ってはいました。

失敗信号(/fail)は出ていましたし、

healthchecks は毎回ダウンを記録していました。

問題は、スクリプトがそこで止まらずに最後まで進み、数秒後に成功の ping を送っていたことです。そのため、届いたメールの件名はこうなっていました。

"kurl-db" is UP. The downtime lasted 0 seconds.

image.png

image.png

警報が鳴るたびに、すぐさま「復旧しました」が付いてくる構造です。

つまり、通知を作っておきながら、その通知をノイズに変えるコードを一緒に入れておいたようなものです。

スクリプトを直しました。

オフサイトへのアップロードが失敗したら、成功の ping を送らずにそのまま終了します。

これでデッドマンスイッチは、問題が解消されるまで下がったままになります。そして、ついでに手を入れたので「成功 ping」の資格条件も引き上げました。ファイルサイズの下限(1MB)、gzip の整合性チェック、そして mysqldump が最後まで到達したことを示す Dump completed のマーカーまで通過してはじめて ping が出ていきます。

スクリプトが最後まで実行されたということと、ダンプが生きているということもまた、結局は別の問題だったからです。

SIZE=$(stat -c %s "$OUT")
if [ "$SIZE" -lt 1000000 ]; then
  echo "[$(date)] BACKUP FAILED — output too small ($SIZE bytes), removing" >> "$LOG"
  rm -f "$OUT"; exit 1
fi
if ! gzip -t "$OUT" 2>>"$LOG"; then
  echo "[$(date)] BACKUP FAILED — corrupt gzip, removing" >> "$LOG"
  rm -f "$OUT"; exit 1
fi
if ! zcat "$OUT" | tail -5 | grep -q "Dump completed"; then
  echo "[$(date)] BACKUP FAILED — no 'Dump completed' marker, removing" >> "$LOG"
  rm -f "$OUT"; exit 1
fi

# Any offsite failure exits before the success ping so the dead-man stays down.
else
  echo "[$(date)] OFFSITE UPLOAD FAILED $(basename "$OUT")" >> "$LOG"
  curl -fsS -m 10 --retry 3 https://hc-ping.com/******/fail >/dev/null 2>&1 || true
  exit 1
fi

# Dead-man switch — only reached when the dump validated AND the offsite copy landed
curl -fsS -m 10 --retry 3 https://hc-ping.com/***(セキュリティ上の理由により非公開)*** >/dev/null 2>&1 || true

「バックアップが動いている ≠ 安全である」で始まった記事ですが、安全網そのものにも同じ一文が当てはまるということを、推敲の最中に実物で確認しました。呆れるように聞こえるかもしれませんが、この事故がこの記事で一番気に入っている部分です。

それでも安全網は確保できました。これで安心して負荷テストをやってみられそうです。

  • スクリプトの作成および補助には Claude の助けを借りました
  • 機械翻訳を使用しているため、誤りがある可能性があります。おかしな点があればご指摘いただけると幸いです。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?