Quay lại blog
7 tháng 10, 2026Sergei Solod30 phút đọc

Cách tôi khôi phục tệp từ bản clone APFS bị hỏng sau khi GNU ddrescue sao chép 99.999654% ổ 4 TB

GNU ddrescue đã sao chép 99.999654% một HDD 4 TB đang hỏng, nhưng bản clone APFS vẫn không vượt qua kiểm tra filesystem và The Sleuth Kit có thể liệt kê đường dẫn trong khi trả về các tệp 0 byte. Tôi khôi phục các cây thư mục đã chọn bằng cách giữ một bản clone master chỉ-đọc, chuyển sang libfsapfs và xây dựng pipeline trích xuất có thể tiếp tục.

APFSGNU ddrescueKhôi phục dữ liệulibfsapfsmacOS

GNU ddrescue hiển thị 100.00%. Lượt khôi phục tệp có cấu trúc đầu tiên của tôi cho ra 0 useful recovered user files.

Hai kết quả đó xuất phát từ cùng một sự cố của ổ cứng 4 TB, và chính sự mâu thuẫn này khiến quá trình khôi phục trở nên thú vị hơn nhiều so với việc “clone ổ đĩa hỏng rồi chép tệp ra”. ddrescue đã làm phần việc của nó rất tốt: nó sao chép được xấp xỉ 99.999654% nguồn vật lý. Bản clone nằm trên phần cứng khỏe mạnh. Thế nhưng APFS vẫn bị hỏng, macOS không thể cung cấp filesystem theo cách bình thường, còn bộ công cụ khôi phục APFS đầu tiên có thể liệt kê phần lớn cây thư mục nhưng lại không đọc được nội dung của các tệp thông thường.

Cuối cùng tôi đã khôi phục được mọi cây thư mục đã chọn, có thể liệt kê và thực sự cần dùng, nhưng chỉ sau khi tách sự cố thành ba bài toán riêng: khôi phục block vật lý, diễn giải filesystem bị hỏng, và trích xuất tệp quy mô lớn có xác thực cùng khả năng resume.

Sự cố trước hết không còn là một bài toán filesystem

Ổ Toshiba 4 TB ban đầu đã đến mức tôi không còn tin tưởng để thực hiện hoạt động filesystem bình thường. Có lần đọc mất 60–75 giây. Thao tác có thể treo. Ổ thỉnh thoảng biến mất khỏi macOS, phát ra tiếng click rõ ràng và đôi khi tự tắt nguồn.

Không lâu trước khi hỏng, tôi đã ghi thêm khoảng 300 GB dữ liệu và đổi tên hàng loạt khoảng 500.000 tệp và thư mục. Thời điểm đó khiến khối lượng công việc nặng về metadata trở nên đáng nghi, nhưng tôi không thể chứng minh nó đã gây ra lỗi phần cứng. Có thể nó chỉ tạo đủ áp lực lên một ổ vốn đã không khỏe để làm vấn đề lộ ra.

Điều tôi có thể xác định là hành vi của ổ đĩa. Khi thiết bị lưu trữ cơ học bắt đầu khựng, biến mất và phát tiếng click, liên tục duyệt thư mục là một lớp trừu tượng sai. Duyệt cây thư mục có thể kích hoạt thêm các lần đọc và seek. Mount filesystem có thể kích hoạt xử lý metadata. Mỗi thử nghiệm đều tiêu tốn thời gian trên thành phần duy nhất mà ta không biết còn sống hữu ích được bao lâu.

Vì vậy tôi đổi mục tiêu từ:

khôi phục các tệp của tôi

sang:

khôi phục nhiều sector còn đọc được nhất có thể

Tôi dùng GNU ddrescue 1.30 với mapfile được lưu bền vững. Kích thước vật lý chính xác của ổ nguồn là:

4,000,787,027,968 bytes

Mapfile rất quan trọng vì nguồn không đủ ổn định cho một lần sao chép duy nhất. Nó giúp quá trình cứu dữ liệu chịu được tình trạng treo, mất kết nối, khởi động lại và các lượt chạy sau mà không quên vùng nào đã được khôi phục.

Tôi cũng gặp một vấn đề thực tế với đường dẫn thiết bị raw trên macOS: phạm vi cứu dữ liệu biểu kiến có thể trở nên vô lý thay vì dừng đúng ở ranh giới thật của thiết bị. Vì vậy tôi giới hạn miền cứu dữ liệu theo kích thước vật lý đã biết ở trên. Trong trường hợp này, đặt giới hạn đầu vào một cách tường minh là biện pháp đảm bảo tính đúng đắn, không phải tối ưu hiệu năng.

Vì sao ddrescue hiển thị 100.00% không có nghĩa là các tệp đã an toàn

Gần cuối giai đoạn khôi phục vật lý, ddrescue báo xấp xỉ:

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

Tỷ lệ phần trăm nổi bật là:

100.00%

Nhưng tổng các trạng thái chưa xử lý vẫn là:

13,835,816 bytes

hay khoảng:

13.84 MB

So với nguồn 4.000.787.027.968 byte, tỷ lệ đã khôi phục xấp xỉ:

99.999654%

Đó là một kết quả khôi phục block rất tốt. Nhưng đó không phải là kết quả về tính toàn vẹn của tệp.

Vị trí của các byte bị thiếu quan trọng hơn con số tổng quát. Vài megabyte mất ở vùng chưa sử dụng có thể không tạo ra ảnh hưởng nào nhìn thấy được. Một vùng nhỏ không đọc được trong video có thể làm hỏng một tệp. Một mất mát còn nhỏ hơn trong metadata filesystem có thể khiến nhiều data extent vốn còn nguyên vẹn trở nên khó định vị.

Đây trở thành mô hình tư duy cốt lõi cho phần còn lại của quá trình khôi phục:

LớpCâu hỏi mà lớp này trả lờiĐiều mà thành công không chứng minh được
Khôi phục blockCác sector vật lý đã được sao chép chưa?APFS có thể tái dựng mọi tệp
Khôi phục filesystemCó thể phân giải path, metadata và extent không?Mọi byte được trích xuất đều hợp lệ
Xác thực tệpTệp có được lấy ra với đúng kích thước hoặc hash mong đợi không?Chưa từng tồn tại một tệp nào mà giờ không thể phát hiện

Tôi thử thêm vài lần cuối trên các vùng còn chưa đọc được. Cuối cùng chúng không còn tạo ra dữ liệu đọc mới hữu ích trong khi ổ Toshiba phát tiếng click rất mạnh. Đó là lúc tôi ngừng dùng ổ gốc làm nguồn khôi phục đang hoạt động.

Vì sao tôi mua hai ổ 5 TB để khôi phục một ổ 4 TB

Ổ mới đầu tiên là Seagate Expansion 5 TB với dung lượng vật lý chính xác:

5,000,981,077,504 bytes

Tôi ghi bản clone Toshiba ở mức block lên đó. Bố cục nguồn chiếm khoảng 4 TB đầu tiên, để lại khoảng 1 TB phía sau vùng đã sao chép. Tôi chủ động không đụng đến phần dung lượng dư đó.

Tôi không mở rộng container APFS. Tôi không phân vùng lại bản clone cho tiện. Tôi không chạy sửa chữa filesystem trên đó. Ổ này trở thành bản clone master.

Sau đó tôi mua ổ 5 TB thứ hai. Ổ này vừa được format, có thể ghi, được kiểm tra độc lập và chỉ dùng để chứa dữ liệu khôi phục.

HDD 4 TB bị hỏng
        │
        │ GNU ddrescue
        ▼
ổ 5 TB #1
bản clone mức block master
CHỈ-ĐỌC
        │
        │ phân tích và trích xuất APFS
        ▼
ổ 5 TB #2
các tệp đã khôi phục
CÓ-THỂ-GHI

Điều đó có nghĩa là tôi mua khoảng 10 TB dung lượng mới theo danh nghĩa để khôi phục một volume có khoảng 3,26 TB dữ liệu đã sử dụng. Ổ bổ sung không phải để có thêm dung lượng. Nó nhằm giữ một invariant:

Nếu một thử nghiệm sai, tôi có thể quay lại đúng bản clone master nguyên vẹn ban đầu.

Sửa chữa, thay đổi kích thước, phân vùng lại hoặc ghi dữ liệu khôi phục lên bản master sẽ trộn lẫn việc bảo tồn nguồn với thử nghiệm. Tách nguồn và đích trên hai ổ vật lý riêng khiến sai lầm vẫn có thể phục hồi được.

Trước khi tin tưởng ổ đích, tôi chạy kiểm tra ghi/đọc khoảng 10 GB. Trong bài test đó, nó duy trì khoảng 144,4 MB/s ở cả hai chiều. Các mẫu đọc raw từ bản clone master đạt khoảng 28–49 MB/s và trong các lần kiểm tra đó không tái hiện kiểu lỗi I/O vật lý của ổ gốc.

Đến lúc này, bản chất vấn đề đã thay đổi. Tôi không còn debug phần cứng đang hỏng nữa. Tôi đang debug metadata APFS bị hỏng nhưng được lưu trên phần cứng hoạt động bình thường trong các bài test của tôi.

Bản clone APFS đọc được như một thiết bị nhưng không hợp lệ như một filesystem

Physical store APFS đã clone có kích thước:

4,000,650,887,168 bytes

Phân vùng liên quan bắt đầu tại sector:

264192

Với sector 512 byte, offset theo byte là:

135,266,304 bytes

Volume APFS báo khoảng:

3,260,976,717,824 bytes

đã được sử dụng.

Một lần kiểm tra APFS chỉ-đọc cuối cùng đi tới cây fsroot và báo:

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

Đây là lúc câu “ddrescue đã sao chép gần như toàn bộ ổ đĩa” không còn đủ để làm chẩn đoán hoàn chỉnh. Bản clone raw tồn tại. Cấu trúc filesystem bên trong vẫn không nhất quán.

Tôi cố ý giữ việc kiểm tra filesystem ở chế độ không sửa đổi. Tôi không biến fsck_apfs -n thành một thao tác repair trên bản clone master chất lượng cao duy nhất mình có. Repair có thể phù hợp với thiết bị lưu trữ thông thường, nhưng ở đây nó sẽ làm thay đổi chính bằng chứng mà tôi vẫn đang cố hiểu.

The Sleuth Kit liệt kê được namespace APFS nhưng thất bại khi đọc nội dung tệp

The Sleuth Kit 4.15.0 là bộ công cụ khôi phục đầu tiên khiến bản clone có vẻ đầy hy vọng. Dùng toàn bộ thiết bị đã clone, offset phân vùng đã biết và superblock APFS mà tôi xác định được, tôi có thể liệt kê tên thư mục thực bằng fls:

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

Tôi cố ý dùng N. Số disk trên macOS thay đổi qua các lần kết nối lại và reboot, nên tôi không coi lần gán /dev/disk6 trước đó là danh tính thiết bị.

fls có thể duyệt phần lớn namespace. Chỉ riêng một cây lớn đã lộ ra khoảng 8.900 thư mục. Trong chốc lát, có vẻ như phần khó nhất đã được giải quyết.

Sau đó tôi thử lấy nội dung tệp.

Một lỗi đại diện là:

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

tsk_recover có thể bắt đầu trích xuất rồi thất bại trên một block APFS. Các lần thử riêng lẻ bằng icat cũng cho cùng một kiểu vấn đề.

Điểm phân biệt quan trọng là:

duyệt cây thư mục hoạt động

không đồng nghĩa với:

đọc nội dung tệp hoạt động

Một parser có thể còn đủ metadata để tìm ra pathname nhưng vẫn thất bại ở bước sau khi phân giải đối tượng tệp, metadata extent hoặc các block nội dung cần thiết để trả về byte stream.

Tôi làm cho engine khôi phục hàng loạt đầu tiên chịu lỗi tốt hơn, nhưng parser vẫn không phù hợp với kiểu hư hỏng này

Phản ứng đầu tiên của tôi là làm cho việc trích xuất bằng TSK bền bỉ hơn thay vì lập tức đổi parser.

Tôi viết một wrapper khôi phục bằng Python quanh fls và icat. Nó lưu state bền vững trong SQLite, ghi log lỗi, hỗ trợ resume, tách riêng output chưa hoàn chỉnh và hạ ưu tiên dữ liệu bảo trì macOS ít giá trị trong lượt đầu.

Tôi cũng thêm quy tắc “hotspot”: nếu bốn tệp liên tiếp trong cùng một thư mục đều thất bại với cùng mẫu APFSBlock zero-byte, script sẽ ngừng tốn thời gian vào phần còn lại của nhánh đó, hoãn nó và tiếp tục ở nơi khác. Giả thuyết làm việc là một cụm lỗi giống hệt nhau có thể cùng phụ thuộc vào một metadata dependency bị hỏng, thay vì đại diện cho hàng trăm payload bị phá hủy độc lập.

Cơ chế điều phối hữu ích. Trình đọc APFS bên dưới thì không.

Tại một thời điểm tôi ghi lại, database khôi phục chứa:

DEFERRED_HOTSPOT: 30,356 files
FAILED:              486 files

486 lỗi được phân loại như sau:

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

Và lượt xử lý có cấu trúc tạo ra:

0 useful recovered user files

75 trường hợp lệch kích thước làm lộ một bug trong chính wrapper của tôi: một số tệp metadata hệ thống thực sự trả về dữ liệu, nhưng parser của tôi lại ghi kích thước mong đợi là 0. Sửa cách diễn giải đó là cần thiết, nhưng không làm thay đổi kết quả chủ đạo. Các tệp người dùng thông thường vẫn kết thúc với 0 byte và lỗi could not read APFSBlock.

Tôi chọn một tệp AVIF nhỏ làm test case có thể tái hiện. TSK biết kích thước mong đợi của nó:

expected size: 56,309 bytes
recovered:             0 bytes

Tệp đó có giá trị hơn nhiều so với thêm một lượt xử lý hàng loạt kéo dài nhiều giờ. Nếu cách tiếp cận mới không thể khôi phục một tệp test 56 KB vốn thất bại ổn định, nó không đáng được cho truy cập vào số terabyte còn lại.

Các block trên đĩa và đường đi metadata APFS là hai miền lỗi khác nhau

Đến lúc này tôi có ba quan sát:

GNU ddrescue:
gần như toàn bộ nguồn vật lý đã được sao chép

TSK fls:
có thể tìm thấy nhiều path thật

TSK icat:
nội dung của nhiều tệp thông thường vẫn không đọc được

Ba quan sát đó hoàn toàn tương thích nếu tách chuỗi lookup ra về mặt khái niệm:

pathname
   ↓
bản ghi thư mục
   ↓
đối tượng tệp / metadata inode
   ↓
ánh xạ extent
   ↓
các block dữ liệu vật lý

Các block dữ liệu ở gần cuối chuỗi có thể vẫn còn nguyên trong khi một mắt xích cao hơn của chuỗi metadata bị hỏng. Một khả năng khác là hai cách triển khai APFS duyệt cùng những cấu trúc hỏng theo cách khác nhau.

Tôi không cô lập được một đối tượng APFS hỏng duy nhất có thể giải thích mọi lỗi, nên tôi sẽ không tuyên bố đó là root cause đã được xác nhận. Điều mà bằng chứng thực sự ủng hộ là một thử nghiệm hữu ích hơn nhiều: giữ nguyên các byte của bản clone và thay parser.

142 checkpoint APFS không trở thành đường rollback tức thì

Một lần quét raw, chỉ-đọc vùng checkpoint descriptor của APFS tìm thấy 142 superblock checkpoint NXSB ứng viên, với transaction ID trải từ:

223133

xuống đến:

222992

Câu hỏi hiển nhiên là liệu một checkpoint cũ hơn có tham chiếu tới cây metadata lành hơn hay không.

Tôi biên dịch apfs-fuse và thử nhiều transaction ID checkpoint khác nhau qua fuse-t. Checkpoint mới nhất bị treo. Các XID cũ hơn cũng vậy. Tôi đặt timeout cứng cho từng checkpoint, và hơn 50 lần thử liên tiếp không tạo được mount dùng được. Tôi cũng thử cả backend NFS và SMB của fuse-t.

Điều đó không chứng minh mọi checkpoint đều hỏng. Thử nghiệm phụ thuộc vào checkpoint, apfs-fuse, fuse-t, hành vi thiết bị của macOS và backend mount. Lỗi ở bất kỳ lớp nào trong stack đó cũng có thể tạo ra cùng một biểu hiện.

Một parser thử sau đó báo có 0 snapshot APFS, và điều này cũng củng cố một phân biệt mà tôi phải giữ rõ: các trạng thái transaction checkpoint đó không phải là snapshot APFS mà người dùng nhìn thấy.

Một parser treo ngay ở --help không thể chẩn đoán ổ đĩa của tôi

Tôi cũng biên dịch go-apfs-v2. Quá trình biên dịch hoàn tất, nhưng ngay cả:

apfs --help

cũng treo và phải bị timeout kết thúc.

Việc kiểm tra block tối thiểu trên cả đường dẫn thiết bị raw lẫn buffered cũng treo. Tôi thực hiện cùng kiểu test có giới hạn với apfsutil; cả hai dạng thiết bị đều timeout sau khoảng 15 giây.

Những thất bại đó vẫn hữu ích vì chúng ngăn tôi đi đến kết luận sai. Một công cụ thậm chí không thể hoàn tất ổn định đường đi help của chính nó chỉ là bằng chứng yếu cho câu hỏi liệu một tệp hỏng có còn khôi phục được hay không.

Quy tắc của tôi trở thành:

Hãy xác thực công cụ khôi phục trước khi coi thất bại của công cụ đó là bằng chứng về dữ liệu.

Ngăn macOS tự động mount bản clone đã loại bỏ thêm một nguồn rủi ro

Bản thân macOS cũng là một biến số. Tôi muốn ổ đích khỏe mạnh được mount bình thường, nhưng không muốn Disk Arbitration tự động cố mount bản clone APFS bị hỏng mỗi khi tôi kết nối lại thiết bị lưu trữ.

Trình tự an toàn mà cuối cùng tôi dùng là:

  1. Kết nối và mount ổ đích khôi phục khỏe mạnh.
  2. Xác minh danh tính volume và dung lượng trống.
  3. Đóng băng diskarbitrationd.
  4. Xác minh không có process mount_apfs đang hoạt động.
  5. Kết nối bản clone master.
  6. Nhận diện bản master một cách động bằng kích thước vật lý đã biết và danh tính APFS.
  7. Xác minh bản master không được mount.
  8. Thực hiện công việc khôi phục chỉ-đọc.
  9. Khi dừng, lưu state, cho Disk Arbitration chạy lại, rồi shutdown hoặc eject theo cách bình thường.

Lệnh tôi thực sự dùng để đóng băng Disk Arbitration là:

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

Tôi xác minh state của process chứa T và kiểm tra các process mount_apfs còn sót trước khi tiếp tục.

Bài học an toàn quan trọng không nằm ở một số disk cụ thể. Nó là điều ngược lại: đừng bao giờ tin số disk của ngày hôm qua. Sau reboot, /dev/disk6 trước đó có thể trỏ tới một thiết bị vật lý khác. Tôi dùng UUID APFS và kích thước vật lý đã biết làm danh tính, rồi từ đó xác định đường dẫn thiết bị hiện tại.

libfsapfs khôi phục được chính tệp mà TSK trả về 0 byte

Bước đột phá đến từ libfsapfs, một cách triển khai APFS khác.

Tôi biên dịch nó từ mã nguồn trên macOS. Tệp thực thi fsapfsinfo tạo ra tự nhận dạng là:

fsapfsinfo 20260923

Sau đó xuất hiện một khác biệt quan trọng về giao diện thiết bị trên macOS.

Phân vùng character device dạng raw:

/dev/rdiskNs2

thất bại nhanh với lỗi đọc invalid-argument gần offset 4096.

Dạng block device buffered:

/dev/diskNs2

hoạt động.

Cùng một bản clone vật lý. Cùng một phân vùng APFS. Giao diện thiết bị macOS khác nhau.

fsapfsinfo mở container và tìm thấy một volume. Sau đó tôi thử đúng tệp 56.309 byte mà TSK không trích xuất được.

TSK cho ra:

expected:  56,309
recovered:      0
APFSBlock failure

Qua libfsapfs, entry của tệp cho ra:

size: 56,309
MD5:  c6f56db33eafc1de0f52a035bc255dc7
RC:   0

Đây là kết quả đầu tiên thực sự làm thay đổi chẩn đoán. Các byte đã clone không thay đổi. Tệp test không thay đổi. APFS bị hỏng không được sửa chữa.

Cách triển khai APFS đã thay đổi.

Ít nhất lúc này tôi đã biết rằng một tệp người dùng thật, vốn có vẻ không thể khôi phục qua TSK, vẫn còn được libfsapfs truy cập đủ sâu để đọc toàn bộ nội dung và tính digest.

Tôi trích xuất một tệp đã biết là lỗi trước khi tin tưởng libfsapfs với hàng terabyte dữ liệu

Một digest thành công vẫn chưa đủ để tôi khởi chạy quá trình khôi phục nhiều terabyte. Tôi muốn có các byte thực sự trên ổ đích.

Thay vì đoán API C của libfsapfs, tôi đọc mã nguồn thư viện và đi theo đường đọc mà fsapfsinfo đã dùng để tính digest. Sau đó tôi tạo một extractor nhỏ chỉ-đọc cho đúng tệp test đã biết.

Extractor ghi chính xác:

56,309 bytes

vào ổ khôi phục riêng. MD5 của tệp đã trích xuất là:

c6f56db33eafc1de0f52a035bc255dc7

Giá trị đó khớp với digest trước đó.

Chỉ đến lúc ấy tôi mới mở rộng quy mô. Test một tệp đã chứng minh ba điều riêng biệt: có thể phân giải pathname, có thể trích xuất đủ số byte mong đợi, và các byte được trích xuất tạo ra cùng digest như lần đọc toàn bộ nội dung trước đó.

Bài toán khôi phục hàng loạt chủ yếu là cô lập lỗi và khả năng resume

Khi libfsapfs khôi phục được một tệp mà TSK không thể, bài toán khó lại thay đổi. Tôi cần một hệ thống có thể xử lý cây thư mục rất lớn mà không để một nhánh hỏng, một lần reboot hay một Ctrl+C biến toàn bộ công việc thành việc chạy lại từ đầu.

Vì vậy pipeline khôi phục hàng loạt dùng một số invariant nghiêm ngặt:

  • Bản clone master được mở ở chế độ chỉ-đọc.
  • Dữ liệu khôi phục chỉ được ghi vào ổ 5 TB thứ hai.
  • Tên thư mục và tệp được giữ nguyên.
  • Progress được lưu trong SQLite để tồn tại qua việc process thoát và máy reboot.
  • Mỗi tệp trước hết được ghi vào một path tạm.
  • Tệp tạm chỉ được đổi tên sang path cuối cùng sau khi đã ghi đủ toàn bộ kích thước mong đợi.
  • Các tệp đã tồn tại với đúng kích thước mong đợi có thể được nhận diện khi resume.
  • Công việc thất bại hoặc có vấn đề được tách khỏi công việc đã hoàn tất.
  • Dung lượng trống được kiểm tra và luôn giữ một phần dự phòng an toàn.
  • Các artifact phát triển có thể tạo lại và metadata hệ thống ít giá trị có thể được bỏ qua hoặc hạ ưu tiên.

Một thay đổi về hiệu năng lập tức trở nên quan trọng: tôi ngừng mở lại container APFS độc lập cho từng tệp.

Fast path xử lý từng thư mục một. Worker mở nguồn, liệt kê thư mục đó, khôi phục các regular file ngay trong thư mục rồi đưa các thư mục con vào hàng đợi. Nếu một thư mục lỗi hoặc timeout, controller đánh dấu nó là DEFERRED rồi tiếp tục thay vì chặn toàn bộ lượt xử lý.

Sau khi không còn công việc pending bình thường, các thư mục đã hoãn được quay lại bằng fallback path chậm hơn, với công việc theo từng tệp được cô lập hơn. Những lỗi còn lại sau đó có thể được retry độc lập.

phát hiện thư mục
    ↓
khôi phục các tệp trực tiếp
    ↓
xác minh kích thước mong đợi
    ↓
commit state bền vững
    ↓
đưa thư mục con vào hàng đợi
    ↓
hoãn lỗi cục bộ
    ↓
tiếp tục trên toàn cục
    ↓
fallback và thử lại sau

Kiến trúc này khớp với kiểu lỗi thực tế tốt hơn nhiều so với một lệnh đệ quy khổng lồ. Hư hỏng không đồng đều, nên hệ thống khôi phục cũng không nên ép tiến độ phải đồng đều.

Vì sao tôi kiểm tra kích thước mong đợi và dùng atomic rename thay vì hash hàng triệu tệp

MD5 hữu ích trong phép chứng minh với một tệp vì tôi cần bằng chứng mạnh rằng parser đang đọc trọn nội dung của tệp mà TSK không thể đọc.

Đọc lại toàn bộ byte đã khôi phục lần thứ hai chỉ để hash hàng triệu tệp sẽ tạo thêm một lượng I/O rất lớn. Với lượt trích xuất chính, tôi dùng một invariant khác.

Với mỗi regular file, metadata APFS cung cấp kích thước mong đợi. Worker ghi vào tệp tạm và chỉ chuyển nó sang pathname cuối cùng sau khi lần đọc đầy đủ khớp với kích thước mong đợi đó.

Điều đó có nghĩa là một lần gián đoạn không nên để lại tệp bị thiếu dữ liệu nhưng vẫn mang tên cuối cùng như thể đã hoàn chỉnh.

Khớp kích thước không phải bằng chứng toàn vẹn mật mã. Tôi không coi nó là như vậy. Nhưng xác minh kích thước mong đợi kết hợp với atomic rename là một ranh giới thực tế về tính đúng đắn cho lượt xử lý khối lượng lớn, trong khi hash có mục tiêu vẫn hữu ích cho mẫu kiểm tra và các trường hợp lỗi đã biết.

SQLite biến reboot thành chuyện bình thường thay vì thảm họa

Quá trình khôi phục kéo dài đủ lâu để tôi phải tắt máy rồi tiếp tục sau. Yêu cầu đó đã biến thiết kế từ một “script” thành một “workflow có thể phục hồi”.

Ctrl+C không đơn giản bỏ mặc child process đang chạy. Controller bắt tín hiệu gián đoạn, dừng worker, đưa thư mục đang xử lý về state có thể phục hồi, commit SQLite rồi thoát.

Về mặt khái niệm, một lần dừng sạch trông như:

CURRENT DIRECTORY -> PENDING

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

Sau reboot, tôi lặp lại việc kiểm tra danh tính ổ đĩa và chạy cùng lệnh khôi phục. Database state tiếp tục hàng đợi hiện có.

Một lần restart sau đó bắt đầu với:

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

Điều đó có ý nghĩa hơn nhiều so với một progress bar chung chung. Nó cho thấy hàng chục nghìn đơn vị thư mục đã hoàn tất vẫn tồn tại qua reboot và hàng đợi còn lại được thể hiện rõ ràng.

Ở một lần resume trước đó, thư mục bị gián đoạn trong phiên trước được lấy lên lại. Các tệp đã tồn tại với đúng kích thước mong đợi được nhận diện, và chỉ phần công việc còn thiếu mới phải ghi. Đó chính là hành vi tôi muốn: restart quá trình khôi phục phải là chuyện thường ngày, không phải điều đáng sợ.

326.799 tệp là bằng chứng đầu tiên cho thấy phương pháp có thể mở rộng

Trước khi mở rộng extractor mới sang toàn bộ dữ liệu đã chọn, tôi dùng một cây ưu tiên lớn làm mục tiêu xác thực hàng loạt.

State hoàn tất báo:

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

Ổ đích chứa:

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

hay khoảng:

135.08 GiB

Số lượng tệp đối chiếu khớp chính xác:

302,541 + 24,258 = 326,799

Không còn tệp .partial.* nào sót lại trong cây đã hoàn tất đó.

Tệp test 56.309 byte đã chứng minh parser có thể thành công nơi TSK thất bại. Lần khôi phục 326.799 tệp chứng minh cùng cách tiếp cận có thể chịu được một cây phân cấp thực tế đáng kể với logic resume, nhận diện tệp đã tồn tại và không ghi nhận lỗi tệp nào trong lượt đã hoàn tất.

Đợt khôi phục lớn hơn vượt 1,6 triệu tệp mới trước khi hoàn tất

Sau khi cây ưu tiên hoàn tất sạch sẽ, tôi mở rộng khôi phục sang phần dữ liệu cấp cao nhất đã chọn còn lại.

Tại một lần dừng an toàn có chủ ý, SQLite báo:

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

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

Con số đó xấp xỉ:

840.72 GiB

dữ liệu mới được các lượt xử lý thư mục ghi lại tại thời điểm đó.

Một thư mục DEFERRED duy nhất không có nghĩa là dữ liệu đã mất. Nó có nghĩa fast path chủ động không để vấn đề cục bộ đó tiếp tục trì hoãn công việc không liên quan. Pha fallback tồn tại chính để quay lại các trường hợp như vậy sau.

Các phiên sau tiếp tục từ cùng database. Số lượng hoàn tất tăng lên và hàng đợi pending giảm xuống. Cuối cùng, tôi khôi phục được mọi cây thư mục đã chọn, có thể liệt kê và thực sự cần dùng.

Tôi có thể trung thực tuyên bố điều gì về kết quả khôi phục cuối cùng

Tôi sẽ không mô tả kết quả là “đã khôi phục mọi byte”. Bằng chứng không ủng hộ tuyên bố đó.

Map ddrescue ban đầu vẫn chứa khoảng 13,84 MB chưa được xác nhận là đã sao chép thành công. Tôi cũng không thể chứng minh rằng không có đối tượng filesystem nào trở nên hoàn toàn không thể phát hiện vì metadata cần để liệt kê nó nằm trong những vùng bị hỏng.

Những giới hạn này quan trọng vì khôi phục có cấu trúc có thể chứng minh một đối tượng đã được liệt kê là đã được trích xuất; nó không thể chứng minh sự không tồn tại trong lịch sử của một đối tượng mà namespace bị hỏng giờ không còn có thể bộc lộ.

Tuyên bố cuối cùng mạnh nhất mà tôi có thể đưa ra hẹp hơn:

Mọi cây thư mục đã chọn, có thể liệt kê và tôi cần đều được khôi phục thành công qua quy trình khôi phục có cấu trúc.

Tôi không cần repair bản clone master tại chỗ. Tôi không cần dùng lại ổ Toshiba gốc đang phát tiếng click cho việc trích xuất hàng loạt. Tôi cũng không cần một lượt carving toàn ổ kiểu PhotoRec vốn sẽ đánh đổi cấu trúc thư mục và tên tệp.

Những công cụ thất bại vẫn là bằng chứng hữu ích

Nhìn lại, con đường thành công nghe có vẻ đơn giản:

clone bằng ddrescue
    ↓
libfsapfs
    ↓
extractor có thể resume
    ↓
các tệp đã khôi phục

Nhưng trong lúc điều tra, mọi thứ không hề có cảm giác đơn giản như vậy, và nếu bỏ các hướng tiếp cận thất bại thì cũng sẽ bỏ mất nhiều bài học kỹ thuật hữu ích.

TSK dạy tôi rằng duyệt namespace APFS và lấy nội dung tệp là hai kiểu lỗi khác nhau.

Bug trong wrapper đầu tiên dạy tôi rằng cách script khôi phục tự phân loại lỗi không phải là ground truth.

Cơ chế hotspot dạy tôi cô lập lỗi cục bộ thay vì để nó làm đình trệ tiến độ toàn cục.

Thử nghiệm checkpoint dạy tôi không được nhầm checkpoint transaction của APFS với snapshot.

Các thử nghiệm FUSE dạy tôi rằng mount thất bại có thể liên quan đến nhiều lớp khác ngoài chính dữ liệu filesystem.

Trình đọc APFS treo ở --help dạy tôi phải xác thực công cụ trước khi diễn giải chẩn đoán của nó.

Khác biệt giữa /dev/rdiskNs2 và /dev/diskNs2 dạy tôi rằng đường I/O của hệ điều hành có thể thay đổi hành vi parser ngay cả khi ổ đĩa bên dưới hoàn toàn giống nhau.

Và kiến trúc hai ổ cho tôi quyền thử sai ở mọi nơi khác trong khi vẫn giữ nguyên bản clone master.

Workflow khôi phục mà tôi sẽ dùng lại

  1. Dừng hoạt động filesystem thông thường trên thiết bị lưu trữ đang hỏng cơ học. Nếu việc đọc bị khựng, thiết bị biến mất hoặc phát tiếng click, tôi sẽ ưu tiên một bản clone mức block có thể resume hơn là duyệt bằng Finder.
  2. Dùng GNU ddrescue với mapfile bền vững và miền cứu dữ liệu đã được xác minh. Mapfile giữ tiến độ; kích thước nguồn đã biết ngăn sự nhầm lẫn về kích thước thiết bị trở thành một phần của bài toán khôi phục.
  3. Giữ một bản clone master ở chế độ chỉ-đọc. Không repair, đổi kích thước, phân vùng lại hay dùng nó làm nơi chứa tệp đã khôi phục.
  4. Ghi các tệp đã khôi phục sang ổ vật lý thứ hai. Bảo tồn nguồn và lưu output là hai công việc khác nhau.
  5. Trước tiên hãy chẩn đoán filesystem đã clone ở chế độ chỉ-đọc. Một bản clone vật lý khỏe mạnh nhưng chứa metadata APFS hỏng là bài toán khôi phục logic, không phải cùng một bài toán với ổ nguồn đang phát tiếng click.
  6. Chọn một tệp lỗi có thể tái hiện làm bài test parser. Một tệp 56 KB đã biết là lỗi cho tôi nhiều thông tin về parser thay thế hơn hàng giờ trích xuất hàng loạt một cách mù quáng.
  7. Xác thực chính parser. Nếu công cụ treo trước khi thực sự đọc nguồn một cách có ý nghĩa, đừng xem đó là bằng chứng dữ liệu đã mất.
  8. Đừng giả định một implementation APFS duy nhất định nghĩa khả năng khôi phục. TSK và libfsapfs hành xử rất khác nhau trên cùng các byte đã clone.
  9. Nhận diện ổ đĩa bằng thuộc tính ổn định, không phải số thiết bị tạm thời. UUID filesystem và kích thước vật lý đã biết an toàn hơn /dev/diskN của hôm qua.
  10. Thiết kế việc trích xuất dài hạn có khả năng resume ngay từ đầu. State bền vững, tệp tạm, atomic rename, công việc trì hoãn, retry có giới hạn và xử lý shutdown sạch đều là một phần của tính đúng đắn ở quy mô này.
  11. Tách các cấp xác thực. Phần trăm ddrescue, một pathname nhìn thấy được, kết quả khớp kích thước mong đợi và hash nội dung chứng minh những điều khác nhau.

Con số trông như vạch đích thực ra chỉ là kết thúc của giai đoạn một

Con số gây hiểu lầm nhất trong toàn bộ quá trình khôi phục vẫn là:

100.00%

Nó trông như câu trả lời cho “Tôi đã cứu được ổ đĩa chưa?”

Nhưng điều nó thực sự trả lời hẹp hơn nhiều:

ddrescue đã sao chép thành công bao nhiêu phần của miền cứu dữ liệu vật lý?

Nó không trả lời liệu APFS có thể tái dựng namespace hay không. Không trả lời liệu một parser có thể duyệt metadata hỏng mà parser khác từ chối hay không. Không trả lời liệu một tệp đã khôi phục có đúng độ dài mong đợi hay không. Và cũng không trả lời liệu một quy trình trích xuất hàng triệu tệp có thể chịu được lỗi và reboot mà không làm hỏng state của chính nó hay không.

Tôi cần bằng chứng riêng cho từng lớp.

Khôi phục vật lý thành công trước. Filesystem vẫn bị hỏng. Parser đầu tiên có thể lộ ra tên nhưng thất bại với nội dung của nhiều tệp. Một implementation APFS khác đọc thành công cùng tệp test. Bằng chứng đó trở thành extractor một tệp, extractor trở thành engine khôi phục có thể resume, và cuối cùng engine khôi phục các cây thư mục đã chọn mà tôi cần sang ổ thứ hai trong khi bản clone master vẫn nguyên vẹn.

Ổ cứng gốc không bao giờ trở lại khỏe mạnh. APFS cũng không tự nhiên sửa được chính nó. Điều thay đổi là mô hình khôi phục.

Khôi phục block, khôi phục filesystem và xác thực tệp là các giai đoạn kỹ thuật riêng biệt. Gộp chúng thành một bài toán khiến tình hình trông gần như vô vọng. Tách chúng ra khiến vấn đề trở nên xử lý được.