Tech Blog

AWS S3 + restic で相棒の脳を暗号化オフサイトバックアップ — 記憶を"平文のままクラウドに上げず"に遠隔退避する

RAG ChromaDB Operations Backup restic AWS S3

はじめに

日々の作業ログや判断の記録を放り込むと、意味の近いものを検索で引き出してくれる、外付けの長期記憶のような仕組みを個人で運用している。RAG(過去の記録をベクトル検索で呼び出す仕組み)と呼ばれるもので、設計の思想と、その運用で二度データを壊した事故については別記事に書いた。前提はそちらへ譲る。

AI に5回訂正された夜 — 個人 RAG を5年付き合える相棒にする設計思想

「バックアップを取る」と決めただけで実行せず、記憶用のベクトルDBを二度壊した話

このバックアップ記事の最後で、私は「遠隔の暗号化バックアップを設計する」とだけ書いて終えていた。この記事は、その設計を実際に組んで、動かして、復元まで確かめた続きの記録だ。使ったのは AWS S3(Amazon のオブジェクト保管サービス)と restic(バックアップ専用のツール)の二つだけ。要点を先に置く。

  • 平文(暗号化していない生のデータ)はクラウドに上げない、という原則を崩さずにオフサイト化する
  • 単発のバックアップにせず、既に動いている日次バッチに組み込んで無人で回す
  • 「置いた」で終わらせず、別の場所へ実際に戻せることを確かめて初めて完了とする

なぜローカル退避だけでは、まだ足りないのか

この記憶は、過去に二度壊している。一度目も二度目も、ファイルを直接開く形式のDBを複数プロセスから触ったことが原因だった。その顛末は別記事にある。

「ファイルを直接開くDB」を複数プロセスで触って、二度壊した — 書き手を一つに絞って根治するまで

壊すたびに手元のバックアップから戻してきた。だが、手元にしか無いという事実がずっと引っかかっていた。ローカルの退避は「操作ミスで壊した」からは守ってくれるが、PCそのものの故障・盗難・災害では、退避もろとも消える。外部化した自分の記憶が、一台のマシンの寿命に縛られている。これがローカル退避だけの限界だった。

だから遠隔に置きたい。ところがここに、素直には両立しない二つの要求がぶつかる。

「平文を上げない」と「遠隔に置く」を、どう両立させるか

記録の中身は個人的な会話ログや判断の履歴だ。そのままクラウドに上げたくはない。一方で、災害に耐えるには手元と別の場所=オフサイトに置くしかない。「上げたくない」と「上げなければ守れない」は、一見矛盾する。

解いたのは、restic が既定で持つクライアント側暗号化だった。クライアント側暗号化とは「送る前に、手元で暗号化してしまう」こと。restic は AES-256(現在広く使われる強度の暗号)でデータを暗号化した塊に変えてから S3 へ送る。復号鍵は手元と、本人がオフラインで持つ控えにしか無い。

  data/(平文 = 会話ログ・教訓の一次ソース)


   ┌────────────────────┐
   │ restic が手元で暗号化 │  AES-256/鍵は手元のみ
   └─────────┬──────────┘
             │  暗号化された塊(中身はもう読めない)
        ┌────┴───────────────┐
        ▼                    ▼
   ローカルの控え          AWS S3 へ送信
        │                    │
        │                    ▼
        │            鍵を持たない者(S3事業者を含む)
        └──────────────▶ には、中身は永久に読めない

S3 に置かれるのは暗号化済みの塊だけなので、「平文を上げない」原則は崩れない。クラウド事業者にすら中身は見えない。矛盾に見えたものは、暗号化の位置を「送る前」に決めることで消えた。

なお方式として restic を選んだのは、Windows でそのまま動く単一の実行ファイルで、増分(前回との差分だけを送る)と暗号化が標準で入っているからだ。似た定番ツールに borg があるが、Windows 対応が弱く見送った。

一つの手段に賭けない — 三層で持つ

オフサイトを一つ足すだけでなく、「どれか一つが死んでも記憶は残る」構成にした。保全の対象は data/(記録の一次ソースであるMarkdownとDBの実体、およそ16MB)に絞る。ベクトル検索用のインデックスは、この一次ソースから作り直せる派生物なので、遠隔まで運ぶ必要はない。

対象置き場所性格
ベクトルDBの実体ローカル(世代を残す)破壊的操作の直前スナップ
data/ 一次ソースローカルの restic 控えAWS不要・必ず走る
data/ 一次ソースS3 の restic(暗号化)真のオフサイト

data/ は ②+③+(コードと同じく git の履歴)で三重に持つことになる。②③はそれぞれ古い世代を約30回分だけ残して自動的に間引く。S3 という一つのサービスに全てを賭けない——これは意図した設計だ。オフサイトを持つと安心して手元を軽視しがちだが、逆にした。手元(②)は AWS の状態に関係なく必ず走り、S3(③)はそこに載せる保険という位置づけにした。

副産物 ― 既にある日次バッチに、そのまま乗せた

このバックアップは単独のスクリプトにしなかった。もともと毎日 04:00 に走っている日次バッチ(ノートを教訓へ昇格させる distill という処理。詳細は別記事)に、後段として組み込んだ。

個人ノートを LLM (Ollama) で5カテゴリに自動分類する — distill パイプラインの設計と落とし穴

ここで、読む人が最も混同しやすい一点をはっきりさせておきたい。このバッチは GitHub 上では動かない。自分のPCの上で動く。 コードは GitHub に置いてあるが、GitHub がしてくれるのは「push した data/ を保管する」ことだけで、04:00 に計算を回してくれるわけではない。定時に暗号化してS3へ送る主体は、あくまで手元の Windows タスクスケジューラだ。

                     ┌── 04:00 を逃した ──┐   ← PCがオフ/スリープだった
                     │                   │
   Windowsタスク ────┴─ 次回ログオンで取り返す ┘   (catch-up)
   (毎日 04:00・ローカルPC)


   (1) ベクトルDBを退避 ──失敗──▶ 以降を中止(土台が無いなら進めない)
        │ 成功

   (2) distill(ノート→教訓の昇格)

        ├── 失敗 ───────────────┐  ← 非致命:バッチ全体は落とさない
        ▼                       │
   (3) data/ を多層バックアップ ◀─┘
        ├─▶ ② ローカル restic(AWS不要・必ず走る)
        └─▶ ③ S3 restic ─┬─ 認証プロファイル有 ─▶ 送信
        │                └─ 無 / 失効 ────────▶ 飛ばさず「スキップ」を記録

   (4) 誤った教訓の撤回処理(対の記事へ)

「逃したら次回ログオンで取り返す(catch-up)」の戻りエッジと、S3 送信の中の枝分かれが、この設計の勘所だ。定時に必ず起こす、という作りにはしなかった。理由は次の二つの節に分けて書く。

無人で回すために ― なぜ SSO でなく、最小権限の長期キーにしたか

S3 へ送るには、AWS への認証が要る。普段の手作業なら SSO(一度サインインすれば一定時間使える方式)で十分だ。しかしこのバッチは無人で 04:00 に走る。SSO のトークン(一時的な通行証)は時間で失効するので、無人で走る頃には切れていることがある。

厄介なのは、切れていたときの壊れ方だ。認証が切れて送信できなかったのに、バッチは何事もなく終わり、ログにもエラーが立たない——失敗が無言のまま成功に見える(サイレント・フォールス・ネガティブ)。バックアップにとってこれは最悪で、「取れているつもりで、実は何か月も取れていなかった」を生む。

そこで、このバケット(保管先の入れ物)だけを読み書きできる専用の利用者を AWS 側に一つ作り、失効しない長期キーを手元のプロファイルに置いた。権限は必要な操作だけに絞る(最小権限)。IAM(AWS の権限管理)のポリシーは、概略こういう形だ。

// このバケットに対する list / get / put / delete だけを許可(他は一切許さない)
{
  "Effect": "Allow",
  "Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
  "Resource": [
    "arn:aws:s3:::mint-chroma-backup-<アカウントID>",
    "arn:aws:s3:::mint-chroma-backup-<アカウントID>/*"
  ]
}

もしこのキーが漏れても、できるのはこのバックアップ用バケットの読み書きだけで、他のAWSリソースには一切手が届かない。「無音で失敗しない」ことと「漏れても被害を局所化する」ことを、同時に満たす選び方だ。認証プロファイルが未設定・失効なら、③は前掲の図のとおり無音で飛ばさずスキップとして記録し、②は止めない。

失敗を許容し、リカバリで取り返す ― 定時に起こさない選択

もう一つの設計判断が、タスクスケジューラの二つのスイッチだ。

  • 逃したら次回起動で取り返す(catch-up を有効)
  • 定時にPCを叩き起こさない(4時に自動起動はしない)

つまり「04:00 にPCが眠っていたら、その回は素直に諦める。ただし次にログオンしたとき取り返す」。夜中にマシンを起こしてまで定時実行に固執せず、失敗は許容するが、リカバリは必ずする方針にした。個人の運用で、毎晩4時に一台を起こし続ける実害(電力・寿命・騒音)は、日次バックアップの緊急度に見合わない、と判断した。スリープ時に実際どう振る舞うかは、眠らせて翌朝ログを見て確かめた。

repository does not exist は、複雑な仮説へ飛ぶ前に実体を見る

ここまで整った設計に聞こえるかもしれないが、S3 側を最初に組んだとき、restic が頑として通らなかった。返ってくるのは repository does not exist(バックアップ置き場が存在しない)の一点張り。

最初は認証の問題を疑い、次にエンドポイントの書き方(S3 のアドレス表記)を疑い、さらにファイルパスの扱いの違いまで疑った。仮説はどんどん複雑な方へ膨らんだ。手を止めて、いちばん素朴な確認に戻した——置き場そのものが、本当にそこに在るのか

# 複雑な仮説へ飛ぶ前に、まず「実体があるか」を一発で見る
aws s3 ls s3://mint-chroma-backup-<アカウントID>/ --recursive

空だった。認証もエンドポイントも問題ではなく、単にバックアップ置き場を作る初期化(restic init)が済んでいなかった——それだけだった。クリーンに初期化し直したら、あっさり通った。教訓は明快で、repository does not exist を見たら、認証やパス表記といった複雑な仮説へ飛ぶ前に、まず実体の存在を一度確認する。

もう一つ、この作業で決めたことがある。restic のようにローカルの鍵に依存する処理は、シェルを混ぜない。同じ手順の中で PowerShell と別のシェルを行き来すると、環境の食い違いで空のパスフレーズの置き場を作ってしまう事故を招きやすい。PowerShell に統一する、と手順に固定した。

手動で一度だけ流すときの、実際の呼び出しはこの形になる(鍵の値やアドレスの一部は伏せる)。

# 鍵は git 管理外のファイルから読む(リポジトリに平文を残さない)
$env:RESTIC_PASSWORD    = (Get-Content "<鍵ファイル>")[0]
$env:RESTIC_REPOSITORY  = "s3:s3.ap-northeast-1.amazonaws.com/mint-chroma-backup-<アカウントID>"
restic backup "<data ディレクトリ>" --tag chroma-s3
restic snapshots     # 取れたスナップショット一覧を確認

「置いた」で終わらせない ― 戻せて初めて完了

バックアップの完了を、私は「送った」では定義しない。別の空のディレクトリへ実際に戻し、ファイル数とバイト数を元と突き合わせて一致したことをもって完了とする。過去に「バックアップを取る」と決めただけで実体を作らず二度壊した反省が、この定義の背骨にある。

今回の復元テストでは、S3 から戻したものが 235ファイル・16,154,894バイト、元と1バイトの差もなく一致した。この数字自体に意味があるわけではない。意味があるのは「1バイトも欠けずに戻る」という事実で、それが「このバックアップは本物で、いざというとき本当に記憶を取り戻せる」ことの唯一の証拠になる。

実際の復元検証の出力はこうだ(バケット名・repo ID は伏せている)。「送った」ではなく「戻ってきた」ことを、この行で確かめる。

$ restic snapshots
ID        Time                 Tags        Paths
--------------------------------------------------
xxxxxxxx  2026-07-28 04:01:58  chroma-s3   .../data
$ restic restore latest --target <empty-dir>
restored 235 files / 16,154,894 bytes

完了の定義を並べておく。

  1. 置き場を初期化する(restic init)
  2. 鍵が通ることを確かめる(暗号化設定を復号して読めるか)
  3. バックアップを送る
  4. 別の場所へ実際に戻し、ファイル数とバイト数を照合する
  5. 予算アラートを仕掛ける(次節)

宣言ではなく、この4番の実測ログだけを完了の証拠にする。

ほぼ無料で持てる ― ただし固定費の罠は先に潰す

気になる費用は、月 $0.01 未満——事実上ゼロだ。restic の増分と重複排除(同じ中身を二度持たない仕組み)が効いて、S3 に載る暗号化データは圧縮後わずか数MBに収まる。オブジェクト保管は「置いたGB分+アクセス回数」の従量課金で、常時起動しているサーバのような固定費が無い。

ただしAWSでは、この「固定費」でこそ痛い目を見てきた。常時起動の要素をうっかり残すと、使っていなくても課金が続く。その具体は別記事にある。

ECS Fargate の固定費(ALB + NAT Gateway)に気づいて構成を見直した話

AWS CDK + PowerShell + SSO で誤リージョンにデプロイした話と後始末

だから今回は、安いと分かっていても安全弁を先に付けた。月$1のうち8割(=約$0.80)に達したらメールで警告が来るよう、予算アラートを設定してある。$0.01未満の見込みからすれば、届いた時点で桁が違う。金額そのものより、「想定と桁が違う請求が静かに育つ」事態を、上限に触れる前に捕まえるための仕掛けだ。

一台のマシンの寿命から、記憶を切り離せた

これで、外部化した自分の記憶は、手元のPC一台の生死に縛られなくなった。操作ミスで壊してもローカルの控えから戻せるし、そのPCごと失っても、暗号化された塊が遠隔に残る。しかもその塊は、鍵を持たない誰にも読めない。「平文を上げない」も「災害に耐える」も、どちらも諦めずに済んだ。

残したのは「壊れない」側の備えだ。もう一つ、記憶には「汚れない」側の備えが要る——正しく取れていても、中身に誤った教訓が混じっていれば、相棒は毎回それを思い出して判断を誤る。その汚れをどう自動で取り消すかは、対になる次の記事に書いた。

RAGに溜めた誤った教訓を、あとから撤回する仕組みを作った — 相棒を毎ターン歪める汚染を自動で消す

情報量に飲まれない学習設計 — RAG/LLMチューニングを”深く3つ”に絞り、測るまでを1単位にする

そして、これらを健全なまま保ち続けるための毎週の点検の仕組みは、この記事に書いた。

個人RAGの健全性を、意志でなく仕組みで毎週点検する — 多角ヘルスチェックと、その実行を強制する時限ゲート

気軽にメッセージください

仕事の依頼、案件紹介、ご感想・ご質問なんでもお待ちしております。 高い志をもった同士の皆様と繋がることを切に願っております。 これからも人生を掛けたチャレンジを続けていきます。 何卒よろしくお願い致します。