メインコンテンツにスキップ
  1. ホーム
  2. ガイド
  3. 自宅サーバーが5.5日止まったので海外VPSへ移った

自宅サーバーが5.5日止まったので海外VPSへ移った

自宅のWindowsノートPCが原因不明のまま5.5日間フリーズし、写真・パスワード・ファイルのサーバーが全部止まった。壊れた機械から転送するのではなく、毎晩取っていたクラウドバックアップからVPS側へ引っ張って復元する方式で、26分の停止で移行した記録。公開IPになって初めて露出した穴とその塞ぎ方も書いた。

公開日:
2026-10-05

写真・パスワード・ファイルの置き場を全部自宅のPC1台に集めていた。そのPCが5.5日間フリーズしたまま動かなくなり、全部止まった。家を離れていた期間だったので、帰るまで何もできなかった。原因は結局特定できていない。

その障害の記録と、そこから海外VPS(レンタルサーバー)へ移った記録。壊れた機械からデータを転送するのではなく、毎晩取っていたクラウドバックアップからVPS側へ引っ張って復元したのが一番再利用できる部分だと思う。実際の停止時間は26分だった。そして移行後、自宅のルーターの内側にいたから気づかずに済んでいた穴が一気に表に出た。

前提

  • 自宅のWindows機〈ノート型〉(WSL〈Windowsの中でLinuxを動かす仕組み〉上でサーバーを動かしていた)で、Immich(写真)・Nextcloud(ファイル)・Vaultwarden(パスワード)・Authentik(ログインの入口)など常時14コンテナを動かしている状態
  • 外部公開はCloudflare Tunnel(ルーターに穴を開けずにサーバーを公開する仕組み)経由で、ルーターのポート開放は一切していない
  • 毎晩rclone(いろいろなクラウドストレージへファイルを同期するコマンド)でBackblaze B2(安価なオブジェクトストレージ)へ日次バックアップを取っている
  • 構築の土台は自分の「サーバー」を持つとはどういうことか、バックアップの設計はバックアップを「消されても戻せる」形にする

何が死んだのか

最初の症状は、自分のドメインを開くとCloudflare Error 1033(トンネルの出口であるサーバー側にCloudflareが接続できない時のエラー)が出たこと。Cloudflareの障害情報ページには何も出ていない。別経路のリモートデスクトップで見ると「最後のオンライン 12:07」でオフライン表示。トンネルが切れたのではなく、サーバーの入っているPC自体が落ちている。

遠隔からできることは何もなかった。SSHも入れない。Cloudflare Tunnelは「サーバーが生きていれば外から入れる」仕組みなので、サーバーが死ぬと一緒に死ぬ。

帰ってから見たPCの状態がこれ。電源は入っていて画面も出ているのに、マウスカーソルが一切動かない。 キーボードも効かない。ブルースクリーンも出ていない。電源ボタンの長押しで強制終了して再起動したら、何事もなかったように復旧した。

後日イベントログを調べて停止時間が確定した。

EventLog ID 6008
"The previous system shutdown at 11:11:13 AM on 9/21/2026 was unexpected."

このログが記録されたのは9/26 23:45(自分が強制再起動した時刻)。つまり9/21 11:11:13にハングして、そこから5日半固まったままだった。

  • 9/21〜9/26の間、Systemログにイベントが1件も記録されていない。完全な空白。瞬断の繰り返しではなく、OSが本当に何も処理できない「サイレントハング」だった裏付けになる。
  • ブルースクリーンのダンプファイルが無い(残っていたのは1年半前の古いものだけ)。ディスプレイドライバのクラッシュログも0件。写真の顔認識にGPU(画像処理用の高性能な部品)を使っていたのでGPUドライバのハングが一番疑わしい候補だったが、ログが書かれる前に固まった可能性もあり確定できなかった。

根本原因は今も分かっていない。 BSODもエラーログも残さず固まる種類の故障は、原因究明が原理的に難しい。

🔴 ここで一度、原因を誤診した。 強制再起動後に別のソフトの認証セッションが切れていたので、最初はそれが5.5日の原因だと分析した。実際は「PCが固まっていた」が唯一の原因で、セッション切れは再起動後に浮上した二次的な問題だった。復旧直後に見つかった不具合は、障害の原因ではなく結果であることがある。 時系列のログで裏を取るまで原因を名指ししない。

なぜVPSに移ることにしたか

最初に考えたのは、もっと安い対策ばかりだった。ウォッチドッグタイマー(固まったら自動でリセットする機能)、GPUドライバの更新、旅行時はSSHできる端末を必ず携帯する運用ルール。

🔴 「スマートプラグで遠隔から電源を入れ直す」案は、そもそも使えなかった。 サーバーにしていたのはノート型のWindows機で、内蔵バッテリーがあるのでコンセントを切っても電源は落ちない。ノートPCを常時稼働のサーバーに流用するのは「静かで省電力」という利点があって自分もそれで選んだのだが、固まったときに遠隔で殴る手段を一つ失うという代償がついてくる。これは置き場所を決める段階で知っておきたかった。「WindowsをやめてこのPCを素のLinux専用機にする」案も検討したが否定的な結論になった。マウスも効かないOS全体の停止なのでLinux側だけの不調ではなく、原因がGPUドライバなら素のLinuxでも再発しうるので、OS再インストール+全サービス移行という大工事に見合う保証がない。

それでも移行を決めた理由は、数日後に同じサイレントフリーズが再発したこと。原因が分からない以上「次はいつ、どれだけ止まるか」が見積もれない。データセンターの機械なら、少なくとも電源とネットワークと物理的な復旧は他人の仕事になる。

VPSの比較で実際に効いた軸

2026年9月時点の調査値。今は変わっているはず。

候補スペックと価格の感覚
Contabo6vCPU / 12GB RAM / 200GB SSD が月$9前後。容量あたりで圧倒的に安い
Vultr2vCPU / 8GB で月$24〜48
DigitalOcean2vCPU / 4GB で月$24
Hetzner単価は安いが月間転送量の上限が低く、写真用途では超過料金のリスク
Oracle Cloud Free Tier2 OCPU / 12GB ARM が無料枠
国内VPS2コア / 2GB / SSD 200GB が月770円〜。ただし週1回程度の数秒〜数分の瞬断報告あり
AWS Lightsail以前使っていたが、この用途では割高だったので自宅運用に移った経緯がある

効いた軸は4つ。

① ディスク容量が最優先。 写真のライブラリが実測115GB、ファイル同期が16.6GB。OS・Docker・データベースを含めて170〜180GBと見積もり、200GBのプランにした。「写真が増えるたびに月額が雪だるま式に増えるのが嫌だ」という条件があったので、同じ値段でどれだけディスクが付いてくるかが一番差の出る軸になった。

② メモリは写真の量ではなくコンテナの数で決まる。 ここは一度自分で間違えかけた。「古い写真をクラウドへ逃がすならメモリも小さくていいのでは」と考えたが違う。常時14コンテナが動いていて、以前メモリ不足で全サービスが繰り返し落ちた実績がある。しかもVPSにはGPUが無いので、顔認識や画像検索の処理をCPUとメモリでやることになり、必要メモリはむしろ増える。8GBではなく12GBから始め、安定を確認してから下のプランへ落とす順序にした。

③ リージョン(どの国のデータセンターか)は在庫で決まった。 第一候補にしていたリージョンは月+$4.90の追加料金がつく上に、申し込み画面で在庫切れだった。追加料金ゼロで常に在庫があるリージョンに変えたら、結果としてバックアップ先のB2との経路が短くなり、復元も移行後の日次バックアップも速くなるという副産物があった。「近いほうが良いに決まっている」と思い込んでいた軸が、実際には料金・在庫・バックアップ先との距離で裏返った。

④ 遅延は実測すると論点が変わる。 申し込み画面の「近いリージョン4ms、遠いリージョン158ms」は生のネットワーク遅延で、Cloudflare Tunnel経由の実構成には当てはまらない。自宅のPCで動かしていた時点ですでに写真サーバーの初期応答は73〜87msだった。DNSがCloudflareを指しているので、自宅のWiFiからでも一度エッジへ出て戻る経路になり、「目の前にあるから速い」状態には元々なっていなかった。移行後は0.55〜0.7秒(自宅PC時代は0.3秒前後)。比較のためにGoogle Photosのアプリ画面を測ると248〜255msで、「すでに受け入れている遅さ」と同程度だと分かったので許容できる論点に落ちた。

  • 🔴 サムネイルをCloudflare側にキャッシュさせれば速くなるが、それは自分の写真をCloudflareに置かせることになるのでやらない。速度のために主権を手放すなら移行の意味がない。

契約は6vCPU / 12GB RAM / 200GB SSD / 転送量無制限、Ubuntu 24.04、1ヶ月契約で月$9.00、初期費用ゼロ。年契約の割引は見送った。原因不明の障害から逃げるための移行で、移行先が気に入らなかった時にすぐ降りられないのは本末転倒だから。

移行方式: 壊れた機械から運ぶのをやめる

ここが今回の中心。

普通に考えると、移行とは旧サーバー → 新サーバーへデータを直接転送することだ。ランブックの最初の案もそうなっていた。150GBをrsync(差分だけコピーするコマンド)で送る、数時間〜半日かかる、という計画。

これを見て思ったのが、**「毎晩クラウドにバックアップを取っているのだから、新サーバーがそこからダウンロードすればいいのでは?」**だった。採用した。

これまでの案:  自宅PC ──(細い家庭回線)──> 新VPS
採用した案:    自宅PC ──> B2(クラウド) ──(データセンター間)──> 新VPS
  1. 自宅の細い上り回線に依存しない。VPS↔B2はデータセンター間なので桁違いに速く、実測で約30MB/sだった。
  2. 旧サーバーが移行作業中ずっと安定している必要がない。これが決定的だった。5.5日固まる機械を、半日の転送が終わるまで無事でいてくれと祈りながら使うのは設計として間違っている。
  3. 既にそこにあるので準備作業がゼロ。写真の本体もデータベースのダンプ(中身を1つのファイルに書き出したもの)も毎晩同期済み。
  4. ついでに災害復旧の予行演習になる。「クラウドのコピーしか残っていない状態から戻せるか」を本番で一度やることになるので、バックアップが本当に使えるのかが確定する。

鶏と卵の問題は先に解いてあった

前提がある。B2から引っ張るにはB2へのアクセス鍵が必要で、その鍵が旧サーバーの中にしか無ければ、サーバーを失った時点で何も取り出せない。

ここは1ヶ月前に手を打ってあった。B2アクセス用のrclone設定、Cloudflare Tunnelの設定、docker-compose.yml一式、環境変数ファイルの内容を、Vaultwarden(自分のパスワード管理サーバー)にSecure Note 4件として複製保存していた。 Vaultwardenのモバイルアプリはオフラインキャッシュを持つので、サーバーに一切アクセスできなくてもスマートフォンから取り出せる。後日このメモに書かれた値だけでB2上の全データが見えることを実測で確認した。🔴 この検証は鍵を入れ替えるたびに毎回やる。 「保存したつもり」で中身が古い、という事故が一番ありそうな場所だから。

ランブックの形

手順はPhase A〜Hに分けて1つの文書にした。実行中にその場で考える余地を削るのが目的で、特にDNSの切り替えは後戻りの判断が数秒で要る。

Phase A  旧サーバーを止めて最終バックアップ
Phase B  Vaultwardenから鍵4件を取り出す
Phase C  新VPSの初期設定(ユーザー・SSH鍵・Docker)
Phase D  B2から rclone copy でデータを引っ張る
Phase E  データベースのダンプをリストア
Phase F  docker-compose を復元してコンテナ起動
Phase G  新しいトンネルを作ってDNSを1本ずつ切替
Phase H  移行後(バックアップの再構築・監視・検証)

書き上げた最初の版には穴が4つあり、実行前のレビューで直した。

  • 稼働中のままコピーしたパスワードデータは壊れている可能性がある。 Vaultwardenのデータベースファイルは書き込み中にコピーすると不整合になりうる。当日はコンテナを止めてからバックアップを手動実行する手順に変えた。これで「日次バックアップから最大24時間分が欠ける」問題も同時に消える。
  • 🔴 イメージのバージョンを固定せずに復元すると壊れる。 データベースのダンプは、取得した時点のソフトのバージョンの構造に紐づいている。docker compose pullで最新版を取ると構造が合わなくなる。
  • 環境変数ファイルの復元元を間違って書いていた。 以前使っていたパスワード管理サービスは解約済みで、正しくはVaultwardenだった。手順書は、書いた時点では正しくても構成変更で腐る。
  • 🔴 同じトンネルを2台のホストで同時に動かしてはいけない。 リクエストが2台にランダムに振り分けられて、「たまに繋がらない」という一番デバッグしづらい障害になる。新しいトンネルを別に作って、DNSを1本ずつ向け替える形にした。

他に決めておいたこと。

  • rclone copyを使い、syncは使わない。syncは転送元に無いファイルを転送先から消す。過去にこれで必要なディレクトリを消して障害にした実績がある。
  • 切替順序: 一番軽いパスワードサーバー → ログインの入口(最重要。落ちると他のサービスにログインできなくなる) → ファイル → 写真 → 残り。旧サーバーへのSSH経路は一番最後。 先に向け替えると旧サーバーに入れなくなって確認作業ができない。
  • DNSのTTL(切替が反映されるまでの待ち時間)を事前に短くする手順は不要だった。 対象レコードは全部Cloudflareのプロキシを通る設定で、外に返るアドレスは切替前後で変わらない。振り分けはCloudflare内部で起きるので書き換えの数秒後に反映され、戻すのも数秒。TTLを下げる必要があるのは、プロキシを通さずサーバーのアドレスを直接返している場合だけ。

実際の停止時間は26分

15:19にサービスを止めて、15:45に全部復帰した。 写真150GBを運ぶ移行としては想定よりはるかに短い。クラウド経由にしたことで、一番重い写真の転送が「停止中にやる作業」から外れたのが効いている。

復元後の検証値は、写真59,104ファイル(rclone checkでB2との差分ゼロ)、写真サーバーの登録件数29,720件、ファイル同期サーバー144テーブル・16.6GB、ログイン入口のユーザー3件。

ランブックに書いていなかった落とし穴

  • 🔴 浮動タグが他にもあった。 バージョン固定は写真サーバーだけ警告していたが、ファイル同期・データベース・パスワード管理も:latest(常に最新を取るタグ)のままだった。旧サーバーで実際に動いているイメージのダイジェスト(内容に対する一意の識別子)を全部調べて固定した。docker inspectの.Imageはそのマシンの中だけのIDでダウンロードには使えないのでRepoDigestsを使う。
  • 🔴 写真サーバーが起動ループした。 Failed to read /data/encoded-video/.immich。サムネイルと変換済み動画のフォルダをバックアップ対象から外していたため、そのフォルダにある整合性チェック用の目印ファイルまで一緒に欠けていた。空のフォルダではなく「目印だけがある」状態が必要だった。
  • ファイル同期サーバーがCannot write into "config" directory!で503。 rcloneはファイルの所有者情報を保持しないので、復元したファイルが全部自分のユーザーの所有になっていた。クラウド経由の復元では所有者が落ちることを前提に、復元後のチェック項目に入れておく。
  • 🔴 ユーザーのIDが揃っていなかった。 新VPSに旧サーバーと同じ名前のユーザーを作ってパスを揃えたので、設定ファイルを無改造で使い回せた。これは正解だった。ただし名前は同じでも内部の数値のIDが1つずれていて、一部のコンテナ内でNo user exists for uid 1001で落ちた。名前だけでなく数値IDまで揃える必要がある。
  • 「Dockerのデータとワークスペースを移せば終わり」ではなかった。 動画変換用のコマンドや日本語フォントといったホスト側のOSパッケージが新VPSに無く、移行の夜に機能が動かないことで発覚した。さらにcron(定時実行の仕組み)自体が未インストールで、登録コマンドが黙って失敗していた。バックアップの自動実行が最初から動いていないところだった。
  • 🔴 写真46件が旧サーバーにしか存在しなかった。 復元結果をファイル単位で突き合わせて翌日見つけた。データベースには46件の記録があるのに、写真の実体がVPSにもB2にも無い。原因は、移行当日の最終バックアップでデータベースのダンプは成功し、写真の同期ステップが完走しないまま終わっていたこと。しかも旧スクリプトはステップごとの成否をログに出していなかったので無言で失敗した。旧サーバーがまだ生きていたので手元から押し出して復旧できたが、運が良かっただけ。
    • 教訓1: 復元の検証を「ファイル数の一致」でやってはいけない。 60,525件と60,619件で0.15%差。見逃す水準だった。データベースに記録されているパスが1件ずつ実在するかを全件突き合わせる必要がある。
    • 教訓2: バックアップスクリプトはステップごとにOK/FAILをログへ出す。 まさにこの欠陥を直すつもりだった移行当日に、その欠陥に刺された。

公開IPになって初めて出た問題

ここが一番多くの人が外すところだと思う。移行そのものより危ない。

自宅のPCはルーターの内側にいた。 だから、どのサービスが「外から直接届く形」で動いていようが、外からは届かなかった。Cloudflare Tunnelという1本の出口だけが外部との接点だった。VPSは公開IPを持っている。 全部のポート(通信の入口の番号)が、世界中から直接叩ける状態で始まる。ランブックにこの観点は1行も無かった。

① 中身が剥き出しで公開されていた

写真サーバーのログイン画面が生で公開されていた。Cloudflare側のログイン要求を通らない素の入口。ログイン入口サーバーのHTTPSポートも公開で、本来Cloudflare側の防御を通ってから届くはずのものが迂回できる状態。小さな静的サイトのポートも開いていた。

原因はdocker-compose.ymlのports:の書き方。

ports:
  - '2283:2283'            # 🔴 これは全てのネットワークに向けて開く
  - '127.0.0.1:2283:2283'  # ✅ 自分自身からしか届かない

自宅では両方とも同じ結果になるので、違いに気づく機会がなかった。トンネル経由で公開する構成なら、外に向けて開く必要は最初から無い。

応急処置としてファイアウォールで遮断し、後日、設定ファイル側を127.0.0.1に直して「ファイアウォールで塞いでいる」状態をやめた。この順序は意図的で、ファイアウォールなら数秒で塞げるがコンテナの作り直しは止まる時間が出るから。ただし応急処置で終わらせない。ファイアウォールは1行消えれば無防備に戻るが、そもそも誰も外向きに待ち受けていなければ消しようがない。

② SSHのパスワードログインが「無効にしたのに有効」だった

/etc/ssh/sshd_config.d/50-cloud-init.conf   ← PasswordAuthentication yes
/etc/ssh/sshd_config.d/99-hardening.conf    ← PasswordAuthentication no(自分が書いた)

SSHのサーバーは「最初に見つけた値」を採用する。 番号が小さいファイルが先に読まれるので、99番に書いた自分の設定は永遠に負ける。VPSの初期設定が置いていった50番のファイルを無効化して解決した。🔴 設定を書いたら、書いたことを確認するのではなく、効いているかを確認する。 鍵でログインできることと、パスワードが本当に拒否されることの両方を実測する。

③ IPv6が完全に丸腰だった

ここが一番ひやりとした。遮断はしたが、IPv4のファイアウォールだけだった。

VPSにはIPv6のアドレスも付いている。確認すると、IPv6側のファイアウォールのルールは空、ポリシーは全通過。コンテナはIPv4とIPv6の両方で待ち受けていたので、塞いだつもりの写真サーバーのログイン画面は、IPv6経由では公開されたままだった。🔴 IPv4を塞いだら必ずIPv6も確認する。 設定は完全に別物で、片方だけやっても「塞いだ」とは言えない。

④ 認証なしのポート19本が素通しだった

自分のアシスタントが外部サービスと喋るための中継プロセスが、ポート19本で認証なしで待ち受けていた。ファイアウォールのルールも無かった。この中の1本は自分のメールの受信箱に繋がっていた。 写真のログイン画面(少なくともパスワードは要求する)より危険度は上だった。

思い込みが穴を作った。「外に出ているポートはdocker-composeのports:に書いてあるものだけ」と思っていた。これらはコンテナではなくホスト上で直接動いているプロセスなので、Docker向けのファイアウォール設定では止まらない。棚卸しの書き方にも罠がある。

# 🔴 これでは漏れる
ss -tlnp | grep 0.0.0.0

# ✅ 「自分自身以外」を除外方式で全部出す
ss -tlnp | grep -vE "127\.0\.0\.|\[::1\]"

待ち受けアドレスの表記は0.0.0.0:PORTだけではない。*:PORTという表記(IPv4とIPv6の両方で待ち受け)もあり、0.0.0.0では引っかからない。 19本はこの形で見落とされていた。**「危ないものを探す」のではなく「安全なものを除外して残り全部を見る」**書き方にしないと漏れる。

後日これも応急処置をやめて根本を直した。使っていた中継ソフトには待ち受けアドレスを指定するオプションが存在しなかったので、ソフト側のコードを1行書き換える形になった。直した後の確認が気持ちよかった。公開IPへは「接続拒否」が返る — ファイアウォールで弾いているのではなく、そもそも誰も待ち受けていない状態になった。これが一番強い証明。

⑤ ファイアウォールの確認は、そのサーバー自身からでは絶対にできない

遮断ルールには「外部から来た通信に対して」という条件が付く。VPSの中から自分の公開アドレスに繋ごうとすると内部の経路を通るので、必ず「繋がる」と出る。 塞がっているのに塞がっていないように見える。

# 別のパソコンから実行する(VPS内からでは意味がない)
nc -vz <サーバーのアドレス> 2283   # → Operation timed out  が正しい
nc -vz <サーバーのアドレス> 22     # → succeeded!            が正しい
nc -vz -6 <IPv6アドレス> 2283      # IPv6も必ず測る

この外部からの実測を、VPSを再起動した後・ファイアウォールを触った後・docker-composeを触った後に回す定型チェックとして手順書に書き出した。

⑥ 監視が自分の死を通報できない

死活監視にUptime Kumaを使っているが、それが監視対象と同じVPSの上で動いている。VPSごと落ちたら監視も一緒に落ちて通知が飛ばない。一番知りたい障害だけ通報されない。 バックアップの記事で書いたデッドマンスイッチ(生存信号が途絶えたら警報を出す仕掛け)と同じ構造なので、B2の複製先を見張らせていたGitHub Actions(GitHubが決めた時刻にスクリプトを自動実行する仕組み)に死活監視を1本足して解決した。サーバーの外で動くので、サーバーが丸ごと死んでも黙らない。

再起動して自分で戻ってくるかを確かめる

移行が完了した状態は、一度も再起動していない状態でもある。「今動いている」と「電源が落ちても自分で戻ってくる」は別の話で、自宅サーバー時代は再起動のたびに何かが立ち上がらない問題に何度も遭遇していた。

事前に、systemdのユニット7件がすべてenabled(起動時に自動で立ち上がる設定)か、コンテナ14個の再起動ポリシーがunless-stoppedかalwaysかを確認した。ファイアウォールのルールを永続化する仕組みも必ず含める — ここが抜けると再起動で遮断ルールが消え、さっき塞いだ穴が全部開く。厄介な事情が1つあって、自分のアシスタント自身がこのVPSの上で動いているので、再起動すると自分との会話も切れる。1分の猶予を置いて再起動し、復旧手順をチャットの外に控えておく段取りにした。自分を再起動する作業は、自分以外の場所に手順を置いてから実行する。

結果は成功。22秒で起動し、systemd 7件・コンテナ14個すべて稼働、全9サービスがHTTP 200/302、データも再起動前と完全一致。そして外部のパソコンからのnc実測で、遮断したポートはOperation timed out、SSHはsucceeded! — 永続化が再起動をまたいで効いていることを外部視点で立証できた。

後日ディスクを増設した時にこちらの意図しない強制リセットが起きた(公式ヘルプには「再起動不要」と書かれていたが実態は違った)。その時も全部がデータ欠損ゼロで自動復帰したので、この検証が本物だったことは意図せず二重に確認できた。増設系の作業は、公式が無停止と書いていてもダウンタイムを見込む。

結果

  • 原因不明の5.5日間停止から、26分の停止で海外VPSへ移行した。月額は$9.00から始まり、後にディスク増設で$12.60
  • 応答は0.3秒前後から0.55〜0.7秒に落ちた。遅くなったのは事実で、これは引き受けた
  • 公開IPになって初めて出た露出を6種類見つけて塞いだ。うち2種類(IPv6と、認証なしの19本)は最初の対処では見落としていた

旧サーバーはすぐ捨てずに2週間温存した。これは正解で、移行翌日に「写真46件が旧サーバーにしか無い」と判明した時に助かった。復元の検証が全部終わるまでは、古い機械を消してはいけない。

そしてハングの原因は今も分かっていない。分からないまま、分からないものに依存する構成をやめた、というのが今回やったことの全部だ。

更新履歴 — 2026-10-05: 初版