GNU ddrescue는 100.00%라고 표시했습니다. 하지만 처음 수행한 구조화 파일 복구 패스의 결과는 0 useful recovered user files였습니다.
이 두 결과는 같은 4 TB 하드 드라이브 고장에서 나왔습니다. 바로 이 모순 때문에 이번 복구는 “고장 난 디스크를 클론하고 파일을 복사한다”는 수준보다 훨씬 흥미로운 문제가 됐습니다. ddrescue는 제 역할을 놀라울 정도로 잘해 물리 소스의 약 99.999654%를 복사했습니다. 클론은 정상적인 하드웨어에 있었습니다. 그런데도 APFS는 여전히 손상돼 있었고, macOS에서는 파일시스템에 정상적으로 접근할 수 없었으며, 처음 사용한 APFS 복구 스택은 디렉터리 트리의 상당 부분을 열거하면서도 일반 파일의 내용을 읽는 데 실패했습니다.
결국 필요한 선택·열거된 폴더 트리를 모두 복구했지만, 그러려면 이 사건을 세 가지 별개의 문제로 다뤄야 했습니다. 물리 블록 복구, 손상된 파일시스템 해석, 그리고 검증 및 재개 기능을 갖춘 대규모 파일 추출입니다.
처음부터 파일시스템 문제만은 아니었다
원래의 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가 손실돼도 눈에 보이는 영향이 전혀 없을 수 있습니다. 영상 내부의 작은 읽기 불능 영역이라면 파일 하나만 손상될 수 있습니다. 반대로 파일시스템 메타데이터에서 그보다 훨씬 작은 부분이 손실되면, 데이터 자체는 멀쩡한 많은 extent의 위치를 찾기 어려워질 수 있습니다.
이 점이 이후 복구 전반의 핵심 사고 모델이 됐습니다.
| 계층 | 답할 수 있는 질문 | 성공해도 증명하지 못하는 것 |
|---|---|---|
| 블록 복구 | 물리 섹터를 복사했는가? | APFS가 모든 파일을 재구성할 수 있다는 것 |
| 파일시스템 복구 | 경로, 메타데이터, extent를 해석할 수 있는가? | 추출된 모든 바이트가 유효하다는 것 |
| 파일 검증 | 파일이 예상 크기 또는 해시와 일치하게 도착했는가? | 발견할 수 없게 된 파일이 과거에 전혀 존재하지 않았다는 것 |
남아 있던 읽기 불능 영역에 몇 차례 마지막 시도를 했습니다. 하지만 Toshiba가 심하게 클릭음을 내는 동안 더 이상 유의미한 새 읽기 결과가 나오지 않았습니다. 그 시점부터 원본 드라이브를 능동적인 복구 소스로 사용하지 않았습니다.
4 TB 디스크 하나를 복구하려고 5 TB 드라이브 두 개를 산 이유
첫 번째 새 드라이브는 5 TB Seagate Expansion이었고, 정확한 물리 용량은 다음과 같았습니다.
5,000,981,077,504 bytes
여기에 Toshiba의 블록 수준 클론을 기록했습니다. 소스 레이아웃은 앞쪽 약 4 TB를 차지했고, 복사된 레이아웃 뒤로 약 1 TB가 남았습니다. 그 여분 공간은 의도적으로 그대로 뒀습니다.
APFS 컨테이너를 확장하지 않았습니다. 편의를 위해 클론을 다시 파티셔닝하지도 않았습니다. 파일시스템 복구 작업도 실행하지 않았습니다. 이 디스크가 마스터 클론이 됐습니다.
그다음 두 번째 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는 네임스페이스의 상당 부분을 순회할 수 있었습니다. 큰 트리 하나에서만 약 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 관리 데이터를 후순위로 뒀습니다.
“핫스폿” 규칙도 추가했습니다. 한 디렉터리에서 연속 네 개 파일이 같은 0바이트 APFSBlock 패턴으로 실패하면, 스크립트는 그 브랜치의 나머지에 더 이상 시간을 쓰지 않고 연기한 뒤 다른 곳으로 진행했습니다. 동일한 실패가 몰려 있다면 수백 개의 페이로드가 각각 파괴된 것이 아니라 하나의 손상된 메타데이터 의존성을 공유할 수 있다는 것이 작업 가설이었습니다.
오케스트레이션은 유용했습니다. 기반 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 파일 하나를 골랐습니다. TSK는 그 파일의 예상 크기를 알고 있었습니다.
expected size: 56,309 bytes
recovered: 0 bytes
이 파일 하나가 몇 시간짜리 대량 패스를 또 돌리는 것보다 훨씬 가치 있는 테스트가 됐습니다. 지속적으로 실패하는 56 KB 테스트 파일 하나도 복구하지 못하는 새 접근법이라면 남은 수 TB에 접근시킬 이유가 없었습니다.
디스크 블록과 APFS 메타데이터 경로는 서로 다른 장애 영역이었다
이 시점에는 세 가지 관찰 결과가 있었습니다.
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
아래쪽의 데이터 블록은 살아남아도 위쪽 메타데이터 체인의 연결 하나가 손상될 수 있습니다. 또 다른 가능성은 두 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가 클론을 자동 마운트하지 못하게 해 또 하나의 위험 요소를 없앴다
macOS 자체도 또 하나의 변동 요소였습니다. 정상적인 복구 대상은 평소처럼 마운트하고 싶었지만, 저장장치를 다시 연결할 때마다 Disk Arbitration이 손상된 APFS 클론을 자동으로 마운트하려고 하는 것은 원하지 않았습니다.
결국 사용한 안전한 순서는 다음과 같았습니다.
- 정상적인 복구 대상을 연결하고 마운트한다.
- 볼륨의 정체성과 여유 공간을 확인한다.
diskarbitrationd를 정지 상태로 둔다.- 활성
mount_apfs프로세스가 없는지 확인한다. - 마스터 클론을 연결한다.
- 알려진 물리 크기와 APFS 정체성을 기준으로 마스터를 동적으로 식별한다.
- 마스터가 마운트되지 않았는지 확인한다.
- 읽기 전용 복구 작업을 수행한다.
- 중지할 때는 상태를 저장하고 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는 컨테이너를 열고 볼륨 하나를 찾았습니다. 이어서 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를 맡기기 전에, 실패가 확인된 파일 하나를 실제로 추출했다
다이제스트가 성공했다는 사실만으로 수 TB 규모 복구를 시작하고 싶지는 않았습니다. 실제 바이트가 대상 디스크에 기록되는 것을 확인하고 싶었습니다.
libfsapfs C API를 짐작으로 사용하는 대신 라이브러리 소스를 살펴보고, fsapfsinfo가 다이제스트를 계산할 때 이미 사용하던 읽기 경로를 따라갔습니다. 그리고 알려진 테스트 파일 하나만 대상으로 하는 작은 읽기 전용 추출기를 만들었습니다.
추출기는 정확히
56,309 bytes
를 별도의 복구 디스크에 썼습니다. 추출된 파일의 MD5는 다음 값이었습니다.
c6f56db33eafc1de0f52a035bc255dc7
앞서 계산한 다이제스트와 일치했습니다.
그제야 이 접근법을 확장했습니다. 한 파일 테스트로 세 가지를 따로 증명했습니다. 경로명을 해석할 수 있었고, 예상되는 전체 바이트 수를 추출할 수 있었으며, 추출된 바이트의 다이제스트가 앞서 전체 내용을 읽었을 때와 동일했습니다.
대량 복구의 핵심은 장애 격리와 재개였다
libfsapfs가 TSK로 복구하지 못한 파일을 복구할 수 있게 되자 어려운 문제는 다시 바뀌었습니다. 손상된 브랜치 하나, 재부팅 한 번, Ctrl+C 한 번 때문에 작업을 처음부터 다시 시작하지 않고도 매우 큰 디렉터리 트리를 처리할 시스템이 필요했습니다.
그래서 대량 복구 파이프라인에는 몇 가지 엄격한 불변 조건을 뒀습니다.
- 마스터 클론은 읽기 전용으로 연다.
- 복구 결과는 두 번째 5 TB 디스크에만 쓴다.
- 디렉터리와 파일 이름을 보존한다.
- 진행 상태는 SQLite에 저장해 프로세스 종료와 재부팅 후에도 남긴다.
- 각 파일은 먼저 임시 경로에 쓴다.
- 예상되는 전체 크기를 모두 쓴 뒤에만 임시 파일을 최종 경로로 이름 변경한다.
- 재개 시 예상 크기로 이미 존재하는 파일을 인식할 수 있게 한다.
- 실패하거나 문제가 있는 작업은 완료된 작업과 분리한다.
- 여유 공간을 확인하고 안전 여유분을 유지한다.
- 다시 생성할 수 있는 개발 산출물과 가치가 낮은 시스템 메타데이터는 건너뛰거나 우선순위를 낮출 수 있게 한다.
성능 측면에서 즉시 효과가 있던 변경도 하나 있었습니다. 파일마다 APFS 컨테이너를 따로 다시 열지 않게 한 것입니다.
빠른 경로는 디렉터리를 하나씩 처리했습니다. 워커가 소스를 열고 해당 디렉터리를 열거한 뒤 바로 아래의 일반 파일을 복구하고, 자식 디렉터리는 큐로 반환했습니다. 디렉터리가 실패하거나 타임아웃되면 컨트롤러는 그것을 DEFERRED로 표시하고 전체 패스를 막지 않은 채 다음으로 넘어갔습니다.
일반 보류 작업이 모두 사라진 뒤에는, 보류된 디렉터리를 파일 단위 작업을 더 강하게 격리한 느린 폴백 경로로 다시 방문했습니다. 남은 실패 항목은 그다음 개별적으로 재시도할 수 있었습니다.
discover directory
↓
recover immediate files
↓
verify expected sizes
↓
commit durable state
↓
queue child directories
↓
defer local failures
↓
continue globally
↓
fallback and retry later
이 아키텍처는 하나의 거대한 재귀 명령보다 실제 실패 패턴에 훨씬 잘 맞았습니다. 손상이 균일하지 않다면 복구 시스템도 진행을 균일하게 만들 필요가 없습니다.
수백만 파일을 해시하는 대신 예상 크기 검사와 원자적 이름 변경을 사용한 이유
단일 파일 검증에서는 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개 파일이 이 방식이 확장된다는 첫 증거였다
새 추출기를 선택한 모든 데이터로 확대하기 전에 우선순위가 높은 큰 트리 하나를 대량 검증 대상으로 사용했습니다.
완료 상태는 다음과 같이 보고됐습니다.
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 디렉터리 하나가 있다고 해서 데이터가 사라졌다는 뜻은 아닙니다. 빠른 경로가 그 로컬 문제 때문에 관련 없는 작업까지 지연되지 않도록 의도적으로 중단했다는 뜻입니다. 폴백 단계는 바로 그런 경우를 나중에 다시 방문하기 위해 존재했습니다.
이후 세션도 같은 데이터베이스에서 재개했습니다. 완료 수는 늘고 보류 큐는 줄었습니다. 결국 필요한 선택·열거된 폴더 트리를 모두 복구했습니다.
최종 복구 결과에 대해 솔직하게 말할 수 있는 것
결과를 “모든 바이트를 복구했다”고 표현하지는 않겠습니다. 증거가 그 정도의 주장을 뒷받침하지 않습니다.
원래 ddrescue map에는 성공적으로 복사됐다고 확인되지 않은 약 13.84 MB가 여전히 남아 있었습니다. 또한 파일시스템 객체를 열거하는 데 필요한 메타데이터가 손상 영역에 있었기 때문에 어떤 객체가 완전히 발견 불가능해진 경우가 단 하나도 없다고 증명할 수도 없습니다.
이 제한은 중요합니다. 구조화 복구는 열거된 객체가 추출됐다는 사실은 증명할 수 있지만, 손상된 네임스페이스가 더 이상 보여주지 못하는 어떤 객체가 과거에 존재하지 않았다는 사실까지 증명할 수는 없습니다.
가장 강하게 말할 수 있는 최종 문장은 더 좁습니다.
필요했던 선택·열거된 모든 폴더 트리는 구조화 복구 과정을 통해 성공적으로 복구했습니다.
마스터 클론을 제자리에서 복구할 필요도 없었습니다. 클릭음을 내던 원래 Toshiba를 대량 추출에 다시 사용할 필요도 없었습니다. 디렉터리 구조와 파일 이름을 희생하는 전체 디스크 PhotoRec 방식의 카빙도 필요하지 않았습니다.
실패한 도구도 유용한 증거였다
돌이켜보면 성공 경로는 단순해 보입니다.
ddrescue clone
↓
libfsapfs
↓
resumable extractor
↓
recovered files
하지만 조사하는 동안의 느낌은 전혀 그렇지 않았고, 실패한 접근법을 빼버리면 유용한 공학적 교훈도 상당 부분 사라집니다.
TSK를 통해 APFS 네임스페이스 순회와 콘텐츠 가져오기는 서로 다른 실패 모드라는 것을 배웠습니다.
첫 래퍼 버그를 통해 복구 스크립트 자체의 오류 분류가 절대적인 정답은 아니라는 것을 배웠습니다.
핫스폿 메커니즘을 통해 로컬 실패가 전체 진행을 막게 두지 말고 격리해야 한다는 것을 배웠습니다.
체크포인트 실험을 통해 APFS 트랜잭션 체크포인트와 스냅샷을 혼동하면 안 된다는 것을 배웠습니다.
FUSE 시도를 통해 마운트 실패는 파일시스템 데이터 자체 외에도 여러 계층을 원인으로 가질 수 있다는 것을 배웠습니다.
--help에서 멈춘 APFS 리더를 통해 진단을 해석하기 전에 도구를 먼저 검증해야 한다는 것을 배웠습니다.
/dev/rdiskNs2와 /dev/diskNs2의 차이를 통해 기반 디스크가 동일해도 운영체제 I/O 경로가 파서 동작을 바꿀 수 있다는 것을 배웠습니다.
그리고 두 드라이브 구조 덕분에 마스터 클론은 바꾸지 않은 채로 다른 모든 부분에서 실수할 자유가 생겼습니다.
다시 한다면 사용할 복구 워크플로
- 기계적으로 고장 나는 저장장치에서는 일반 파일시스템 작업을 중단한다. 읽기가 멈추고, 디바이스가 사라지거나, 클릭음이 난다면 Finder로 탐색하는 것보다 재개 가능한 블록 수준 클론을 우선합니다.
- 영구 mapfile과 검증된 복구 도메인으로 GNU ddrescue를 사용한다. mapfile은 진행 상태를 보존하고, 알려진 소스 크기는 디바이스 크기 혼동이 복구 작업에 섞이는 일을 막습니다.
- 마스터 클론 하나를 읽기 전용으로 유지한다. 복구하거나, 크기를 바꾸거나, 다시 파티셔닝하거나, 복구 파일 저장소로 사용하지 않습니다.
- 복구 파일은 두 번째 물리 디스크에 쓴다. 소스 보존과 결과 저장은 서로 다른 작업입니다.
- 클론된 파일시스템은 먼저 읽기 전용으로 진단한다. 손상된 APFS 메타데이터를 담고 있는 정상 물리 클론은 논리 복구 문제이며, 클릭음을 내는 소스 디스크와 같은 문제가 아닙니다.
- 재현 가능한 실패 파일 하나를 파서 테스트로 고른다. 알려진 불량 56 KB 파일 하나가 몇 시간짜리 맹목적인 대량 추출보다 대체 파서에 대해 더 많은 정보를 줬습니다.
- 파서 자체를 검증한다. 소스를 의미 있게 읽기도 전에 도구가 멈춘다면 그것을 데이터가 사라졌다는 증거로 해석하지 않습니다.
- APFS 구현 하나가 복구 가능성을 정의한다고 가정하지 않는다. 동일한 클론 바이트에서 TSK와
libfsapfs는 매우 다르게 동작했습니다. - 임시 디바이스 번호가 아니라 안정적인 특성으로 디스크를 식별한다. 파일시스템 UUID와 알려진 물리 크기가 어제의
/dev/diskN보다 안전합니다. - 장시간 추출은 처음부터 재개 가능하게 만든다. 지속 상태, 임시 파일, 원자적 이름 변경, 보류 작업, 제한된 재시도, 정상 종료 처리는 이 규모에서 정확성의 일부입니다.
- 검증 수준을 분리한다. ddrescue 백분율, 보이는 경로명, 예상 크기 일치, 콘텐츠 해시는 서로 다른 것을 증명합니다.
결승선처럼 보였던 숫자는 1단계의 끝에 불과했다
이번 복구 전체에서 가장 오해를 부른 숫자는 여전히 다음 값이었습니다.
100.00%
“디스크를 살렸나?”라는 질문의 답처럼 보였습니다.
실제로 답한 질문은 훨씬 좁았습니다.
How much of the physical rescue domain did ddrescue successfully copy?
이 숫자는 APFS가 네임스페이스를 재구성할 수 있는지 답하지 않았습니다. 한 파서가 거부한 손상 메타데이터를 다른 파서가 순회할 수 있는지도 답하지 않았습니다. 복구된 파일이 예상 길이인지도 답하지 않았습니다. 수백만 파일 추출이 오류와 재부팅을 거치면서도 자체 상태를 손상시키지 않고 살아남을 수 있는지도 답하지 않았습니다.
각 계층마다 별도의 증거가 필요했습니다.
물리 복구가 먼저 성공했습니다. 파일시스템은 여전히 손상돼 있었습니다. 첫 파서는 이름을 보여줄 수 있었지만 많은 파일 내용에서 실패했습니다. 다른 APFS 구현은 같은 테스트 파일을 성공적으로 읽었습니다. 그 검증은 한 파일 추출기가 됐고, 추출기는 재개 가능한 복구 엔진이 됐으며, 엔진은 결국 마스터 클론을 손대지 않은 채 필요한 선택 폴더 트리를 두 번째 디스크에 복구했습니다.
원래 하드 드라이브가 건강해진 적은 없습니다. APFS가 마법처럼 스스로 복구된 것도 아닙니다. 달라진 것은 복구 모델이었습니다.
블록 복구, 파일시스템 복구, 파일 검증은 서로 다른 공학 단계입니다. 이들을 하나의 문제로 다뤘을 때 상황은 거의 절망적으로 보였습니다. 분리하고 나니 다룰 수 있는 문제가 됐습니다.