페이지 캐시와 파일 IO - buffered와 direct, fsync가 실제로 하는 일
write()가 성공으로 돌아왔다고 데이터가 디스크에 있는 것은 아니다. 이 한 문장이 데이터베이스의 커밋 설계, Kafka의 내구성 모델, 로그 유실 사고를 전부 관통한다. 커널이 파일 쓰기를 어떻게 다루는지 보면 그 이유가 나온다.
write()가 끝나는 곳은 메모리다
일반적인 write()는 페이지 캐시에 데이터를 복사하고 그 페이지를 dirty로 표시한 뒤 돌아온다. 디스크에는 나중에 커널의 writeback 스레드가 쓴다. 언제 쓰는지는 dirty_ratio, dirty_expire_centisecs 같은 커널 파라미터가 정한다.
그래서 얻는 것.
- 쓰기가 빠르다. 디스크 지연이 호출자에게 보이지 않는다.
- 읽기 캐시가 공짜로 생긴다. 방금 쓴 것을 읽으면 디스크에 가지 않는다. 같은 데이터를 여러 프로세스가 읽어도 복사본은 하나다.
- 작은 쓰기가 병합된다. 커널이 모아서 순차로 내보낸다.
잃는 것 하나. 전원이 나가면 페이지 캐시의 dirty 페이지는 사라진다. 프로세스가 죽는 것은 괜찮다. 페이지 캐시는 커널에 있으므로 프로세스가 죽어도 남고, 결국 디스크에 쓰인다. 사라지는 것은 커널이 함께 죽을 때(전원, 커널 패닉)다.
fsync가 보장하는 것과 못 하는 것
fsync(fd)는 그 파일의 dirty 페이지를 디스크에 내보내고 장치가 확인할 때까지 기다린다. 이것이 내구성의 유일한 문지기다.
몇 가지 함정이 있다.
메타데이터는 별개다. 새 파일을 만들고 쓴 뒤 fsync하면 파일 내용은 안전하지만 디렉터리 엔트리는 아닐 수 있다. 부모 디렉터리도 fsync해야 “그 파일이 존재한다”가 보장된다. 데이터베이스와 로그 시스템이 디렉터리를 따로 동기화하는 이유다.
fdatasync는 메타데이터를 건너뛴다. 파일 크기가 안 바뀌는 덮어쓰기라면 이쪽이 싸다. DB가 미리 파일을 할당해 두고 덮어쓰는 이유 중 하나다.
실패를 한 번만 알려 줄 수 있다. fsync가 오류를 반환한 뒤 다시 호출하면 성공이 돌아올 수 있다. 커널이 오류 플래그를 지우기 때문이고, 페이지도 이미 버려져 재시도할 원본이 없을 수 있다. 2018년 PostgreSQL이 이 문제를 발견해 대응을 바꿨다(fsyncgate). fsync 실패는 재시도 대상이 아니라 즉시 중단 대상이라는 결론이 여기서 나왔다.
장치가 거짓말할 수 있다. 쓰기 캐시가 켜진 디스크는 캐시에 담고 성공을 돌려준다. 배터리 백업이 없으면 그 데이터도 전원과 함께 사라진다.
O_DIRECT: 캐시를 건너뛴다
O_DIRECT는 페이지 캐시를 우회해 사용자 버퍼에서 장치로 직접 보낸다. 데이터베이스가 쓰는 이유는 속도가 아니라 이중 캐싱을 피하고 자기가 캐시를 통제하기 위해서다. DB는 이미 자기 버퍼 풀을 갖고 있고, 어떤 페이지를 언제 내보낼지 자기가 안다. 커널이 그 위에서 또 캐싱하면 메모리만 두 배로 쓴다.
대가가 크다. 정렬 요구(블록 크기 배수), 캐시 없음, 작은 IO에 매우 불리. 일반 애플리케이션이 쓸 도구는 아니다.
O_DIRECT도 fsync를 대신하지 않는다. 장치 캐시는 여전히 남아 있다.
세 가지 정책
| 정책 | 예 | 내구성 | 비용 |
|---|---|---|---|
| buffered, fsync 안 함 | 일반 로그 파일, Kafka 기본 | 커널 크래시에 취약 | 가장 빠름 |
| buffered + 커밋마다 fsync | PostgreSQL 기본, innodb_flush_log_at_trx_commit=1 | 개별 커밋 보장 | fsync 지연이 커밋 지연 |
| buffered + 주기적 fsync | innodb_flush_log_at_trx_commit=2 | 최근 1초 유실 가능 | 중간 |
Kafka가 fsync를 기본으로 하지 않는 것은 내구성을 포기한 것이 아니라 다른 곳에서 얻기 때문이다. acks=all + min.insync.replicas로 여러 노드의 페이지 캐시에 들어간 것을 확인한다. 노드 하나의 전원이 나가도 다른 노드에 있다. 복제를 내구성의 수단으로 쓰는 설계이고, 그래서 단일 노드 Kafka는 이 전제가 빠진 상태다.
이 설명이 깨지는 곳
- 컨테이너와 가상화가 한 겹 더 있다. 호스트의 페이지 캐시, 하이퍼바이저의 캐시 정책, 네트워크 스토리지의 캐시까지 겹치면
fsync의 의미가 층마다 달라진다. - 클라우드 블록 스토리지는 자체 복제를 한다. 로컬 디스크의
fsync모델과 다르고, 지연 특성도 다르다. - 그룹 커밋이 fsync 비용을 나눈다. 여러 트랜잭션의 커밋을 모아 한 번에
fsync하므로, 동시성이 높을수록 커밋당 비용이 준다. “fsync는 N ms”라는 단일 수치는 부하에 따라 달라진다. - 페이지 캐시가 크다고 항상 좋은 것은 아니다. 큰 순차 읽기 한 번이 캐시를 밀어내(cache pollution) 다른 워크로드를 느리게 만들 수 있다.
무엇을 재면 확인되는가
innodb_flush_log_at_trx_commit이나synchronous_commit을 바꿔 가며 커밋 지연과 처리량을 재고, 그 사이에 전원 차단(컨테이너 강제 종료가 아니라 호스트 수준)에서 무엇이 사라지는지 본다./proc/meminfo의Dirty와Writeback을 부하 중에 관찰한다. 쓰기가 실제로 언제 나가는지 보인다.- 동시성을 올려 가며 커밋당
fsync비용이 줄어드는지(그룹 커밋) 본다.
ParityPay 8편에서 acks별 유실을 쟀지만 단일 노드였고, 프로세스를 죽였지 커널을 죽이지는 않았다. 프로세스 종료로는 페이지 캐시 유실을 재현할 수 없다는 것이 이 글을 쓰며 정리된 한계다.
실무와의 접점
ISMS 대응 로그 수집 같은 작업에서 “로그가 유실되지 않는다”를 어떻게 보장했는지 지금 다시 보면, 대부분 애플리케이션 버퍼와 네트워크 전송까지만 보고 있었다. 수집기가 fsync를 하는지, 컨테이너가 죽을 때와 노드가 죽을 때가 어떻게 다른지는 묻지 않았다. “유실되지 않는다”는 문장은 어느 층까지의 보장인지를 적어야 검증 가능한 문장이 된다.
정리
write()는 페이지 캐시까지다. 프로세스가 죽어도 남지만 커널이 죽으면 사라진다.- 내구성의 문지기는
fsync하나다. 파일 내용과 디렉터리 엔트리는 따로 동기화해야 한다. fsync실패는 재시도 대상이 아니라 중단 대상이다.O_DIRECT는 속도가 아니라 캐시 통제를 위한 것이고,fsync를 대신하지 않는다.- Kafka는
fsync대신 복제로 내구성을 얻는다. 단일 노드에서는 그 전제가 빠진다. - 그룹 커밋 때문에 “fsync 비용”은 단일 수치가 아니라 동시성의 함수다.
댓글
아직 댓글이 없습니다