기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
169 lines
13 KiB
Markdown
169 lines
13 KiB
Markdown
---
|
|
id: 8fafaeea-8746-4537-87a4-679a3a91aa4c
|
|
kind: CONCEPT
|
|
slug: write-completion-is-not-durability
|
|
title: 완료라는 말의 네 가지 뜻 — write · writeback · flush · 전원 장애 생존
|
|
topic: storage-virtualization
|
|
topicName: 스토리지 가상화
|
|
project: virtualization
|
|
status: 초안
|
|
basisVersion: Linux O_DIRECT 와 QEMU/libvirt disk 의 cache=none · cache=writeback 두 설정 기준
|
|
studio: "https://hyeonworks.com/studio/documents/8fafaeea-8746-4537-87a4-679a3a91aa4c/edit"
|
|
assets:
|
|
- key: write-completion-boundaries
|
|
file: ../../../final/assets/diagrams/write-completion-boundaries/write-completion-boundaries.svg
|
|
sourceRevision: no-commit · 밖에서 반입한 문서 한 편. 고정할 저장소 리비전이 없다
|
|
source:
|
|
- final/document.md#147-vm에서는-page-cache가-두-번-나타날-수-있다
|
|
- final/document.md#148-write-완료와-영속화는-다르다
|
|
- final/document.md#149-direct-i-o
|
|
- final/document.md#150-fsync-가-필요한-이유
|
|
- final/document.md#151-flush
|
|
- final/document.md#152-가장-위험한-상황-거짓-완료
|
|
- final/document.md#153-qemu-cache-mode
|
|
- final/document.md#154-cache=none
|
|
- final/document.md#155-cache=writeback
|
|
- final/document.md#156-writeback-=-위험-이라고-단정하면-안-되는-이유
|
|
- final/document.md#157-device-side-cache
|
|
- final/document.md#170-postgresql-예시-wal과-durability
|
|
- final/document.md#133-page-cache-write-가-바로-ssd-write는-아니다
|
|
- final/document.md#174-핵심-claim
|
|
---
|
|
|
|
# 완료라는 말의 네 가지 뜻 — write · writeback · flush · 전원 장애 생존
|
|
|
|
buffered I/O 에서 write() 는 페이지 캐시까지만 내려가고, 가상 머신에서는 그 페이지 캐시가 게스트와 호스트 양쪽에 두 번 나타날 수 있다. 게스트가 성공을 돌려받은 데이터가 호스트 메모리에 머무는 동안 호스트 전원이 나가면 물리 SSD 에는 이전 상태가 남는다. 그래서 완료라는 말이 네 단계로 갈린다 — write() 완료, writeback 완료, fsync/flush 완료, 전원 장애에도 안전한 영속성. Direct I/O 와 QEMU 캐시 모드가 이 가운데 어디까지 가는지를 바꾼다. 페이지 캐시와 fsync 를 아는 사람을 독자로 둔다. Keycloak 과 PostgreSQL 을 가상 머신 위에서 돌리는 실험에서는 이 구분을 먼저 세워야 지연을 재는 것과 데이터가 남는지를 재는 것이 한 실험에 섞이지 않는다.
|
|
|
|
## 관계
|
|
|
|
- **Guest 의 write() 가 virtqueue 에 실리기까지 — VFS · Filesystem · Page Cache · Block Layer · virtio-blk**
|
|
게스트 쪽 경로를 VFS 부터 virtqueue 까지 이어 그린 글이다. 이 글은 그 경로 위에서 완료 응답이 무엇을 보장하는지만 본다.
|
|
- **/dev/vda 뒤에 있는 것 — qcow2 · RAW · Host block device**
|
|
백엔드가 파일이면 게스트 파일시스템 아래에 호스트 파일시스템과 호스트 페이지 캐시가 한 겹 더 놓인다. 두 번째 페이지 캐시가 왜 생기는지를 그 글이 설명한다.
|
|
- **VM 아래에 한 번 더 있는 Host block stack — blk-mq · I/O Scheduler · NVMe**
|
|
fsync 요구가 호스트로 내려간 뒤 어느 계층을 지나는지는 그 글이 맡는다.
|
|
- **빨라진 구성이 durability contract 를 지운 것은 아닌지 먼저 가른다**
|
|
이 개념이 세운 네 경계를 설정 선택과 성능 개선의 판정에 적용하는 규칙이다.
|
|
- **이 VM 들의 QEMU disk cache mode 는 무엇인가**
|
|
이 호스트가 cache=none 구성인지 cache=writeback 구성인지 SSOT 에 적혀 있지 않다.
|
|
- **Guest 의 fsync() 지연과 Host storage 지연은 같이 오르는가**
|
|
fsync() 지연 값은 그 질문이 게스트와 호스트를 같은 시간축으로 잰 뒤에 이 글에 들어온다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## 게스트와 호스트에 페이지 캐시가 두 번 생긴다
|
|
|
|
일반적인 buffered I/O 에서는 `write()` 가 호출될 때마다 물리 SSD 까지 즉시 내려갈 필요가 없다. 커널은 그 내용을 메모리의 페이지 캐시에 받아 두고, 나중에 writeback 으로 파일시스템과 블록 계층을 지나 저장장치 쪽에 내려보낸다. 저장장치보다 최신인 페이지를 dirty page 라고 한다. 저장장치에 `ABC` 가 있는 상태에서 애플리케이션이 `DEF` 를 추가하면 페이지 캐시는 `ABCDEF` 를 dirty 상태로 들고 있고 SSD 에는 아직 `ABC` 만 있다.
|
|
|
|
가상 머신에서는 이 페이지 캐시가 한 번 더 나타날 수 있다. 게스트가 buffered I/O 를 쓰고 디스크 백엔드가 호스트의 파일이며 호스트도 페이지 캐시를 쓰는 구성이라고 하자. 게스트 PostgreSQL 의 쓰기는 게스트 `ext4` 와 게스트 페이지 캐시를 지나 게스트 블록 계층과 `virtio-blk` 를 거쳐 `virtqueue` 로 나간다. 가상 머신 경계를 넘은 다음에는 QEMU 와 디스크 이미지 파일을 지나 호스트 페이지 캐시에 다시 담긴다. 같은 데이터가 게스트 메모리와 호스트 메모리 양쪽에 캐시될 수 있다.
|
|
|
|
## write() 성공이 어디까지를 뜻하는가
|
|
|
|
`write()` 가 성공을 돌려준 시점에 데이터는 게스트 페이지 캐시에 있고, virtio 를 지나 호스트 페이지 캐시까지 갔을 수도 있다. 그 상태에서 호스트 전원이 나가면 물리 SSD 에는 그 데이터가 없기 때문에, 완료라는 말이 네 단계로 갈린다.
|
|
|
|
```text label="완료의 네 단계"
|
|
write() 완료
|
|
≠
|
|
writeback 완료
|
|
≠
|
|
fsync/flush 완료
|
|
≠
|
|
전원 장애에도 안전한 durability
|
|
```
|
|
|
|
## fsync() 는 요구를 계층 전체로 내려보낸다
|
|
|
|
애플리케이션이 부르는 쓰기 호출은 이렇게 생겼다.
|
|
|
|
```c label="이 호출이 성공해도 정전 이후 생존은 보장되지 않는다"
|
|
write(fd, data, size);
|
|
```
|
|
|
|
필요한 시점에 `fsync()` 를 불러 변경 내용을 필요한 영속성 경계까지 반영하도록 요청한다.
|
|
|
|
```c label="영속성 경계까지 반영을 요청한다"
|
|
fsync(fd);
|
|
```
|
|
|
|
가상 머신에서는 이 요청이 한 계층에서 끝나지 않는다. 게스트 PostgreSQL 의 `fsync()` 가 게스트 파일시스템으로 가고, 게스트 블록 계층에서 `FLUSH` 등으로 바뀐다. 그다음 `virtio-blk` 를 통해 QEMU 와 백엔드로 내려가고, 호스트 스토리지 계층을 지나 물리 저장장치까지 그 의미가 전달되어야 한다.
|
|
|
|

|
|
|
|
`WRITE` 와 `FLUSH` 가 요구하는 것은 서로 다르다.
|
|
|
|
```text label="WRITE 와 FLUSH 가 요구하는 것"
|
|
WRITE
|
|
↓
|
|
"이 데이터를 써라"
|
|
|
|
FLUSH
|
|
↓
|
|
"앞서 쓴 데이터를 필요한 영속성 경계까지
|
|
반영하고 완료 상태를 보장해라"
|
|
```
|
|
|
|
실제 순서 보장과 영속성의 의미는 이 두 줄보다 복잡하다. SSOT 도 그렇게 밝혀 두었고, 이 글은 두 요청의 구분까지만 말한다.
|
|
|
|
## Direct I/O 가 우회하는 대상은 페이지 캐시다
|
|
|
|
buffered I/O 에서는 QEMU 의 쓰기가 호스트 페이지 캐시를 거쳐 호스트 파일시스템과 블록 계층을 지나 SSD 로 간다. Direct I/O 에서는 호스트 페이지 캐시를 건너뛰고 호스트 파일시스템과 블록 I/O 경로로 바로 들어간다. Linux 의 `O_DIRECT` 가 여기에 쓰인다. 우회하는 대상은 페이지 캐시이고, Direct I/O 로 썼다고 영속성이 자동으로 보장되지는 않는다.
|
|
|
|
## cache=none 과 cache=writeback 이 정하는 것
|
|
|
|
QEMU 와 libvirt 로 정의한 디스크에서 대표적으로 볼 수 있는 설정은 `cache=none` 과 `cache=writeback` 이다. 두 값이 정하는 것은 QEMU 가 호스트 페이지 캐시와 쓰기 완료·플러시의 의미를 어떤 방식으로 쓰는가다. 이름만으로는 캐시가 아예 없는지, 무조건 위험한지가 정해지지 않는다.
|
|
|
|
| 설정 | 호스트 페이지 캐시를 어떻게 쓰나 |
|
|
|---|---|
|
|
| `cache=none` | 우회하는 방향으로 구성한다. 이중 캐시를 줄일 수 있다 |
|
|
| `cache=writeback` | 쓸 수 있게 구성한다. 일반 쓰기가 호스트 메모리에서 빠르게 완료될 수 있다 |
|
|
|
|
호스트 페이지 캐시를 우회한다고 해서 쓰기가 곧바로 영속 매체에 반영되지는 않는다. `cache=writeback` 으로 두어도 게스트의 `fsync()` 와 `FLUSH` 가 무시되지 않는다. 정상적인 계층이라면 게스트의 `fsync` 와 `FLUSH` 가 virtio `FLUSH` 로 이어진다. QEMU 와 백엔드에서 호스트 동기화와 플러시로 이어지고, 저장장치에서 필요한 완료를 확인한 뒤 게스트 완료로 돌아온다.
|
|
|
|
`cache=writeback` 을 위험하다는 한 낱말로 줄이지 않으려고 SSOT 는 정확한 표현을 따로 적어 두었다.
|
|
|
|
> writeback caching에서는 volatile cache가 존재할 수 있으므로, Guest의 flush/fsync semantics가 전체 backend/storage stack에서 올바르게 보존되는지가 중요하다.
|
|
|
|
그래서 확인할 것은 게스트가 요구한 영속성이 게스트 파일시스템에서 게스트 블록 계층과 virtio, QEMU 와 백엔드, 호스트 스토리지를 지나 장치까지 가는 동안 그 의미가 깨지지 않는지다.
|
|
|
|
두 설정 가운데 어느 쪽을 쓸지 이 프로젝트는 정하지 않았다. SSOT 는 두 설정의 뜻과 각각의 오해를 갈라 놓기만 했고, 어느 쪽을 쓰기로 했다고도 그렇게 해서 무엇을 감수했다고도 적지 않았다. 게스트를 만든 `virt-install` 세 줄을 SSOT 의 실험대 구축 절이 그대로 옮겨 적었는데 거기에도 캐시 옵션이 없다. 디스크는 `--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2` 와 시드 볼륨 둘로만 지정돼 있어서, 이 호스트의 디스크 정의에 값이 적혀 있는지부터가 확인되지 않았다. 관계로 걸어 둔 질문이 그것에 먼저 답한다.
|
|
|
|
## 호스트 페이지 캐시를 우회해도 장치 안에 캐시가 있다
|
|
|
|
Direct I/O 로 호스트 페이지 캐시를 건너뛰어도 요청은 호스트 블록 계층과 NVMe 드라이버, NVMe 컨트롤러를 지나 장치 쪽 캐시를 거쳐 플래시에 닿는다. 스토리지 컨트롤러나 장치가 휘발성 쓰기 캐시를 가질 수 있다.
|
|
|
|
```text label="RAM 을 떠난 뒤에도 세 상태가 다르다"
|
|
RAM에서 나갔다
|
|
≠
|
|
Device에 command가 전달됐다
|
|
≠
|
|
전원이 끊겨도 살아남는 상태가 됐다
|
|
```
|
|
|
|
운영에서는 장치의 플러시와 FUA(Force Unit Access) 의미, 전원 손실 보호(power-loss protection)를 갖췄는지도 확인 대상이 된다. SSOT 는 그것이 중요할 수 있다고만 적었고, 이 호스트의 장치가 무엇을 갖췄는지는 적지 않았다.
|
|
|
|
## 거짓 완료는 성능 문제가 아니다
|
|
|
|
게스트가 `WRITE` 에 이어 `FLUSH` 를 요청했는데 데이터는 호스트 메모리에만 있고 물리 저장장치에는 이전 데이터가 들어 있다고 하자. 이때 게스트에게 `FLUSH 완료` 라고 응답하면 PostgreSQL 은 영속성이 확보됐다고 판단한다. 직후에 호스트 전원이 나가면 메모리의 데이터가 사라진다. 이것은 영속성 계약이 깨지는 정확성(correctness) 문제다.
|
|
|
|
PostgreSQL 로 보면 무엇이 어긋나는지 드러난다. 잔액을 바꾸고 커밋하는 트랜잭션을 생각해 본다.
|
|
|
|
```sql label="durability 가 걸리는 트랜잭션"
|
|
BEGIN;
|
|
|
|
UPDATE users
|
|
SET balance = 1000
|
|
WHERE id = 1;
|
|
|
|
COMMIT;
|
|
```
|
|
|
|
PostgreSQL 은 WAL(Write-Ahead Log) 등의 영속성 프로토콜을 쓰며 필요한 시점에 스토리지 동기화를 수행한다. WAL 쓰기는 게스트 페이지 캐시와 `fsync` 를 지나 게스트 파일시스템과 게스트 블록 계층, `virtio-blk`, QEMU, 호스트 스토리지를 거쳐 물리 저장장치까지 간다. 그 완료가 되돌아와야 필요한 영속성 조건이 충족됐다고 보고 `COMMIT` 을 성공으로 처리한다. 가상 머신 스토리지 계층이 플러시와 `fsync` 의 의미를 제대로 보존하지 않으면, PostgreSQL 의 영속성 가정과 실제 저장장치 동작이 어긋날 수 있다.
|
|
|
|
## 이 호스트에서 확인하지 않은 것
|
|
|
|
이 글에 이 테스트 호스트에서 잰 값은 하나도 없다. 두 가상 머신의 디스크 캐시 모드가 무엇으로 설정돼 있는지 SSOT 에 없어서, 이 호스트가 `cache=none` 구성인지 `cache=writeback` 구성인지 여기서 말하지 않는다. 거짓 완료가 이 환경에서 실제로 일어나는지도 재현하지 않았다. 장치의 플러시와 FUA 의미가 어떻게 동작하는지, 전원 손실 보호를 갖췄는지도 확인하지 않았다. `fsync()` 지연 값은 관계로 걸어 둔 질문이 게스트 지연과 호스트 스토리지 지연을 같은 시간축으로 잰 뒤에 이 글에 들어온다. 이 글은 이 구조에서 완료 응답이 무엇을 보장하는지까지 설명하고, 이 서버가 그 보장을 지키는지는 재지 않았다.
|
|
|
|
<!-- body:end -->
|