파일 시스템 실무
파일 디스크립터 뒤에 있는 커널의 세 단계 자료구조와 dup2를 이용한 리다이렉션, 페이지 캐시와 fsync로 데이터를 안전하게 저장하는 방법을 실습합니다. 디스크 사용량을 살펴보는 명령, RAID의 패리티와 고장 허용, SSD의 TRIM처럼 서버를 운영할 때 만나는 주제도 정리합니다.
앞의 글들에서 파일과 디렉터리, 파일 시스템의 내부 구조를 살펴보았습니다. 이 글에서는 프로그램을 짜고 서버를 운영할 때 직접 만나는 주제들을 다룹니다. 파일 디스크립터가 커널 안에서 무엇을 가리키는지, 쓴 데이터가 언제 디스크에 닿는지, 디스크가 찼을 때 무엇을 확인하는지, 여러 디스크를 묶는 RAID와 SSD는 무엇을 고려해야 하는지 차례로 정리합니다.
파일 디스크립터가 가리키는 것
프로세스가 open()으로 파일을 열면 커널은 파일 디스크립터(file descriptor, fd)라는 작은 정수를 돌려줍니다. 관례상 프로세스가 시작할 때 0번은 표준 입력(stdin), 1번은 표준 출력(stdout), 2번은 표준 에러(stderr)에 연결되어 있고, 새로 연 파일은 비어 있는 가장 작은 번호를 받습니다. 0, 1, 2도 보통의 디스크립터여서 닫거나 다른 대상으로 바꿀 수 있습니다.
세 단계의 자료구조
fd 번호 뒤에는 세 단계의 자료구조가 있습니다. 실제 커널 구현을 그대로 옮긴 것은 아니지만, 무엇이 공유되고 무엇이 따로인지 이해하는 데 쓰는 모델입니다.
| 단계 | 어디에 있나 | 담는 것 |
|---|---|---|
| fd 테이블 | 프로세스마다 하나 | fd 번호마다 열린 파일 설명을 가리키는 포인터 |
| 열린 파일 설명(open file description) | 커널 전체에서 공유 | 현재 읽고 쓰는 위치(오프셋), 열 때 정한 모드와 상태 플래그, 참조 수 |
| inode | 파일마다 하나 | 파일의 메타데이터와 데이터 블록의 위치 |
open()을 부를 때마다 열린 파일 설명이 새로 만들어지므로, 같은 파일을 두 번 열면 오프셋도 두 개가 됩니다. 반대로 fork()로 만든 자식은 부모의 fd 테이블을 복사받는데, 복사된 fd들은 부모와 같은 열린 파일 설명을 가리킵니다. 그래서 부모와 자식은 오프셋을 공유해, 한쪽이 읽으면 다른 쪽의 위치도 움직입니다. 이 규칙은 fork(2)와 open(2)에 설명되어 있습니다.
fork 이후 두 fd가 하나의 열린 파일 설명을 가리킵니다
각 fd 테이블은 따로 있지만 열린 파일 설명은 같다. 공유 위치 3에서 이어 읽는다. 각 단계의 값과 전환 이유를 비교합니다.
파일 관리 · fork와 fd
fork로 상속한 fd는 같은 열린 파일 설명의 오프셋을 공유합니다
부모와 자식의 fd는 각 프로세스의 테이블에 있습니다. 상속한 두 fd가 같은 열린 파일 설명을 참조하므로 오프셋과 파일 상태 플래그를 공유합니다. 같은 파일을 따로 open한 경우와 구분합니다.
본문의 성공 경로에서 자식이 ABC 3바이트를 읽은 뒤 부모가 wait로 기다리고 읽으면 DEF가 이어집니다. 공유 오프셋은 0 → 3 → 6이며, inode의 메타데이터와 구분합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 fork 후 공유부터 부모 읽기까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | fork 이후 두 fd가 하나의 열린 파일 설명을 가리킵니다 |
| 관계 설명 | 부모와 자식의 fd는 각 프로세스의 별도 테이블에 있으며 fork로 상속되어 같은 열린 파일 설명을 가리킨다. offset과 파일 상태 플래그는 그 열린 파일 설명에 공유된다. 설명은 shared.txt의 inode를 참조한다. inode가 같다는 사실만으로 별도 open의 offset이 공유되지는 않는다. 간선은 참조 방향이며 프로세스 간 메시지나 읽기 실행 순서가 아니다. |
| 관계 설명 | 부모와 자식의 fd는 서로 별도 테이블에 있지만 동일한 열린 파일 설명을 참조해 offset을 공유한다. 그 설명은 같은 파일 inode를 가리킨다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| fork 후 공유 각 fd 테이블은 따로 있지만 열린 파일 설명은 같다. |
|
|
| 자식 읽기 3바이트 ABC를 읽는다. |
|
|
| 부모 읽기 공유 위치 3에서 이어 읽는다. |
|
|
fork 이후 두 fd가 하나의 열린 파일 설명을 가리킵니다
각 fd 테이블은 따로 있지만 열린 파일 설명은 같다. 공유 위치 3에서 이어 읽는다. 각 단계의 값과 전환 이유를 비교합니다.
파일 관리 · fork와 fd
fork로 상속한 fd는 같은 열린 파일 설명의 오프셋을 공유합니다
부모와 자식의 fd는 각 프로세스의 테이블에 있습니다. 상속한 두 fd가 같은 열린 파일 설명을 참조하므로 오프셋과 파일 상태 플래그를 공유합니다. 같은 파일을 따로 open한 경우와 구분합니다.
본문의 성공 경로에서 자식이 ABC 3바이트를 읽은 뒤 부모가 wait로 기다리고 읽으면 DEF가 이어집니다. 공유 오프셋은 0 → 3 → 6이며, inode의 메타데이터와 구분합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 fork 후 공유부터 부모 읽기까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | fork 이후 두 fd가 하나의 열린 파일 설명을 가리킵니다 |
| 관계 설명 | 부모와 자식의 fd는 각 프로세스의 별도 테이블에 있으며 fork로 상속되어 같은 열린 파일 설명을 가리킨다. offset과 파일 상태 플래그는 그 열린 파일 설명에 공유된다. 설명은 shared.txt의 inode를 참조한다. inode가 같다는 사실만으로 별도 open의 offset이 공유되지는 않는다. 간선은 참조 방향이며 프로세스 간 메시지나 읽기 실행 순서가 아니다. |
| 관계 설명 | 부모와 자식의 fd는 서로 별도 테이블에 있지만 동일한 열린 파일 설명을 참조해 offset을 공유한다. 그 설명은 같은 파일 inode를 가리킨다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| fork 후 공유 각 fd 테이블은 따로 있지만 열린 파일 설명은 같다. |
|
|
| 자식 읽기 3바이트 ABC를 읽는다. |
|
|
| 부모 읽기 공유 위치 3에서 이어 읽는다. |
|
|
#include <fcntl.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
int fd = open("shared.txt", O_CREAT | O_RDWR | O_TRUNC, 0644);
if (fd < 0 || write(fd, "ABCDEFGHIJ", 10) != 10 || lseek(fd, 0, SEEK_SET) != 0) {
perror("setup");
return 1;
}
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) { /* 자식: 공유 위치 0에서 3바이트 */
char buf[4] = {0};
if (read(fd, buf, 3) == 3)
printf("자식이 읽음: %s\n", buf);
fflush(stdout); /* _exit()는 stdio 버퍼를 비우지 않는다. */
_exit(0);
}
waitpid(pid, NULL, 0); /* 자식이 끝날 때까지 기다린다. */
char buf[4] = {0};
if (read(fd, buf, 3) == 3) /* 자식이 옮긴 위치에서 이어 읽는다. */
printf("부모가 읽음: %s\n", buf);
close(fd);
return 0;
}그림의 단계대로, 이 프로그램이 성공하면 자식은 위치 0에서 ABC를 읽어 공유 위치를 3으로 옮기고, 부모는 자식이 끝나기를 기다린 뒤 위치 3에서 DEF를 읽습니다. 부모와 자식의 fd 테이블은 따로 있지만, 두 fd가 같은 열린 파일 설명을 가리키기 때문입니다. 자식이 _exit() 전에 fflush()를 부른 것은, 출력을 파일이나 파이프로 보낼 때 stdio 버퍼에 남은 자식의 줄이 사라지지 않게 하기 위해서입니다.
dup()도 같은 열린 파일 설명을 가리키는 번호를 하나 더 만듭니다. 다음 코드는 dup()으로 얻은 번호와 따로 open()한 번호의 차이를 보여 줍니다. 파이썬의 os 모듈 함수들은 같은 이름의 시스템 콜을 거의 그대로 부르며, 이 코드는 Windows에서도 같은 결과를 냅니다.
import os
import tempfile
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "shared.txt")
with open(path, "wb") as f:
f.write(b"ABCDEFGHIJ")
a = os.open(path, os.O_RDONLY)
b = os.dup(a) # a와 같은 열린 파일 설명을 가리키는 새 번호
c = os.open(path, os.O_RDONLY) # 같은 파일을 따로 연다: 열린 파일 설명이 새로 생긴다
print("a로 3바이트:", os.read(a, 3))
print("b로 3바이트:", os.read(b, 3)) # a가 옮긴 위치에서 이어 읽는다
print("c로 3바이트:", os.read(c, 3)) # 자기 위치 0에서 읽는다
os.lseek(a, 1, os.SEEK_SET)
print("a를 1로 옮긴 뒤 b로 2바이트:", os.read(b, 2))
for fd in (a, b, c):
os.close(fd)a로 3바이트: b'ABC'
b로 3바이트: b'DEF'
c로 3바이트: b'ABC'
a를 1로 옮긴 뒤 b로 2바이트: b'BC'b는 a와 오프셋을 공유하므로 a가 읽은 뒤의 위치 3에서 DEF를 읽고, a를 위치 1로 옮기자 b도 거기서 BC를 읽습니다. 따로 연 c는 자기만의 오프셋을 가지므로 처음부터 ABC를 읽습니다. 셋 모두 같은 inode를 가리키지만 오프셋은 열린 파일 설명에 있으므로, 어느 단계가 공유되는지 구분하면 결과를 예측할 수 있습니다.
dup2로 하는 리다이렉션
셸에서 ./program > output.txt라고 하면, 셸은 output.txt를 연 뒤 dup2()로 그 열린 파일 설명을 fd 1에 연결하고 나서 프로그램을 실행합니다. 프로그램은 평소처럼 fd 1에 쓸 뿐인데, 그 번호가 이제 파일을 가리키므로 출력이 파일로 갑니다. 프로그램을 고치지 않고도 출력할 곳을 바꿀 수 있는 이유가 이것입니다. 다음 코드는 같은 일을 한 프로세스 안에서 해 보고, fd 1을 원래대로 되돌립니다.
import os
import sys
import tempfile
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "output.txt")
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
saved = os.dup(1) # 원래의 표준 출력을 다른 번호로 보관한다
sys.stdout.flush()
os.dup2(fd, 1) # 1번이 output.txt를 가리키게 한다
os.close(fd) # 1번이 남아 있으니 원래 번호는 닫아도 된다
os.write(1, b"to file\n") # 1번에 쓰면 파일로 간다
os.dup2(saved, 1) # 1번을 원래대로 되돌린다
os.close(saved)
print("화면으로 출력")
with open(path, encoding="utf-8") as f:
print("output.txt의 내용:", f.read().strip())화면으로 출력
output.txt의 내용: to filefd 1에 쓴 to file은 화면 대신 output.txt로 갔고, fd 1을 되돌린 뒤의 print는 다시 화면으로 나왔습니다. 파이프도 같은 원리입니다. ls | grep txt에서 셸은 pipe()로 읽는 끝과 쓰는 끝이 있는 파이프를 만들고, dup2()로 ls의 fd 1을 쓰는 끝에, grep의 fd 0을 읽는 끝에 연결합니다.
페이지 캐시와 쓰기 정책
디스크는 메모리보다 훨씬 느립니다. HDD의 임의 접근은 수 밀리초, SSD는 수십에서 수백 마이크로초가 걸리는 반면, DRAM은 100나노초 안팎입니다. 그래서 운영체제는 최근에 읽고 쓴 파일 데이터를 메모리의 페이지 캐시(page cache)에 담아 두고, 같은 데이터를 다시 읽으면 디스크를 거치지 않고 캐시에서 바로 돌려줍니다.
Linux에서 free 명령의 buff/cache 값이 큰 것은 정상입니다. 놀고 있는 메모리를 캐시로 쓰고 있다는 뜻이며, 프로세스에 메모리가 필요해지면 커널이 캐시를 줄여 내줍니다. 이 값에는 파일 캐시 말고도 커널의 버퍼와 회수할 수 있는 커널 메모리가 함께 들어 있으므로, 새 프로그램이 실제로 쓸 수 있는 메모리는 available 열을 봐야 합니다.
쓴 데이터는 언제 디스크에 닿나
write()는 보통 데이터를 페이지 캐시에 복사하고 바로 돌아옵니다. 이렇게 고쳐졌지만 아직 디스크에 쓰이지 않은 페이지를 더티 페이지(dirty page)라고 하며, 커널의 플러셔(flusher) 스레드가 나중에 모아서 디스크로 내보냅니다. 이런 방식을 라이트백(write-back)이라고 합니다. Linux 커널의 기본값으로는 플러셔가 5초마다 깨어나 30초보다 오래된 더티 페이지를 내보내고, 더티 페이지가 메모리의 일정 비율을 넘으면 그보다 일찍 내보내며, 더 많아지면 쓰는 프로세스의 속도를 늦춥니다.
sysctl vm.dirty_writeback_centisecs vm.dirty_expire_centisecs # 깨어나는 주기와 오래됨의 기준(1/100초)
sysctl vm.dirty_background_ratio vm.dirty_ratio # 미리 내보내기와 쓰기 늦추기를 시작하는 비율(%)
grep -E '^(Dirty|Writeback):' /proc/meminfo # 지금 더티 상태이거나 쓰는 중인 양라이트백은 쓰기를 모아서 처리하므로 빠르지만, 전원이 꺼지면 아직 내보내지 않은 데이터를 잃습니다. 앞 글의 저널링은 파일 시스템의 구조를 지킬 뿐, 아직 메모리에만 있던 데이터까지 지켜 주지는 않습니다.
fsync와 안전한 저장
데이터가 디스크에 확실히 기록되어야 하는 순간에는 다음 도구를 씁니다.
| 도구 | 하는 일 |
|---|---|
fsync(fd) | 그 파일의 바뀐 데이터와 메타데이터를 저장 장치로 보내고, 장치가 기록을 마칠 때까지 기다린다 |
fdatasync(fd) | 데이터와, 파일 크기처럼 데이터를 읽는 데 필요한 메타데이터만 보낸다. 수정 시각 같은 나머지는 미룬다 |
O_SYNC 플래그 | 열 때 지정하면 write()마다 fsync()를 부른 것처럼 기록을 마칠 때까지 기다린다 |
O_DIRECT 플래그 | 페이지 캐시를 거치지 않고 사용자 버퍼와 장치 사이에서 바로 옮기려 한다. 정렬 조건이 있고, 그것만으로 영속성을 보장하지는 않는다 |
fsync()는 반환값을 꼭 확인해야 합니다. 저장 장치에 쓰다가 오류가 나면 fsync()가 오류를 돌려주는데, 이를 무시하면 데이터를 잃고도 저장에 성공한 줄 알게 됩니다. 또 fsync(2)에 따르면 파일을 fsync()해도 그 파일을 담은 디렉터리의 항목까지 디스크에 닿는다는 보장은 없어서, 새 파일을 만들거나 이름을 바꾼 뒤에는 디렉터리도 fsync()해야 합니다. 자체 버퍼 풀로 캐시를 관리하는 데이터베이스 가운데에는 페이지 캐시와 이중으로 캐시하지 않으려고 O_DIRECT를 쓰는 것도 있습니다.
설정 파일처럼 통째로 바꿔 쓰는 파일은 다음 순서로 저장하면, 저장하는 도중에 전원이 꺼져도 옛 내용이나 새 내용 중 하나가 온전히 남습니다. 임시 파일에 새 내용을 쓰고 fsync()한 뒤, 이름을 바꿔 원래 파일을 대신하게 하고, 디렉터리를 fsync()합니다.
import os
import tempfile
def save_atomically(path, data):
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(data)
f.flush() # 파이썬의 버퍼 -> 커널의 페이지 캐시
os.fsync(f.fileno()) # 페이지 캐시 -> 저장 장치
os.replace(tmp, path) # 이름을 한 번에 바꾼다
if hasattr(os, "O_DIRECTORY"): # 디렉터리를 열 수 있는 POSIX 시스템이라면
folder = os.open(os.path.dirname(path), os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(folder) # 바뀐 디렉터리 항목도 저장 장치로
finally:
os.close(folder)
with tempfile.TemporaryDirectory() as folder:
target = os.path.join(folder, "settings.json")
save_atomically(target, b'{"theme": "dark"}')
save_atomically(target, b'{"theme": "light"}')
with open(target, "rb") as f:
print("저장된 내용:", f.read().decode())
print("남은 파일:", os.listdir(folder))저장된 내용: {"theme": "light"}
남은 파일: ['settings.json']f.flush()와 os.fsync()는 서로 다른 단계입니다. flush()는 파이썬이 프로세스 안에 모아 둔 버퍼를 커널의 페이지 캐시로 보낼 뿐이고, 저장 장치로 보내는 것은 fsync()입니다. os.replace()가 부르는 rename()은 POSIX가 요구하는 대로 이름을 한 번에 바꾸므로, 다른 프로세스는 옛 파일이나 새 파일 중 하나만 보게 됩니다. Windows에서는 디렉터리를 이렇게 열 수 없으므로 마지막 단계를 건너뛰며, 두 환경 모두 같은 결과를 출력합니다.
더 깊이: fsync의 오류는 한 번만 보고된다
플러셔가 더티 페이지를 내보내다 오류가 나면, 그 오류는 쓰기를 요청한 프로세스가 아직 아무것도 모르는 시점에 생깁니다. 그래서 커널은 오류를 파일에 기록해 두었다가 나중의 fsync()로 알려 줍니다. fsync(2)에 따르면 Linux 4.13부터는 이 오류를 그 데이터를 썼을 수 있는 모든 파일 디스크립터에 보고하며, 대부분의 로컬 파일 시스템은 오류가 기록될 때 그 파일을 열고 있던 모든 디스크립터에 보고합니다.
이 기록은 커널의 errseq_t라는 자료형으로 관리되는데, 커널 문서는 그 규칙을 이렇게 설명합니다. 오류를 지켜보는 쪽이 한 번 확인하고 나면, 새 오류가 기록될 때까지 그다음 호출은 0을 돌려줍니다. 그래서 fsync()가 EIO로 실패한 뒤 다시 fsync()를 불러 성공했다고 해서, 앞에서 실패한 데이터가 저장 장치에 기록되었다고 볼 수는 없습니다. 데이터를 지켜야 하는 프로그램은 첫 실패를 데이터를 잃었을 수 있다는 신호로 다루고, 자기가 가진 원본으로 다시 쓰거나 작업 전체를 실패로 처리해야 합니다.
선읽기
Linux는 프로세스가 파일을 순서대로 읽는 것을 알아차리면, 아직 요청하지 않은 다음 부분을 미리 페이지 캐시로 읽어 둡니다. 이것을 선읽기(read-ahead)라고 하며, 순차 읽기가 디스크의 속도를 거의 그대로 낼 수 있게 해 줍니다. 응용 프로그램은 posix_fadvise()로 순차 접근인지 임의 접근인지 알려 선읽기를 조절할 수 있고, 장치의 선읽기 크기는 blockdev --getra로 512바이트 섹터 단위의 값을 확인할 수 있습니다.
디스크를 살펴보는 명령
서버의 디스크 문제를 볼 때 자주 쓰는 명령들입니다. 출력은 시스템마다 다르므로, 각 명령이 무엇을 보여 주는지 기억해 두면 됩니다.
df -hT # 파일 시스템마다 종류, 크기, 사용량
du -sh /var/* | sort -rh | head # 디렉터리별 사용량을 큰 순서로
df -i # inode 사용량
lsof /var/log/syslog # 이 파일을 열고 있는 프로세스
lsof +L1 # 지워졌지만 아직 열려 있는 파일
iostat -x 1 5 # 장치별 I/O 통계를 1초 간격으로 5번
sudo e2fsck -n /dev/sdb1 # 마운트하지 않은 ext 계열 파일 시스템을 고치지 않고 검사만공간이 남았는데 새 파일을 만들 수 없다면 inode가 바닥났을 수 있습니다. 작은 파일이 수백만 개 쌓이는 메일 서버나 캐시 디렉터리에서 흔한 일로, df -h로는 공간이 남아 보여도 df -i의 사용률이 100%이면 파일을 만들 때 ENOSPC("No space left on device") 오류가 납니다. ext4에서는 파일 시스템을 만들 때 inode의 수가 정해지므로, 작은 파일이 많을 용도라면 처음부터 inode를 넉넉히 잡아야 합니다.
반대로 큰 파일을 지웠는데 공간이 늘지 않는다면, 어떤 프로세스가 그 파일을 아직 열고 있을 가능성이 큽니다. 10장 1편에서 본 것처럼, 이름을 지워도 열린 참조가 남아 있으면 공간은 회수되지 않습니다. lsof +L1로 그 프로세스를 찾아 파일을 닫게 하거나 다시 시작하면 공간이 돌아옵니다.
오류의 종류도 원인을 좁혀 줍니다. ENOSPC는 블록이나 inode가 바닥났다는 뜻이고, 디스크 할당량(quota)을 넘었다면 EDQUOT가 납니다. EROFS는 파일 시스템이 읽기 전용이라는 뜻으로, ext4는 오류를 발견하면 마운트 옵션에 따라 스스로 읽기 전용으로 바꾸기도 합니다. EACCES라면 10장 2편의 연습 문제처럼 경로에 있는 디렉터리와 파일의 권한을 확인합니다.
RAID
RAID(redundant array of independent disks)는 디스크 여러 개를 묶어 하나처럼 쓰면서, 성능이나 용량을 늘리거나 디스크가 고장 나도 데이터를 잃지 않게 하는 기술입니다. 데이터를 여러 디스크에 나누어 쓰는 스트라이핑(striping), 같은 데이터를 여러 디스크에 쓰는 미러링(mirroring), 데이터로 계산한 패리티(parity)를 함께 저장해 잃은 데이터를 다시 계산하는 방법을 조합합니다.
저장 장치 · RAID 비교
RAID 10의 고장 허용은 각 미러 쌍의 생존 여부로 결정됩니다
같은 용량 S의 디스크 N개를 각 레벨의 최소 구성 조건에 맞춰 비교합니다. 용량 공식과 고장 허용 조건을 함께 읽어야 하며, RAID 10에서 임의의 N/2개 고장을 모두 견딘다는 뜻은 아닙니다.
| 레벨 | 구성과 가용 용량 | 고장 허용 조건 |
|---|---|---|
| RAID 0 | 2개 이상 스트라이프 · N × S | 중복이 없어 디스크 하나의 고장에도 배열 데이터가 손실될 수 있습니다. |
| RAID 1 | 2개 이상 동일 데이터의 복제 · S | 동기화된 복제본 하나가 남으면 계속 제공할 수 있습니다. N-way 미러의 효율은 1/N입니다. |
| RAID 5 | 3개 이상 분산 패리티 · (N−1) × S | 디스크 1개 고장을 허용합니다. 작은 쓰기에는 패리티 갱신 비용이 있습니다. |
| RAID 6 | 4개 이상 이중 패리티 · (N−2) × S | 디스크 2개 고장을 허용합니다. 패리티 갱신과 리빌드 비용을 고려합니다. |
| RAID 10 | 4개 이상 짝수, 2개씩 미러 후 스트라이프 · N × S / 2 | 각 미러 쌍에 정상 디스크가 하나 이상 있어야 합니다. |
- 레벨: RAID 0
- 구성과 가용 용량: 2개 이상 스트라이프 · N × S
- 고장 허용 조건: 중복이 없어 디스크 하나의 고장에도 배열 데이터가 손실될 수 있습니다.
- 레벨: RAID 1
- 구성과 가용 용량: 2개 이상 동일 데이터의 복제 · S
- 고장 허용 조건: 동기화된 복제본 하나가 남으면 계속 제공할 수 있습니다. N-way 미러의 효율은 1/N입니다.
- 레벨: RAID 5
- 구성과 가용 용량: 3개 이상 분산 패리티 · (N−1) × S
- 고장 허용 조건: 디스크 1개 고장을 허용합니다. 작은 쓰기에는 패리티 갱신 비용이 있습니다.
- 레벨: RAID 6
- 구성과 가용 용량: 4개 이상 이중 패리티 · (N−2) × S
- 고장 허용 조건: 디스크 2개 고장을 허용합니다. 패리티 갱신과 리빌드 비용을 고려합니다.
- 레벨: RAID 10
- 구성과 가용 용량: 4개 이상 짝수, 2개씩 미러 후 스트라이프 · N × S / 2
- 고장 허용 조건: 각 미러 쌍에 정상 디스크가 하나 이상 있어야 합니다.
N은 디스크 수, S는 디스크 하나의 용량이며 메타데이터 공간을 제외했습니다. 읽기·쓰기 속도 배수는 장치·컨트롤러·요청 패턴에 따라 달라집니다.
RAID 5의 패리티는 같은 위치에 있는 데이터들의 XOR입니다. XOR은 같은 값을 두 번 하면 원래대로 돌아오므로, 디스크 하나를 잃어도 남은 데이터와 패리티를 XOR하면 잃은 데이터가 나옵니다.
from functools import reduce
def xor_columns(blocks):
return bytes(reduce(lambda x, y: x ^ y, column) for column in zip(*blocks))
disks = [b"\x12\x34", b"\xab\xcd", b"\x0f\xf0"] # 데이터 디스크 3개의 같은 위치
parity = xor_columns(disks) # 패리티 = 모든 데이터의 XOR
print("패리티:", parity.hex())
lost = 1 # 디스크 1이 고장 났다
survivors = [d for i, d in enumerate(disks) if i != lost]
rebuilt = xor_columns(survivors + [parity]) # 남은 데이터와 패리티를 XOR
print("복원한 디스크 1:", rebuilt.hex(), "/ 원래 값:", disks[lost].hex())패리티: b609
복원한 디스크 1: abcd / 원래 값: abcd디스크 1이 고장 나도 남은 두 디스크와 패리티로 abcd를 다시 계산해 냈습니다. 하지만 디스크를 둘 잃으면 식 하나로 모르는 값 둘을 구할 수 없어 복구하지 못합니다. RAID 6은 서로 다른 방식으로 계산한 패리티를 둘 두어 디스크 두 개의 고장까지 견딥니다. 패리티에는 쓰기 비용이 따릅니다. RAID 5에서 블록 하나를 고치려면 옛 데이터와 옛 패리티를 읽고 새 데이터와 새 패리티를 써야 하므로, 작은 쓰기 하나가 I/O 네 번이 됩니다.
RAID는 백업이 아니다
RAID는 디스크가 고장 나도 서비스를 계속하게 해 주지만, 실수로 지운 파일이나 랜섬웨어가 암호화한 파일, 프로그램 버그로 망가진 데이터는 모든 디스크에 그대로 반영됩니다. 그래서 RAID와 별개로, 다른 장치나 다른 장소에 복사본을 두는 백업이 필요합니다.
고장 난 디스크를 바꾸면 RAID는 남은 디스크의 데이터로 새 디스크의 내용을 다시 만드는 리빌드(rebuild)를 합니다. 디스크가 클수록 리빌드는 오래 걸리고, 그동안 남은 디스크들은 평소보다 많은 부하를 받습니다. RAID 5라면 리빌드 도중에 디스크가 하나 더 고장 나거나 읽기 오류가 나면 데이터를 잃으므로, 큰 디스크로 구성할 때는 RAID 6이나 RAID 10을 고려합니다. 다만 RAID 10은 어느 디스크가 고장 나느냐에 따라 견디는 범위가 달라지므로, 구성을 보고 판단해야 합니다.
SSD와 파일 시스템
SSD는 움직이는 부품이 없어 임의 접근이 빠르지만, 플래시 메모리의 성질 때문에 HDD에 없던 문제가 생깁니다. 플래시는 페이지(보통 4KiB에서 16KiB) 단위로 쓰지만, 이미 쓴 페이지를 덮어쓸 수 없고 지울 때는 페이지 수백 개를 묶은 블록 단위로만 지울 수 있습니다. 그래서 SSD 안의 컨트롤러는 고친 내용을 빈 페이지에 쓰고, 논리 주소가 가리키는 물리 페이지를 바꾸는 FTL(flash translation layer)을 둡니다.
옛 내용이 남은 페이지가 쌓이면, 컨트롤러는 블록 안의 유효한 페이지를 다른 곳으로 옮기고 블록을 지우는 가비지 컬렉션(garbage collection)을 합니다. 이렇게 옮기는 쓰기 때문에 SSD 내부에서는 호스트가 요청한 것보다 많은 양을 쓰게 되는데, 이것을 쓰기 증폭(write amplification)이라고 합니다. 플래시 셀은 지우고 쓸 수 있는 횟수에 한도가 있으므로, 컨트롤러는 쓰기를 모든 블록에 고르게 퍼뜨리는 웨어 레벨링(wear leveling)도 합니다.
문제는 SSD가 파일이 지워졌다는 사실을 모른다는 점입니다. 파일 시스템이 비트맵에서 블록을 비워도 SSD 쪽에서는 그 페이지가 여전히 유효해 보이므로, 가비지 컬렉션 때 쓸데없이 옮기게 됩니다. TRIM 명령은 운영체제가 더 이상 쓰지 않는 블록을 SSD에 알려 주어 이 낭비를 줄입니다. Linux에서는 마운트 옵션 discard로 지울 때마다 알리거나, fstrim으로 모아서 한꺼번에 알리며, 많은 배포판이 fstrim.timer로 주기적인 fstrim을 켜 둡니다.
sudo fstrim -av # TRIM을 지원하는 마운트된 파일 시스템 모두에 TRIM
systemctl status fstrim.timer # 주기적인 TRIM 타이머의 상태정리
- fd 번호는 프로세스의 fd 테이블에서 열린 파일 설명을 가리키고, 열린 파일 설명이 오프셋을 담으며, 그것이 다시 inode를 가리킵니다.
fork()와dup()은 열린 파일 설명을 공유해 오프셋도 함께 움직이고, 따로open()하면 오프셋도 따로 생깁니다. - 셸의 리다이렉션과 파이프는
dup2()로 fd 0과 1이 가리키는 대상을 바꾸어 만듭니다. write()는 페이지 캐시에 쓰고 돌아오며, 더티 페이지는 플러셔 스레드가 나중에 내보냅니다. 기록을 확실히 하려면fsync()의 반환값까지 확인하고, 새로 생긴 이름은 디렉터리도fsync()해야 합니다.- 통째로 바꾸는 파일은 임시 파일에 쓰고
fsync()한 뒤rename()으로 바꾸면, 옛 내용이나 새 내용 중 하나가 온전히 남습니다. - 공간이 남았는데 파일을 만들 수 없으면
df -i로 inode를, 지웠는데 공간이 늘지 않으면lsof +L1로 열린 참조를 확인합니다. - RAID는 스트라이핑, 미러링, 패리티를 조합해 디스크 고장을 견디지만 백업을 대신하지 못합니다. SSD는 덮어쓸 수 없는 플래시의 성질 때문에 FTL, 가비지 컬렉션, TRIM이 필요합니다.
연습 문제
1. 공유되는 오프셋 따라가기
ABCDEFGHIJ가 든 파일을 fd 3으로 열어 6바이트를 읽은 상태에서, dup(3)이 4를 돌려주었고 같은 파일을 따로 읽기용으로 연 결과는 5였습니다. 차례로 fd 4로 2바이트, fd 5로 2바이트를 읽고, fd 3의 위치를 1로 옮긴 뒤, fd 4로 2바이트를 더 읽습니다. 세 번의 읽기에서 얻는 내용과, 마지막에 두 열린 파일 설명의 위치를 구하세요.
해설 보기
fd 3과 fd 4는 같은 열린 파일 설명을 가리키므로 위치 6을 공유합니다. fd 4로 읽으면 GH를 얻고 공유 위치는 8이 됩니다. 따로 연 fd 5는 자기 위치 0에서 AB를 읽어 위치가 2가 됩니다. fd 3을 위치 1로 옮기면 fd 4의 위치도 1이 되므로 다음 읽기는 BC이고, 공유 위치는 3이 됩니다. 마지막에 fd 3과 fd 4가 공유하는 열린 파일 설명의 위치는 3, fd 5의 열린 파일 설명의 위치는 2입니다.
2. 지웠는데 늘지 않는 공간
디스크가 가득 차서 10GB짜리 로그 파일 /var/log/app.log를 rm으로 지웠는데, df로 보니 여유 공간이 그대로입니다. 왜 이런 일이 생기는지, 어떻게 확인하고 해결하는지 설명하세요. 다음부터 같은 일을 피하려면 로그 파일을 어떻게 비우는 것이 좋을까요?
해설 보기
로그를 쓰는 프로세스가 그 파일을 아직 열고 있기 때문입니다. rm은 디렉터리 항목, 곧 이름만 지우므로 inode의 링크 수는 0이 되지만, 열린 참조가 남아 있는 동안에는 데이터 블록이 회수되지 않고 프로세스는 이름 없는 파일에 계속 씁니다. lsof +L1로 지워졌지만 열려 있는 파일과 그 프로세스를 찾고, 프로세스를 다시 시작하거나 로그 파일을 다시 열라는 신호를 보내면 마지막 참조가 닫히면서 공간이 돌아옵니다. 다음부터는 이름을 지우는 대신 truncate -s 0 /var/log/app.log처럼 파일을 둔 채 크기를 0으로 줄이거나, logrotate처럼 파일을 바꾼 뒤 프로세스에 다시 열라고 알리는 도구를 씁니다. 크기를 줄일 때는 프로세스가 O_APPEND로 파일을 열었어야 합니다. 그렇지 않으면 프로세스가 옛 오프셋에 이어 써서, 앞부분이 비어 있는 희소 파일이 생깁니다.
3. RAID 10에서 두 디스크가 고장 나면
2TB 디스크 네 개를 A와 B, C와 D의 두 미러 쌍으로 묶고 두 쌍 위에 스트라이핑한 RAID 10이 있습니다. 쓸 수 있는 용량은 얼마인가요? A와 C가 고장 난 경우와 A와 B가 고장 난 경우의 결과를 비교하세요.
해설 보기
미러 쌍 하나가 2TB를 제공하므로 두 쌍을 합친 용량은 4TB입니다. A와 C가 고장 나도 각 쌍에 B와 D가 남아 있어 모든 데이터를 계속 읽을 수 있습니다. A와 B가 함께 고장 나면 첫 미러 쌍의 사본이 모두 사라지고, 스트라이핑으로 그 쌍에 나뉘어 있던 데이터를 잃으므로 배열 전체를 쓸 수 없게 됩니다. 고장 난 디스크의 수가 같아도 같은 미러 쌍에 속하느냐에 따라 결과가 달라지므로, RAID 10이 디스크 두 개의 고장을 늘 견딘다고 말할 수는 없습니다.
다음 장의 I/O 하드웨어와 소프트웨어에서는 파일 시스템 아래로 내려가, 운영체제가 디스크와 키보드, 네트워크 카드 같은 장치와 데이터를 주고받는 방법을 살펴봅니다.