riq0h.jp/content/post/あるはずのバックアップが全部消えていた.md
Rikuoh 69a6ea639f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix
2023-11-16 16:21:14 +09:00

8 KiB
Raw Permalink Blame History

title date draft tags
あるはずのバックアップが全部消えていた 2023-11-12T19:04:27+09:00 false
tech
diary

自分だけは、と思っていても起きる時は起きる。いつまでもあると思うな親と金とバックアップ。Cloudflare R2にcronで毎日アップロードしていたMastodonインスタンスのデータベースが、いつの間にか全部消えていた。AP実装においてデータベースはすべてである。これをなくしたらもはや取り返しはつかない。

だからこそインスタンスの運営者はデータベースの保全に気を配る。わざわざオブジェクトストレージにアップロードしているのもサーバ本体の破損にデータベースが巻き込まれるのを防ぐためで、ローカルよりもクラウド上の方が安全との判断からだ。にも拘らず、ちょっとした行き違いが重なると瞬時に死の刃が首元まで迫ってくる。かつて僕が書いた自動化スクリプトの例を以下に記す。

#!/bin/bash

echo "Backup begin..."
cd /home/ユーザ/インスタンスのディレクトリ/
docker exec mastodon-db-1 pg_dump -Fc -U user db | gzip -c >> backup.gz
chmod 744 ./backup.gz
echo "Success!"

su - ユーザ << bash
 echo "Uploading to Cloudflare R2..." 
 cd /home/ユーザ/プロジェクトディレクトリ/
 npx wrangler r2 object put "あんたのバケット名/$(date +\%Y\%m\%d_\%H-\%M-\%S).gz" --file=/home/ユーザ/インスタンスのディレクトリ/backup.gz
 rm /home/ユーザ/インスタンスのディレクトリ/backup.gz
bash

実際のところ、このスクリプト自体にさほど問題はない。万全を期すならメールかSlackボットで通知させるなど他にやりようはあったとはいえ、今回の原因はもっと単純な仕様の読み落としによるものだった。スクリプトの後半部分、wranglerでCloudflare R2にファイルをアップロードする公式的なやり方が、意外にもバックアップ全消しの引き金を司っていた。

日々、流れ込む投稿やユーザ情報を蓄えるデータベースは肥大化の定めから逃れられない。インスタンス設立当初には10MBにも満たなかったバックアップはすぐに100MBを越え、現在では300MBをも上回る。 しかし、wranglerがサポートする最大アップロード容量は315MBまでだったのだ。 データベースのサイズが315MBを越えたある日以降――それがいつなのかはもはや知る由もないが――毎日行われる予定のアップロード処理は途中で止まっていたことになる。

一方、バックアップを預かるCloudflare R2のバケット側では、一週間以上古いファイルを自動で削除する設定が施してあった。むやみにストレージをあふれさせるわけにはいかないのでこれは特段におかしい措置ではない。ところが、新しいバックアップが来ない状態で定期削除が実行され続けると、1日経過するたびに既存のファイルが古い方から消されていき最終的にすべてのバックアップが消滅する。結果、記事冒頭の画像の通り、まさにもぬけの殻と相成った。

また、自動化スクリプトでは最後にローカル側のバックアップを削除している。必要なファイルはクラウド上にアップロードされているはずなので、ローカルに残していても意味はない。元よりサーバの生存をあてにしない前提のスクリプトゆえこれ自体も悪手とまでは言えない。ストレージ容量の節約を図る狙いもあった。

以上のように処理の一つ一つには必ずしも大きな落ち度はなかったのに、想定外にもたらされた制限がドミ倒しのごとく連鎖してバックアップを全部失ってしまった。なお、ずいぶんすっとぼけた前振りを延々としてきたが、インシデント発覚時点で僕のMastodonインスタンスは全然普通に動いていたので現実の障害には至っていない。

さしあたり、容量制限によって機能を果たせないwranglerに代わりrcloneを採用してスクリプトの内容を刷新した。加えて、ローカル側にも古いファイルを一定期間置く形に改めた。こうすればローカルとオブジェクトストレージの両方に一応バックアップが残るだろう。

#!/bin/bash

echo "Backup begin..."
cd /home/ユーザ/インスタンスのディレクトリ/
docker exec mastodon-db-1 pg_dump -Fc -U mastodon_user mastodon_db | gzip -c >> "/home/ユーザ/backup/$(date +\%Y\%m\%d_\%H-\%M-\%S).gz"
echo "Success!"

echo "Syncing..."
find /home/ユーザ/backup/ -mtime +7 -name "*.gz" | xargs rm -f
rclone sync copy /home/ユーザ/backup/ r2:mastodon-backup

echo "Mastodone."

■11月13日追記
初稿公開後、FediverseのFFからエラー制御ぐらいは組み込んだ方がよいのではとの指摘を受け、実際にスクリプトも頂戴したのでありがたく活用させて頂くことにした。以下に実例を示す。

#!/bin/env bash
set -euo pipefail

# Configurable variables
INSTANCE_DIR="/home/ユーザ/インスタンスのディレクトリ"
BACKUP_DIR="/home/ユーザ/backup"
BACKUP_LIFETIME_DAYS=7
DATE_FORMAT="%Y%m%d_%H-%M-%S"
DB_CONTAINER="mastodon-db-1"
DB_USER="mastodon_user"
DB_NAME="mastodon_db"
RCLONE_DESTINATION="r2:mastodon-backup"

# Error handling
trap 'echo "😢 An error occurred. Exiting." && exit 1' ERR

# Start backup
echo "🚀 Backup ready..."
cd "$INSTANCE_DIR"

# Database backup
BACKUP_FILE="$BACKUP_DIR/$(date +$DATE_FORMAT).gz"
docker exec $DB_CONTAINER pg_dump -Fc -U $DB_USER $DB_NAME | gzip -c >"$BACKUP_FILE"
echo "✅ Success!"

# Sync backup
echo "🔄 Syncing..."
find "$BACKUP_DIR" -mtime +$BACKUP_LIFETIME_DAYS -name "*.gz" -exec rm -f {} \;
rclone copy "$BACKUP_DIR" $RCLONE_DESTINATION

echo "👍 Mastodone."

僕の例はとりあえず目的に適う最小限のものだったが、このスクリプトは予め変数を代入させることで編集性を高めている。さらに、set -euo pipefailコマンドで安全性にも配慮している。-oはシェルオプションの有効化、-eはコマンドがエラーを起こした際の強制終了、-uは変数の未入力をエラーとして扱う効果をそれぞれ持つ。

最後にpipefailがパイプラインの左側のエラーを検知してくれる。一見、-eオプションがあればpipefailは不要と思われるが、実はパイプラインに限っては右側のエラーしか認識しないため確実な制御には一連の手続きが必須である。これらのオプションによりエラーが検出されると、trapコマンドがエラーシグナル終了コード1を拾って専用のメッセージが発動する仕組みだ。

いや、しかしさすがに丁寧すぎやしないか まるで教本のサンプルコードを彷彿させる。でもこういう感じのを5分くらいでさっと書けるようになったら楽しいだろうな。

おまけ

そもそもCloudflare R2上のバックアップファイルを定期的に消していたのは一定以上のデータ格納量を上回ると課金されるからだが、今回の件とは無関係にMastodonインスタンスのメディアファイルをずっと預けっぱなしにしていたのでどのみち金を払う羽目になった。それにしてもおぞましい金額だ。メルカリで使い古しの参考書を売って100円ほど銭を稼がなければならない。