ブログに戻る
2026年10月7日Sergei Solod35 分で読めます

GNU ddrescueで4 TBドライブの99.999654%をコピーした後、破損したAPFSクローンからファイルを復旧した方法

故障しかけた4 TB HDDをGNU ddrescueで99.999654%までコピーできたものの、APFSクローンはファイルシステムチェックに失敗し、The Sleuth Kitはパスを列挙できても0バイトのファイルしか返しませんでした。読み取り専用のマスタークローンを保持し、libfsapfsに切り替え、再開可能な抽出パイプラインを構築することで、必要なディレクトリツリーを復旧しました。

APFSGNU ddrescueデータ復旧libfsapfsmacOS

GNU ddrescueは100.00%と表示しました。ところが、最初に行った構造化ファイル復旧では0 useful recovered user filesという結果になりました。

この2つの結果は、同じ4 TBハードディスクの故障から生じたものです。この矛盾があったからこそ、この復旧は「壊れたディスクをクローンして、ファイルをコピーする」だけでは済まない、ずっと興味深い作業になりました。ddrescue自体は驚くほどよく仕事をしており、物理ソースの約99.999654%をコピーできていました。クローン先のハードウェアも正常でした。それでもAPFSは破損したままで、macOSから通常どおりファイルシステムへアクセスできず、最初に使ったAPFS復旧スタックはディレクトリツリーの大部分を列挙できる一方、通常ファイルの内容を読み出せませんでした。

最終的には、必要としていた選択済み・列挙済みのフォルダツリーをすべて復旧できました。ただしそのためには、この障害を、物理ブロックの復旧、破損したファイルシステムの解釈、そして検証と再開機能を備えた大規模ファイル抽出という3つの別々の問題として扱う必要がありました。

まず、これはファイルシステムだけの問題ではなくなった

元の4 TB Toshibaは、通常のファイルシステム操作を任せるのがもう危険だと判断する状態まで悪化していました。読み取りに60〜75秒かかることがあり、処理がハングすることもありました。ドライブはmacOSから断続的に消え、はっきりとクリック音を出し、ときには電源まで落ちました。

故障の少し前には、約300 GBの追加データを書き込み、約500,000個のファイルとディレクトリに影響する一括リネームも行っていました。時期が重なっていたため、このメタデータ負荷の大きい処理は疑わしく見えましたが、これがハードウェア故障の原因だったと証明することはできません。すでに状態の悪かったドライブに負荷をかけ、問題を表面化させただけかもしれません。

確実に把握できたのは、ドライブの挙動そのものでした。機械式ストレージが停止しかけ、消え、クリック音を出している状況では、ディレクトリを繰り返し閲覧するという発想自体が適切ではありません。ディレクトリ走査は追加の読み取りやシークを発生させます。ファイルシステムのマウントもメタデータ処理を引き起こす可能性があります。どの実験も、残り寿命が分からない唯一の部品の時間を消費します。

そこで目的を、

recover my files

から、

recover as many readable sectors as possible

へ切り替えました。永続的なmapfileとともにGNU ddrescue 1.30を使いました。ソースドライブの正確な物理サイズは次のとおりでした。

4,000,787,027,968 bytes

ソースは一度きりのコピーで済むほど安定していなかったため、mapfileは不可欠でした。これにより、停止、切断、再起動、後続パスが発生しても、どの領域をすでに回収できたかを忘れずに復旧を続けられました。

rawのmacOSデバイスパスでは、実際のデバイス境界で終わるはずの復旧範囲が意味不明な値になるという実務上の問題にも遭遇しました。そのため、復旧ドメインを上記の既知の物理サイズに明示的に制限しました。このケースでは、入力範囲を明示的に限定することは速度最適化ではなく、正しさのための措置でした。

ddrescueの100.00%表示が、ファイルの安全を意味しなかった理由

物理復旧の終盤、ddrescueはおおよそ次のように報告していました。

domain size:  4000 GB
rescued:      4000 GB
non-tried:   13481 kB
non-trimmed: 327680 B
non-scraped:      0 B
bad-sector:   27136 B

見出しとなるパーセンテージは次のとおりでした。

100.00%

しかし、未解決の状態を合計すると、まだ

13,835,816 bytes

これはおよそ次の値でした。

13.84 MB

4,000,787,027,968バイトのソースに対する回収率は、およそ次の値でした。

99.999654%

これはブロック復旧としては非常に優秀な結果です。しかし、ファイル整合性の結果ではありません。

失われたバイト数そのものより、どこに失われたかのほうが重要です。未使用領域から数MB失われても、目に見える影響は何もないかもしれません。動画内の小さな読み取り不能領域なら、1ファイルだけが壊れる可能性があります。一方、ファイルシステムのメタデータ内でさらに小さな領域が失われると、それ以外は無傷な多数のデータextentの位置を特定しにくくなることがあります。

この考え方が、その後の復旧全体の中心になりました。

層答えられる問い成功しても証明できないこと
ブロック復旧物理セクタをコピーできたか?APFSがすべてのファイルを再構築できること
ファイルシステム復旧パス、メタデータ、extentを解決できるか?抽出されたすべてのバイトが正しいこと
ファイル検証期待サイズまたはハッシュどおりにファイルを取得できたか?発見不能になったファイルが過去に一度も存在しなかったこと

残っていた読み取り不能領域に対して、最後に何度か試しました。しかしToshibaが激しくクリック音を出す一方で、有用な新しい読み取り結果は出なくなりました。そこで、元のドライブを能動的な復旧ソースとして使うのをやめました。

1台の4 TBディスクを復旧するために5 TBドライブを2台買った理由

最初に新しく用意したのは5 TB Seagate Expansionで、正確な物理容量は次のとおりでした。

5,000,981,077,504 bytes

そこへToshibaのブロックレベルクローンを書き込みました。ソースのレイアウトは先頭約4 TBを占め、コピーされたレイアウトの外側に約1 TBが残りました。この余剰容量には意図的に手を触れませんでした。

APFSコンテナを拡張しませんでした。利便性のためにクローンを再パーティション化することもしませんでした。ファイルシステム修復も実行しませんでした。このディスクをマスタークローンにしました。

その後、2台目の5 TBドライブを購入しました。新規フォーマット済みで書き込み可能、個別にテスト済みで、復旧した出力の保存だけに使いました。

failing 4 TB HDD
        │
        │ GNU ddrescue
        ▼
5 TB drive #1
master block-level clone
READ-ONLY
        │
        │ APFS parsing and extraction
        ▼
5 TB drive #2
recovered files
WRITABLE

つまり、使用済みデータが約3.26 TBあるボリュームを復旧するために、公称で合計約10 TBの新しいストレージを購入したことになります。追加ディスクの目的は容量ではありません。次の不変条件を守るためでした。

If an experiment is wrong, I can return to the same untouched master clone.

マスター上で修復、サイズ変更、再パーティション化を行ったり、復旧した出力を書き込んだりすると、保存と実験が混ざってしまいます。ソースと出力先を別々の物理ドライブに置くことで、ミスをやり直せる状態にしました。

出力先を信頼する前に、約10 GBの書き込み/読み取りテストを実行しました。そのテストでは、両方向で約144.4 MB/sを維持しました。マスタークローンからのサンプルraw読み取りは約28〜49 MB/sで、この確認中には元のドライブで見られた物理I/O障害のパターンは再現しませんでした。

ここで問題の性質が変わりました。故障中のハードウェアをデバッグしているのではなく、テスト上は正常に動作するハードウェア上に保存された、破損APFSメタデータをデバッグしている状態になりました。

APFSクローンはデバイスとして読めたが、ファイルシステムとしては無効だった

クローン化したAPFS物理ストアのサイズは次のとおりでした。

4,000,650,887,168 bytes

対象パーティションの開始セクタは次のとおりでした。

264192

512バイトセクタなので、バイトオフセットは次のとおりです。

135,266,304 bytes

APFSボリュームの使用量はおおよそ

3,260,976,717,824 bytes

と報告されていました。

読み取り専用のAPFSチェックは最終的にfsrootツリーまで到達し、次のように報告しました。

Checking fsroot tree.
error: (oid 0xe36cb) apfs_root:
btn: dev_read_finish(3828538, 1): Input/output error
fsroot tree is invalid.

ここで「ddrescueがディスクのほぼ全体をコピーした」という事実だけでは、完全な診断として役に立たなくなりました。rawクローン自体は存在しています。しかし、その内部のファイルシステム構造は依然として不整合でした。

ファイルシステムチェックは意図的に非変更モードのままにしました。唯一の高品質なマスタークローンに対してfsck_apfs -nを修復処理へ切り替えることはしませんでした。通常のストレージなら修復が適切な場合もありますが、ここでは、まだ理解しようとしている証拠そのものを書き換えることになります。

The Sleuth KitはAPFS名前空間を列挙できたが、ファイル内容の取得に失敗した

The Sleuth Kit 4.15.0は、クローンに初めて希望が見えた復旧スタックでした。クローンしたデバイス全体、既知のパーティションオフセット、特定済みのAPFSスーパーブロックを使うことで、flsから実際のディレクトリ名を列挙できました。

fls \
  -o 264192 \
  -B 4594668 \
  -p \
  /dev/rdiskN

Nとしているのは意図的です。macOSのディスク番号は再接続や再起動で変わるため、以前の/dev/disk6という割り当てをデバイスの同一性として扱いませんでした。

flsは名前空間のかなり大きな部分を走査できました。1つの大きなツリーだけで約8,900個のディレクトリが見えました。一瞬、難しい部分は解決したように思えました。

次に、ファイル内容の取得を試しました。

代表的な失敗は次のとおりです。

libc++abi: terminating due to uncaught exception
of type std::runtime_error:
could not read APFSBlock

tsk_recoverは抽出を開始できても、APFSブロックで失敗しました。個別のicat試行でも同じ種類の問題が出ました。

重要な違いは、

directory traversal works

からといって、

file content retrieval works

とは限らないことでした。パーサーにはパス名を見つけるだけのメタデータが残っていても、その後、バイトストリームを返すために必要なファイルオブジェクト、extentメタデータ、またはコンテンツブロックを解決する段階で失敗することがあります。

最初の一括復旧エンジンは耐障害化したが、この破損にはパーサー自体が合っていなかった

最初に取った対応は、すぐにパーサーを替えるのではなく、TSKでの抽出をより壊れにくくすることでした。

flsとicatを包むPython復旧ラッパーを作りました。SQLiteに永続状態を保持し、失敗を記録し、再開に対応し、部分出力を分離して保存し、最初のパスでは価値の低いmacOSのハウスキーピングデータを後回しにしました。

さらに「ホットスポット」ルールも追加しました。あるディレクトリ内で4ファイル連続して同じ0バイトのAPFSBlockパターンで失敗した場合、そのブランチの残りに時間を使うのをやめ、延期して別の場所へ進む仕組みです。同じ失敗が集中しているなら、何百個ものペイロードが個別に破壊されたのではなく、1つの破損メタデータ依存関係を共有している可能性がある、というのが作業仮説でした。

オーケストレーションは役に立ちました。しかし、根本のAPFSリーダーはそうではありませんでした。

記録していたある時点で、復旧データベースには次の状態がありました。

DEFERRED_HOTSPOT: 30,356 files
FAILED:              486 files

486件の失敗の内訳は、

409  APFSBlock crashes
75   rc=0 but output-size mismatch
2    other rc=1 failures

そして、構造化パスで得られた結果は次のとおりでした。

0 useful recovered user files

75件のサイズ不一致によって、自作ラッパーのバグも見つかりました。一部のシステムメタデータファイルはデータを返していたのに、こちらのパーサーが期待サイズを0として記録していました。この解釈を修正することには意味がありましたが、支配的な結果は変わりませんでした。通常のユーザーファイルは依然としてcould not read APFSBlockで0バイトに終わっていました。

再現可能なテストケースとして、小さなAVIFファイルを1つ選びました。TSKはその期待サイズを把握していました。

expected size: 56,309 bytes
recovered:             0 bytes

このファイルは、さらに何時間も一括処理を回すよりはるかに価値のあるテストになりました。常に失敗する56 KBのテストファイル1つすら復旧できない新方式なら、残り数TBに触れさせる価値はありません。

ディスクブロックとAPFSメタデータの経路は、別々の障害領域だった

この時点で、3つの観察結果がありました。

GNU ddrescue:
almost the entire physical source was copied

TSK fls:
many real paths were discoverable

TSK icat:
many ordinary file contents still failed

検索チェーンを概念的に分ければ、これらの観察結果は矛盾しません。

pathname
   ↓
directory record
   ↓
file object / inode metadata
   ↓
extent mapping
   ↓
physical data blocks

下側にあるデータブロックが生き残っていても、上側のメタデータチェーンのリンクが壊れていることがあります。別の可能性として、2つのAPFS実装が同じ破損構造を異なる方法で走査していることも考えられます。

すべての失敗を説明する単一の破損APFSオブジェクトを特定できたわけではないため、それを確定した根本原因だとは主張しません。証拠が実際に裏付けたのは、もっと有用な実験でした。クローン済みのバイト列は変更せず、パーサーだけを替える。

142個のAPFSチェックポイントは、即座に使えるロールバック経路にはならなかった

APFSチェックポイントディスクリプタ領域をrawかつ読み取り専用で走査すると、候補となるNXSBチェックポイントスーパーブロックが142個見つかり、トランザクションIDは、

223133

から、

222992

までの範囲でした。古いチェックポイントがより健全なメタデータツリーを参照しているのではないか、というのは当然の疑問でした。

apfs-fuseをビルドし、fuse-t経由で異なるチェックポイントのトランザクションIDを試しました。最新チェックポイントは停止しました。古いXIDも同じでした。チェックポイントごとに厳格なタイムアウトを追加しましたが、50回を超える連続試行でも利用可能なマウントは得られませんでした。NFSとSMBの両方のfuse-tバックエンドも試しました。

だからといって、すべてのチェックポイントが破損していたと証明されたわけではありません。この実験はチェックポイント、apfs-fuse、fuse-t、macOSのデバイス挙動、そしてマウントバックエンドに依存していました。そのスタック内のどこで失敗しても、見た目上は同じ結果になり得ます。

後に使った別のパーサーはAPFSスナップショットが0件だと報告しました。これによって、混同してはいけない区別もより明確になりました。これらのチェックポイントのトランザクション状態は、ユーザーから見えるAPFSスナップショットと同じものではありません。

--helpでハングするパーサーに、自分のディスクは診断できない

go-apfs-v2もビルドしました。ビルド自体は完了しましたが、

apfs --help

ですらハングし、タイムアウトで強制終了する必要がありました。

rawとバッファリングされたの両デバイスパスに対する最小限のブロック検査も同様にハングしました。apfsutilでも同種の時間制限付きテストを行いましたが、両方のデバイス形式が約15秒でタイムアウトしました。

これらは有用な失敗でした。誤った結論を出さずに済んだからです。自分自身のヘルプ処理すら安定して完了できないツールの失敗は、破損ファイルが復旧可能かどうかを判断する証拠としては弱いものです。

そこで自分のルールは次のようになりました。

復旧ツールの失敗をデータについての証拠として扱う前に、まず復旧ツール自体を検証する。

macOSによるクローンの自動マウントを防ぎ、もう1つのリスク源を除いた

macOS自体も変動要因でした。正常な出力先は普通にマウントしたい一方、ストレージを再接続するたびにDisk Arbitrationが破損APFSクローンを自動マウントしようとするのは避けたかったのです。

最終的に使った安全な手順は次のとおりです。

  1. 正常な復旧先を接続してマウントする。
  2. そのボリュームの同一性と空き容量を確認する。
  3. diskarbitrationdを停止状態にする。
  4. 動作中のmount_apfsプロセスがないことを確認する。
  5. マスタークローンを接続する。
  6. 既知の物理サイズとAPFSの同一性から、マスターを動的に特定する。
  7. マスターがマウントされていないことを確認する。
  8. 読み取り専用で復旧作業を行う。
  9. 停止時は状態を保存し、Disk Arbitrationを再開してから、通常どおりシャットダウンまたは取り出しを行う。

実際にDisk Arbitrationを停止状態にするために使ったコマンドは次のとおりです。

sudo kill -STOP "$(pgrep -x diskarbitrationd)"

プロセス状態にTが含まれていることを確認し、続行前に余計なmount_apfsプロセスがないかも確認しました。

重要な安全上の教訓は、特定のディスク番号ではありません。むしろ逆です。昨日のディスク番号を絶対に信用しない。 再起動後には、以前の/dev/disk6が別の物理デバイスを指すことがあります。APFS UUIDと既知の物理サイズを同一性として扱い、そこから現在のデバイスパスを導出しました。

libfsapfsは、TSKが0バイトで返した同じファイルを復旧できた

突破口になったのは、別のAPFS実装であるlibfsapfsでした。

macOS上でソースからビルドしました。生成されたfsapfsinfoバイナリは自分自身を、

fsapfsinfo 20260923

と識別しました。そこで、macOSのデバイスインターフェースに関する重要な違いが表れました。

raw キャラクタデバイスのパーティション、

/dev/rdiskNs2

は、オフセット 4096付近で無効引数の読み取りエラーとなり、すぐに失敗しました。

一方、バッファリングされた ブロックデバイス形式の、

/dev/diskNs2

は動作しました。

同じ物理クローン。同じAPFSパーティション。違うのはmacOSのデバイスインターフェースだけです。

fsapfsinfoはコンテナを開き、1つのボリュームを見つけました。そこで、TSKが抽出に失敗した、まさに同じ56,309バイトのファイルをテストしました。

TSKの結果は次のとおりでした。

expected:  56,309
recovered:      0
APFSBlock failure

libfsapfsでは、そのファイルエントリから次の結果が得られました。

size: 56,309
MD5:  c6f56db33eafc1de0f52a035bc255dc7
RC:   0

これは診断を実質的に変えた最初の結果でした。クローン済みのバイト列は変わっていません。テストファイルも変わっていません。破損APFSを修復したわけでもありません。

変わったのはAPFS実装でした。

少なくとも、TSKでは復旧不能に見えた実際のユーザーファイルが、libfsapfsでは完全な内容を読み出し、ダイジェストを計算できるところまで到達可能だと分かりました。

libfsapfsに数TBを任せる前に、既知の失敗ファイルを1つ実際に抽出した

ダイジェストが成功しただけで、数TB規模の復旧を開始するつもりはありませんでした。出力先ディスクに実際のバイト列を書き出せることを確認したかったのです。

libfsapfsのC APIを推測で使うのではなく、ライブラリのソースを調べ、fsapfsinfoがダイジェスト計算時にすでに使っていた読み取り経路を追いました。そのうえで、既知のテストファイル1つだけを対象にする小さな読み取り専用エクストラクターを作りました。

エクストラクターは正確に、

56,309 bytes

を別の復旧ディスクへ書き込みました。抽出したファイルのMD5は次の値でした。

c6f56db33eafc1de0f52a035bc255dc7

これは先ほどのダイジェストと一致しました。

ここまで確認して初めて方式を拡大しました。1ファイルのテストで、パス名を解決できること、期待されるバイト数を完全に抽出できること、そして抽出したバイト列のダイジェストが先ほどの全文読み取り時と一致することという、3つの別々の事実を証明できました。

一括復旧の本質は、障害の封じ込めと再開だった

libfsapfsでTSKでは復旧できなかったファイルを取り出せるようになると、難所はまた変わりました。1つの破損ブランチ、1回の再起動、あるいは1回のCtrl+Cによって最初からやり直しにならず、非常に大きなディレクトリツリーを処理できる仕組みが必要でした。

そのため、一括復旧パイプラインではいくつかの厳格な不変条件を設けました。

  • マスタークローンは読み取り専用で開く。
  • 復旧した出力は2台目の5 TBディスクにだけ書き込む。
  • ディレクトリ名とファイル名を保持する。
  • 進捗はSQLiteに保存し、プロセス終了や再起動後も残す。
  • 各ファイルはまず一時パスへ書き込む。
  • 期待サイズの全量を書き終えた後にだけ、一時ファイルを最終パスへリネームする。
  • 再開時には、期待サイズで既に存在するファイルを認識できる。
  • 失敗または問題のある作業を、完了済み作業から分離する。
  • 空き容量を確認し、安全用の余裕を維持する。
  • 再生成可能な開発成果物や価値の低いシステムメタデータは、スキップまたは低優先度化できる。

性能面で直ちに効いた変更が1つありました。ファイルごとにAPFSコンテナを独立して開き直すのをやめたことです。

高速経路では、1ディレクトリずつ処理しました。ワーカーがソースを開き、そのディレクトリを列挙し、直下の通常ファイルを復旧して、子ディレクトリをキューへ返します。ディレクトリが失敗またはタイムアウトした場合、コントローラーはそれをDEFERREDとして記録し、全体パスを止めずに次へ進みました。

通常の保留中作業がなくなった後、延期したディレクトリを、より分離度の高いファイル単位処理を行う遅いフォールバック経路で再訪しました。残る失敗は、その後個別に再試行できました。

discover directory
    ↓
recover immediate files
    ↓
verify expected sizes
    ↓
commit durable state
    ↓
queue child directories
    ↓
defer local failures
    ↓
continue globally
    ↓
fallback and retry later

このアーキテクチャは、1本の巨大な再帰コマンドより、実際の障害パターンにはるかによく合っていました。破損が均一でない以上、復旧システム側も進捗を均一にする必要はありません。

数百万ファイルをハッシュする代わりに、期待サイズ確認とアトミックなリネームを使った理由

1ファイルでの実証では、TSKが読めなかったファイルの完全な内容をパーサーが読み取っているという強い証拠が必要だったため、MD5が役に立ちました。

しかし、数百万ファイルすべてについて、ハッシュのためだけに復旧済みの全バイトをもう一度読み直すと、大量のI/Oが追加されます。そのため、本番の抽出パスでは別の不変条件を使いました。

各通常ファイルについて、APFSメタデータから期待サイズを取得しました。ワーカーは一時ファイルへ書き込み、完全な読み取り結果がその期待サイズと一致した場合にだけ最終パスへ昇格させました。

これにより、中断が起きても、短い不完全ファイルが最終ファイル名のまま残ることは避けられます。

サイズ一致は暗号学的な整合性証明ではありません。そう扱ってもいません。ただし、期待サイズの検証とアトミックなリネームの組み合わせは、大量処理のパスにおける実用的な正しさの境界になりました。一方、狙いを絞ったハッシュは、サンプルや既知の失敗ケースでは引き続き有用でした。

SQLiteのおかげで、再起動は大事故ではなく退屈な作業になった

復旧は長時間に及び、途中でコンピューターを止めて後から再開する必要がありました。その要件によって、設計は単なる「スクリプト」から「復旧可能なワークフロー」へ変わりました。

Ctrl+Cを押しても、実行中の子プロセスを単に放置することはありませんでした。コントローラーが割り込みを捕捉し、ワーカーを止め、処理中のディレクトリを復旧可能な状態に戻し、SQLiteへコミットして終了しました。

正常停止は概念的には次のような形です。

CURRENT DIRECTORY -> PENDING

CTRL+C: RECOVERY STOPPED SAFELY
STATE SAVED. RUN THE SAME COMMAND TO RESUME.

再起動後は、ディスクの同一性確認をもう一度行い、同じ復旧コマンドを起動しました。状態データベースは既存キューから処理を再開しました。

後のある再開時には、開始状態は次のとおりでした。

DEFERRED:     1
DONE:     73,015
PENDING:   7,254

これは一般的なプログレスバーより、はるかに意味のある情報です。数万個の完了済みディレクトリ単位が再起動を越えて残っており、残作業のキューも明示されていることが分かります。

それ以前の再開では、前回のセッションで中断したディレクトリが再び拾われました。期待サイズで既に存在するファイルは認識され、不足している作業だけが書き込まれました。求めていたのはこの挙動です。復旧の再起動は怖いイベントではなく、日常的な操作であるべきでした。

326,799ファイルで、方式がスケールすることを初めて確認できた

新しいエクストラクターを選択済みデータ全体へ広げる前に、優先度の高い大きなツリー1つを一括検証の対象にしました。

完了状態は次のように報告されました。

directories completed:     8,940
new files written:       302,541
existing/resumed files:   24,258
recorded failed files:         0

出力先には、

326,799 files
145,039,215,948 bytes

つまり約、

135.08 GiB

がありました。ファイル数は正確に一致しました。

302,541 + 24,258 = 326,799

完了したこのツリーには、残存する.partial.*ファイルはありませんでした。

56,309バイトのテストファイルは、TSKが失敗した場所でこのパーサーが成功できることを証明しました。326,799ファイルの復旧は、同じ方式が、再開ロジックと既存ファイル検出を備えた相当規模の実ディレクトリ階層でも動作し、その完了パスでは記録されたファイル失敗が0件のまま処理を完遂できることを示しました。

より大規模な復旧は、完了前に新規ファイル160万件を超えた

優先ツリーが問題なく完了した後、残りの選択済みトップレベルデータへ復旧範囲を広げました。

意図的に安全停止したある時点で、SQLiteは次のように報告しました。

DONE directories:       39,015
PENDING directories:    17,017
DEFERRED directories:        1

new files written:   1,636,305
new bytes written: 902,715,716,335

これは約、

840.72 GiB

で、その時点のディレクトリ処理で記録された新規書き込みデータ量です。

DEFERREDが1ディレクトリあったからといって、データが失われたという意味ではありません。高速経路が、その局所問題によって無関係な作業まで遅れないよう意図的に止めた、という意味です。フォールバックフェーズは、まさにこのようなケースを後で再訪するために用意していました。

その後のセッションも同じデータベースから再開しました。完了数は増え、保留キューは減っていきました。最終的に、必要としていた選択済み・列挙済みのフォルダツリーをすべて復旧しました。

最終的な復旧について、正直に主張できること

この結果を「すべてのバイトを復旧した」と表現するつもりはありません。証拠はそこまで裏付けていません。

元のddrescue mapには、コピー成功が確認されていない約13.84 MBがまだ残っていました。また、あるファイルシステムオブジェクトを列挙するために必要なメタデータが破損領域に含まれていた結果、そのオブジェクトが完全に発見不能になったものが1つもない、と証明することもできません。

この制約は重要です。構造化復旧では、列挙できたオブジェクトを抽出できたことは証明できます。しかし、破損した名前空間からもう見えないオブジェクトが過去に存在しなかった、と証明することはできません。

最も強く言える最終結論は、もっと限定的です。

必要としていた選択済み・列挙済みのフォルダツリーは、構造化復旧プロセスを通じてすべて正常に復旧できました。

マスタークローンをその場で修復する必要はありませんでした。クリック音を出していた元のToshibaを一括抽出で再利用する必要もありませんでした。ディレクトリ構造とファイル名を犠牲にする、ディスク全体のPhotoRec方式のカービングも必要ありませんでした。

失敗したツールも、有用な証拠だった

振り返ると、成功した経路は単純に見えます。

ddrescue clone
    ↓
libfsapfs
    ↓
resumable extractor
    ↓
recovered files

しかし調査中の感覚はそうではありませんでした。失敗した方式を削ってしまうと、有用な工学的な教訓の多くも失われます。

TSKからは、APFS名前空間の走査とコンテンツ取得が別々の障害モードであることを学びました。

最初のラッパーのバグからは、復旧スクリプト自身のエラー分類が真値ではないことを学びました。

ホットスポット 仕組みからは、局所的な失敗によって全体進捗を止めるのではなく、その失敗を分離することを学びました。

チェックポイント実験からは、APFS トランザクションチェックポイントとスナップショットを混同してはいけないことを学びました。

FUSEの試行からは、マウント失敗がファイルシステムデータ以外の複数レイヤーを原因として持ち得ることを学びました。

--helpでハングしたAPFS リーダーからは、その診断を解釈する前にツール自体を検証することを学びました。

/dev/rdiskNs2と/dev/diskNs2の挙動差からは、基になるディスクが同一でも、OSのI/O パスによってパーサーの挙動が変わり得ることを学びました。

そして2ドライブ構成のおかげで、マスタークローンを変更せずに残したまま、その他の部分では自由に失敗できました。

もう一度やるなら使う復旧ワークフロー

  1. 機械的に故障しているストレージで、通常のファイルシステム操作を止める。 読み取りが停止する、デバイスが消える、クリック音が出るといった状態なら、Finderで探索するより、再開可能なブロックレベルクローンを優先します。
  2. 永続mapfileと検証済み復旧ドメインを使ってGNU ddrescueを実行する。 mapfileは進捗を保持し、既知のソースサイズはデバイスサイズの混乱が復旧作業そのものへ入り込むのを防ぎます。
  3. マスタークローンを1つ、読み取り専用で保持する。 修復、サイズ変更、再パーティション化をせず、復旧ファイルの保存先にも使いません。
  4. 復旧ファイルは2台目の物理ディスクへ書き込む。 ソースの保存と出力の保存は別の仕事です。
  5. クローンしたファイルシステムを、まず読み取り専用で診断する。 破損APFSメタデータを含む健全な物理クローンは論理復旧の問題であり、クリック音を出すソースディスクと同じ問題ではありません。
  6. 再現性のある失敗ファイルを1つ選び、パーサーテストにする。 既知の不良56 KBファイル1つから、何時間もの盲目的な一括抽出より多くのことが代替パーサーについて分かりました。
  7. パーサー自体を検証する。 ソースを意味のある形で読み始める前にツールがハングするなら、それをデータ消失の証拠とは解釈しません。
  8. 1つのAPFS実装だけで復旧可否が決まると思わない。 同じクローン済みバイト列に対して、TSKとlibfsapfsは大きく異なる挙動を示しました。
  9. 一時的なデバイス番号ではなく、安定した特性でディスクを識別する。 ファイルシステムUUIDと既知の物理サイズは、昨日の/dev/diskNより安全です。
  10. 長時間の抽出は最初から再開可能にする。 永続状態、一時ファイル、アトミックなリネーム、延期作業、回数制限付き再試行、正常停止処理は、この規模では正しさの一部です。
  11. 検証レベルを分ける。 ddrescueのパーセンテージ、見えているパス名、期待サイズとの一致、コンテンツハッシュは、それぞれ別のことを証明します。

ゴールに見えた数字は、第1段階の終わりにすぎなかった

今回の復旧で最も誤解を招いた数字は、結局のところ次の値でした。

100.00%

これは「ディスクを救えたのか?」への答えのように見えました。

実際に答えていたのは、もっと狭い問いです。

How much of the physical rescue domain did ddrescue successfully copy?

これは、APFSが名前空間を再構築できるかには答えていません。あるパーサーが拒否する破損メタデータを、別のパーサーなら走査できるかにも答えていません。復旧ファイルが期待どおりの長さかにも答えていません。そして、数百万ファイルの抽出が、エラーや再起動をまたいでも自身の状態を壊さず継続できるかにも答えていません。

各レイヤーについて、別々の証拠が必要でした。

最初に成功したのは物理復旧でした。ファイルシステムはまだ壊れていました。最初のパーサーは名前を見せられましたが、多くのファイル内容で失敗しました。別のAPFS実装は同じテストファイルを正常に読み出しました。その実証が1ファイル用エクストラクターになり、エクストラクターが再開可能な復旧エンジンになり、最終的にはマスタークローンを未変更のまま保ちつつ、必要な選択済みフォルダツリーを2台目のディスクへ復旧できました。

元のハードドライブが健全になったわけではありません。APFSが魔法のように自己修復したわけでもありません。変わったのは、復旧モデルでした。

ブロック復旧、ファイルシステム復旧、ファイル検証は、別々の工学上の段階です。 それらを1つの問題として扱うと、状況はほとんど絶望的に見えました。分けて考えることで、対処可能な問題になりました。