[OSTEP] 43. Log-structured File Systems
안녕하세요, pingu52입니다.
이전 장에서 다룬 FFS(Fast File System)는 디스크의 물리적 구조를 고려해 지역성(Locality)을 살려 성능을 높였습니다. 하지만 90년대 초 버클리 연구진은 하드웨어 트렌드가 바뀌고 있다는 점에 주목했고, 완전히 다른 관점의 파일 시스템을 제안합니다.
디스크를 거대한 로그처럼 사용하여, 모든 쓰기를 큰 순차 쓰기로 바꾸는 LFS(Log-structured File System) 입니다.
이번 글에서는 LFS가 왜 등장했는지, 그리고 덮어쓰지 않는 설계에서 파일 위치가 계속 바뀌는데도 어떻게 데이터를 찾고, 쓰레기를 어떻게 회수하는지까지 연결해서 정리합니다.
1. 등장 배경: 기술 트렌드의 변화
LFS는 다음 관찰에서 출발합니다.
-
메모리가 커지고 있다 메모리가 커질수록 더 많은 데이터가 캐시에 올라가고, 디스크 트래픽은 점점 쓰기 중심으로 바뀝니다. 따라서 파일 시스템 성능은 결국 쓰기 성능이 좌우하기 쉽습니다.
-
순차 I/O와 랜덤 I/O의 격차 디스크 전송 대역폭은 증가했지만, seek와 회전 지연은 크게 좋아지지 않습니다. 따라서 랜덤 쓰기보다 순차 쓰기를 만드는 것이 훨씬 유리합니다.
-
기존 파일 시스템의 common workload 성능 한계 FFS는 block group으로 가까이 모으더라도, 작은 파일 생성 같은 작업에서 inode, 비트맵, 디렉터리 블록 등 다수의 메타데이터를 갱신해야 합니다. 결국 많은 짧은 seek와 회전 지연이 발생해 peak sequential bandwidth에 한참 못 미칩니다.
-
RAID-aware하지 않음 RAID-4/5 같은 parity 기반 RAID는 작은 쓰기에서 read-modify-write 문제가 발생합니다. 기존 파일 시스템은 이 최악 패턴을 회피하도록 설계되지 않았습니다.
요약하면 목표는 명확합니다.
- 쓰기를 가능한 한 크고 연속적인 순차 쓰기로 바꿔서, positioning 비용을 상각한다
2. 순차 쓰기: 덮어쓰지 않고 append-only로 기록한다
LFS는 기존 블록을 덮어쓰지 않습니다. 변경이 생기면 데이터 블록과 관련 메타데이터(inode 등)를 디스크의 빈 공간 끝에 새로 기록합니다.
하지만 여기서 중요한 디테일이 하나 있습니다.
2.1 순차 주소로 쓰는 것만으로는 부족하다
연속된 주소에 블록을 쓰더라도, 두 번의 작은 write 사이에 시간이 벌어지면 디스크가 회전해버립니다. 그 결과 다음 블록은 한 바퀴 가까이 기다린 뒤에야 기록될 수 있습니다.
즉, 성능을 얻으려면
- 순차 주소
- 그리고 충분히 큰 contiguous write
두 조건이 동시에 필요합니다.
2.2 세그먼트(segment)와 write buffering
LFS는 이를 위해 write buffering을 사용합니다.
- 작은 업데이트들을 메모리에 모아 둔다
- 충분히 쌓이면 큰 덩어리로 한 번에 디스크에 쓴다
이때 큰 덩어리를 세그먼트(segment) 라고 부릅니다. 실제 세그먼트는 보통 몇 MB 단위입니다.
3. 얼마나 버퍼링해야 하는가
세그먼트가 크면 클수록 positioning 오버헤드를 잘 상각하고 peak bandwidth에 가까워질 수 있습니다. 이를 간단한 모델로 유도할 수 있습니다.
- positioning 비용:
- peak 전송률:
- 한 번에 쓰는 데이터 크기:
peak 대비 만큼의 효율을 얻고 싶다면 를 만족하도록 를 잡으면 되고, 정리하면 다음이 됩니다.
이 식이 말하는 직관은 단순합니다.
- 원하는 효율 가 1에 가까워질수록, 필요한 는 급격히 커집니다
- 그래서 LFS는 KB 단위가 아니라 MB 단위로 버퍼링하는 설계를 택합니다
4. 문제: inode를 어떻게 찾는가
FFS에서는 inode table이 고정 위치에 있어서 i-number만 알면 inode 위치를 계산할 수 있었습니다.
하지만 LFS는 덮어쓰지 않습니다. inode를 갱신할 때마다 inode는 로그 끝으로 이동하고, 최신 inode는 디스크 여기저기에 흩어집니다.
즉, i-number만으로는 inode를 찾을 수 없습니다.
5. 해결: indirection 계층, inode map(imap)
LFS는 i-number와 inode 사이에 indirection 계층을 하나 둡니다. 이것이 inode map(imap) 입니다.
- 입력: inode 번호
- 출력: 최신 inode의 디스크 주소
imap은 보통 배열처럼 구현할 수 있고, 엔트리 하나는 디스크 포인터 4바이트 정도로 생각할 수 있습니다. inode가 새 위치에 기록될 때마다 해당 inode 번호의 imap 엔트리도 함께 갱신됩니다.
여기서 또 중요한 디테일이 있습니다.
5.1 imap도 고정 위치에 두지 않는다
imap을 디스크의 고정된 위치에 두면, inode를 갱신할 때마다
- 로그 끝에 쓰기
- 다시 imap 고정 위치에 쓰기
가 되어 seek가 생기고 성능이 무너집니다.
그래서 LFS는 imap도 로그에 함께 씁니다. 구체적으로는 imap 전체가 아니라 갱신된 조각(chunk) 을 데이터 블록, inode와 함께 같은 세그먼트에 기록합니다.
6. 마지막 퍼즐: checkpoint region(CR)
imap 조각도 디스크 여기저기에 흩어지는데, 그럼 최신 imap 조각을 어디서 찾을까요?
파일 시스템은 반드시 시작점이 하나 필요합니다. LFS는 이를 위해 디스크의 알려진 고정 위치에 **checkpoint region(CR)**을 둡니다.
- CR은 최신 imap 조각들의 주소들을 저장합니다
- CR은 자주 쓰면 seek를 유발하므로, 보통 수십 초 단위로 주기적으로만 갱신합니다
따라서 파일 접근 경로는 다음처럼 정리됩니다.
- CR 읽기
- imap 조각 위치 확인 및 imap 확보
- inode 위치 확인
- inode 읽기
- 데이터 블록 읽기
6.1 읽기 성능은 어떤가
처음에는 CR과 imap을 읽어야 하지만, imap은 메모리에 캐시될 수 있습니다. imap이 캐시된 뒤에는 inode 번호로 inode 주소를 찾는 것은 메모리 lookup이 되고, 그 이후 데이터 읽기는 일반 UNIX 파일 시스템과 유사하게 진행됩니다.
7. 디렉터리와 recursive update problem
디렉터리는 전통적인 UNIX와 동일하게 (이름, inode 번호) 매핑의 집합입니다.
덮어쓰지 않는 파일 시스템에서는 inode 위치가 바뀔 때 디렉터리도 갱신해야 할 것처럼 보입니다. 그런데 디렉터리를 갱신하면 그 디렉터리의 inode도 바뀌고, 그러면 상위 디렉터리도 바뀌는 식으로 트리 전체로 파급되는 문제가 생깁니다. 이를 recursive update problem이라고 부릅니다.
LFS는 imap으로 이를 피합니다.
- 디렉터리는 이름을 inode 번호에만 매핑한다
- inode의 실제 위치 변화는 imap에서만 관리한다
즉, 디렉터리를 바꿀 필요 없이 imap만 갱신하면 됩니다.
8. 새로운 문제: garbage collection, Cleaner
LFS는 덮어쓰지 않기 때문에, 갱신이 반복되면 구버전 데이터와 구버전 inode가 디스크에 남습니다. 이들은 더 이상 참조되지 않는 garbage가 됩니다.
garbage를 회수하지 않으면 디스크는 결국 가득 차므로, LFS는 백그라운드에서 Cleaner가 공간을 회수합니다.
중요한 점은 Cleaner가 블록 단위로 땜빵하듯 free를 만들면 안 된다는 것입니다. 그렇게 하면 디스크에 작은 구멍만 남고, LFS는 더 이상 큰 연속 공간에 세그먼트를 쓰기 어렵게 되어 성능이 급락합니다.
따라서 Cleaner는 segment 단위로 동작합니다.
- 오래된 세그먼트 M개를 읽고
- live block만 골라
- N개의 새 세그먼트로 압축해 다시 쓰고 (N < M)
- 기존 M 세그먼트를 통째로 free로 만든다
9. 메커니즘: 블록이 live인지 어떻게 판단하는가
LFS는 각 세그먼트의 앞부분에 segment summary block을 둡니다. 여기에 각 블록이
- 어느 inode 번호에 속하는지
- 파일의 몇 번째 블록 오프셋인지
를 기록합니다.
블록 D가 디스크 주소 A에 있을 때, liveness 판정은 다음 아이디어입니다.
- summary에서 inode 번호 N과 오프셋 T를 얻는다
- imap으로 N의 최신 inode 위치를 찾고 inode를 읽는다
- inode가 가리키는 T번째 블록 주소가 A와 같으면 live
- 다르면 이미 더 최신 버전이 있으므로 garbage
추가로 LFS는 truncation이나 delete 같은 경우를 빠르게 처리하기 위해 inode 버전 번호를 활용해 비교만으로 단축하는 최적화도 사용합니다.
10. 정책: 언제 무엇을 청소할 것인가
Cleaner의 또 다른 축은 정책입니다.
-
언제 청소할지
- 주기적으로
- 유휴 시간에
- 또는 디스크가 거의 찼을 때
-
어떤 세그먼트를 선택할지
- 원 논문은 hot/cold 세그먼트 분리를 제안합니다
- hot은 계속 덮어써져서 시간이 지나면 더 많은 블록이 dead가 되므로, 너무 빨리 청소하면 손해가 큽니다
- cold는 상대적으로 안정적이어서, dead가 생기면 일찍 청소하는 편이 효율적일 수 있습니다
11. 크래시 복구
LFS에서도 쓰기 도중 크래시는 발생합니다. 크래시는 크게 두 경우에 문제가 됩니다.
- 세그먼트를 쓰는 도중 크래시
- CR을 갱신하는 도중 크래시
11.1 CR 원자성 확보: CR을 2개 둔다
CR 갱신이 중간에 깨지면 시작점이 망가질 수 있습니다. 그래서 LFS는 CR을 디스크 양 끝에 2개 두고 번갈아 갱신합니다. 또한 timestamp를 이용한 헤더와 꼬리 블록으로 일관성을 검사해, 가장 최신이면서 일관된 CR을 선택합니다.
11.2 roll-forward: 마지막 checkpoint 이후를 복구한다
CR은 수십 초마다만 갱신되므로, 단순히 마지막 CR로 돌아가면 그 사이의 업데이트가 통째로 사라집니다.
이를 줄이기 위해 LFS는 roll-forward를 수행합니다.
- 마지막 일관된 CR로부터 로그 끝을 찾고
- 그 이후 세그먼트들을 순차적으로 훑으며
- 유효한 업데이트를 재구성해 반영합니다
12. 요약
- 핵심 철학: 모든 업데이트를 append-only로 기록해 쓰기를 큰 순차 쓰기로 만든다
- segment와 write buffering: 큰 contiguous write를 만들기 위한 메커니즘
- imap: 움직이는 inode를 추적하기 위한 indirection 계층
- CR: 최신 imap 조각을 찾기 위한 고정 시작점
- Cleaner: segment 단위로 live block만 재배치해 큰 연속 free 공간을 만든다
- liveness 판정: segment summary block과 imap, inode를 이용해 주소 일치로 판단한다
- crash recovery: 2개의 CR과 roll-forward로 일관성과 최신성 사이를 절충한다
LFS의 아이디어는 copy-on-write 계열 파일 시스템과 스토리지 계층 전반에 영향을 남겼고, 오늘날에도 큰 쓰기와 공간 회수라는 문제는 여전히 유효합니다.
13. 용어 정리
Log-structured: 덮어쓰기를 피하고 로그 append로 상태를 갱신하는 설계Segment: 큰 순차 쓰기를 만들기 위한 기록 단위Imap (Inode Map): inode 번호에서 최신 inode 디스크 주소로의 매핑Checkpoint Region (CR): 최신 imap 조각 주소들을 저장하는 고정 시작점Cleaner: live block만 재배치하고 segment 단위로 공간을 회수하는 백그라운드 작업Segment Summary Block: 각 블록의 inode 번호와 오프셋을 기록해 liveness 판단을 돕는 메타데이터Roll-forward: 마지막 checkpoint 이후 로그를 스캔해 유효한 업데이트를 복구하는 기법
