[個人開発] Plaud脳内WikiをGCPからMac miniに移した。サーバーレスをやめた理由とGCP撤去までの記録

Kenji
Kenji

Engineer / 合同会社irori

公開日
Plaud
Mac mini
MCP
Claude

こんにちは。

前回に引き続き、Plaudの議事録から「自分の脳内Wiki」を作ったプロジェクトの話です。3本目になります。

2本目の時点ではGCPの上でCloud Run + Firestore + Vertex AIという構成でした。今回はそれを全部やめて、Mac mini 1台に移した話です。

早速主題

自宅に届いたMac miniに、同期・インデックス・Wiki生成・MCPサーバー・Web UIを全部載せ替えました。9月23日に計画を立てて、27日の夜にはMac miniのMCPサーバーがインターネットから応答し、29日にはGCP側を撤去してbillingの紐付けも外しています。並行稼働は2週間の予定でしたが、2日で打ち切りました。この記事は撤去から2週間ほど経った時点で書いています。

役割 GCP Mac mini
実行基盤 Cloud Run Jobs / Services Node + launchd
スケジューラ Cloud Scheduler(毎朝5時) launchd StartCalendarInterval
構造化データ・ベクトル Firestore(KNN) SQLite(WAL、全件コサイン検索)
埋め込み Vertex AI gemini-embedding-001 ruri-v3-310m(ローカル、transformers.js)
Wiki生成のLLM Gemini on Vertex Claude(Mac mini上のClaude Codeログイン)
公開 Cloud Run URL / IAP Tailscale Funnel(MCP)/ Serve(Web UI)
監視 Cloud Monitoring → メール Slack通知
バックアップ GCSの耐久性に依存 持たない

なぜサーバーレスをやめたか

Mac miniを買った動機の半分はこのWikiのGCP費用で、もう半分は会社のGitHub Actionsの課金でした(CIの話は別記事に書きました)。どちらも、24時間起きているマシンが1台手元にあれば要らなくなる出費です。

2本目でランニングコストは「デイリー¥300〜500、初回の一括生成に¥4,000」と書きました。支配項はLLM費用です。Mac mini上のClaude Codeのログイン(サブスクリプション)でWiki生成ができれば、LLM費用がサブスクの中に収まります。埋め込みもローカルモデルにすればVertexの課金も消えます。常時起動の1台があるなら、Cloud Runのゼロスケールに払う意味は薄いと考えました。

ただ、そのために前提をいくつか捨てています。

議事録本文を東京リージョンに閉じる、をやめた

1本目の設計では「機密データはasia-northeast1で完結」を優先して、LLMにはGemini on Vertexを選んでいました。今回はWiki生成をClaudeに変えるので、抽出・マージのたびに議事録本文がAnthropicに送られます。自分の会社の議事録で自分が判断できる範囲なので受け入れましたが、誰にでも勧められる選択ではないです。

バックアップを持たない

生データはPlaud側に残っているので、Mac miniのディスクが飛んでも再同期すれば戻ります。チャンクもWikiも再生成できます(Wikiは利用上限の関係で数日かかります)。再生成できないのはタスクの完了状態と名寄せの別名表だけで、ここは失っても許容すると決めました。個人規模のツールなので、バックアップの仕組みを維持するほうが高くつくと思っています。

SLAを持たない

Mac miniが止まれば全部止まります。自宅の回線が落ちても止まります。代わりに、毎朝5時のパイプラインが完了したらSlackに通知が来るようにして、朝の通知が来なければ何かあったと気づける形にしました。監視はこれだけです。

中身

計画はPhase 0〜7に分けてIssueを1つずつ切って進めました。Phase 1〜6は9月26日〜27日の2日間、Phase 7の撤去は29日です。Claude Codeと一緒に進めているのでこの速度になります。

SQLite

Firestoreの代わりはSQLiteにしました。Node 22.13以降にはnode:sqliteが組み込まれていて追加依存がいらないこと、WALモードにすれば常駐2プロセス + 毎朝のジョブの同時アクセスに耐えることが理由です。

ベクトル検索は、埋め込みをFloat32ArrayのBLOBで保存して、プロセス内で全件コサイン類似度を計算しています。チャンク数は4,421件。この規模なら全件計算で足りるので、sqlite-vecのような拡張はまだ入れていません。

テーブルはFirestoreのドキュメントをそのままdata(JSON)列に持ち、絞り込みに使うフィールドだけを列に出しています。型にフィールドが増えてもスキーマ変更がいらず、移行もドキュメントをそのまま入れればいい、という手抜きです。

ローカル埋め込みはruri-v3-310m

ここは測って決めました。本番(Cloud Run版)で代表クエリ20件の結果を記録しておいて、ローカルモデルの結果と比べています。

モデル 本番1位がローカル上位5に入る Wiki 上位5一致 1件あたり(M1 Pro)
ruri-v3-310m fp32 17/20 0.62 372ms
ruri-v3-310m q8 14/20 0.54 150ms
ruri-v3-130m fp32 11/20 0.54 135ms
multilingual-e5-base 12/20 0.60 185ms

ruri-v3-310mのfp32が頭一つ抜けていたので採用しました。ずれた箇所を目で見ても、ローカル側の上位も同じ話題の別会議で、困る感じではありませんでした。768次元なのでベクトルの次元数を変えずに済んだのも助かりました。

埋め込みモデルは混在させられないので、移行後に全チャンク4,421件と全Wikiページ1,514件を作り直しています。実機で約35分でした。

Wiki生成はClaude Agent SDK

LLMクライアントはClaude Agent SDK経由で、Mac mini上のClaude Codeログインを使います。APIキーは持ちません。子プロセスの環境からANTHROPIC_API_KEYを消して、サブスクリプションだけで動かしています。

ツールは全部無効、設定ファイルも読まない、空の一時ディレクトリで実行、という縛った設定にしています。リポジトリのCLAUDE.mdを読んでコーディングモードに入られると困るからです。

Gemini版と同じ録音でWikiを作って比べたところ、Claudeのほうが同等以上でした。話者ラベル(「Speaker 1」)の人物ページが出ない、人名の綴りが正確、という差があります。一方でアクションアイテムの抽出はClaudeのほうが粗く(同じ会議で5対12)、ここはプロンプト調整を先送りしました。

余談:比較中、Gemini側はVertexの429(RESOURCE_EXHAUSTED)で5回リトライしても止まりました。Claude側はエラーなしで完走。環境依存の話なので一般化はしません。

Plaudトークンの落とし穴

移行で一番のリスクはPlaudのリフレッシュトークンでした。ローテーションされる仕組みなので、同じトークン系列を2か所で使うと片方が失効します。GCP側とMac mini側を並行稼働させるには、Mac mini側で別途plaud loginして独立した系列を持たせるしかありません。ただ「2回目のログインで既存の系列が死なないか」は試すまで分かりませんでした。

結果は共存OKでした。ただ検証で1つハマっています。アクセストークンのTTLは24時間で、直近の同期から24時間経っていない状態で手動実行してもリフレッシュ経路を通りません。「手動実行が成功した」は何の証明にもなっておらず、翌朝5時の定期実行を待つしかありませんでした。

Mac miniならではのハマり

クラウドでは考えなくてよかったことが、自宅の1台だと全部自分に返ってきます。

FileVaultは切った

停電から復帰したときに自動で起動してほしいのでpmset autorestart 1にしていますが、FileVaultが有効だと再起動後にプリブート認証画面で止まり、sshdもTailscaleも上がりません。毎朝5時のパイプラインを無人で回すのがこのマシンの役目なので、FileVaultは無効にしました。Apple siliconはFileVault無効でもSSDがSecure Enclaveの鍵で暗号化されている(diskutil apfs listでEncrypted at restと出る)ので、基板から抜いて読まれる心配はしていません。

macOSの自動アップデートを止めた

素のmacOSでは更新後の再起動時刻を指定できません。無人運用で「いつ再起動するか分からない」のは、毎朝5時のパイプラインと重なる可能性そのものです。実際、Mac miniが届く前日に27.0が勝手に入っていました。本体の自動インストールだけオフにして、セキュリティ更新は自動のまま残しています。

runnerとパイプラインを別ユーザーにした

このMac miniは会社のCI(self-hosted runner)も動かしています。runnerはOrganization内のどのリポジトリのジョブからでも使えるので、runnerのユーザーから議事録データに到達できないようにする必要があります。runnerはci、パイプラインはadachi、DATA_DIRは700、トークンは600にしました。

この分離のせいで自動デプロイが素直に書けなくなりました。runner(ci)はadachiのサービスを再起動できませんし、sudoも渡したくない。落とし所は「runnerはビルドとテストを通したコミットSHAを受け渡しディレクトリに書くだけ」にして、adachi側のLaunchDaemonがWatchPathsで検知し、自分でfetchしてビルドして再起動する形です。SHAしか渡さないので、adachiが実行するのはmainにマージ済みのコードに限られます。ビルドが2回走りますが、Mac miniなら数十秒です。

実測では、mainへのマージから両サービスの再起動と/healthz確認まで約20秒でした。

Claudeの認証情報にLaunchDaemonから届かない

実装中に見つけた抜けです。Claude Codeは認証情報をadachiのlogin keychainに置きます。login keychainはGUIログインで解錠されるので、自動ログインするユーザーがciである以上、再起動後はロックされたままでLaunchDaemonからClaudeを呼べないだろう、と考えていました。なので最初は「読めなければWiki生成だけ飛ばして、Slackに『adachiでログインしてください』と出す」縮退で逃げています。

9月28日の朝5時、初回の定期実行でサマリは届きました。

⚠️ パイプライン完了(警告あり)
• 同期: 0件
• Wiki生成をスキップ: Claude の認証情報(adachi の login keychain)が読めません。
  Mac mini に adachi でログインすると解錠されます。

ただ、このときadachiは前夜からGUIログイン済みで、keychainもno-timeoutでした。解錠されているのに読めない。実機で検証すると、systemドメインのLaunchDaemonが見るkeychainの検索リストは/Library/Keychains/System.keychainだけで、ユーザーのlogin keychainは解錠されていようが入っていませんでした。「自動ログインをadachiに変える」案でも解決しないわけです。専用keychainをdaemon側でunlock-keychainする案も、Claude Codeはkeychainを指定せず検索リストから探すので見つけられません。

結局、claude setup-tokenで発行したCLAUDE_CODE_OAUTH_TOKENを.env(600)に置いて、keychainに依存しない形にしました。CIでClaudeを動かすときと同じ仕組みで、サブスクリプション内なので追加費用もありません。plistではなく.envに置くのは、plistは644でciからも読めるからです。

同じ理由でgitのfetchもosxkeychainが使えず、PATのファイル認証にしています。

Funnelの検証はMac miniからやってはいけない

公開経路の確認で、Mac miniから公開FQDNをcurlしても意味がありませんでした。MagicDNSでtailnet内の経路に解決されてFunnelを通らないからです。dig @1.1.1.1でインターネット側の名前解決をしてcurl --resolveで叩く必要があります。

GCPの撤去

並行稼働は2週間の予定でしたが、2日でやめました。Firestoreの最終差分を見たら全コレクションmissing=0で、GCSのwiki/も9月25日から更新がない。GCP側はデータ移行以降、新しいものを何も作っていなかったので、待っても得るものがありませんでした。

撤去はterraform destroyを使えませんでした。applyは開発機でやっていてMac miniにはstateもterraformも無く、infra/ごと廃止するのでstateを持ち込む意味もない。gcloudで1リソースずつ消しています。順番は、Cloud Schedulerをpause(可逆)→ Cloud Run Jobs → Monitoring → サービスアカウント → WIF → Cloud Run Service(web-ui、mcp-server)→ Secret Manager(plaud-tokensは75バージョン溜まっていました)→ Artifact Registry(825MB)→ Firestore → GCS → billingの紐付け解除。プロジェクト自体は、Claude.aiコネクタ用のGoogle OAuthクライアントが載っているので残しています。消すと接続手段が無くなるので。

コネクタがまだCloud Runを向いていた

撤去に着手した時点で、Claude.aiのコネクタはまだCloud RunのURLを向いていました。MCPツールを呼ぶとCloud Run側にリクエストログが出て、Mac miniのmcp-server.logは増えない。返ってくる内容もFirestoreのGemini版。Cloud Runのplaud-mcp-serverを先に消していたら、Claude.aiからもClaude Codeからも議事録が引けなくなっていました。

切り替えの確認は、Mac mini側にしか無いWikiページを引けるかどうかで見ました。撤去の前に「クライアントが実際にどこを向いているか」を確かめる、というのは当たり前なんですが、設定画面を見ただけで切り替わった気になっていました。

rsyncでWiki本文325件を巻き戻した

もう1つ、撤去の最終差分の取り込みでやらかしました。移行スクリプト(gcloud storage rsync)をそのまま実行したら、Mac mini側でClaudeが再生成済みだったWiki本文325件が、GCS上の古いGemini版で上書きされました。

rsyncは「両方にあって内容が違うファイル」を移行元で上書きします。計画書には「ローカルにしか無いファイルは消さない」としか書いておらず、移行先が独自に更新しているケースを考えていませんでした。片方向の初回移行しか想定していないスクリプトを、両方が動いている状態で使い回したのが間違いです。DRY_RUNを持っていたのはFirestore側のスクリプトだけで、GCS側には無かった、というのも効いています。

幸い、事実(wiki_facts、12,524件)とページのsummaryはSQLite側に無傷で残っていて、巻き戻ったのは本文だけでした。本文はDBに持たないLLMの生成物なので、現役の事実から作り直すしかありません。npm run wiki:regenというコマンドを足して、updated_atが本文のmtimeより新しいページを検出して書き直せるようにしました。検出は335件。1ページあたり入力が9万トークン超×Opusなので、PRの時点では1件だけ試して、残りは小分けに回す予定にしています。

2日間、黙って止まっていた

撤去の翌朝、5時のパイプラインが動かず、Slackにも何も出ませんでした。気づいたのは2日後です。

原因は、libexec/notify.shというファイルが無くて、run-pipeline.shとdeploy.shがsourceの失敗で起動直後に死んでいたこと。失敗通知そのものがnotify.shを使うので、Slackには成功も失敗も出ません。デプロイも同じ理由で死ぬので、自動デプロイでは直らない。

なぜ無かったかというと、notify.shはkeychain対応のPRで新規追加したファイルで、そのデプロイをしたのは1世代前のdeploy.shでした。旧版はコピー対象をdeploy.sh run-pipeline.sh lock.shの3つで列挙していて、notify.shを知らない。libexecの更新は常に1世代前のスクリプトが行うので、新しいファイルを追加するコミットは、そのファイルを自分ではコピーできません。

「毎朝のサマリが来なければ気づける」という監視の設計は、来ないことに2日気づかなかった時点で破綻しています。ディレクトリ同期に変える、sourceの前に自己ブートストラップする、sync_stateの最終実行時刻が古いことを別で検知する、あたりが宿題で、Issueは開いたままです。

いまの状態

10月13日時点でこうなっています。

  • 9月30日以降、毎朝のサマリは全部✅。同期が10件前後ある日で60〜70分、2件の日で13分
  • GCS/Firestoreの本番データ(録音359件、Wikiページ1,514件、事実9,659件、タスク1,403件)は件数照合で不足0のまま移行済み。録音は365件まで増えた
  • 再起動から18秒でmcp-serverとweb-uiが上がる。mainマージから自動デプロイまで約20秒
  • MCPサーバーはTailscale Funnel経由で、Claude.ai・Claude Code・モバイルから同じURLを見ている
  • GCPは撤去済み。残っているのはOAuthクライアントだけ

コストは、GCP側はbillingを外したので止まっています。Claude側はサブスクリプションの中で、利用上限に当たった記録はまだありません。実数の比較はこれから出します。

余談:撤去の副作用が1つ。gcloudのquota projectが消したプロジェクトを指したままで、別プロジェクトのterraformがbilling account disabledで落ちました。gcloud auth application-default set-quota-projectで直ります。

まとめ

  • 常時起動の1台が手元にあると、サーバーレスの前提が変わる。ゼロスケールより、サブスク内でLLMを回せるほうが効いた
  • 代わりに引き受けたのは、可用性(SLAなし)、バックアップ(持たない)、データの送信先(Anthropicに送る)。個人規模だからできる選択
  • SQLite(node:sqlite、WAL)、ruri-v3-310m(本番一致17/20)、Claude Agent SDK(ツール全無効)、認証はCLAUDE_CODE_OAUTH_TOKEN
  • FileVault、自動アップデート、ユーザー分離、LaunchDaemonから見えないlogin keychain、Funnelの検証方法。クラウドでは考えなくてよかったことが全部返ってくる
  • 撤去でやらかしたのは、コネクタの向き先を確かめずに消しかけたことと、双方向に動いている状態で片方向のrsyncを回したこと
  • 「通知が来なければ気づける」は、来ないことに気づかなければ成立しない

コストの実数が出たら、また続きを書きます。

この記事を書いた人

Kenji
Kenji(Engineer)

合同会社iroriのエンジニア。Rails / Next.js / AWS を中心とした受託開発と、生成AIを使った開発フローの整備を担当しています。このブログでは、実際に自分の環境で試した手順や、詰まったポイントと解決までの記録を書いています。

Related Posts

[個人開発] Plaudの議事録から「自分の脳内Wiki」を作って、MCPでClaudeに繋いだ話

[個人開発] Plaudの議事録から「自分の脳内Wiki」を作って、MCPでClaudeに繋いだ話

[個人開発] Plaud脳内Wikiを実運用に乗せてみた — Web UIを足して、名寄せと格闘中

[個人開発] Plaud脳内Wikiを実運用に乗せてみた — Web UIを足して、名寄せと格闘中

[GitHub Actions] 会社のCIをMac miniのself-hosted runnerに移した

[GitHub Actions] 会社のCIをMac miniのself-hosted runnerに移した