write()가 성공하면 데이터는 디스크에 있을까: 짧은 쓰기, 늦게 오는 오류, fsync

write()의 성공은 디스크 기록을 뜻하지 않습니다. 짧은 쓰기 처리, Linux 4.13 이후 writeback 오류 보고, fsync와 fdatasync의 범위, 새 파일과 rename 뒤 디렉터리 fsync가 필요한 이유를 정리했습니다.

개발운영체제리눅스

설정 파일이나 작업 결과를 저장하는 코드는 보통 open(), write(), close()로 끝납니다. write()가 오류 없이 돌아왔으니 저장은 끝났다고 보기 쉽습니다. 하지만 Linux write(2) 매뉴얼의 NOTES는 write()가 성공해도 데이터가 디스크에 기록되었다는 보장은 없다고 적고 있습니다.

write()의 성공과 “전원이 나가도 남아 있음”은 서로 다른 단계입니다. 이 글은 그 사이에 있는 네 가지를 차례로 봅니다. 몇 바이트가 받아들여졌는지, 쓰기 오류가 언제 보고되는지, fsync()와 fdatasync()가 무엇을 내려보내는지, 그리고 파일 이름이 들어 있는 디렉터리는 따로 다뤄야 한다는 점입니다.

Linux 동작은 Linux man-pages 6.19의 write(2), fsync(2), open(2), rename(2)를 기준으로 했고, 이식 가능한 요구 사항은 POSIX.1-2024 기준입니다.

반환값은 받아들인 바이트 수다

write(fd, buf, count)가 돌려주는 값은 실제로 쓴 바이트 수이고, count보다 작을 수 있습니다. POSIX.1-2024는 반환값이 실제로 쓴 바이트 수이며 count를 넘지 않는다고만 정합니다.

일반 파일에서도 짧은 쓰기는 생깁니다. Linux write(2)에 나오는 경우는 이렇습니다.

  • 저장 장치에 남은 공간이 모자랄 때
  • RLIMIT_FSIZE 파일 크기 제한에 닿았을 때
  • 일부를 쓴 뒤 시그널 핸들러에 의해 호출이 끊겼을 때
  • Linux에서 write() 한 번이 옮기는 최대치인 0x7ffff000(2,147,479,552)바이트보다 많이 요청했을 때. 32비트와 64비트 시스템 모두 같습니다.

시그널 처리 규칙은 POSIX와 Linux 매뉴얼이 같습니다. 한 바이트도 쓰기 전에 끊기면 -1과 EINTR이 돌아오고, 한 바이트라도 쓴 뒤에 끊기면 그때까지 쓴 바이트 수가 성공으로 돌아옵니다. 짧은 쓰기 뒤에 남은 부분을 다시 write()하면 더 써지거나, 디스크가 찬 경우처럼 오류가 납니다.

그래서 반환값을 한 번 보고 끝내면 안 되고, 남은 바이트가 0이 될 때까지 반복해야 합니다.

#include <errno.h>
#include <stddef.h>
#include <sys/types.h>
#include <unistd.h>

/* 짧은 쓰기와 EINTR을 처리하며 len 바이트를 모두 쓴다 */
static int write_all(int fd, const char *buf, size_t len)
{
    while (len > 0) {
        ssize_t n = write(fd, buf, len);
        if (n < 0) {
            if (errno == EINTR)
                continue;      /* 아무것도 쓰기 전에 끊김: 다시 시도 */
            return -1;
        }
        if (n == 0) {          /* 진전이 없으면 실패로 본다(방어 코드) */
            errno = EIO;
            return -1;
        }
        buf += n;
        len -= (size_t)n;
    }
    return 0;
}

이 반복이 보장하는 것은 “모든 바이트를 커널이 받아들였다”까지입니다. 디스크에 닿았다는 뜻은 아닙니다.

쓰기 오류는 나중에 도착할 수 있다

write()가 돌아온 뒤에도 데이터는 커널 메모리에 남아 있다가 나중에 저장 장치로 내려갈 수 있습니다. fsync(2)는 이 상태를 파일의 수정된 in-core 데이터, 곧 수정된 버퍼 캐시 페이지라고 부릅니다. 이 메모리가 free 출력에서 어디에 잡히는지는 free 명령의 buff/cache와 available을 다룬 글에 정리해 두었습니다.

이렇게 나중에 내려가는 과정(writeback)에서 오류가 나면, 그 오류를 원래의 write() 호출이 받을 방법은 없습니다. Linux write(2)의 EIO 설명은 이 경우를 직접 다룹니다.

  • EIO는 앞선 write()가 쓴 데이터의 writeback과 관련된 오류일 수 있고, 그 write()는 같은 파일의 다른 파일 디스크립터로 한 것일 수도 있습니다.
  • Linux 4.13부터 writeback 오류는 이후의 write()에서 보고될 수도 있고, 이후의 fsync()에서는 반드시 보고됩니다. write()에서 이미 보고했는지와는 관계없습니다.

fsync(2)의 EIO 설명도 같은 방향입니다. Linux 4.13부터 writeback 오류는 그 데이터를 썼을 수 있는 모든 파일 디스크립터에 보고됩니다. NFS처럼 어떤 데이터가 어느 디스크립터로 들어왔는지 추적하는 파일 시스템은 더 정확하게 보고하고, 대부분의 로컬 파일 시스템은 오류가 기록될 때 그 파일에 열려 있던 모든 디스크립터에 보고합니다.

공간 부족도 늦게 드러날 수 있습니다. write(2)는 NFS 같은 일부 파일 시스템에서는 write() 성공이 공간 예약조차 보장하지 않으며, 이때 오류가 이후의 write(), fsync(), 심지어 close()까지 미뤄질 수 있다고 적습니다. fsync(2)도 쓰기 시점에 공간을 할당하지 않는 파일 시스템에서 앞선 쓰기가 공간 부족으로 실패하면 fsync()가 ENOSPC나 EDQUOT를 돌려준다고 설명합니다.

그래서 close()의 반환값도 확인해야 합니다. 다만 이것은 늦게 온 오류를 놓치지 않기 위해서이고, close()가 데이터를 디스크에 쓰게 만드는 것은 아닙니다. write(2)는 확실히 하는 유일한 방법이 모든 데이터를 쓴 뒤 fsync()를 호출하는 것이라고 말합니다.

fsync와 fdatasync가 내려보내는 것

Linux fsync(2)에 따르면 fsync()는 파일의 수정된 데이터를 디스크(또는 다른 영구 저장 장치)로 내려보내고, 디스크 캐시가 있으면 그것도 통과하거나 비웁니다. 그리고 장치가 전송 완료를 보고할 때까지 기다립니다. 파일 데이터와 함께 크기나 타임스탬프 같은 파일 메타데이터도 내려보냅니다.

fdatasync()는 이후의 데이터 읽기에 필요하지 않은 메타데이터는 내려보내지 않습니다. 매뉴얼의 예로 보면 접근 시각(st_atime)이나 수정 시각(st_mtime)만 바뀐 경우는 flush가 필요 없고, 파일 크기(st_size)가 바뀌었다면 메타데이터 flush가 필요합니다. 목적은 모든 메타데이터 동기화가 필요 없는 프로그램의 디스크 작업을 줄이는 것입니다. 파일 끝에 덧붙이는 쓰기는 크기를 바꾸므로, 그 경우 fdatasync()도 크기 정보는 내려보냅니다.

매 write()마다 같은 효과를 내는 방법도 있습니다. open(2)의 O_DSYNC는 각 write() 뒤에 fdatasync()를 부른 것처럼, O_SYNC는 fsync()를 부른 것처럼 동작합니다. O_DIRECT는 캐시 효과를 줄이려는 플래그일 뿐이고, 단독으로는 O_SYNC가 주는 데이터와 메타데이터 전송 보장을 주지 않습니다.

fsync()가 실패하면 저장이 실패한 것으로 봐야 합니다. POSIX.1-2024는 fsync()가 실패하면 처리 중이던 I/O가 완료되었다고 보장하지 않는다고 정합니다. 그 뒤에 처음부터 다시 쓸지, 사용자에게 알릴지는 프로그램이 정할 일입니다.

파일을 fsync해도 이름은 따로 남는다

여기서 많이 놓치는 부분이 있습니다. 파일 이름은 파일 안이 아니라 그 파일이 들어 있는 디렉터리의 항목입니다. 이름과 파일 자체가 어떻게 따로 움직이는지는 unlink와 열린 파일을 다룬 글에서 다뤘습니다.

Linux fsync(2)는 이렇게 적습니다. fsync()를 호출해도 그 파일을 담은 디렉터리의 항목까지 디스크에 닿았다고 보장되지는 않으며, 그러려면 디렉터리를 가리키는 파일 디스크립터에 대해 fsync()를 따로 호출해야 합니다.

새로 만든 파일이라면 내용은 fsync()로 내려갔어도 그 이름이 디렉터리에 기록되었는지는 별개라는 뜻입니다. rename()으로 이름을 바꾼 경우도 디렉터리가 바뀐 것이므로 같은 문제가 남습니다.

POSIX.1-2024의 fsync() APPLICATION USAGE도 디렉터리에 파일을 만드는 등 디렉터리를 바꾼 프로그램은 그 디렉터리에 fsync()를 호출해 항목을 동기화할 수 있다고 설명합니다. 같은 단락은 대부분의 프로그램에서는 디렉터리 항목 동기화가 필요하지 않을 것이라고도 덧붙입니다. 그러니 모든 파일 작업에 붙일 의무 절차라기보다, 이름이 바뀐 결과까지 장애 후에 남아야 하는 경우에 쓰는 도구로 보는 편이 맞습니다.

rename의 원자성과 내구성은 다른 성질이다

파일을 안전하게 교체하는 흔한 방법은 임시 파일에 새 내용을 쓴 뒤 rename()으로 원래 이름을 덮는 것입니다. Linux rename(2)는 newpath가 이미 있으면 원자적으로 교체되어, 다른 프로세스가 newpath에 접근했을 때 그 이름이 없는 순간은 없다고 설명합니다. 실패하더라도 newpath의 인스턴스 하나는 남겨 둔다고 보장합니다.

이 약속은 실행 중인 다른 프로세스가 보는 모습에 대한 것입니다. 데이터를 저장 장치로 내려보내라는 요청이 아니고, 시스템이 갑자기 꺼졌을 때 어느 쪽이 남는지에 대해서도 rename(2)는 말하지 않습니다. 원자성은 “중간 상태가 보이지 않는다”는 성질이고, 내구성은 “장애 뒤에도 남는다”는 성질입니다. 두 가지를 모두 원하면 각각을 따로 챙겨야 합니다.

임시 파일 open
  → write_all          모든 바이트를 커널이 받음
  → fsync(임시 파일)    파일 내용과 메타데이터를 내려보냄, 오류 확인
  → close              늦게 온 오류 확인
  → rename             다른 프로세스에게는 한 번에 바뀐 것으로 보임
  → fsync(디렉터리)     바뀐 이름을 내려보냄

순서가 중요합니다. rename() 전에 임시 파일을 fsync()해 두지 않으면, 새 이름은 아직 내용이 저장 장치에 닿지 않은 파일을 가리키게 될 수 있습니다. 앞에서 본 대로 write()의 성공은 디스크 기록을 보장하지 않고, rename()은 데이터를 내려보내는 호출이 아니기 때문입니다.

이 흐름을 실제 코드로 옮길 때 가장 조심해야 할 곳은 첫 줄, 임시 파일을 여는 단계입니다. save.tmp처럼 정해 둔 이름을 O_CREAT | O_TRUNC로 열면, 그 경로에 이미 있던 파일을 비워 버리거나 그 자리에 놓인 심볼릭 링크를 따라가 전혀 다른 파일을 덮어쓸 수 있습니다. 같은 이름을 쓰는 다른 프로세스와 부딪힐 수도 있습니다.

그래서 임시 파일은 대상 파일과 같은 디렉터리 안에, 미리 짐작할 수 없는 이름으로, 반드시 새 파일로 만들어야 합니다. mkstemp()를 쓰거나, 디렉터리를 연 뒤 openat()에 O_CREAT | O_EXCL을 주는 방식을 꼼꼼히 설계하는 것이 그런 예입니다. Linux open(2)에 따르면 O_CREAT와 O_EXCL을 함께 주면 그 호출은 파일을 새로 만들어야만 성공하고, 경로가 심볼릭 링크이면 링크가 어디를 가리키든 실패합니다. 같은 디렉터리에 두는 데도 이유가 있습니다. rename(2)는 두 경로가 같은 마운트된 파일 시스템에 있지 않으면 EXDEV로 실패하고, 같은 파일 시스템이라도 마운트 지점이 다르면 동작하지 않는다고 적습니다.

실패 처리는 위 순서를 그대로 따라가면 됩니다. rename()을 부르기 전 단계에서 실패했다면 임시 경로는 아직 프로그램의 것이므로, 임시 파일을 지우고 저장 실패를 돌려줍니다. rename() 자체가 오류를 돌려준 경우는 이렇게 단순하지 않습니다. Linux rename(2)의 BUGS 절은 NFS에서는 호출이 실패했다고 해서 이름이 바뀌지 않았다고 가정할 수 없다고 경고합니다. 서버가 rename을 마친 뒤 죽으면, 서버가 다시 올라왔을 때 처리되는 재전송 요청이 실패를 돌려주기 때문입니다. 이 상황은 애플리케이션이 다뤄야 한다고 적혀 있습니다. 그러니 아무 일도 없었다고 보고 임시 경로를 무작정 지우지 말고, 결과를 알 수 없는 상태로 다룬 뒤 프로그램의 복구 설계에 따라 어느 이름에 무엇이 있는지 확인하고 나서 정리해야 합니다. fsync()가 실패했을 때도 같은 자리에서 다시 부르기보다 실패로 처리하는 편이 앞 절의 설명과 맞습니다. rename() 뒤 디렉터리 fsync()가 실패하면 다른 프로세스에게는 새 내용이 이미 보이지만 이름의 내구성은 확인되지 않은 상태입니다. 이때도 호출한 쪽에 실패를 알려 “저장 완료”로 표시하지 않게 해야 합니다.

이 글이 약속하지 않는 것

여기까지는 시스템 호출이 문서로 약속하는 범위입니다. 실제 장애 뒤에 무엇이 남는지는 그 아래 계층에도 달려 있습니다.

  • POSIX.1-2024는 fsync()의 전송 방식을 구현이 정하도록 두었습니다. RATIONALE에는 _POSIX_SYNCHRONIZED_IO가 정의되지 않은 시스템에서 아무 일도 하지 않는 구현도 허용된다고 적혀 있습니다.
  • Linux fsync(2)는 오래된 커널과 덜 쓰이는 파일 시스템의 fsync()가 디스크 캐시를 비우지 못할 수 있고, 그 경우 안전하게 쓰려면 디스크 캐시를 꺼야 한다고 설명합니다.
  • 파일 시스템 종류, 마운트 옵션, 저장 장치 펌웨어, 네트워크 파일 시스템에 따라 장애 뒤의 상태는 달라질 수 있습니다. 이 글은 그 조합마다의 결과를 다루지 않습니다.
  • 데이터베이스처럼 여러 파일의 갱신을 하나로 묶어야 하는 경우는 이 글의 순서만으로는 부족하고, 별도의 설계가 필요합니다.

write()의 반환값은 커널이 받아들인 양이고, Linux 4.13 이후 writeback 오류는 이후의 fsync()에서 반드시 보고되며, 새 이름은 디렉터리 fsync()로 따로 내려보내야 합니다.

블로그로