Skip to content

[INF-599] Fence the local disk tier on the first unrecoverable disk error - #22

Merged
Juhyun-Kim-Memphis merged 3 commits into
our_mainfrom
inf-599/disk-fence-our-main
Oct 7, 2026
Merged

Juhyun-Kim-Memphis merged 3 commits into
our_mainfrom
inf-599/disk-fence-our-main

Conversation

@Juhyun-Kim-Memphis

@Juhyun-Kim-Memphis Juhyun-Kim-Memphis commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

무엇을 바꾸나

L4(SSD offload) 디스크가 죽었을 때, store 가 첫 번째 복구 불가능한 디스크 오류에서 로컬 디스크 tier 를 격리(fence) 하도록 합니다. 격리되면 그 store 는 디스크 읽기·쓰기를 즉시 멈추고, master 에서 LOCAL_DISK segment 를 내립니다. 메모리 tier 는 계속 서빙합니다.

이 PR 은 upstream 후보 브랜치 inf-599/disk-fence (upstream main 245e7106 기준, upstream PR kvcache-ai#4490)를 our_main 위로 cherry-pick 한 것입니다. 커밋은 셋입니다.

커밋 내용 upstream 브랜치
6fe89e5e disk fence 본체 54952130 과 동일
e0dbb2a8 리뷰 반영 1: 격리 시 master 의 offload 대기열 반납, 읽기 실패는 syncfs 없이 검사, gauge 0 초기화, 로그 보강 75fe664c 과 동일
b5d2bab7 리뷰 반영 2 (our_main 전용): 격리 시 kvcache-ai#3006 carry 로 들고 있던 task NACK 해당 없음

왜 필요한가

2026-09-10 사고에서 디스크 한 장이 죽자 모든 엔진이 1시간 동안 FILE_OPEN_FAIL 을 받았습니다. 지금 코드에서는 다음 세 가지가 겹칩니다.

  • 실패한 읽기·쓰기는 FILE_* 오류 코드만 돌려주고 errno 는 버려집니다. 그래서 "장치가 죽었다"와 "짧은 쓰기·디스크 가득 참"을 구분할 수 없습니다.
  • heartbeat 는 죽은 디스크에도 계속 offload 작업을 받고, SEGMENT_NOT_FOUND 를 보면 segment 를 다시 mount 합니다.
  • (R3) drain 의 unmount RPC 가 master 의 긴 sweep 중에 타임아웃되면 draining_ latch 가 풀리고, 다음 heartbeat 가 segment 를 다시 mount 합니다. 리그에서 master 의 디스크 index 6.2만~7.6만 건이 사라지는 것을 재현했습니다.

어떻게 동작하나

  1. StorageFile 이 처음 실패한 syscall 의 errno 를 기록합니다(sys_errno()). PosixFile 의 write/read/pwritev/preadv/fdatasync 실패 지점에서 설정합니다. 짧은 읽기·쓰기는 errno 0 으로 남겨 논리 오류로 취급합니다.
  2. StorageBackendInterface::SetDiskErrorObserver 를 추가했습니다(기본은 아무것도 안 함). BucketStorageBackend 가 open/preadv/pwritev/fdatasync/write/read 실패를 errno 와 함께 알립니다. 다른 backend 는 영향이 없습니다.
  3. FileStorage::OnDiskError 가 실패한 스레드에서 바로 errno 를 분류합니다. 카운터나 시간 창은 쓰지 않습니다.
분류 errno 근거
즉시 격리 ENODEV, ENXIO, EROFS, ESHUTDOWN "장치가 사라졌다"는 신호. 우리 스택(NVMe → dm-stripe → XFS)에서는 관측되지 않았지만, 다른 스택을 위해 남겨 둠
파일시스템 검사 후 판단 EIO, EUCLEAN, EBADMSG, ENODATA, EREMOTEIO, EILSEQ, ETIMEDOUT, ENOLINK EIO 는 리그에서 실측(모든 실패 경로의 유일한 errno). 나머지는 block layer·XFS verifier 조사 결과
무시 그 밖의 전부: 0(짧은 읽기·쓰기), ENOSPC, EDQUOT, EFBIG, ENOENT, EEXIST, EACCES, EMFILE, ENOMEM, EBADF, … 파일 단위·논리 오류. ENOSPC 는 실측. 캐시는 evict 할 수 있으므로 격리 사유가 아님
  1. 파일시스템 검사 = syncfs + fstat(열어 둔 offload 디렉터리 fd). 생성자에서 offload 디렉터리를 O_RDONLY|O_DIRECTORY 로 열어 두고, 검사할 때 syncfs 로 XFS 로그를 강제로 쓴 뒤 fstat 의 errno 를 봅니다. fstat 이 무시 분류가 아닌 errno 로 실패하면 격리합니다.
    • syncfs 는 쓰기 계열 실패(pwritev·fdatasync·write·open)에서만 돌립니다. 읽기 실패(preadv·read)는 fstat 만 봅니다. 읽기 실패는 로그에 쓸 것을 남기지 않고(리그에서 읽기 실패만으로는 XFS 가 shutdown 된 적이 없음), 나쁜 파일 하나가 읽기 실패마다 파일시스템 전체 flush 를 일으켰기 때문입니다(E3: 150초에 1,435회). 그 대가로, offload 쓰기가 전혀 없는 store 는 장치가 죽어도 첫 쓰기 실패 때 격리됩니다. 격리하지 않은 실패의 로그 줄(action=disk_error_not_fenced)에 어느 검사를 했는지(check=fstat 또는 check=syncfs+fstat) 남깁니다.
    • 디렉터리 fd 를 열지 못했으면 경로에 stat 을 써서 같은 신호를 봅니다(예전에는 검사가 EBADF 로 조용히 꺼졌음).
    • fstat: XFS 가 shutdown 된 경우에만 정확히 EIO 를 돌려주고, 장치 I/O 를 하지 않으며, dmsetup suspend 중에도 멈추지 않았습니다(실측).
    • syncfs 가 필요한 이유: XFS 는 로그 쓰기가 실패해야 shutdown 됩니다. 죽은 장치에서 store 가 처음 보는 EIO 는 보통 shutdown 이전이고, idle 상태에서는 180초 동안 shutdown 되지 않았습니다. syncfs 가 로그를 밀어 shutdown 을 일으키므로 바로 뒤의 fstat 이 그것을 봅니다.
    • statfs 를 쓰지 않은 이유: statfs 와 XFS geometry ioctl 은 shutdown 이후에도 성공합니다. 검사로 쓸 수 없습니다.
    • sentinel 파일을 쓰지 않은 이유: 4 KiB sentinel 은 6-leg stripe 의 죽은 leg 를 놓쳤습니다(실측).
    • 불량 블록 하나만 있는 건강한 파일시스템에서는 syncfs 가 EIO 를 한 번 내도 fstat 은 성공하므로 격리하지 않습니다(의도한 동작).
    • 검사는 한 번에 하나만 돕니다(filesystem_check_running_). 검사가 도는 동안 들어온 실패는 자기 검사를 건너뛰고 돌고 있는 검사의 결론을 따릅니다.
  2. 격리의 효과 (프로세스가 살아 있는 동안 유지):
    • FileStorage::BatchLoad 가 장치를 건드리지 않고 FILE_READ_FAIL 을 돌려줍니다(원격 BatchGet, 로컬 BatchGetLocal, promotion 모두 이 한 곳을 지남).
    • OffloadObjects 는 다음 bucket 경계에서 멈춥니다. bucket 에 들어가지 못한 task 는 기존 NACK sweep 이 반납하지만, our_main 의 [Bug]: [SSD] Offload keys deferred to the ungrouped pool lose their tasks — NACKed at deferral, silently dropped next cycle, never written to SSD kvcache-ai/Mooncake#3006 carry 대상(이번 cycle 에 deferred 된 키와, 중단된 첫 bucket 에 실려 있던 ungrouped pool 의 carryover 키)은 sweep 이 NACK 하지 않고 carry 맵에 남깁니다. 이 task 들은 아래 heartbeat 단계에서 NACK 합니다(b5d2bab7).
    • heartbeat 를 즉시 깨워 enable_offloading_ = false, draining_ = true 로 만듭니다. 그다음 한 번만 다음을 하고 LOCAL_DISK segment 를 unmount 합니다.
      • carry 맵의 task 를 모두 NACK 하고 backend 의 ungrouped pool 을 비웁니다(our_main 전용).
      • OffloadObjectHeartbeat(false) 를 보냅니다. master 는 이 store 앞으로 쌓인 offload 대기열을 반납하고, 그 task 들이 잡고 있던 source memory replica 의 refcount 를 내리며, 더는 enqueue 하지 않습니다. drain 이 이미 unmount 했으면 건너뜁니다. best effort 이고, 실패하면 master 의 TTL reaper(600초)가 예전처럼 회수합니다.
      • 처음 버전에서는 enable_offloading_ = false 만 바꿨는데, heartbeat 가 draining 이면 바로 return 하므로 이 값은 master 에 전달되지 않았습니다. unmount(UnregisterClient)는 대기열을 버리지만 task 와 refcount 는 그대로 두므로, 해당 memory replica 들이 최대 600초 동안 evict 되지 않았습니다(리뷰 S1). 지금은 위 두 단계가 이를 반납합니다.
    • 새 gauge mooncake_ssd_disk_fenced(0/1). 생성자에서 0 으로 설정하므로 격리 전에도 시리즈가 있습니다(처음 버전에서는 첫 격리 전까지 시리즈가 없었음). 격리된 store 는 프로세스도 살아 있고 메모리 tier 도 서빙하므로 겉보기에는 정상입니다. 이 gauge 가 알람을 걸 수 있는 유일한 신호입니다.
  3. R3 latch 수정: DrainLocalDiskSegment 에서 unmount RPC 가 실패해도 draining_ 을 더 이상 되돌리지 않습니다. draining 중에는 heartbeat 가 매 tick 마다 unmount 를 다시 시도하고(master 는 이미 unmount 된 segment 를 성공으로 처리하므로 재시도는 안전), 다시 mount 하는 SEGMENT_NOT_FOUND 분기에는 도달하지 않습니다.

observer 는 RPC 를 하지 않고, I/O 경로나 heartbeat 가 잡는 lock 도 잡지 않습니다. enable_offloading_ 은 heartbeat 가 RPC 를 하는 동안 잡고 있는 offloading_mutex_ 아래에 있으므로, 그 변경은 heartbeat 스레드에서만 합니다.

our_main 으로 옮기며 생긴 충돌

our_main(16f99c46 = upstream 7faef38b sync + kvcache-ai#4183 carry)은 245e7106 보다 upstream 커밋 86개 뒤에 있습니다. 충돌은 두 곳이었고 동작에는 영향이 없습니다.

자동으로 합쳐진 나머지 hunk 는 원본 패치와 추가·삭제 줄이 한 줄도 다르지 않음을 diff 로 확인했습니다. 리뷰 반영 1(e0dbb2a8)은 충돌 없이 cherry-pick 되었고, 추가·삭제 줄이 upstream 75fe664c 과 같습니다.

our_main 의 OffloadObjects 는 deferred task carry(fork PR #15, upstream kvcache-ai#3009 backport)를 갖고 있습니다. 처음 버전 본문에는 "격리 시 PartitionUnbucketedTasks 가 같은 방식으로 미처리 task 를 NACK 한다"고 적었지만, carry 대상 키에는 틀린 설명이었습니다. 격리 검사는 키를 all_bucket_keys 에 넣기 전에 loop 를 끊으므로, carryover 키는 "이전에 carry 된, 이번에 emit 되지 않은 키"로 다시 carry 되고 NACK 되지 않습니다. 격리 뒤에는 OffloadObjects 가 다시 불리지 않으므로 이 task 들은 TTL 까지 남았습니다. 격리 검사 위치는 그대로 두고, 격리를 적용하는 heartbeat 에서 carry 맵 전체를 NACK 하도록 고쳤습니다(b5d2bab7). carry 맵은 heartbeat 스레드만 만지므로 새 동기화는 필요 없고, 격리 뒤 첫 heartbeat 가 반드시 이 단계를 지나므로 carry 된 task 는 경로와 무관하게 모두 반납됩니다. clang-format 20.1.0 을 적용해도 바뀌는 것이 없습니다.

테스트

리그(ganymede inf599-rig, Release, USE_CUDA=OFF)에서 이 브랜치를 별도 소스·빌드 디렉터리로 전체 빌드했고(pybind store.so 포함), 아래 suite 가 모두 통과했습니다.

suite 결과
posix_file_test 19/19 (새 테스트 2)
file_storage_test 34/34 (새 테스트 8)
storage_backend_test 97/97 (새 테스트 1)
ssd_metrics_test 6/6 (직렬화된 SSD metric 에 새 gauge 포함)
file_storage_promotion_test 11/11
offload_on_evict_test 1/1
master_service_ssd_test 7/7
rpc_timeout_test 8/8
promotion_on_hit_test 55/55

합계 238개, 실패 0 (리뷰 반영 후 b5d2bab7 기준). 전체 target 빌드(725 단계, ninja -j32 245초)와 import mooncake.store 도 확인했습니다. upstream 후보 브랜치와 file_storage_test 개수가 같은 것은 our_main 에 없는 upstream 테스트(kvcache-ai#4097 등)가 하나 빠지고 our_main 전용 테스트가 하나 더해졌기 때문입니다. storage_backend_test 가 2개 적은 것도 같은 이유입니다.

새 테스트 11개(파일 3개)는 각각 결정 하나씩을 고정합니다. 분류표, errno 기록, backend 연결, 죽은 파일시스템·살아 있는 파일시스템·ENOSPC·ENODEV 에서의 격리 여부, 단일 검사 보장, 그리고 R3 latch 입니다. R3 latch 테스트(DrainUnmountFailureKeepsLatchAndRetries)는 upstream base 에서 컴파일되고 실패하며(4개 assertion), 이 브랜치에서 통과합니다.

리뷰 반영으로 바뀐 테스트:

  • 격리 시나리오는 이제 실제 put 으로 master 에 offload task 를 쌓아 두고, 격리 후 그 키의 Upsert 가 성공하는지(= master 가 task 와 refcount 를 반납했는지)를 봅니다. 처음 버전은 외부 효과가 없는 로컬 플래그(enable_offloading_)만 봤습니다.
  • "격리된 OffloadObjects 가 backend 에 닿지 않는다" assertion 은 memory replica 가 있는 키로 바꿨습니다. 처음 버전의 키는 replica 가 없어 격리 검사가 없어도 통과했습니다.
  • 읽기 실패의 검사는 syncfs 없이, 쓰기 실패의 검사는 syncfs 와 함께 도는지 봅니다. gauge 가 실패 전부터 직렬화되는지 봅니다.
  • 새 테스트 DiskFenceNacksCarriedOffloadTasks(our_main 전용): 격리 전에 pool 로 deferred 된 키의 task 가 격리 후 반납되고, carry 맵과 pool 이 비는지 봅니다.
  • 각 수정을 되돌린 빌드에서 해당 assertion 이 실패하는 것을 리그에서 확인했습니다(S1·S2·S3·N2 는 upstream 브랜치, carry NACK 은 이 브랜치).

리뷰 반영 후 리그 E2E 재실행(upstream 브랜치 빌드, our_main 과 공통 코드):

시나리오 결과
E1 XFS shutdown +0.032초 격리(op=open), 같은 ms 에 release_offload_queue OK, +0.418초 unmount ack
E2 장치 전체 dead +0.464초 격리(op=fdatasync, 쓰기 계열이라 syncfs 로 shutdown 유도), +0.864초 unmount ack
E3 bucket 파일 하나 불량 격리 0, disk_error_not_fenced 1,555줄 전부 op=preadv … check=fstat, store a 의 syncfs 호출 0회(strace 60초), 검증 실패 키 500개 = 불량 bucket 키 500개
E7 격리 + 느린 unmount +0.040초 격리, 1차 unmount RPC_TIMEOUT(+1.05초) → heartbeat 재시도 OK(+11.14초), 재mount 0
S1 확인 격리 직전 12초 동안 store a 의 메모리 segment 에 쓴 키 1,200개를 격리 직후 Upsert: 수정 전 빌드(54952130)는 976개가 OBJECT_HAS_REPLICATION_TASK(source replica 고정), 수정 후는 E1·E2·E7 모두 1,200개 성공

알려진 한계 (구현하지 않음)

  1. 멈춘(hung) 장치는 errno 를 내지 않으므로 격리되지 않습니다. 검사 중 장치가 멈추면 syncfs 가 실패한 스레드를 막습니다.
  2. stripe 의 한 leg 만 죽고 XFS 가 아직 shutdown 되지 않은 경우, shutdown 이후의 실패가 올 때까지 격리되지 않습니다. 그 stripe 의 buffered read 는 errno 없이 짧게 돌아와 논리 오류로 분류됩니다.
  3. 조용히 버려지는 쓰기(dm-flakey drop_writes)는 감지하지 못합니다.
  4. 불량 블록 하나는 의도적으로 격리하지 않습니다. 파일 단위 eviction 은 upstream [Store] Self-heal dangling LOCAL_DISK replicas on the read path kvcache-ai/Mooncake#3889 범위입니다.
  5. io_uring 경로(UringFile, 운영은 USE_URING=false), errno 를 남기지 않는 std::filesystem 호출, eviction 중 unlink, close() 오류, .meta 의 writeback 오류는 observer 에 보고되지 않습니다.
  6. 이미 죽은 디스크로 시작하는 경우(R4)는 그대로입니다. 메모리 전용 시작은 범위 밖입니다.
  7. 격리는 store 를 재시작해야 풀립니다. 자동 재활성화는 없습니다.
  8. 실패한 drain 은 여전히 HTTP 500 을 즉시 돌려주고 grace sleep 을 건너뜁니다. 프로세스가 재시도 성공 전에 종료되면 예전처럼 master 의 liveness 만료가 정리합니다.
  9. EIO 외의 "검사" 분류 errno 는 조사 결과일 뿐 리그에서 만들어 보지 못했습니다. 잘못 분류돼도 비용은 syncfs+fstat 한 번입니다.
  10. 격리 시점에 열어 둔 offload 디렉터리 fd 때문에, 죽은 XFS 는 store 를 재시작하기 전까지 umount 할 수 없습니다(EBUSY). 격리를 풀려면 어차피 재시작해야 하므로 운영 절차는 "store 재시작 → 디스크 교체" 순서입니다.
  11. 검사를 건너뛴 실패(다른 스레드의 검사가 도는 중)는 로그를 남기지 않습니다. 그래서 격리를 일으킨 실패와 로그의 op 가 다를 수 있습니다(리뷰 N1, 반영하지 않음).
  12. 시작 직후 LoadBucketMetadata 의 EIO 로 격리되면 Init 이 mount·재등록을 마친 뒤 첫 heartbeat 가 곧바로 unmount 합니다(무해, 리뷰 N7). 실패한 drain(HTTP 500)도 latch 는 되돌릴 수 없다는 점은 응답 본문에 적지 않았습니다(리뷰 N8).

링크

A dead offload disk today keeps being used: every failed read or write
returns a FILE_* error code that cannot tell a dead device from a short
write or a full disk, the errno is dropped, and the heartbeat keeps
taking offload work and re-mounting the LOCAL_DISK segment.

Keep the errno and decide at the first failure:

- StorageFile records the errno of its first failed syscall
  (sys_errno()); PosixFile sets it for write/pwritev/read/preadv/
  fdatasync. Short reads and writes stay logical (errno 0).
- StorageBackendInterface gains SetDiskErrorObserver (default no-op).
  BucketStorageBackend reports open/pwritev/fdatasync/write/read/preadv
  failures with their errno.
- FileStorage classifies the errno inline on the failing thread:
  ENODEV/ENXIO/EROFS/ESHUTDOWN fence; EIO and the block-layer and
  corruption errnos run one filesystem check (syncfs to force the XFS
  log, then fstat on a held offload-dir fd, which fails with EIO exactly
  when XFS is shut down) and fence only if it fails; everything else
  (ENOSPC, ENOENT, ...) is ignored. No counters or time windows.
- A fence is latched for the process lifetime: reads short-circuit and
  offload stops at once; the heartbeat (woken immediately) disables
  offloading and unmounts the segment through the drain latch.
- The drain latch is no longer reset when the unmount RPC fails. While
  draining, each heartbeat retries the unmount until the master
  acknowledges it (the master treats an already-unmounted segment as
  success) and never re-mounts.
- New gauge mooncake_ssd_disk_fenced (0/1).

(cherry picked from commit 5495213,
upstream-candidate branch inf-599/disk-fence on 245e710. Conflicts:
file_storage.h includes (kept our_main <unordered_set> beside the new
<condition_variable>/<mutex>); file_storage_test.cpp helper block (dropped
upstream-only ConcurrentOffloadBackend context from kvcache-ai#4097, kept the new
FailingReadBackend). No behaviour change.)
Follow-up to the disk fence, from review:

- ApplyDiskFence now sends one OffloadObjectHeartbeat with offloading
  disabled before the unmount (skipped if a drain already unmounted).
  The unmount drops the master's offload queue for this store but not
  the source-replica references its tasks hold, so those memory replicas
  stayed pinned until the master's TTL reaper (600 s). The heartbeat
  makes the master release them and stop enqueuing; it is best effort.
- The filesystem check runs syncfs only after a write-path failure
  (pwritev, fdatasync, write, open). A failed read leaves the log
  nothing to write (read failures alone never shut XFS down on the rig),
  and one bad file otherwise caused a whole-filesystem flush on every
  failed read. Read failures check with fstat only.
- If the offload dir fd could not be opened, the check falls back to
  stat on the path instead of silently never fencing.
- mooncake_ssd_disk_fenced is set to 0 at construction, so the series
  exists before a fence.
- The unmount retry logs failures at WARNING; the vector_read and
  vector_write failure logs carry the errno; disk_error_not_fenced
  names the check that ran (fstat or syncfs+fstat).

Tests: the fenced scenarios now queue a real offload task and check that
the master released it (Upsert succeeds) instead of checking a local
flag; the fenced OffloadObjects assertion uses a key with a memory
replica, so it fails without the fence; read failures are checked
without syncfs and write failures with it; the gauge is exported before
any failure.
our_main only (the kvcache-ai#3006 deferred carry is not upstream).

After a fence no OffloadObjects call comes to re-emit the backend's
ungrouped pool, so the tasks carried for it (this cycle's deferrals, and
the pool's carryover that a fence-aborted first bucket never reached)
were neither written nor NACKed and pinned their source replicas until
the master's TTL reaper. ApplyDiskFence now NACKs every carried task and
drops the pool, once, on the heartbeat thread that owns the carry map.
The fence check in OffloadObjects stays where it is: keys it leaves
unbucketed are NACKed by the sweep unless carried, and carried keys are
NACKed here.

Test: a task deferred to the pool before the fence is released by it
(Upsert succeeds), and the carry map and pool are empty.
@Juhyun-Kim-Memphis
Juhyun-Kim-Memphis marked this pull request as ready for review October 7, 2026 09:00
@Juhyun-Kim-Memphis
Juhyun-Kim-Memphis merged commit a108376 into our_main Oct 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant