ZeroFS가 S3 버킷 위의 POSIX 파일시스템에 고가용성과 내구성을 제공하는 방식, 펜싱, 세미동기 복제, 페일오버, fsync 보장을 설명합니다.
Engineering · 고가용성
2026년 6월 28일·Pierre Barre·11분 읽기
ZeroFS는 S3 버킷에 존재하는 로그 구조 데이터베이스에 POSIX 파일시스템을 저장합니다. 이를 단일 노드에서 실행하는 일은 간단합니다. 노드 손실을 견디는 일은 처음 보이는 것보다 더 어렵습니다. 두 번째 노드를 추가하는 뻔한 방법들은 조용히 내구성을 약화시키는데, 파일시스템에서 내구성은 계약의 전부이기 때문입니다. 다운타임은 복구할 수 있지만, 호출자가 이미 승인되었다고 본 쓰기는 그렇지 않습니다. 페일오버 전반에 걸쳐 이 원칙을 지키는 것이 ZeroFS의 고가용성의 핵심 대부분입니다.
오브젝트 스토리지는 내구적이지만 느리고 요청당 비용이 청구되므로, 모든 파일시스템 연산을 각각 별도의 업로드로 커밋하면 각 쓰기마다 수십 밀리초와 과금되는 PUT이 붙게 됩니다. ZeroFS는 모든 LSM 엔진이 하듯이 쓰기를 메모리 내 memtable에 버퍼링하고, 이를 일괄로 버킷에 플러시합니다. fsync 시, memtable이 가득 찼을 때, 그리고 주기적 타이머에서입니다. write()는 데이터가 memtable에 도달하자마자 반환되고, fsync()가 그것을 나머지 경로를 따라 S3까지 밀어 넣습니다.
이로 인해 단일 노드는 서로 무관한 두 방식으로 노출됩니다. 승인되었지만 아직 플러시되지 않은 쓰기는 그 노드의 메모리에만 존재하므로, 다음 플러시 전에 크래시가 발생하면 유실됩니다. POSIX는 내구성 경계가 write가 아니라 fsync이므로 이를 허용하지만, 둘 사이의 간극은 실제로 존재합니다. 그리고 이미 S3에 도달한 데이터는 완전히 안전하더라도, 노드가 내려가 있는 동안 그것을 서비스할 주체가 없습니다. 첫 번째 문제는 플러시되지 않은 쓰기에 관한 것이고, 두 번째는 부재한 서버에 관한 것이며, 대기 노드는 두 문제에 동시에 답할 수 있습니다. 핵심 작업은 단일 노드가 이미 갖고 있던 내구성 구멍을 다시 열지 않으면서 그 대기 노드를 추가하는 데 있습니다.
하나의 버킷 위의 두 노드는 스플릿 브레인에 빠질 수 있습니다. 둘 다 자신이 리더라고 판단하고 동시에 쓰는 경우입니다. 파일시스템에서는 이것이 곧 손상을 뜻하며, 두 작성자가 갱신을 뒤섞어 기록하고 나면 사후에 그것을 풀어낼 방법은 없습니다. 따라서 보호는 타이밍이 좋다는 가정에 의존할 수 없습니다. 임의의 지연, 네트워크 분할, 클록 스큐 아래에서도 성립해야 합니다.
ZeroFS는 데이터베이스당 단일 작성자 모델이며, 작성자는 노드 간 협상으로 정해지는 것이 아니라 스토리지 계층이 선택합니다. 데이터베이스를 쓰기용으로 열 때 매니페스트에 대한 조건부 갱신을 발행해 writer_epoch를 증가시키고, 이전에 그것을 보유하던 작성자를 펜싱합니다. 이전 작성자의 다음 매니페스트 쓰기는 즉시 실패합니다. 한 번에 단 하나의 노드만 내구적인 상태를 커밋할 수 있습니다. 펜싱된 리더의 진행 중 SST 파일은 참조되지 않은 채 남고 나중에 가비지 컬렉션으로 회수되므로, 라이브 데이터를 절대 덮어쓰지 않으며, 리더는 첫 쓰기가 거부되는 순간 멈춥니다. 두 노드가 잠시 동안 자신이 리드한다고 믿을 수는 있지만, 실제로 커밋에 성공하는 쪽은 하나뿐입니다. 다른 쪽은 자신이 펜싱되었음을 발견하고 중지합니다. 이는 어떤 클록도 참조하지 않고 성립하며, 다른 모든 것은 그 위에 쌓입니다.
펜싱은 조건부 쓰기(put-if-not-exists)에 의존하며, S3, Azure Blob, Google Cloud Storage는 이를 직접 지원합니다. 이를 지원하지 않는 S3 호환 스토어의 경우 ZeroFS는 Redis를 통해 같은 보장을 얻습니다. 또한 노드는 시작 시 설정된 역할을 신뢰하는 대신, 둘 중 누가 현재 활성 상태인지 피어에게 질의하므로, 두 작성자가 콜드 스타트에서 함께 올라오는 일도 없습니다.
Raft와 Paxos에서 오는 직관은 장애 허용 합의에는 홀수 개 참여자가 필요하다는 것입니다. 하나를 잃어도 과반이 남도록 세 노드, 둘을 잃어도 되도록 다섯 노드가 필요하다는 식입니다. 그런 산술이 존재하는 이유는 클러스터가 누가 리드하는지와 어떤 쓰기가 커밋되었는지를 자기들끼리 합의해야 하고, 과반이 분열 없이 동률을 깨는 방법이기 때문입니다. ZeroFS는 그런 투표를 하지 않습니다. 전체 방식이 기대는 단 하나의 사실, 즉 현재 어떤 노드가 writer epoch를 보유하는지는 버킷의 매니페스트에 대한 단일 compare-and-swap으로 결정되며, 승자를 고르는 쪽은 노드가 아니라 오브젝트 스토어입니다.
선형화 가능한 compare-and-swap 레지스터는 그 자체만으로도 임의 개수 경쟁자에 대한 합의를 해결하기에 충분합니다. 이것은 Raft 같은 프로토콜이 더 약한 원시 연산들로부터 여러 페이지에 걸쳐 재구성하는 강한 원시 연산입니다. 오브젝트 스토어는 조건부 쓰기를 통해 이를 직접 제공합니다. 그렇기 때문에 세 대의 머신이 아니면 서로 유지해야 했을 합의는, 그 합의의 대상인 커밋된 데이터 옆 버킷 안에 존재합니다. 그러면 ZeroFS 노드는 서비스만 하면 됩니다. 하나의 노드만으로도 이미 정합하며, 두 번째 노드는 인계를 위해서와 fsync되지 않은 꼬리 부분을 보유하기 위해 존재할 뿐, 쿼럼을 구성하기 위해 존재하지 않습니다. 유지할 쿼럼이 없으므로 짝수 개 노드여도 괜찮습니다. 버킷에 접근할 수 없으면 파일시스템은 멈추지만, 그것을 읽는 다른 어떤 것도 마찬가지일 것이며, 그 안의 데이터가 덜 안전해지는 일은 없습니다.
이제 정해진 것은, 대기 노드의 역할이 리더가 수락했지만 아직 플러시하지 않은 쓰기를 보유하는 것입니다. 복제는 세미동기식이며 엄격하게 순서가 보장됩니다. 각 쓰기에 대해 리더는 이를 대기 노드로 보내고, 로컬에 적용해 클라이언트에게 응답하기 전에 확인 응답을 기다립니다. 대기 노드가 먼저 확인하므로, 클라이언트가 관찰할 수 있었던 모든 것은 이미 복제된 상태이며, 대기 노드는 가시 상태보다 뒤처지지 않습니다. 수신했지만 아직 적용하지 않은 쓰기는 테일 버퍼에 놓여, 인계를 해야 할 경우 재생할 준비가 되어 있습니다.
대기 노드를 기다리는 것이 합리적인 것은 대기 노드가 실제로 있을 때뿐입니다. 대기 노드가 뒤처지거나 네트워크에서 떨어져 나가면, 리더는 접근할 수 없는 복제본 때문에 파일시스템 전체를 멈춰 세우는 대신 전송을 중단하고 단독으로 계속 실행하며, 대기 노드가 돌아오면 다시 전송을 재개합니다. ZeroFS는 이 상태를 Connected와 Solo라고 부릅니다. Solo 상태의 리더는 단독 노드와 같은 수준으로 쓰기를 내구적으로 만드는데, 이는 곧 fsync되지 않은 쓰기가 더 이상 두 번째 사본으로 뒷받침되지 않는다는 뜻입니다. Solo는 내구성만 약화시키고 안전성은 건드리지 않습니다. 펜싱은 복제에 아무 빚도 지지 않기 때문입니다. 단독으로 실행되는 리더에게도 두 번째 작성자가 합류할 수는 여전히 없습니다.
리더는 100ms마다 하트비트를 내보내며, 각각은 자신의 writer epoch를 담고 있습니다. 대기 노드가 약 2초의 takeover TTL 동안 하트비트를 하나도 받지 못하면 스스로 승격합니다. 데이터베이스를 쓰기용으로 열어 옛 리더를 펜싱하고, 버퍼링해 둔 tail을 재생하고, 서비스를 시작합니다. 옛 리더가 돌아오더라도 그 하트비트는 오래된 epoch를 싣고 있으므로 무시되며, 인계가 진행 중인 뒤에는 리더십을 되찾을 수 없습니다. 그리고 아직 재생되지 않은 tail을 가진 대기 노드에 도달한다면, 그 접촉 자체가 타이머를 끝까지 기다리는 대신 즉시 승격해야 한다는 신호가 됩니다.
sequenceDiagram participant C as Client participant L as Leader participant S as Standby participant O as Object storage Note over C,O: Steady state C->>L: write L->>S: ship (semi-sync) S-->>L: replicated L-->>C: ack (after local apply) L->>O: flush on fsync L->>S: heartbeats (writer epoch) Note over L: Leader crashes or is partitioned L--xS: heartbeats stop Note over S: no heartbeat for ~2s (takeover TTL) S->>O: open as writer, fence old leader S->>S: replay buffered tail Note over S: now serving as the new leader C->>L: in-flight write L--xC: fail, timeout, or not-leader C->>S: reroute, resend with op-id S-->>C: definitive result (deduplicated) Note over L: a returning old leader stays fenced
리더 장애의 종단 간 흐름: 정상 상태, 인계, 그리고 클라이언트의 재라우팅된 재시도.
클라이언트는 두 노드의 주소를 모두 보유하므로, 한쪽이 서비스 제공을 멈추면 다른 쪽을 찾아 계속 진행합니다. 까다로운 경우는 재시도인데, 리더가 죽었을 때 진행 중이던 연산이 커밋되었을 수도 있고 아닐 수도 있기 때문입니다. 모든 변경 호출은 안정적인 operation id를 담고 있으며, 새 리더는 이를 사용해 중복 제거를 수행합니다. 그 덕분에 클라이언트는 mkdir이나 rename을 다시 보내고 타임아웃이나 중복 효과 대신 실제 응답을 돌려받을 수 있습니다. 퇴위한 리더는 쓰기뿐 아니라 읽기 응답도 멈춰야 합니다. 노드는 자신의 리스가 유효한 동안에만 서비스하며, 리스는 스토리지 엔진의 데이터베이스 관점을 따르고, 새 epoch를 감지하는 매니페스트 폴링이 한 번의 간격 안에 오래된 데이터베이스를 닫기 때문입니다. 따라서 유휴 상태의 읽기 전용 전직 리더도 자신이 패배했음을 알기 위해 쓰기를 시도할 필요 없이 스스로 물러납니다.
복제는 fsync되지 않은 창을 좁힐 수는 있어도 닫을 수는 없습니다. 두 노드를 동시에 잃거나, Solo로 동작 중인 단독 노드를 잃으면, 승인되었지만 플러시되지 않았던 쓰기는 사라집니다. 쉬운 길은 그 손실을 조용히 넘기고 이를 설계의 대가라고 부르는 것입니다. ZeroFS는 그렇게 하지 않습니다. 일부 시간에 조용히 실패하는 내구성 보장은 아무것도 그 위에 지을 수 없는 보장이기 때문입니다. 대신 ZeroFS가 약속하는 것은 정확하며, 단일 노드에서도 노드 쌍에서도 똑같이 성립합니다. 성공한 fsync는 그 디스크립터에서 승인된 모든 쓰기가 오브젝트 스토리지에 내구적으로 존재함을 의미하고, HA 환경에서는 어떤 페일오버도 견딘다는 뜻이기도 합니다. 복구가 실제로 그 쓰기들을 가져오지 못한 경우 fsync는 ESTALE을 반환합니다. 더 이상 존재하지 않는 데이터에 대해 성공을 보고하지 않습니다.
전체는 하나의 값, 즉 lineage token에 달려 있습니다. 이는 단일하고 끊기지 않은 내구적 계보를 가리킵니다. 인계는 자신이 리더의 데이터를 상속받았음을 보여줄 수 있을 때 그 토큰을 유지하는데, 정확히 연결된 대기 노드가 자신의 tail을 재생하는 경우가 그렇습니다. 반대로 그렇지 못할 때는 새 토큰을 발행합니다. 두 노드 모두의 콜드 재시작 이후나, Solo 상태 기간 뒤에 일어나는 인계 이후가 여기에 해당합니다. 후자의 경우 전송되지 않은 쓰기가 애초에 대기 노드에 도달하지 않았기 때문입니다. Solo에 진입하는 리더는 첫 Solo 쓰기를 승인하기 전에 오브젝트 스토리지에 계보를 상속 불가능한 것으로 기록합니다. 그러면 이후의 어떤 인계도 자신이 보유하지 않은 쓰기를 주장하는 대신 반드시 새로 생성해야 합니다. 클라이언트가 만드는 각 fsync되지 않은 변경에는 당시의 현재 토큰이 태그로 붙습니다. fsync 시 클라이언트는 아직 남아 있는 것들 가운데 가장 오래된 토큰을 제시하고, 리더는 플러시를 수행한 뒤 이를 현재 살아 있는 계보와 대조하며, 불일치하면 호출을 실패시킵니다. 그 한 번의 비교로부터 세 가지 경우가 나옵니다.
| 무슨 일이 있었는가 | 토큰 | fsync 결과 |
|---|---|---|
| 깔끔한 단일 노드 크래시 | 유지됨: 대기 노드가 쓰기를 보유했고 tail을 재생함 | 단일 노드에서처럼 투명하게 성공 |
| 플러시 전에 두 노드 모두 손실 | 재생성됨: 콜드 재시작은 무엇을 상속받았는지 증명할 수 없음 | ESTALE: 쓰기는 메모리에만 있었음 |
| Solo 이후 인계 | 재생성됨: Solo 리더가 먼저 계보를 오염시킴 | ESTALE: 새 리더는 그 쓰기를 받은 적이 없음 |
이 검사는 디스크립터 단위이며 POSIX를 충실히 따릅니다. 모든 열린 핸들에 걸친 파일 전체를 반영하고, 다른 파일에 대해서는 아무 말도 하지 않으며, 일반 데이터 쓰기뿐 아니라 메타데이터, 디렉터리 엔트리, 두 디렉터리에 걸친 rename까지 포괄합니다. 실패가 실제로 경계를 넘었을 때 애플리케이션은 다음 fsync에서 그것을 알게 됩니다. 그 호출은 거짓 성공 대신 깔끔하게 실패하며, 쓰기 이후 첫 fsync가 구속력 있는 약속이기 때문에, 이 실패는 마지막 성공한 것 이후의 fsync되지 않은 작업이 사라졌다는 정직한 진술입니다. 다시 수행하고 다시 fsync하면 됩니다. 클라이언트 라이브러리는 이를 별도의 stale-handle 오류로 노출하므로, 애플리케이션은 이를 다른 실패와 구분해 의도적으로 복구할 수 있습니다.
"스플릿 브레인이 없다"거나 "fsync는 결코 거짓말하지 않는다" 같은 주장은, 그 뒤에 있는 테스트만큼만 가치가 있습니다. 그래서 ZeroFS는 바로 이런 실패를 장애 주입 하에서 찾아내도록 만들어진 프레임워크인 Jepsen으로 검증됩니다.
여기에는 두 계층이 사용됩니다. 첫 번째는 단일 노드를 블랙박스로 취급합니다. Jepsen은 9P 마운트를 통해 임의의 파일시스템 연산 시퀀스를 발행하고, 모든 결과를 참조 모델과 대조하며, 어떤 불일치든 최소 실패 사례로 축소합니다. 크래시 모드에서는 실행 중간에 서버를 죽이고 메모리 내 memtable을 버린 뒤, 오브젝트 스토리지로부터 복구하여 살아남은 상태가 마지막 fsync와 일관되는지 확인합니다. 이 계층은 모든 변경마다 CI에서 실행됩니다.
두 번째는 MinIO 위에 실제 리더와 대기 노드를 세우고, 그 위에 네메시스를 풀어놓습니다. 리더, 대기 노드 또는 둘 다를 죽이고, 둘 사이 링크를 분할하고, 어느 쪽도 도달할 수 없도록 오브젝트 스토어를 멈추고, 클라이언트가 계속 쓰기와 fsync를 호출하는 동안 stop 신호로 리더를 리스 기간 너머까지 정지시킵니다. 각 실행 후 체커는 위 보장들이 의존하는 질문들을 던집니다. 두 노드가 동시에 커밋한 적이 있었는가? 승인되고 fsync된 쓰기가 페일오버를 거치며 사라진 적이 있었는가? 대기 노드가 이미 인계를 마친 뒤 얼어붙었던 리더가 해동되었을 때, 오래된 데이터를 서비스하는 대신 펜싱된 채로 돌아오는가? 이 실행들 전반에서 노드 쌍은 유실, 중복, 오래된 읽기를 보이지 않으며, 해동된 리더는 자신이 펜싱되었음을 발견하고 무언가를 손상시키는 대신 물러납니다. 전체 스위트는 리포지토리에 있습니다.
몇 가지 한계는 분명히 말해 둘 가치가 있습니다. 이것은 두 노드와 하나의 단일 대기 노드 구성이므로, 가용성은 페일오버를 통해 얻지 쓰기 처리량을 통해 얻지 않습니다. 여전히 작성자는 정확히 하나뿐이며, 읽기를 여러 머신에 분산하는 것은 별개의 기능입니다. 페일오버는 즉시가 아니라 경계가 있는 동작입니다. 크래시 시 대기 노드는 2초의 takeover TTL을 기다리며, 클라이언트는 오류를 내는 대신 블로킹과 재시도로 그 간극을 건넙니다. advisory byte-range lock은 한 서버의 메모리에 존재하며 복제되지 않으므로, 페일오버 시 사라집니다. 그것들은 advisory이며 데이터를 차단하지 않지만, 같은 주의사항은 일반적으로 네트워크 파일 잠금에도 적용되므로 이를 감안해 설계할 가치가 있습니다. 그리고 fsync는 여전히 내구성 계약입니다. 연결된 대기 노드는 추가로 fsync되지 않은 쓰기를 보호하지만, 그것은 완충 장치일 뿐 중요할 때 fsync 호출을 멈춰도 된다는 뜻은 아닙니다. 전체 구성, 완전한 보장 표, 그리고 각 페일오버 사례의 자세한 내용은 고가용성 문서에 있습니다.
이 어떤 것도 fsync되지 않은 창을 닫지는 못합니다. 오브젝트 스토리지를 기반으로 하는 어떤 파일시스템도 그럴 수 없습니다. 그것이 보장하는 것은 그 창을 가로지르는 일이 결코 조용히 일어나지 않는다는 점입니다. 펜싱은 클록을 신뢰하지 않고 작성자를 결정하므로, 분할이 파일시스템을 손상시킬 수 없습니다. 세미동기 복제는 대기 노드를 최신 상태로 유지하므로, 보통의 페일오버는 아무것도 잃지 않습니다. 그리고 실패가 복제가 덮는 범위를 넘어설 때, 다음 fsync는 거짓 성공 대신 오류를 반환합니다. ZeroFS가 하는 보장은 자신이 지킬 수 있는 것들이며, 막을 수 없는 실패는 숨기는 대신 보고합니다.