[운영체제] 영속성에 관하여2 - file 시스템

영속 저장 장치 (persistent storage) 는 또 하나의 핵심적인 가상화의 퍼즐 족가이다. 하드 디스크 드라이브나 솔리드스테이트드라이브 (SSD) 와 같은 저장 장치는 영구적으로 정보를 저장한다. 운영체제는 이러한 장치들을 신중하게 다루어야한다.

핵심 질문 : 어떻게 영속 장치를 관리하는가
운영체제가 영속 장치를 어떻게 관리해야 할까? API들은 어떤 것이 있을까?
구현의 중요한 측면은 무엇일까?

A. 파일과 디렉터리

저장 장치의 가상화에 대한 두 가지 주요 개념이 개발되었다. 첫 번째는 파일이다. 파일은 단순히 읽거나 쓸 수 있는 순차적인 바이트의 배열이다. 각 파일은 저수준의 이름 (low-level name)을 갖고 있고, 보통은 숫자로 표현되지만 사용자는 그 이름에 대해서 알지 못한다. 이 번호를 아이노드 번호(inode number) 라고 부른다. 각 파일은 아이노드 번호와 연결되어 있다.

대부분 시스템에서 운영체제는 파일의 구조를 모른다.

두 번째 개념은 디렉터리 이다. 디렉터리도 저수준의 이름을 갖는다. 파일과의 차이점은 디렉터리의 내용은 구체적으로 정해져 있어서 <사용자가 읽을 수 있는 이름, 저수준의 이름> 쌍으로 이루어진 목록이 있다.

한 파일의 사용자용 이름이 “foo”, 저수준 이름이 “10”이라면, 파일이 들어있는 디렉토리에는 (”foo”, “10”) 이라는 항목이 있어서 둘을 연결한다. 디렉터리의 항목은 파일 또는 다른 디렉터리를 가리키므로, 임의의 디렉터리 트리(directory tree, 또는 디렉터리 계층(directory hierarchy)) 를 구성할 수 있다.

디렉터리 계층은 루트 디렉터리부터 시작하며 (Unix 기반에서는 / 로 표현된다.), 원하는 파일이나 디렉터리의 이름을 표현할 때까지 구분자를 사용하여 하위 디렉터리를 명시할 수 있다.

어떤 자원을 접근하는 가장 첫 단계는 그 대상의 이름 을 아는 것이기 때문에 시스템에서 이름짓기 기능은 매우 중요하다. 파일 시스템은 파일들을 효율적으로 명명할 수 있는 훌륭한 기능 하나를 제공한다.

파일 시스템 인터페이스

1. 파일의 생성

open 시스템 콜을 사용하면 파일을 생성할 수 있다.

open() 을 호출하면서 O_CREAT 플래그를 전달하면 프로그램은 새로운 파일을 만들 수 있다.

O_WRONLY 플래그를 사용하면 파일이 열렸을 때 쓰기만 가능하도록 만들고, O_TRUNC 플래그를 사용하여 파일이 이미 존재할 때는 파일의 크기를 0 byte로 줄여서 기존 내용을 모두 삭제한다.

int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC);

open() 의 중요한 항목은 리턴값이다. 리턴값은 파일 디스크립터(file descriptor) 이다. 파일 디스크립터는 프로세스마다 존재하는 정수로서 UNIX 시스템에서 파일을 접근하는 데 사용된다. 해당 파일을 읽고 쓰는 권한을 가지고 있으면 파일을 읽고 쓰는데 사용할 수 있다.

파일 디스크립터를 파일 객체를 가리키는 포인터로 볼 수도 있다.

2. 파일의 읽기와 쓰기

echocat 은 어떻게 파일에 쓰고 저장할 수 있을까?

Linux에서는 strace 라는 도구가 있어서 프로그램이 실행되는 동안에 호출된 모든 시스템 콜을 추적할고, 그 결과를 화면에서 확인할 수 있다.

cat의 작동 순서

prompt> strace cat foo
. . .
open(foo , O_RDONLY|O_LARGEFILE) = 3
read(3,hello\n , 4096) = 6
write(1,hello\n , 6) = 6
hello
read(3, , 4096) = 0
close(3) = 0
. . .
  1. open()
    1. 파일은 읽기만 가능하다 : O_RDONLY
    2. 64 bit 오프셋이 사용되도록 설정한다. : O_LARGEFILE
    3. 성공하면 3이라는 값을 파일 디스크립터로 리턴한다.

왜 파일 디스크립터의 값이 3번일까?

이는 프로세스가 이미 세 개의 파일을 열어 놓았기 때문이다. 이미 열려진 세 개의 파일은 표준 입력, 표준 출력, 오류 메시지를 기록할 수 있는 표준 에러이다. 각가 0,1, 2로 표현된다.

  1. read()
    1. 첫 번쨰 인자 : 파일 디스크립터 ⇒ 어떤 파일을 읽을 것인지 파일 시스템에 알려준다.
    2. 두 번째 인자 : read() 결과를 저장할 버퍼를 가리킨다. 위에서는 읽은 결과를 표시했다.
    3. 세 번째 인자 : 버퍼의 크기
    4. 성공하면 리턴하며 읽은 바이트 수를 반환한다.
  2. write()
    1. 1번 파일 디스크립터는 표준 출력 (STDOUT)이다. 1번을 사용한다.
  3. read() : 출력 이후 다시 읽으려고 시도한다.
    1. 읽을 것이 없으면 0을 리턴한다.
  4. close()
    1. 파일 디스크립터를 주어 어떤 파일을 닫을 지 알려준다.

쓰기도 비슷한 과정을 거친다.

비 순차적 읽기와 쓰기

위와 같이 쓰기를 여러 번 반복해야할 때, 다시 파일의 처음부터 접근하면 안된다. 이때 문서 내의 임의의 오프셋에서 읽기를 수행하기 위한 시스템 콜이 lseek() 이다.

lseek()의 프로토타입

off_t lseek(int fildes, off_t offset, int whence);
  1. 첫 번쨰 인자는 파일 디스크립터다.
  2. 두 번째 인자는 offset으로 파일의 특정 위치를 가리킨다.
  3. 세 번째 인자는 탐색 방식을 결정한다.
    • SEEK_SET : 오프셋은 바이트로 offset 바이트로 설정된다.
    • SEEK_CUR : 오프셋은 현재 위치에 offset 바이트를 더한 값으로 설정된다.
    • SEEK_END : 오프셋은 파일의 크기에 offset 바이트를 더한 값으로 설정된다.

프로세스가 open() 한 각 파일에 대해 운영체제는 현재 오프셋을 추적하여 다음 읽기 또는 쓰기 위치를 결정한다. 열린 파일의 개념에는 현재 오프셋이 포함된다.

fsync()를 이용한 즉시 기록

write() 호출의 목적은 대부분 해당 데이터를 가까운 미래에 영속 저장 장치에 기록해 달라고 파일 시스템에게 요청하는 것이다. 성능상 이유로 파일 시스템은 쓰기들을 일정 시간 동안 메모리에 모은다

( 버퍼링 ).

fsync() 를 호출하면 지정한 파일의 모든 갱신된 데이터를 강제적으로 즉시 디스크에 내려보낸다. 이를 사용하기 위해서는 파일이 존재하는 디렉터리도 fsync() 해주어야 한다. 그러지 않을 경우 응용 프로그램 수준의 버그를 만들어낸다.

파일 이름 변경

rename() 을 사용하여 파일의 이름을 바꿀 수 있다.

이 명령어는 시스템 크래시에 대해 원자적 으로 구현되었다.

int fd = open(foo.txt.tmp , O_WRONLY|O_CREAT|O_TRUNC);
write(fd, buffer, size); // 파일의 새로운 버전 쓰
fsync(fd);
close(fd);
rename(foo.txt.tmp , foo.txt );
  • 새로운 버전의 파일을 임의 이름으로 쓰고 디스크에 기록한다.
  • 그 후에 새로운 파일의 메타데이터와 내용이 디스크에 기록되었다는 것을 확인하면, 임시 파일 이름을 원래 파일 이름으로 변경한다.

파일 정보 추출

파일 시스템은 각 파일에 대한 정보를 보관한다. 파일에 대한 정보를 메타데이터 라고 부른다.

stat(), fstat() : 파일의 메타데이터를 출력한다.

struct stat {
	dev_t st_dev; /* ID of device containing file */
	ino_t st_ino; /* inode number */
	mode_t st_mode; /* protection */
	nlink_t st_nlink; /* number of hard links */
	uid_t st_uid; /* user ID of owner */
	gid_t st_gid; /* group ID of owner */
	dev_t st_rdev; /* device ID (if special file) */
	off_t st_size; /* total size, in bytes */
	blksize_t st_blksize; /* blocksize for filesystem I/O */
	blkcnt_t st_blocks; /* number of blocks allocated */
	time_t st_atime; /* time of last access */
	time_t st_mtime; /* time of last modification */
	time_t st_ctime; /* time of last status change */
};

파일 시스템은 아이노드 에 이 정보를 보관한다. 즉, 앞의 내용과 같은 정보를 저장하는 디스크 자료구조가 아이노드라고 이해하자.

파일 삭제

파일 삭제는 rm 시스템콜을 사용한다. 하지만 stracerm 을 추적해보면 unlink() 라는 시스템 콜만이 삭제에 관여한다.

unlink() 는 지워야 하는 파일 이름을 인자로 받은 후에 성공하면 0을 리턴한다. 이 시스템 콜에 대해 자세히 알고 싶다면 디렉터리에 대한 이해가 필요하다.

디렉터리 생성

디렉터리 관련 시스템 콜들은 디렉터리를 생성하고, 읽고, 삭제한다. 단, 디렉터리에는 절대로 직접 쓸 수 없다. 디렉터리는 파일 시스템의 메타데이터로 분류되며, 항상 간접적으로만 변경된다.

mkdir() : 디렉터리 생성을 위한 시스템 콜

prompt> strace mkdir foo
. . .
mkdir(foo , 0777) = 0
. . .

처음 디렉터리가 생성되면 두 가지 항목이 존재한다.

  1. 자기 자신을 가리키기 위한 "." 디렉터리
  2. 부모 디렉터리를 가리키기 위한 ".." (dot-dot) 디렉터리

디렉터리 읽기

ls 를 사용하면 디렉터리를 읽을 수 있다.

ls 를 간단히 구현해보면 다음과 같다.

int main(int argc, char *argv[]) {
		DIR *dp = opendir( . );
	  assert(dp != NULL);
		struct dirent *d;
		while ((d = readdir(dp)) != NULL) {
			printf(%d %s\n , (int) d>d_ino, d>d_name);
		}
		closedir(dp);
		return 0;
}
  • 각 디렉터리 항목을 하나씩 읽은 후, 파일의 이름과 아이노드 번호를 출력한다.

struct dirent 자료 구조

struct dirent {
	char d_name[256]; /* filename */
	ino_t d_ino; /* inode number */
	off_t d_off; /* offset to the next dirent */
	unsigned short d_reclen; /* length of this record */
	unsigned char d_type; /* type of file */
};

디렉터리 삭제하기

redir() 을 사용하여 디렉터리를 삭제할 수 있다. 사용하기 위해서는 지우기 전에 디렉터리가 비어 있어야 한다는 조건이 붙는다.

하드 링크

파일 삭제 시 unlink() 를 사용한 이유를 이해하기 위해서는 파일 시스템 트리에 항목을 추가하는 link() 시스템 콜을 알아야한다.

link() 시스템 콜은 원래의 경로명새로운 경로명을 인자로 받는다. 그리고 (새로운 경로명, 파일의 아이노드) 로 새로운 항목을 만든다. 이제 새로운 경로명 으로도 해당 파일에 접근할 수 있게 된다.

unlink() 는 경로명과 아이노드 번호의 연결을 끊는 것을 의미한다. 이때 아이노드 번호의 참조 횟수를 검사하는데, 이 수가 0이 되면 파일 시스템은 아이노드와 관련된 데이터 블럭을 해제하여 파일을 삭제한다.

심볼릭 링크 (소프트 링크)

ln-s 옵션으로 새로운 링크를 만들 수 있다. 하드 링크와 차이점은 하드 링크는 디렉토리에 대해서는 만들 수 없고, 다른 디스크 파티션에 있는 파일에 대해서도 걸지못하는 등 제한이 있다. 이를 해결하기 위해 symbolic link 라는 새로운 파일 형식을 만들어서 연결하는 파일의 경로명을 저장한다.

발생할 수 있는 문제점은 dangling reference 이다. 소프트 링크에 연결된 파일이 삭제된 경우, 소프트 링크로 접근하면 해당 파일이 존재하지 않는 문제이다.

파일 시스템 생성과 마운트

다수의 파일 시스템들이 존재할 때 이들을 묶어서 어떻게 하나의 큰 디렉터리 트리를 구성할까?

시스템콜로는 mkfs 를 사용한다. 새로이 생성된 파일 시스템은 현재 디스크에 존재한다. 새로이 생성된 파일 시스템을 루트 디렉터리에서 시작하는 기존의 디렉터리 구성을 통해 접근할 수 있도록 해주어야한다. 이를 마운트 라고 한다. 내부적으로는 mount() 시스템 콜로 작업한다. 이는 기존의 디렉터리 중 하나를 마운트 지점(mount point) 으로 지정하고, 마운트 지점에 생성된 파일 시스템을 붙여 넣는다.

Reference