[OS] 38. Sun's Network File System (NFS)

Park Yeongseo·2024년 3월 25일
0

OS

목록 보기
47/54
post-thumbnail

Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.

그림에서 보면, 서버에는 디스크들이 있고 클라이언트는 네트워크를 통해 메시지를 보내 그 디스크들에 있는 디렉토리와 파일들에 접근한다. 이런 환경에서는 클라이언트 간의 데이터 공유가 쉬워진다. 한 클라이언트에서 파일에 접근하고, 나중에 다른 클라이언트가 파일에 접근하는 경우에도 같은 파일을 보게 될 것이기 때문이다. 또한 이 환경은 중앙 집중형 관리도 가능해지고, 보안상의 이점을 가진다는 장점도 가지고 있다.

1. A Basic Distributed File System

이제는 단순화된 파일 시스템의 구조에 대해 알아보자. 클라이언트-서버 분산 파일 시스템은 지금까지 배워온 파일 시스템들보다 더 많은 구성 요소들을 필요로 한다. 클라이언트 쪽에는 클라이언트-사이드 파일 시스템과 이를 통해 파일과 디렉토리에 접근하는 클라이언트 애플리케이션들이 필요하다. 이 애플리케이션들은 클라리언트-사이드 파일 시스템에 시스템 콜을 요청한다. 서버에 저장된 파일들에 접근하기 위함이다. 따라서 클라이언트 애플리케이션에게 해당 파일 시스템은 (아마 성능의 차이가 있을 수는 있지만) 로컬 파일 시스템과 별 차이가 없는 것처럼 보인다. 이렇게 분산 파일 시스템은 파일에의 투명한 접근을 제공한다. 로컬 파일 시스템을 이용할 때와 다른 파일 시스템을 이용할 때 서로 다른 API를 써야만 한다면 심히 불편할 것이므로, 이러한 투명성의 제공은 중요하게 고려해야할 측면이다.

클라이언트-사이드 파일 시스템의 역할은 요청된 시스템 콜에 대한 응답에 필요한 액션들을 실행하는 것이다. 예를 들어, 클라이언트가 read() 요청을 보내면 클라이언트-사이드 파일 시스템은 서버-사이드 파일 시스템(혹은 파일 서버)에 특정 블럭을 읽기 위한 메시지를 보낸다. 그러면 파일 서버는 디스크에서 해당 블럭을 읽고 요청된 데이터를 담은 메시지를 다시 클라이언트에게 보낸다. 클라이언트-사이드 파일 시스템은 해당 데이터를 read() 시스템 콜에 의해 제공되는 사용자 버퍼에 복사하고, 이로써 요청은 완료된다. 이후 클라이언트 측에서 호출되는, 동일한 블럭에 대한 read() 시스템 콜은 클라이언트 메모리나 클라이언트 디스크에 될 것임에 유의하자. 만약 캐시가 제대로 잘 된다면, 이후의 read()에 대해서는 추가적인 네트워크 트래픽이 발생할 필요가 없어질 것이다.

위 그림은 클라이언트/서버 분산 파일 시스템을 이루는 두 핵심 소프트웨어인 클라이언트-사이드 파일 시스템및 서버-사이드 파일 시스템과, 두 파일 시스템 사이의 상호 작용이 어떻게 이루어지는지를 간단히 보여주고 있다. 이제는 좀 더 구체적인 예시로, Sun의 Network File System(NFS)에 대해 알아보자.

2. On To NFS

가장 초기의, 그리고 꽤 성공적이었던 분산 시스템은 Sun Microsystems에 의해 개발된 NFS다. Sun은 NFS를 정의할 때, 조금은 특이한 방식을 선택했는데, 폐쇄적이고 독점적인 시스템을 만드는 대신 클라이언트와 서버 사이의 통신에서 사용할, 특정 메시지 형식을 명시하는 공개 프로토콜(open protocol)을 개발한 것이다. 이를 통해 서로 다른 그룹들은 자기 자신만의 NFS를 만들면서도, 파일 시스템 간 상호작용이 가능하도록 할 수 있게 된다. 오늘날에도 시장에는 많은 NFS 서버들이 있으며, 이러한 NFS의 성공은 Sun이 취한 "오픈 마켓" 전략에 있다고 볼 수 있다.

3. Focus: Simple And Fast Server Crash Recovery

이번 장에서는 오랫동안 표준이었던, 클래식한 NFS 프로토콜(NFSv2)에 대해 알아본다. NFSv3로 넘어갈 때에는 NFSv2와 큰 변화가 없었지만, NFSv4로 넘어갈 때에는 많은 변화가 일어나기는 했다.

NFSv2에서의 설계상 핵심 목표는 단순하고 빠른 서버 충돌 복구에 있다. 다 클라이언트-단일 서버 환경에서 이러한 목표는 중요한데, 서버가 다운되면 다른 모든 클라이언트와 그 사용자들은 그로부터 데이터를 받지 못할 것이므로 당연하다. 서버가 다운되면 전체 시스템이 다운되는 것이다.

4. Key To Fast Crash Recovery: Statelessness

이 목표는 우리가 무상태(stateless) 프로토콜이라 부르는 것을 통해 가능해진다. 서버는 설계상, 각 클라이언트에서 무슨 일이 일어나고 있는지를 추적하지 않는다. 예를 들어, 서버는 어떤 클라이언트가 어떤 블럭을 캐싱하고 있는지, 현재 각 클라이언틀이 열어놓은 파일에는 무엇이 있는지, 각 파일에 대한 파일 포인터 위치는 어떤지 등을 알지 않아도 된다. 간단히 말해, 서버는 클라이언트가 뭘 하고 있는지에 대해서는 관심이 없다. 프로토콜은 각 프로토콜 요청에 해당 요청이 완료되기 위해 필요한 모든 정보들을 담아 전달하도록 설계되어 있다. 그렇다면 일단은 상태 유지(stateful) 프로토콜을 먼저 살펴보자.

상태 유지 프로토콜의 일례로 open() 시스템 콜을 생각해보자. open()은 경로명을 입력으로, 파일 디스크립터를 반환한다. 이 디스크립터는 이후, 여러 파일 블럭들에 접근하기 위한 read()write() 요청에서 쓰인다. 아래의 코드는 애플리케이션의 사용례다.

char buffer[MAX];
int fd = open("foo", O_RDONLY); // get descriptor "fd"
read(fd, buffer, MAX); // read MAX from foo via "fd"
read(fd, buffer, MAX); // read MAX again
...
read(fd, buffer, MAX); // read MAX again
close(fd);

이제 클라이언트-사이드 파일 시스템이 서버에 "파일 foo를 열고 그 디스크립터를 나에게 달라."라는 프로토콜 메시지를 보내려한다고 해보자. 파일 서버는 해당 파일을 로컬로 열고, 그 디스크립터를 클라이언트에 보낸다. 이후의 읽기 작업에서 클라이언트 애플리케이션은 이 디스크립터를 read() 시스템 콜 호출에서 사용한다. 클라이언트-사이드 파일 시스템은 디스크립터를 "이 파일 디스크립터의 해당하는 파일의 몇 바이트를 나에게 달라."라는 프로토콜 메시지에 넣어 파일 서버로 전달한다.

위의 예에서 파일 디스크립터는 클라이언트와 서버 사이에서 사용되는 공유 상태(shared state)에 해당한다. 하지만 공유 상태는 충돌이 일어난 경우에서의 복구를 어렵게 만든다. 서버에서 첫 번째 읽기가 완료되고, 클라이언트가 두 번째 요청을 보내기 전에 충돌이 일어났다고 해보자. 그리고 서버가 다시 제대로 돌아가기 전에 클라이언트가 두 번째 요청을 보낸다고 해보자. 이때 서버는 클라이언트가 전달한 파일 디스크립터가 가리키는 파일이 무엇인지를 알지 못한다. 이 정보는 메모리에 임시로 저장되며, 서버에서 충돌이 일어나면 손실되기 때문이다. 이러한 상황을 처리하기 위해서는 클라이언트와 서버 사이에 어떤 복구 프로토콜(recovery protocol)이 약속되어 있어야 한다. 이 프로토콜에서 클라이언트는 서버에 요청을 보내기 위해, 해당 요청이 완료될 수 있기 위해 필요한 충분한 정보를 메모리와 같은 곳에 담고 있어야 한다.

상태 유지 서버가 클라이언트 충돌을 처리해야하는 상황이 온다면 일은 더 복잡해진다. 예를 들어, 클라이언트가 파일을 열고 나서 충돌이 발생한다고 해보자. 이때 open()에서는 서버에서 파일을 열면서 만들어진 파일 디스크립터를 사용하는데, 그렇다면 해당 파일을 닫아도 되는지는 서버가 어떻게 알 수 있을까? 보통의 경우 클라이언트는 마지막에 close()를 호출해 서버에 해당 파일을 닫아도 된다고 알린다. 하지만 만약 클라이언트 충돌이 발생하게 되면 서버는 close()를 받지 못하게 될 수 있다. 이 경우 파일을 계속해서 열어둬서는 안되므로, 서버는 파일을 닫기 위해, 해당 요청을 보낸 클라이언트에 충돌이 발생했다는 사실을 알아야 한다.

이러한 이유로 NFS 설계자들은 무상태 접근법을 사용했다. 각 클라이언트에는 요청이 종료되기 위해 필요한 모든 정보들이 있다. 다른 멋진 충돌 복구 방법도 필요 없다. 서버는 그냥 재실행되고, 클라이언트는 (최악의 경우) 요청을 다시 보내면 된다.

5. The NFSv2 Protocol

그렇다면 무상태로 동작하는 네트워크 프로토콜은 어떻게 정의할 수 있을까? 여기서 open()과 같은 상태 유지가 필요한 시스템 콜은 고려 대상이 되지 않는다. 하지만 클라이언트 애플리케이션의 경우에는 open(), read(), write(), close()를 포함한 여러 표준 API 콜들을 사용할 수 있어야 한다. 따라서 이 프로토콜은 무상태적이면서도, 그런 POSIX 시스템 콜 API를 지원할 수 있어야 한다.

NFS 프로토콜 설계를 이해하기 위한 한 가지 핵심 요소는 파일 핸들(file handle)이다. 파일 핸들은 특정 연산이 일어날 파일, 혹은 디렉토리를 유일하게 설명하는 데에 쓰이며, 따라서 많은 프로토콜 요청들은 이 파일 핸들을 포함하게 된다.

파일 핸들은 다음의 세 중요 구성 요소로 이루어진다고 생각할 수 있다. 바로 볼륨 식별자(volume identifier), 아이노드 번호, 생성 번호(generation number)다. 이 세 구성요소는 클라이언트가 접근하고자 하는 파일, 혹은 디렉토리의 유일 식별자를 구성한다.

  • 볼륨 식별자
    + 서버에 해당 요청이 어떤 파일 시스템에 접근하고자 하는지 알림
  • 아이노드 번호
    + 요청이 접근하고자 하는 것이 파티션 내의 어떤 파일인지 알림
  • 생성 번호
    + 아이노드 번호를 재사용하기 위해 사용.
    + 아이노드 번호가 사용될 때마다 생성 번호를 1 증가시킴으로써 서버는 오래된 파일 핸들이 새로 할당된 파일에 접근하는 일을 방지함.

위는 NFS 프로토콜 중 중요한 일부들이다. LOOKUP 프로토콜 메시지는 파일 핸들을 얻기 위해 사용되며, 이 파일 핸들은 이후의 파일 데이터 접근에 사용된다. 클라이언트는 디렉토리 파일 핸들과 찾을 파일의 이름을 전달하며, 해당 파일의 핸들과 그 속성이 다시 서버로부터 클라이언트로 전달된다.

예를 들어, 클라이언트가 이미 파일 시스템의 루트 디렉토리에 대한 디렉토리 파일 핸들을 가지고 있다고 해보자. 클라이언트 측에서 실행되는 애플리케이션이 파일 /foo.txt를 열면, 클라이언트-사이드 파일 시스템은 서버에 루트 파일 핸들과 파일명 foo.txt를 LOOKUP 리퀘스트에 담아 보낸다. 만약 요청이 성공하면 foo.txt에 대한 파일 핸들이 반환될 것이다.

이때 함께 반환되는 속성들은 파일 시스템이 각 파일에 대해 가지고 있는 메타데이터로, 파일 생성 시간, 최종 수정 시간, 크기, 소유자, 권한 정보 등이 포함되어 있다. stat()을 이용했을 때 얻을 수 있는 정보들이다.

파일 핸들을 얻고 나면 클라이언트는 READ, WRITE 프로토콜 메시지를 이용해 파일을 읽거나 쓸 수 있다. READ 프로토콜 메시지는 파일의 핸들, 파일 내 오프셋, 읽을 바이트 수를 필요로 한다. 서버는 이를 가지고 읽기 작업을 수행해 클라이언트에 데이터를 반환한다. WRITE의 경우도 비슷하게 처리되는데, 데이터가 클라이언트에서 서버로 전달되며, 반환되는 것은 성공 코드와 최신으로 갱신된 속성 밖에 없다는 점만 다르다.

마지막으로 살펴볼 것은 중요한 프로토콜 메시지는 GETATTR 요청이다. 이 프로토콜 메시지는 파일 핸들을 입력으로, 해당 파일의 속성들을 가져오기 위해 사용한다. 이 요청이 왜 중요한지는 아래서 캐싱에 대해 논의할 때 다시 알아보게 될 것이다.

6. From Protocol To Distributed File System

클라이언트-사이드 파일 시스템은 클라이언트 애플리케이션에서 만들어진 요청을 관련된 프로토콜 메시지 집합으로 변환한다. 이 메시지에는 서버에서 요청이 처리되기 위해 필요한 모든 정보들이 들어있다.

예를 들어, 하나의 파일을 읽는 단순한 애플리케이션 하나를 생각해보자. 아래의 다이어그램은 이 애플리케이션이 만드는 시스템 콜에는 어떤 것들이 있는지, 그리고 해당 시스템 콜들에 대한 응답으로 클라이언트-사이드 파일 시스템과 파일 서버가 어떤 일을 하는지가 나타나 있다.

다이어그램을 볼 때 다음의 사항들에 유의하자.
1. 클라이언트가 어떻게 파일 접근에 필요한 모든 관련 상태들을 추적하는지를 알아야 한다.
- 여기에는 정수 파일 디스크립터를 NFS 파일 핸들러로 변환하기 위한 매핑, 현재 파일 포인터 등이 있다.
- 이는 클라이언트가 각 읽기 요청을 제대로 형식화된 읽기 프로토콜 메시지로 변환해, 서버로부터 해당 파일의 바이트를 읽어오기 위해 필요하다.
- 읽기가 성공하면 클라이언트는 파일 커서 위치를 변경시킨다. 이후 같은 파일에 대한 읽기 요청은 같은 파일 핸들, 달라진 오프셋을 통해 이뤄진다.
2. 서버 상호작용이 어디서 일어나는지를 알 수 있다.
- 처음 파일이 열리는 경우, 클라이언트-사이드 파일 시스템은 LOOKUP 요청 메시지를 보낸다.
- /home/remzi/foo.txt와 같이 긴 경로명이 사용되는 경우, 클라이언트는 세 번의 LOOKUP을 보내야 한다. 두 번의 디렉토리와 한 번의 파일 탐색을 위해서다.
3. 각 서버 요청에 해당 요청이 완료되기에 필요한 모든 정보가 들어있음을 알 수 있다.
- 서버를 무상태적으로 만듦으로써 서버 오류가 일어나는 경우에 복구가 쉽게 일어날 수 있도록 한다.

7. Handling Server Failure With Idempotent Operations

클라이언트는 서버에 메시지를 보내더라도 응답을 받지 못할 수 있다. 이러한 실패에는 많은 이유가 있을 수 있다. 예를 들면 네트워킹 중 메시지가 손실되는 경우가 있을 수 있다. 요청이 손실되면 서버가 해당 요청을 받지 못할 것이고, 응답이 손실되면 요청이 제대로 처리되더라도 클라이언트는 응답을 받지 못할 것이다. 혹은 서버에 충돌이 일어나 메시지에 응답을 할 수 없을 수도 있다. 이 경우 서버는 리부팅되고 재실행될 것이지만, 그동안 일어나는 요청들에 대한 응답은 이루어지지 못할 것이다.

NFSv2는 이러한 모든 응답 실패들을 하나의 간단한 방식을 통해 처리한다. 바로 요청을 재시도하는 것이다. 구체적으로, 요청을 보내고 나서 클라이언트는 일정 기간의 시간으로 타이머를 설정한다. 만약 타이머가 다 가기 전에 응답이 오면 타이머는 취소된다. 하지만 응답이 오기 전에 타이머가 끝나면, 클라이언트는 해당 요청이 제대로 처리되지 않았다 가정하고 해당 요청을 다시 보낸다. 이때 만약 서버가 응답을 해주면 위와 같은 응답 실패는 잘 처리된 것이라 볼 수 있다.

이런 일이 가능한 것은 대부분의 NFS 요청들이 가지는 성질, 멱등성(idempotency) 덕분이다. 연산이 멱등적이라는 것은 그 연산의 수행이 여러 번 수행된 결과와 한 번 수행된 결과가 서로 동일하다는 것을 의미한다. 예를 들어 어떤 값을 메모리의 특정 위치에 저장하는 연산은 세 번 일어나든, 한 번 일어나든 같은 결과로 이어지기 때문에 멱등적이다. 이와 달리 메모리의 특정 위치에 있는 값을 1 증가시키는 연산의 경우, 한 번 일어나는 것과 세 번 일어나는 것의 결과가 서로 다르기 때문에 멱등적이지 않다. 일반적으로 데이터를 읽는 연산들의 경우는 멱등적이지만, 데이터를 갱신하는 연산의 경우에는 멱등적일 수도 있고, 아닐 수도 있다.

NFS 충돌 복구 설계의 핵심은 가장 자주 쓰이는 연산들이 가지는 멱등성에 있다. LOOKUP과 READ의 경우 갱신이 아니라 파일 서버로부터 정보를 읽어올 뿐이기 때문에 당연하게도 멱등적이다. 흥미롭게도 WRITE의 경우도 멱등적이다. WRITE 메시지에는 데이터, 카운트, 데이터를 쓸 정확한 위치가 들어있으며, 이는 몇 번을 반복해도 같은 결과로 이어지기 때문이다.

이러한 방식으로, 클라이언트는 모든 종류의 타임아웃을 획일적인 방법으로 처리할 수 있게 된다. 만약 WRITE 요청이 손실되는 경우, 클라이언트는 해당 요청을 그저 다시 보내면 된다. 만약 서버가 이번에는 그 요청을 제대로 받으면 해당 작업을 수행하면 된다. 서버가 다운되는 경우에도 마찬가지로, 클라이언트는 요청을 재전송하면 된다. 마지막으로 서버가 요청을 제대로 처리했지만 그로부터의 응답이 손실된 경우에도 클라이언트는 해당 요청을 재전송한다. 해당 요청을 다시 받은 서버는 이전과 똑같은 작업을 수행하며, 이 요청은 멱등적이기 때문에 여러 번 수행되어도 그 결과는 똑같이 유지된다.

다만 어떤 연산들의 경우에는 멱등적으로 만들기 어려울 수 있다. 에를 들어 이미 있는 디렉토리를 새로 만든다면 mkdir 요청이 실패했다는 응답을 받게 될 것이다. 클라이언트에서 서버로 MKDIR 프로토콜 메시지를 보내 서버에 디렉토리가 정상적으로 만들어졌지만, 그 응답은 손실됐다고 해보자. NFS에서 클라이언트는 해당 요청을 재전송해야한다. 그런데 사실 서버에는 이미 해당 디렉토리가 생성되어 있으므로, 이후 재전송된 모든 요청들은 실패하게 되며, 서버는 실패했다는 응답만을 보낼 수 밖에 없다.

8. Improving Performance: Client-side Caching

분산 파일 시스템에는 많은 이점들이 있지만, 모든 읽기 및 쓰기 요청을 네트워크를 통해 보내는 것은 큰 성능 문제로 이어질 수도 있다. 네트워크는 보통, 메모리나 디스크에 비해 그리 빠르지 않기 때문이다. 그러므로 해결해야 할 다른 문제가 발생한다. 분산 파일 시스템의 성능은 어떻게 향상시킬 수 있을까?

정답은 클라이언트 사이드의 캐싱이다. NFS 클라이언트-사이드 파일 시스템은 서버로부터 읽어온 파일의 데이터 및 메타 데이터를 클라이언트 메모리에 저장한다. 이러한 캐싱을 통해, 맨 처음의 접근은 비싸더라도, 후속 접근의 경우는 상당히 빠르게 이루어질 수 있게 된다.

캐시는 임시 쓰기 버퍼의 역할도 할 수 있다. 클라이언트 애플리케이션이 파일에 쓸 때, 클라이언트는 우선 해당 데이터들을 클라이언트 메모리에 버퍼 처리하고, 이후에 서버로 해당 데이터를 보내 실제로 쓴다. 이런 쓰기 버퍼링은 클라이언트 애플리케이션에서 호출한 write()를 즉시 성공시킴으로써 쓰기의 지연을 크게 줄인다.

이렇듯 캐시의 사용은 성능을 효과적으로 향상시킬 수 있다. 하지만 캐시를 사용하는 경우, 당연히 한 가지 처리해줘야 할 문제가 새로 발생한다. 바로 캐시 일관성 문제(cache consistency problem)다.

9. The Cache Consistency Problem

세 클라이언트와 한 서버가 있는 경우를 생각해보자. 클라이언트 C1는 파일 F를 읽고, 그 복사본을 로컬 캐시에 저장한다. 다른 클라이언트 C2는 파일 F에 새로운 내용을 덮어쓴다. 이때 새로운 버전의 F를 F[v2]라고 부르도록 하고, 오래된 버전은 F[v1]이라 부르도록 하자. 마지막으로 클라이언트 C3은 아직 F에 접근하지 않은 상태다.

여기서 일어날 수 있는 문제로는 두 가지가 있다. 첫 번째로, 클라이언트 C2는 F에 새 내용을 덮어 쓰기 전에 해당 내용을 자신의 캐시에 버퍼링 해놓을 것이다. 이 겨우, F[v2]는 C2의 메모리에 있으므로, C3가 F의 내용을 읽을 때에는 오래된 F[v1]의 내용을 얻게 될 것이다. 이런 문제를 업데이트 가시성(update visibility) 문제라 부른다. 한 클라이언트에서 일어난 업데이트를 다른 클라이언트에 언제 반영해 보여줄 것인가에 대한 것이다.

두 번째 문제는 오래된 캐시(stale cache)라 부른다. 이 경우 C2는 자신의 내용을 파일 서버에 반영시키고, 파일 서버는 최신의 F[v2]를 가지게 된다. 이때 C1이 파일 F를 읽으려고 한다고 해보자. 이미 F의 내용이 C1에 캐싱되어 있으므로, C1은 파일 서버가 아닌 캐시로부터 그 내용을 가져오게 된다. 이미 파일 서버 내의 내용이 바뀌었음에도 오래된 버전의 내용을 사용하게 되는 이 상황도 바람직하지 않다.

NFSv2는 이 캐시 일관성 문제들을 두 가지의 방식으로 해결한다. 우선 업데이트 가시성 문제를 해겨하기 위해, 클라이언트는 flush-on-close라고 부르는 것을 사용한다. 이는 구체적으로 파일이 쓰이고 나서 클라이언트 애플리케이션에 의해 닫히는 경우, 클라이언트가 캐시 내에 있는 모든 업데이트들을 서버에 보내 반영시키게 하는 것이다. 이를 통해 NFS는 어떤 파일이 갱신되고 닫힌 후 다른 클라이언트가 해당 파일을 읽으려 하면 항상 그 최신 버전을 볼 수 있도록 보장할 수 있다.

두 번째로, 오래된 캐시 문제를 해결하기 위해 NFSv2 클라이언트는 캐싱된 컨텐츠를 읽기 전에 해당 파일이 변경된 적이 있는지를 체크한다. 구체적으로는 캐싱된 블러을 사용하기 전 클라이언트-사이드 파일 시스템은 GETATTR 요청을 서버로 보내 파일의 속성들을 받아온다. 여기에는 해당 파일이 서버 상에서 가장 마지막으로 변경된 시간이 포함되어 있다. 만약 이 정보가 클라이언트 캐시에 있는 정보보다 최신의 것이라면, 클라이언트는 해당 파일을 무효화, 즉 클라이언트 캐시에서 삭제한다. 캐시가 비어있으니 이후의 읽기 요청은 서버로 보내지고, 그러면 해당 파일의 최신 버전을 가져올 수 있게 된다. 만약 서버에서 가져온 최근 수정 시간이 캐시에 있는 것과 같으면 당연히 캐시에 있는 것을 사용할 것이다.

그런데 Sun에서 오래된 캐시 문제에 대한 이 해결법을 구현할 때, 새로운 문제가 발생했다. NFS 서버가 GETATTR 요청들로 가득 차 버린 것이다. 엔지니어링 원칙 중에는 "흔히 일어나는 경우를 잘 처리하도록 설계하라."라는 것이 있다. 이 경우 가장 흔히 일어나는 일은 한 클라이언트만이 한 파일에 반복적으로 접근하는 것이었다. 그런데 이런 경우에도 클라이언트는 항상 다른 클라이언트가 해당 파일을 변경한 적이 없는지를 확인하기 위해 GETATTR 요청을 서버로 보내야했다.

이러한 상황을 해결하기 위해 각 클라이언트에는 속성 캐시(attribute cache)도 추가됐다. 클라이언트는 여전히 파일에 접근하기 전에 해당 파일의 속성을 읽어야 하지만, 대부분은 이를 서버가 아닌 자신의 캐시에서 가져온다. 특정 파일의 속성은 파일에 처음 접근할 때 캐싱되고, 정해진 시간 이후 타임아웃된다. 정해진 시간 동안의 모든 파일 접근은 캐싱된 속성을 사용하며 서버와의 통신은 발생하지 않게 된다.

10. Assessing NFS Cache Consistency

flush-on-close의 경우, 말은 맞지만 또 다른 성능상의 문제점을 가진다. 구체적으로 클라이언트에 임시, 혹은 짧은 시간동안만 쓰이는 파일이 생성되고 곧 지워지는 경우에도 이는 서버에도 곧장 반영된다. 더 이상적인 구현 방법은 그런 짧은 수명의 파일은 삭제될 때까지 메모리에만 남겨두고, 서버 쓰기에서는 완전히 누락시켜 성능을 향상시키는 방법일 것이다.

또한 속성 캐시의 추가는 클라이언트가 정확히 어떤 버전의 파일을 얻었는지를 파악하기 어렵게 만든다. 어떨 때에는 파일의 최신 버전을 얻게 될 것이고, 어떨 때는 오래된 버전을 얻을 수도 있다. 이는 대부분의 흔한 경우 잘 동작하지만, 가끔은 이상하게 오작동할 수도 있다.

11. Implications On Server-Side Write Buffering

지금까지는 클라이언트에서의 캐싱에 초점을 맞췄지만, 사실 서버에서도 캐싱은 필요하다. 예를 들어 디스크로부터 데이터와 메타데이터가 읽혀질 때, NFS 서버는 이를 메모리에 저장하고, 이후 해당 데이터에 대한 읽기 요청이 들어오면 디스크에 접근하지 않고 메모리 내 캐시의 데이터를 이용해 응답함으로써 조금이나마 성능을 향상시킬 수 있다.

더 흥미로운 것은 쓰기 버퍼링의 경우다. NFS 서버는 쓰기가 디스크 등의 영구 저장소에 성공적으로 반영되기 전까지는 WRITE 프로토콜 요청에 성공을 반환하지 않는다. 서버는 데이터의 복사본을 메모리에 저장하고 WRITE 프로토콜 요청에 바로 성공을 알릴 수도 있지만, 이는 잘못된 결과를 낳을 수도 있다.

그 이유는 클라이언트가 서버 오류를 처리하는 방식에 있다. 클라이언트가 다음의 연속적인 세 쓰기 요청을 만들었다고 해보자.

write(fd, a_buffer, size); // fill 1st block with a’s
write(fd, b_buffer, size); // fill 2nd block with b’s
write(fd, c_buffer, size); // fill 3rd block with c’s

이 쓰기들은 파일의 세 블럭들을 a, b, c 블럭들로 채운다. 파일이 원래는 다음가 같이 생겼다고 해보자.

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz

위 세 쓰기가 정상적으로 완료되었을 때의 결과로 기대되는 것은 다음과 같다.

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
cccccccccccccccccccccccccccccccccccccccccccc

이 세 쓰기가 각각 별개의 WRITE 프로토콜 메시지로 서버로 보내졌다고 해보자. 이때 첫 번째 쓰기는 디스크에까지 잘 반영되고, 클라이언트도 이것이 성공했음을 잘 전달받았다고 하자. 다음의 경우 서버가 쓰기를 메모리에 캐싱하고, 곧바로 클라이언트에 성공했다고 알렸다고 해보자. 그런데 메모리에 캐싱된 내용이 디스크에 쓰이기 전에 서버 충돌이 일어나, 서버가 재시작 및 재실행 됐다고 하자. 이후 서버는 세 번째 쓰기 요청을 받고 계속해서 진행한다.

클라이언트는 세 요청 모두에 대해 성공했다는 응답을 받게 될 것이다. 하지만 서버 내 파일에 저장된 내용은 사실 다음과 같다.

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
cccccccccccccccccccccccccccccccccccccccccccc

이러한 문제를 해결하기 위해 NFS 서버는 반드시 영구 저장소에 각 쓰기를 커밋한 후에야 클라이언트에 성공을 알려야 한다. 이는 클라이언트가 쓰기 중 일어난 서버 오류를 감지할 수 있게 하고, 해당 요청을 성공할 때까지 재전송할 수 있게 한다.

다만 이 경우 NFS 서버에는 성능의 문제가 발생할 수 있으며, 분산 파일 시스템 전체의 병목이 될 수 있다. 이를 해결하기 위한 해결법 중 하나로는 전원 손실이 일어나는 경우에 대비해 배터리와 연결된 RAM(battery-backed RAM)을 사용하는 것이 있다. 이 경우 디스크에 곧장 쓰지 않고도 데이터 손실의 걱정 없이 WRITE 요청에 즉시 성공을 반환할 수 있게 된다. 또 다른 방법으로는 디스크 쓰기를 아주 빠르게 수행할 수 있도록 설계된 파일 시스템을 사용하는 것도 있다.

profile
박가 영서라 합니다

0개의 댓글