본문으로 건너뛰기

3. OS캐시와 분산

8강. OS의 캐시 구조

OS의 캐시 구조를 알고 애플리케이션 작성하기

OS는 메모리를 이용해서 디스크 액세스를 줄입니다. 원리를 알고 이를 전제로 애플리케이션을 작성하면 OS에 상당 부분을 맡길 수 있습니다. 그 원리가 바로 OS 캐시입니다. Linux의 경우는 페이지 캐시(page cache)나 파일 캐시(file cache), 버퍼 캐시(buffer cache)라고 하는 캐시 구조를 갖추고 있습니다.

여기서는 파일 캐시보다 페이지 캐시라고 하겠습니다. 그 이유는 뒤에서 설명하듯 Linux는 파일 전체가 아니라 페이지 단위로 디스크를 캐싱하기 때문입니다.

가상 메모리 구조

가상 메모리 구조가 존재하는 가장 큰 이유는 물리적인 하드웨어를 OS에서 추상화하기 위해서입니다. 그림 3.2를 바탕으로 가상 메모리를 좀 더 자세히 살펴보겠습니다. 그림 3.2에서 1️⃣은 메모리, 2️⃣는 OS, 3️⃣은 애플리케이션 프로세스입니다.

그림 3.2 가상 메모리 구조

그림 3.2의 1️⃣ 메모리에는 ❶과 같은 주소(=어드레스)가 붙어 있습니다. ❶에는 0x00002123이라는 32비트(bit) 주소가 붙어 있습니다. 그러나 ❶의 어드레스를 직접 프로그램에서 사용하게 되면 여러 곤란한 일이 일어납니다.

따라서 프로세스에서 메모리를 필요로 하게 되면 그림 3.2 [1]처럼 느닷없이 ❶의 어드레스를 가져오는 것이 아니라, 그림 3.2 [2]와 같이 OS가 1️⃣ 메모리에서 비어 있는 곳을 찾습니다. 1️⃣ 메모리는 OS가 관리하고 있으며, 그림 3.2 [3]처럼 비어 있는 곳을 반환할 때 ❶의 0x00002123과는 다른 어드레스 ❷를 반환합니다.

그 이유는 개별 프로세스에서는 메모리의 어느 부분을 사용하는지 관여하지 않고, 반드시 특정 번지부터 시작 또는 0x000부터 시작 하는 것으로 정해져 있으면 다루기 쉽기 때문입니다.

그런데 5강에서 디스크의 경우에도 OS가 모아서 읽어낸다고 했는데, 그림 3.2 [2]에서 메모리를 확보할 때에도 마찬가지 방식으로 그림 3.2의 ❸과 같이 메모리 1바이트씩 액세스하는 것이 아니라 적당히 4KB 정도를 블록으로 확보해서 프로세스에 넘깁니다. 여기서 1개의 블록을 페이지라고 합니다. OS는 프로세스에서 메모리를 요청받으면 페이지를 1개 이상, 필요한 만큼 페이지를 확보해서 프로세스에 넘깁니다.

Linux의 페이지 캐시 원리

OS는 확보한 페이지를 메모리상에 계속 확보해두는 기능을 갖고 있습니다. 프로세스가 디스크로부터 데이터를 읽어내는 과정을 그림 3.3으로 살펴보겠습니다. OS는 그림 3.3의 ❶과 같이 우선 디스크로부터 4KB 크기의 블록을 읽어냅니다. 읽어낸 것은 그림 3.3의 ❷와 같이 한 번은 메모리상에 위치시켜야 합니다. 프로세스는 디스크에 직접 액세스할 수 없기 때문입니다. 어디까지나 프로세스가 액세스할 수 있는 것은 (가상) 메모리입니다.

그림 3.3 페이지 캐시

따라서 OS는 그림 3.3의 ❷와 같이 읽어낸 블록을 메모리에 씁니다. 그리고 나서 OS는 그 메모리 주소(그림 3.3의 ❸)를 프로세스(1️⃣)에 가상 어드레스로서 알려줍니다. 그러면 프로세스가 해당 메모리인 ❸에 액세스하게 됩니다(그림 3.3의 ❹).

데이터 읽기를 마친 프로세스(1️⃣)가 이번 디스크 읽기는 끝났고 데이터는 전부 처리했으므로 더 이상 불필요하게 됐어도 그림 3.3의 ❸을 해제하지 않고 남겨둡니다. 그렇게 하면 다음에 다른 프로세스(2️⃣)가 같은 디스크인 그림 3.3의 ❶에 액세스할 때에는 남겨두었던 페이지를 사용할 수 있으므로 디스크를 읽으러 갈 필요가 없게 됩니다. 이것이 페이지 캐시입니다. 커널이 한 번 할당한 메모리를 해제하지 않고 계속 남겨두는 것이 페이지 캐시의 기본입니다(그림 3.3의 ❺).

페이지 캐시의 친숙한 효과

이는 예외인 경우를 제외하고 모든 I/O에 투과적으로 작용합니다. Linux에서는 디스크에 데이터를 읽으러 가면 꼭 한 번은 메모리로 가서 데이터가 반드시 캐싱됩니다. 따라서 두 번째 이후의 액세스가 빨라집니다.

Linux에서는이라고 했지만, 현대의 OS는 대체로 페이지 캐시와 비슷한 구조를 갖추고 있습니다. OS를 계속 가동시켜 두면 메모리가 허락하는 한 디스크상의 데이터를 계속 캐싱하게 됩니다. 따라서 OS를 계속 가동시켜 두면 빨라집니다.

VFS

이렇듯 디스크의 캐시는 페이지 캐시에 의해 제공되지만, 실제 이 디스크를 조작하는 디바이스 드라이버와 OS 사이에는 파일시스템이 끼어 있습니다.

그림 3.4 VFS

Linux에는 ext3, ext2, ext4, xfs 등 몇몇 파일시스템이 있는데 그 하위에 디바이스 드라이버가 있으며, 이 디바이스 드라이버가 실제로 하드디스크 등을 조작합니다. 파일시스템 위에는 VFS(Virtual File System, 가상 파일시스템)이라는 추상화 레이어가 있습니다. 파일시스템은 다양한 함수를 갖추고 있는데, 그 인터페이스를 통일하는 것이 VFS의 역할입니다. 또한 VFS가 페이지 캐시의 구조를 지니고 있습니다. 어떤 파일시스템을 이용하더라도, 어떤 디스크를 읽어내더라도 반드시 동일한 구조로 캐싱됩니다.

VFS의 역할은 파일시스템 구현의 추상화와 성능에 관련된 페이지 캐시 부분입니다.

Linux는 페이지 단위로 디스크를 캐싱합니다

앞에서는 페이지 캐시라고 했고, 강의 8 첫 부분에서는 파일 캐시라고 하면 이름이 적절하지 않다고 했는데 이제 그 이유를 설명하겠습니다.

예를 들어 그림 3.5의 ❶ 디스크상에 4GB 정도의 매우 큰 파일이 있고, 그림 3.5의 ❷ 메모리가 2GB밖에 없다고 하겠습니다. 2GB 중에 500MB 정도를 OS가 프로세스에 할당했다고 하면(그림 3.5의 ❸), 이제 1.5GB 정도 여유가 있다고 할 때 4GB 파일을 캐싱할 수 있을까?라는 문제가 발생합니다(그림 3.5의 ❹).

그림 3.5 디스크를 페이지 단위로 캐싱

파일 캐시라고 생각한다면 파일 1개 단위로 캐싱하고 있다는 이미지를 주므로 4GB는 캐싱할 수 없다고 생각할 수 있지만, 실제로는 그렇지 않습니다. OS는 그림 3.5의 ❺와 같이 읽어낸 블록 단위만으로 캐싱할 수 있는 범위가 정해집니다. 여기서는 디스크상에 배치되어 있는 4KB 블록만을 캐싱하므로 특정 파일의 일부분만, 읽어낸 부분만을 캐싱할 수 있습니다.

이렇게 보면 앞서 말한 파일 캐시라는 명칭이 적절하지 않은 이유도 납득할 수 있습니다.

메모리가 비어 있으면 캐싱

페이지 캐시의 특성을 살펴보면, 우선 Linux는 메모리가 비어 있으면 전부 캐싱합니다. 한편 프로세스에서 메모리를 요청했을 때 캐시로 인해 더 이상 메모리가 남아 있지 않다면 오래된 캐시를 버리고 프로세스에 메모리를 확보해줍니다.

메모리를 늘려서 I/O 부하 줄이기

메모리를 늘리면 캐시에 사용할 수 있는 용량이 늘어납니다. 캐시에 사용할 수 있는 용량이 늘어나면 보다 많은 데이터를 캐싱할 수 있고, 캐싱되면 디스크를 읽는 횟수가 줄어듭니다.

페이지 캐시는 투과적으로 작용합니다

실제 사례를 들어보겠습니다. 그림 3.9도 마찬가지로 sar -r로 본 메모리이며, 이는 캐시가 투과적으로 작용한다는 것을 나타내고 있습니다. ❷행을 보면 갑자기 메모리 사용량이 96.98%로 올라가고 있습니다.

그림 3.9 OS 부팅 직후에 수GB 파일을 read한 결과

그림 3.9의 ❶행에서 ❷행 사이에 매우 큰 파일을 read한 것입니다. 이것이 전부 캐시에 저장되어 96%를 사용하게 되었습니다. 실제로 ❶행까지 캐시가 50~60MB 정도만 사용하던 것이 갑자기 4GB 정도의 캐시가 발생하게 된 것입니다. OS 부팅 직후에는 커널이 디스크를 그다지 읽지 않았으므로 캐시로 데이터가 거의 유입되지 않았지만, 특정 파일을 read하면 이를 쭉 캐싱해갑니다. 파일을 캐싱하는 원리는 대략 이런 형태로 되어 있습니다.

9강. I/O 부하를 줄이는 방법

캐시를 전제로 한 I/O 줄이는 방법

첫 번째 포인트는 데이터 규모에 비해 물리 메모리가 크면 전부 캐싱할 수 있으므로 이 점을 생각하는 것입니다. 다루고자 하는 데이터의 크기에 주목해야 합니다.

또한 대규모 데이터 처리에는 데이터 압축이 중요하다고 했는데, 압축해서 저장해두면 디스크 내용을 전부 그대로 캐싱해둘 수 있는 경우가 많습니다. 예를 들어 LZ법 등 일반적인 압축 알고리즘의 경우 압축률은 보통이더라도 텍스트 파일을 대략 절반 정도로 압축할 수 있습니다.

또 하나는 경제적인 비용과의 밸런스를 고려하는 점입니다. 최근에는 메모리가 8GB~16GB 정도가 일반적인 서버 한 대의 메모리 구성입니다. 열심히 소프트웨어를 개발해서 그래, 이건 데이터 크기를 엄청 줄여서 캐싱되도록 할 수 있을 거야라며 5명을 반 년 정도 투입해서 대단한 압축 알고리즘을 생각해냈다고 하더라도, 애초에 8GB 메모리에 다 들어갈 내용이었다면 비용적으로는 굳이 그렇게까지 하지 않아도 되는 일일 수 있습니다.

반대로 메모리가 32GB나 64GB 정도 되어야 캐싱할 수 있다고 하면 하드웨어 비용이 급격히 높아지므로, 소프트웨어로 메모리 사용을 줄일 수 있도록 노력할 필요가 있습니다.

복수 서버로 확장시키기

메모리를 늘려서 전부 캐싱할 수 있다면 좋겠지만, 당연히 데이터를 전부 캐싱할 수 없는 규모가 될 수 있습니다. 그렇게 되면 먼저 복수 서버로 확장시키는 방안을 생각해볼 필요가 있습니다.

이전 강에서 다뤘던 것처럼 AP 서버를 늘리는 것과 DB 서버를 늘리는 것은 둘 다 서버를 늘리는 것이지만 필요한 리소스, 요구되는 리소스가 전혀 다릅니다. DB 서버는 늘리면 좋다라는 논리가 그대로 들어맞지 않습니다.

단순히 대수만 늘려서는 확장성을 확보할 수 없습니다

캐시 용량을 늘려야 한다고 했지만, 사실 단순히 대수만 늘리는 것으로는 안 됩니다. 예를 들어 그림 3.11과 같이 단순히 데이터를 복사해서 대수를 늘리게 되면 애초에 캐시 용량이 부족해서 늘렸는데, 그 부족한 부분도 그대로 동일하게 늘려가게 됩니다. 그림 3.11의 검은 부분이 변함없이 캐싱되지 않는 상황이 됩니다.

그림 3.11 단순히 복사해서 늘릴 경우

이는 서버를 늘림으로써 시스템 전체로서는 아주 조금은 빨라질지 모르지만, 증설 비용에 대비해서 성능 향상은 극히 부족한 것입니다. 확장성을 확보하려고 할 때 서버를 늘려서 10배에서 100배 정도는 빨라져야 합니다. 따라서 단순히 대수를 늘리는 것은 좋은 방안이 아닙니다.

10강. 국소성을 살리는 분산

국소성을 고려한 분산이란?

캐시 용량을 늘리기 위해 어떻게 하면 여러 대의 서버로 확장시킬 수 있는지에 대해 알아보겠습니다. 이를 위해서는 국소성을 고려해서 분산시키도록 합니다.

그림 3.12의 ❶ DB 서버에 액세스 패턴 A일 때는 1️⃣로 액세스가 많이 오고, 액세스 패턴 B일 때는 2️⃣로 오는 것처럼 데이터로 액세스하는 경향에 대한 처리 방식에 따라 특정한 방향으로 치우치는 경우가 자주 있습니다.

그림 3.12 액세스 패턴을 고려한 분산

서버 ❶과 ❷ 양측에 별다른 액세스 패턴을 고려하지 않고 분배한 경우, 2️⃣로의 액세스는 여전히 계속되므로 서버 ❶이 데이터 영역 2️⃣도 캐싱해야 할 필요가 있습니다. 그러나 그림처럼 액세스 패턴을 고려해서 분배한 경우는 2️⃣ 부분은 더 이상 액세스되지 않으므로 그만큼 캐시 영역을 다른 곳으로 돌릴 수 있습니다. 서버 ❷에서도 동일하게 생각할 수 있습니다. 결국 시스템 전체로서는 메모리에 올라간 데이터량이 늘어나게 됩니다.

파티셔닝

국소성을 고려한 분산을 실현하기 위해서는 파티셔닝이라는 방법을 자주 사용합니다. 파티셔닝(partitioning)은 한 대였던 DB 서버를 여러 대의 서버로 분할하는 방법을 말합니다.

분할 방법은 여러 가지가 있지만, 간단한 것은 테이블 단위 분할입니다. 예를 들어 하테나 북마크는 entry 테이블, bookmark 테이블, tag 테이블, keyword 테이블로 분할해서 각기 다른 서버로 관리하도록 하고 있습니다. 이것이 테이블 단위 분할에 의한 파티셔닝입니다.

테이블 단위로 분할했으면 entrybookmark 테이블로의 요청은 entry + bookmark 서버로, tagkeyword로의 요청은 tag + keyword 서버로 보내 처리될 수 있도록 당연히 애플리케이션을 변경할 필요가 있습니다.

다른 분할 방법으로는 테이블 데이터 분할이 있습니다. 그림 3.14가 그 예로, 특정 테이블 하나를 여러 개의 작은 테이블로 분할합니다. 하테나 다이어리에서는 실제로 이 분할 방법을 사용하고 있으며, 구체적으로는 ID(id:~)의 첫 문자로 파티셔닝을 하고 있습니다.

예를 들어 ID의 첫 문자가 a~c인 사람의 데이터는 그림 3.14의 서버 1️⃣, d~f인 사람의 데이터는 서버 2️⃣와 같이 나눕니다. id:naoyan~p인 서버 3️⃣으로 결정되었고, id:yaottiid:naoya와는 다른 서버 4️⃣에 있습니다.

그림 3.14 테이블 데이터 분할

이 분할의 문제점은 분할의 입도를 크거나 작게 조절할 때 데이터를 한 번 병합해야 한다는 번거로움이 있다는 점입니다. 이 점을 제외하면 애플리케이션에서 할 일은 ID의 첫 문자를 보고 액세스할 DB 서버를 분배하는 처리를 살짝 넣기만 하면 됩니다. 구현상으로는 간단합니다.

요청 패턴을 섬으로 분할

조금 독특한 방법인데 용도별로 시스템을 섬으로 나누는 방법도 있습니다. 하테나 북마크에서 자주 하고 있는 방법입니다.

하테나 북마크에서는 HTTP 요청의 User-Agent나 URL 등을 보고, 예를 들어 통상의 사용자이면 섬 1️⃣, 일부 API 요청이면 섬 3️⃣, Google bot이나 Yahoo! 등의 봇(bot, 로봇)이면 섬 2️⃣와 같은 식으로 나누는 방법을 사용하고 있습니다.

그림 3.15 요청 패턴에 의해 섬으로 분할

검색 봇은 그 특성상 아주 오래된 웹 페이지에도 액세스하러 옵니다. 사람의 경우라면 좀처럼 액세스하지 않을 페이지에도 액세스하고, 또한 광범위하게 액세스합니다. 그렇게 되면 캐시가 작용하기 어렵습니다. 동일한 페이지에 잇따라 방문하는 경우에는 캐시로 성능을 끌어올리기 쉽지만, 이처럼 광범위한 액세스에는 그럴 수가 없습니다.

그러나 봇에 대해 그렇게 빨리 응답할 필요는 없으므로 섬으로 나눠놓습니다. 한편 봇 이외의 액세스, 즉 사용자로부터의 액세스는 최상위 페이지나 인기 엔트리 페이지 등 최신, 인기 페이지에 거의 액세스가 집중되므로 빈번하게 참조되는 부분은 캐싱하기 쉽습니다.

이렇게 해서 캐싱하기 쉬운 요청, 캐싱하기 어려운 요청을 처리하는 섬을 나누게 되면 전자는 국소성으로 인해 안정되고 높은 캐시 적중률을 낼 수 있게 됩니다. 후자의 요청이 전자의 캐시를 어지럽히므로 섬으로 나누지 않는 경우에 비해 전체적으로 캐시 효율이 떨어지게 됩니다.

페이지 캐시를 고려한 운용의 기본 규칙

지금까지 캐시를 고려한 데이터 등을 분할하는 방법에 대해 살펴보았습니다. 페이지 캐시와 관련해서는 운용 면에서도 생각해야 할 부분이 있습니다.

첫 번째 포인트는 OS 기동 직후에 서버를 투입하지 않는다는 것입니다. 갑자기 배치하면 캐시가 없으므로 오직 디스크 액세스만 발생하게 됩니다. OS를 시작해서 기동하면 자주 사용하는 DB의 파일을 한 번 cat해줍니다. 그렇게 하면 전부 메모리에 올라갑니다. 그렇게 한 후에 로드밸런서에 편입시킵니다.

다음 포인트는 성능평가나 부하시험에 대해서입니다. 그때 초깃값을 버려야 한다는 것을 기억해야 합니다. 최초의 캐시가 최적화되어 있지 않은 단계에서 대략 5,000요청/초라고 해도, 캐시가 올라가 있지 않았을 때와 올라가 있을 때 낼 수 있는 속도는 완전히 다릅니다. 따라서 성능평가나 부하시험도 캐시가 최적화된 후에 실시할 필요가 있습니다.

Wrap Up

페이지 캐시는 Linux가 디스크 I/O를 줄이기 위해 기본적으로 활용하는 구조이며, 애플리케이션은 이 전제를 이해한 상태에서 설계할수록 OS에 더 많은 일을 맡길 수 있습니다. 또한 I/O 부하를 줄이기 위해서는 메모리 증설만이 아니라 데이터 압축, 파티셔닝, 요청 패턴 분리처럼 국소성을 살리는 분산 전략까지 함께 고려해야 합니다.

Summary

OS는 디스크에서 읽은 데이터를 페이지 단위로 메모리에 남겨두고, 다음 액세스에서 이를 재사용하도록 페이지 캐시를 제공합니다. Linux의 페이지 캐시는 VFS 위에서 동작하므로 파일시스템 종류와 관계없이 동일한 구조로 캐싱되며, 파일 전체가 아니라 읽어낸 4KB 블록 단위로 캐시가 쌓입니다. 따라서 메모리가 남아 있으면 최대한 캐싱하고, 부족해지면 오래된 캐시를 버려 프로세스 메모리를 확보하는 방식으로 동작합니다. 대규모 시스템에서는 이 특성을 전제로 데이터 규모, 압축, 메모리 비용을 함께 따져야 하며, 단순 복제 대신 국소성을 고려한 파티셔닝과 요청 분산으로 전체 캐시 효율을 높여야 합니다. 또한 서버 투입 전 워밍업과 캐시가 올라온 뒤의 성능평가처럼 운영에서도 페이지 캐시를 고려하는 것이 중요합니다.

Reference