CPU 캐시는 얼마나 고려해야 할까?

사실 어느 정도 미리 결론을 내려보자면, CPU 캐시까지 고려해가면서 어플리케이션을 구축해야 하는 상황은 흔하지는 않고, 또한 잘못된 선최적화를 할 수 있는 가능성이 있기 때문에 소프트웨어를 설계할 때 CPU 캐시는 중요 설계 요소로 넣지 않을 것을 권장합니다. 왜냐하면 CPU 캐시 활용을 반드시 해야만 할 정도로 소프트웨어 성능이 필요한 상황이라면, 그전에 성능 문제는 다른 아키텍처 요소(예를 들면 인프라, DB 등)에 있거나 아니면 처음부터 성능적으로 큰 설계 결함이 있는 경우일 가능성이 높기 때문입니다. (그것도 아니면 ‘이제 심심한데 CPU 캐시를 써서 성능을 올려볼까?’라는 어떤 행복한(?) 경우겠지요. ^^)

분명히 CPU 캐시는 DRAM보다는 상대적으로 빠른 액세스를 보장합니다. 하지만 많은 서비스의 서버 아키텍처에서 대부분의 병목은 네트워크, 디스크 I/O, 외부 시스템에서 발생하며 이들 자원의 액세스 속도에 비하면 DRAM에 대한 액세스 속도는 대부분의 영역에서 문제가 되지 않는다고 봐도 좋을 정도입니다. 성능이 중요한 RDBMS 같은 경우도 CPU 캐시를 활용하는 전략보다는 디스크 액세스나 Working 메모리 영역을 보다 효율화하는 쪽으로 집중을 하고 있지요. 그런 Storage 성격이 강한 어플리케이션에서 CPU Cache는 용량적인 측면에서 큰 이득을 보기가 어렵기 때문입니다.

물론, 어플리케이션 특성에 따라 CPU 캐시 전략을 잘 세우면 boost-up된 성능을 얻을 수 있다는 점은 부정할 수 없는 사실입니다. 네트워크나 디스크 I/O 같이 외부 I/O가 병목이 아니고 스레드간의 공유 메모리에 대한 액세스가 빈번한 상황이라면, 공유 메모리가 CPU 캐시 영역에 있고 없음은 큰 성능 차이로 나타날 수도 있습니다.
하지만 이런 점에도 불구하고 저의 입장은 미리 밝혔듯이, 처음부터 CPU 캐시를 중요 설계 요소로 삼지 않는 것이 좋다는 것입니다. 무엇보다 CPU 캐시 구조와 특성은 CPU 종류마다 다르기 때문에, 어떤 CPU에 맞춘 튜닝이 다른 CPU에서도 똑같이 동작하지는 않습니다. 예를 들면, 이전의 인텔 CPU는 모든 코어가 하나의 L2 캐시를 공유했지만 요즘은 각 코어마다 별도의 L2 캐시를 가지고 있고 L3 캐시를 모든 코어가 공유하는 형태입니다. 실질적으로 어플리케이션 스레드 성능에 영향을 주는 캐시는 L1-L2캐시라는 점을 감안하면 L2 캐시 구조에 따라 이런저런 차이가 있을 수 밖에 없습니다. 어쨌거나 중요한 것은 CPU 캐시 튜닝은 end-point 튜닝이기 때문에 가장 나중에 해야 할 선택이라는 점이죠.

그렇지만, CPU 캐시의 특성과 그로 인해 받을 수 있는 영향에 대해서는 알고 있는 것이 좋습니다. 어플리케이션의 올바른 성능 튜닝을 위해서도 필요하겠지만, 이 부분을 간과하게 되면 잘못된 믿음을 얻을 수도 있기 때문입니다. 흔한 사례 하나를 들어보도록 하죠.

일반적인 웹 어플리케이션을 생각해봅시다. 여러 개의 스레드가 사용자의 요청을 받고, 이 스레드는 사용자 요청을 처리하면서 처리한 내용을 로그로 기록합니다. 보통 로그를 디스크에 그때그때 기록하는 것은 비효율적이기 때문에 메모리 버퍼에 로그를 모아두었다가 한번에 이 버퍼의 내용을 디스크에 기록하게 합니다. (log4j의 경우는 AyncAppender가 이런 방식으로 작동을 합니다.)
여기서 버퍼는 여러 스레드가 동시에 접근하는 메모리 영역입니다. 즉, 이 버퍼에 대해 CPU 캐시의 이득을 충분히 활용할 수 있다면, 스레드가 버퍼에 로그 내용을 기록할 때 보다 나은 성능을 얻을 수 있습니다. 물론 DMA는 CPU 캐시의 내용을 디스크로 바로 전송할 수는 없기 때문에, 한번은 DRAM으로 CPU 캐시의 내용이 내려가야 하지만 매번 DRAM상의 실제 버퍼 영역에 로그를 저장하는 것보다는 나은 성능을 얻을 수 있습니다.

다음 이미지는 100만개의 로그를 200개의 스레드를 가지고 버퍼 크기에 따라 디스크에 기록하는데 얼만큼의 시간(ms)이 걸렸는지를 측정한 것입니다. 두 테스트 모두 디스크 성능이테스트 수치에 영향을 주지 않는 환경에서 수행되었습니다. 하지만 테스트를 수행한 서버 사양이 다르기 때문에 두 테스트간의 수치 차이는 의미가 없습니다. 한 테스트 내의 상대적인 수치를 보시면 됩니다. (그래프가 낮을 수록 좋습니다.)

1) L2 Cache – 256KB
L2 256KB

2) L2 Cache – 1MB
l2-1MB

1번 경우(L2 Cache-256KB)는 버퍼 크기가 64KB~128KB 구간에서 가장 성능이 좋게 나타납니다. L2 Cache 크기가 256KB이기는 하지만 스레드간에 공유하는 데이터가 버퍼만 있는게 아니기 때문에, 256KB에서는 128KB보다 아주 조금 부족한 성능이 나타납니다. 1024KB의 경우 512KB보다 조금 더 나은 성능을 보여주는데, 이는 이 테스트 환경에서는 블럭 크기 등의 측면에서 1MB가 더 효율적이기 때문이라고 할 수 있습니다.

L2 캐시가 1MB인 경우는 상황이 달라집니다. 1번에서 가장 성능이 좋았던 64KB~128KB 구간은 이 테스트에서는 그리 인상적이지 않습니다. 버퍼 크기를 1MB로 잡았을 때가 캐시 크기와 디스크 효율이 합쳐져서 다른 경우보다 더 나은 성능을 보여줍니다. 다만, 버퍼 외의 스레드간에 공유하는 데이터가 더 많다면 512KB쪽이 더 나은 성능을 보여줄 수도 있을 겁니다.

만약 이 테스트를 싱글스레드에서 돌렸다면 CPU 캐시의 이득은 거의 볼 수 없기 때문에, 버퍼 크기가 작을 때(4KB 혹은 그 미만) 혹은 1MB 정도에서 가장 나은 성능 수치를 얻었을 것입니다. 하지만 그 값은 실제로 적용하기에는 적합한 값이 아니겠죠. 애석하게도 시간적 여유가 부족한 현업에서는 이런 식으로 성능 테스트가 이뤄지는 경우가 많아서, 잘못 산정된 값을 적용하는 경우가 종종 있습니다. (이것이 잘못된 믿음의 한 경우입니다.)

첨언을 하자면 이 포스팅에서는 일부러 단순하고 흔한 문제를 들었는데, 실제로 이런 상황에 대응하는 사례는 복잡하고 다양합니다. 버퍼 구조를 다층화한다거나, leader-follow 패턴을 구현할 때는 일부러 leader thread와 follower 스레드를 최대한 같은 코어에 배치시켜 CPU cache miss를 최소한으로 줄이기도 합니다. 어쨌건 이 포스팅에서 예로 든 로그와 비슷한 패턴에 대해 참고가 되길 바랍니다. (부족한 부분은 언제든 따끔하게 지적해주시기 바랍니다. ^^)

정말 zookeeper에서는 SSD를 쓰지 않아야 할까?

Misconfiguration-Zookeeper를 읽다가 ZK Transaction Log를 저장하는데 SSD를 쓰지 말라는 내용을 보고 이에 대해 제 의견을 덧붙여봅니다.

먼저 SSD를 쓰지 말라는 이유를 살펴보면 다음과 같습니다.

  • 주키퍼는 디스크의 순차 IO에 최적화되어 있다.
  • SSD를 사용함으로써 얻는 이득은 적고, high latency spike를 일으킬 수 있다.

이 중 high latency spike는 SSD의 특성으로, 흔히 알려진 프리징 현상과 관계가 있습니다.
SSD는 페이지 단위로 기록을 하는데, 오직 비어있는 페이지에만 기록을 할 수 있습니다. 만약 데이터가 남아있는 페이지에 기록을 하려면 기존의 내용을 지운(ERASE) 다음 기록을 해야 합니다. 여기서 발생하는 문제는 특정 페이지를 overwirte 하려면 해당 페이지를 포함한 블럭 전체를 삭제하고 재기록을 해야 한다는 점이죠.
SSD의 특성상 READ/WRITE는 빠르지만 상대적으로 ERASE는 느립니다. (블럭 크기에 따라 10배 이상 차이가 나기도 합니다.) 프리징 현상도 이런 ERASE와 관련이 있으며, Misconfiguration-Zookeeper에서는 40초 동안 SSD disk가 멈출 수 있다고 하지만 실제로는 이보다 짧을 수도, 더 길 수도 있습니다. 다시 말하자면 SSD 프리징 현상은 주키퍼만에 국한된 문제는 아니라는 얘기입니다.

그렇다면 TRIM을 사용하면 되지 않을까? 네, TRIM을 사용하면 Misconfiguration-Zookeeper에서 얘기한 high latency spike를 일으키는 프리징 문제는 해결할 수 있습니다. 프리징으로 인한 타임아웃이 논-이슈가 되겠죠. 하지만 이왕 글을 시작했으니 몇 가지 더 살펴보도록 합시다. (다만, RAID를 사용하면 SSD TRIM을 사용하는데 제약이 많습니다.)

UPDATE: TRIM만이 해결책인 것은 아닙니다. 서비스 운영 환경은 천차만별이고 문제와 해결 방식은 여러가지가 있습니다. 지적을 해주신 분이 계시지만 OS/RAID 컨트롤러에서 제어하는 방식도 있고 Backed Buffer 를 사용하는 등등 여러 방법이 있다고 합니다. 제가 말하고 싶은 것은 SSD를 제대로 설정하고 사용해야 한다는 것입니다.

UPDATE 2: 서버 환경이라면 RAID로 디스크를 구성하면 좋겠지만 꼭 그렇지 않은 경우도 있다고 생각합니다. 저는 RAID로 SSD를 구성하지 않고 단일 SSD로 주키퍼 노드를 운영했습니다. 환경에 따라 다르긴 하겠지만 제 경우 주키퍼 노드 운영에서 가장 크리티컬한 문제는 타임아웃 발생이었고, 디스크 장애로 zookeeper 노드 하나가 클러스터에서 빠져나가는 것은 얼마든지 감당할 수 있다고 보았습니다. 이런 상황이라면 굳이 RAID로 디스크를 구성할 필요는 없다고 봅니다.

UPDATE 4: 서버가 재부팅되는 상황 등에서 zookeeper 상태를 잃어버려도 상관없다면 ramdisk에 zookeeper transaction log를 저장하는 방식도 괜찮습니다. 실제로 512MB RAMDISK에 transaction log를 저장하면서 6개월간 문제없이 운영을 하기도 했었구요. (forceSync=no 옵션 사용). 그리고 경험상 한번 클러스터에서 이탈한 노드는, 다시 참여할 때 이전 정보를 토대로 업데이트를 하는 것보다는 참여한 시점에 새롭게 모든 데이터를 받아오는 것이 더 낫다고 보고 있습니다. 만약 데이터를 받아오는 시간이 너무 오래 걸려 문제가 된다면 zookeeper를 과도하게 쓰고 있는게 아닐까라는 생각을 해봅니다.

Zookeeper의 Transaction Log 파일과 관련있는 설정 중 zookeeper.preAllocSize가 있습니다. 기본 값은 64MB이고, 이 값의 크기만큼 처음에 Transaction Log파일이 만들어지고, 파일의 (개념적인) 잔여 공간이 4KB 미만이라면 preAllocSize 만큼 파일의 크기를 늘립니다. 즉, Transaction Log가 계속 쌓이면 preAllocSize만큼 새로운 디스크 공간 할당이 일어나고, 새로 할당된 공간에 개별 Transaction Log의 내용이 기록됩니다. (Zookeeper 소스의 server/persistence/Util.java 참고)
이 과정은 기존 디스크라면 Sequential IO를 최대한 활용하여 성능을 높일 수 있는 방식이지만, SSD의 경우 새로운 블럭을 할당하는 빈도가 높아지고, 경우에 따라서는 기존 블럭을 삭제하고 재기록을 해야합니다. 따라서 프리징으로 인한 Client timeout을 유발하는 원인이 되기도 합니다. preAllocSize를 작게 설정하는 것도 이런 이슈 해결에 도움이 되지는 않습니다. preAllocSize를 작게 설정할 수록 오히려 재기록이 자주 발생할테니까요.
해당 글에서 왜 Trim을 언급하지 않았는지에 대한 이유를 나름대로 추측해보면, 서버의 디스크 장비는 RAID로 구성을 하는 경우가 많습니다. 하둡 같이 replication 메커니즘의 클러스터의 서버는 raid 구성을 하지 않기도 하지만, 그래도 보통은 RAID 구성을 하는게 좋겠지요. 그러나 RAID SSD Trim을 지원하기 시작한 시점이 2012년 중반인 것으로 알고 있고 그것도 Windows, 인텔 기반의 RAID-0, RAID-1에 한해서라고 합니다.(이 부분에 대해서 정확한 내용을 알고 계신 분은 첨언 부탁드립니다.) 해당 글이 발표된 시점이 2012년 중순이라는 점을 감안하면 그 시점에서는 SSD RAID TRIM이 제대로 작동하지 않았고, 현재도 그렇습니다.

UPDATE 3: 비 Windows 운영체제에서는 아직도 RAID SSD TRIM을 제대로 활용할 수 없다고 합니다. 인텔 RST 11.6 + Intel SSD 조합으로는 성공적으로 SSD TRIM(RAID 1)을 사용하는 사례가 있다고 합니다. 제보해주신 gilbert님 감사드립니다.

UPDATE 5: preAllocSize를 SSD의 블럭, 페이지 크기를 고려하여 설정하더라도 대부분의 SSD는 wear leveling을 우선하기 때문에 디스크 사용량이 일정량에 이르면 결국 GC/write gathering 문제를 피할 수 없습니다. 다만, 쓰기와 partial erase를 동시에 수행하는 일부 SSD(인텔 SSD중 DC 코드가 붙은 모델 등)에서는 partial erase/GC 대상 블럭의 내용은 버퍼로 옮겨져서 access 되고, 이 버퍼의 데이터에 access하는 것은 write gathering/GC의 영향을 받지 않고 모든 작업이 끝나면 블럭 단위로 저장되기 때문에, zookeeper 노드에서 SSD에 다른 쓰기 작업이 없는 상황에서 SSD 블럭/페이지를 고려하여 preAllocSize를 설정하는 것은 의미가 있다고 합니다.

또한, 비용적인 측면에서 생각을 해보면, Zookeeper는 disk 보다는 memory를 더 주요하게 사용하며 상대적으로 디스크 성능 향상을 통해 얻는 이득은 적습니다. SSD 도입 비용을 생각해보면 HDD 가 더 효율적인 선택이 될 수 있다는 것입니다.

하지만 zookeeper 운영 매뉴얼에서도 밝히고 있듯이, IO 측면에서 transaction log와 data, application log를 서로 분리하는 것은 실제 운영 환경에서는 아주 중요한 권고 사항입니다. 일부 운영 환경에서 스왑이 발생했음에도 불구하고 SSD의 성능 때문에 Zookeeper timeout이 발생하지 않은 사례도 있고, 문제 원인과 해결 방식이 명확히 있기 때문에 주키퍼에서 SSD를 무조건 쓰지 말라는 것은 조금 지나친 가이드가 아닌가 생각을 해봅니다.

Halcon 이미지와 .NET Bitmap 변환

Halcon Image를 .NET Bitmap으로 변환하는 기능이 없어서(혹은 있는데 못찾아서) 후다닥 간단하게 만들어 봤습니다.
제가 살펴본 버전이 Halcon 10이라서 Halcon 11에는 이미 더 나은 API가 제공되고 있을지도 모르겠네요.

변환 기능만 고려하고 성능, 예외처리 같은 건 고려하지 않았기 때문에 본격적으로 사용하기에는 많이 부족합니다.
필요하다면 아래 소스는 자유롭게 쓰셔도 됩니다.

		/// <summary>
		/// Halcon Image를 .NET Bitmap 형식으로 변환합니다.
		/// </summary>
		/// <param name="halconImage"></param>
		/// <returns></returns>
		public static Bitmap ConvertHalconImageToBitmap(HObject halconImage)
		{
			if (halconImage == null)
			{
				throw new ArgumentNullException("halconImage");
			}

			HTuple pointerRed;
			HTuple pointerGreen;
			HTuple pointerBlue;
			HTuple type;
			HTuple width;
			HTuple height;

			// Halcon 컬러 이미지의 포인터를 얻는다.
			HOperatorSet.GetImagePointer3(halconImage, out pointerRed, out pointerGreen, out pointerBlue, out type, out width, out height);

			// .NET에서 포인터를 사용하기 위해 IntPtr 형식으로 변환
			IntPtr ptrR = new IntPtr(pointerRed);
			IntPtr ptrG = new IntPtr(pointerGreen);
			IntPtr ptrB = new IntPtr(pointerBlue);

			// 24bit RGB 형식을 기본으로 사용함
			Bitmap bitmap = new Bitmap((Int32)width, (Int32)height, PixelFormat.Format24bppRgb);

			BitmapData bmpData = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.ReadWrite, bitmap.PixelFormat);
			int bytes = Math.Abs(bmpData.Stride) * bitmap.Height;
			byte[] rgbValues = new byte[bytes];

			// Stride 값 보정
			int unmapByes = Math.Abs(bmpData.Stride) - (width * 3);

			// 루프를 돌면서 lock된 bit 배열에 데이터를 써넣음.
			for (int i = 0, offset = 0; i < bytes; i += 3, offset++)
			{
				if ((offset + 1) % width == 0)
				{
					i += unmapByes;
				}

				rgbValues[i] = Marshal.ReadByte(ptrB, offset);
				rgbValues[i + 1] = Marshal.ReadByte(ptrG, offset);
				rgbValues[i + 2] = Marshal.ReadByte(ptrR, offset);
			}

			Marshal.Copy(rgbValues, 0, bmpData.Scan0, bytes);
			bitmap.UnlockBits(bmpData);
			return bitmap;
		}

		/// <summary>
		/// .NET bitmap을 Halcon HImage로 변환합니다.
		/// </summary>
		/// <param name="bitmap"></param>
		/// <returns></returns>
		public static HImage ConvertBitmapToHalconImage(Bitmap bitmap)
		{
			if (bitmap == null)
			{
				throw new ArgumentNullException("bitmap");
			}

			int width = bitmap.Width;
			int height = bitmap.Height;
			int ptrSize = width * height;

			// R/G/B 각 채널에 대해 메모리를 할당
			IntPtr ptrR = Marshal.AllocHGlobal(ptrSize);
			IntPtr ptrG = Marshal.AllocHGlobal(ptrSize);
			IntPtr ptrB = Marshal.AllocHGlobal(ptrSize);

			// Bitmap에서 RGB 값을 읽어서 저장
			int offset = 0;
			for (int y = 0; y < height; y++)
			{
				for (int x = 0; x < width; x++)
				{
					Color c = bitmap.GetPixel(x, y);
					Marshal.WriteByte(ptrR, offset, c.R);
					Marshal.WriteByte(ptrG, offset, c.G);
					Marshal.WriteByte(ptrB, offset, c.B);
					offset++;
				}
			}

			// Himage로 변환
			HImage halconImage = new HImage();
			halconImage.GenImage3("byte", width, height, ptrR, ptrG, ptrB);

			Marshal.FreeHGlobal(ptrB);
			Marshal.FreeHGlobal(ptrG);
			Marshal.FreeHGlobal(ptrR);

			return halconImage;
		}

Eclipse 4.2, STS 3.0 GUI 성능 패치 (Bug386070)

Eclipse Juno 4.2와 Juno를 기반으로 한 STS 3.0에서 성능 저하 문제가 있습니다.

히스토리를 자세히 살펴보지는 않았지만, 아마도 DirectContributionItem.java의 updateIcons()에서 아이콘 상태 변경과 관련하여 순환 문제가 있었고, disabled 상태에 대해서는 이를 회피하는 식으로 패치가 이루어진 듯 합니다.

Eclipse 4.2.1에 해당사항이 반영될 예정이지만, 많은 개발자들이 당장 실사용에 어려움을 겪고 있기에 사전에 패치가 공개되었습니다. Eclipse Juno에서는 업데이트 사이트를 통해서 패치를 받을 수 있고, STS는 Dashboard를 통해 바로 내려받을 수 있습니다.

Eclipse Update Site
http://dist.springsource.com/release/TOOLS/patches/bug386070/

SpringSource Dashboard

 

관련 URL
– https://bugs.eclipse.org/bugs/show_bug.cgi?id=386070

 

로그 수집/전송 단계를 외부 프로세스로 분리해야 하는 이유

이 포스팅에서 다루고자 하는 대상은 로그를 생성하는 어플리케이션과 로그를 저장하는 시스템이 서로 별개라서, ‘로그 전송’ 단계가 있는 경우입니다. 자바를 예를 들면, log4j에서 DBAppender를 사용하여 외부의 DB에 로그와 관련된 정보를 저장하는 경우가 해당됩니다.  다만, 그저 단순히 로컬 디스크에 로그를 저장하고 그치고 마는 경우라도 참고할만한 내용이 있을 것입니다.

log4j 같이 inner-process 타입 로깅의 경우 어플리케이션과 같은 프로세스 내에서 로그를 기록합니다. 이 방식에서는 어플리케이션 프로세스의 상태가 로그 저장에 영향을 줄 수 있습니다. 가령 예를 들어 Out of memory exception이 발생한 경우, 이 OOME를 유발한 행위에 대한 로그가 실제로 기록되는지는 보장할 수 없습니다. 저장될 로그의 내용이 프로세스 내부의 어떤 버퍼에 의해 관리되고 있다면 더욱 그렇습니다. 또한, 어딘가에 임시로 기록이 되었다고 하더라도, 어플리케이션이 재기동하기전까지는 다른 계층으로 전송되지 않는다는 점이 문제점으로 남습니다.

단순 유실/즉시성의 문제를 넘어 다른 문제도 존재합니다. 웹서비스에서는 대부분의 로그가 사용자로부터의 Request 요청과 밀접하게 연관되어 있습니다. Request 요청 횟수가 많아지면 당연히 그에 따라 로그도 증가합니다. 문제는 DDoS 같은 상황에서 웹서버의 부하가 인프라 전체의 부하로 확대될 수 있다는 점입니다. 로그 전송 단계에 주의를 기울이지 않은 시스템에서는 웹서버의 작은 부하가 전체 인프라를 다운시켜버리는 상황도 종종 발생합니다. 따라서 한 계층의 부하가 다른 계층으로 전파되지 않도록, 중간에서 부하를 제어할 수 있는 메커니즘이 필요합니다.

그리고 서비스와 로깅에 필요한 컴퓨팅 리소스를 적절히 분배하는 것 또한 중요합니다. 한 머신에 node.js 의 인스턴스가 10개 떠 있다고 하더라도, 로그를 처리하는 프로세스는 1개면 충분한 경우가 있다는 것이죠.

일반적으로 inner-process 로깅의 경우 형태는 단순하지만, 앞서 언급한 상황에 효율적으로 대응하기가 어렵고 한계도 있습니다. 대신 outer-process 형태의 로깅은 하드웨어의 장애가 아닌 이상 이런 이슈에 대해 비교적 수월하게 대응을 할 수 있습니다.

이런 측면에서 flume, fluentd 등은 활용성이 높은 솔루션입니다. 특히, 이 두 오픈소스는 유연하게 구성을 할 수 있고, 플러그인을 통한 확장성이 좋아서 outer-process 로깅 패턴을 적용하는데 좋은 출발점이 될 수 있으니 참고하시기 바랍니다.

[zookeeper] SessionTimeoutException vs SessionExpiredException

SessionTimeoutException과 SessionExpiredException의 차이에 대해 정리해둡니다.

먼저, Session timeout은 주키퍼 클라이언트와 주키퍼 클러스터(ensemble) 사이의 연결(Connection)이 끊겼다는 것을 나타냅니다.
클라이언트측에서 감지하여 발생시키는 예외로, 서버와는 무관합니다.

Session expired는 주키퍼 클러스터에 대해 클라이언트의 세션이 만료되었다는 것을 나타냅니다.
Session expired의 경우는 Session timeout 과는 조금 다른 방식으로 처리되는데, Session timeout이 순수하게 클라이언트에서 timeout 감지하여 처리하는 반면, Session expired의 경우는 서버로부터 한번 더 결과를 얻어와서 처리를 하게 됩니다. 자세히 살펴보기 위해 소스를 곁들입니다.

// ClientCnxn.java

void readConnectResult() throws IOException {
	...
	ConnectResponse conRsp = new ConnectResponse();
	conRsp.deserialize(bbia, "connect");
	negotiatedSessionTimeout = conRsp.getTimeOut();
	if (negotiatedSessionTimeout <= 0) {
		zooKeeper.state = States.CLOSED;

		eventThread.queueEvent(new WatchedEvent(
			Watcher.Event.EventType.None,
			Watcher.Event.KeeperState.Expired, null));
		eventThread.queueEventOfDeath();
		throw new SessionExpiredException(
			"Unable to reconnect to ZooKeeper service, session 0x"
			+ Long.toHexString(sessionId) + " has expired");
	}
	...

과정은 다음과 같습니다.

  1. 클라이언트가 서버와 연결을 맺고 “connect” 요청을 보냄.
  2. 응답을 받은 후 자신의 세션이 만료되었음을 감지.
  3. throw Exception

Session expired는 서버와의 통신이 일어나야만 발생합니다. 즉, 서버가 클라이언트에게 expired를 알려주어야만 발생한다는 것이죠. 또한 클라이언트 입장에서 랜선을 뽑아버린다거나 서버의 하드웨어에 장애가 생겨서 TCP 통신을 할 수 없는 경우라면 Session expired는 발생하지 않습니다.
주키퍼 클라이언트를 사용하는 경우, 두 예외에 대해 동일하게 대응하는 경우가 많은데 이 두 예외의 차이점을 고려해야 합니다.

주키퍼 클러스터와 클라이언트가 단절된 경우
> Session timeout이 지속적으로 발생하지만 Session Expired는 발생하지 않습니다. 즉, 클라이언트끼리는 정상적으로 통신하고 있을 수 있습니다. 따라서 클라이언트는 주키퍼 클러스터에 연결할 때, Session Expired가 아니면 계속 연결을 시도할 가치가 있습니다.

주키퍼 클러스터와 클라이언트의 단절이 오래 지속되는 경우
> 주키퍼 리더는 해당 세션을 삭제합니다. 주키퍼와 통신하는 다른 클라이언트는 Session Expired를 받게 됩니다. 원래의 클라이언트가 죽었다고 간주할 수 있습니다.

zookeeper 운영시 권장 사항

– Recommendations for operating zookeeper.

주키퍼를 운영하면서 얻은 나름의 경험을 정리해봅니다. 시간이 지나면서 이 글의 내용이 맞지 않게 될 수도 있겠지만, 현 시점에서 주키퍼를 운영하시거나 운영할 계획이 있는 분들에게 참고가 되었으면 합니다.

1. zookeeper is not DB, not noSQL, not Cache solution.
주키퍼를 DB나 캐시용으로 쓰지 마십시오. 처음에는 단순히 코디네이터 용도로 쓰다가 편의성 때문에 DB 처럼 쓰는 경우가 있는데 절대 금물입니다.
특히 heavy write는 주키퍼 클러스터의 성능에 매우 많은 영향을 끼치므로 피해야 합니다.

2. do servers only for zookeeper
가능하면 주키퍼를 위한 전용 서버를 구축하기를 권장합니다. 비용상의 문제로 별도의 한 서버에 다른 솔루션과 병행 운영을 해야 한다면, 해당 솔루션이 CPU나 I/O에 줄 수 있는 영향을 반드시 파악해야 합니다.

3. same network, same rack
주키퍼 클러스터는 최소 latency를 가지는 네트워크 상에 있어야 합니다. 가능한 같은 스위치 내, 같은 랙에 있는 것이 좋습니다. (당연히 서로 다른  IDC에 있는 서버끼리 주키퍼 클러스터를 구성하는 것은 말도 안되는 설정입니다.) EC2 같은 가상 환경에서 주키퍼 클러스터를 운영한다면 가상 머신을 호스팅하고 있는 물리 서버의 네트워크 거리를 확인하시기 바랍니다.

4. more servers, low (write) performance
주키퍼 서버를 많이 두는 것은 그만큼 가용성을 확보한다는 의미가 있습니다. 또한 서버가 많을 수록 read 성능은 증가하나 write 성능은 감소합니다. 그 동안의 경험상 주키퍼 클러스터는 5대로 구성하는 것이 가장 안정적이었습니다. 1대는 주키퍼 서버 자체의 장애에 취약하고, 3대는 장애가 날 경우 flapping 현상 등 주키퍼 노드끼리 혼란스러워하는 현상이 일어나기 쉽습니다.

5. off swap, tune GC, don’t specify memory too big to avoid stop-the-world
주키퍼가 사용하는 메모리는 가능하면 스왑 영역에 들어가지 않게 하는 것이 좋습니다. 주키퍼의 메모리 영역이 스왑 메모리를 사용하는 순간 I/O 성능은 급격히 떨어지고 이는 GC에도 영향을 미칩니다. 그리고 일반적으로 Full GC를 하는 동안 주키퍼의 실행은 잠시 멈추는데(stop-the-world), 이 시간이 길어지면 다른 주키퍼 노드가 타임아웃으로 오인할 가능성이 있습니다. 대게 한 클러스터의 주키퍼는 같은 설정을 가지고 있으므로, 어느 한 노드에서 이런 현상이 일어난다는 것은 다른 노드도 이런 위험성을 내포하고 있다는 뜻이 됩니다. 결국 클러스터 전체가 불안정성을 가지고 운영되는 셈입니다. 만약 이러한 현상이 일어난다면 GC 튜닝에 공을 들여야 합니다. JVM 프로세스가 메모리를 많이 사용하면 할 수록 일반적으로 Full GC 시간도 길어집니다.

– 많은 수의 문자열을 저장하는 것을 피할 것.
– GC 튜닝은 throughput 성능보다는 pause time을 줄이는 방향으로 할 것 -> 주키퍼에 저장하는 데이터 패턴에 따라 G1 GC가 유용한 경우가 많음.

6. specify session timeout – not too short and not too long
세션 타임 아웃 시간을 너무 짧게 잡아서도 안되고 너무 길게 잡아서도 안됩니다. 너무 짧게 잡으면 네트워크 지연이나 GC로 인한 정지 시간 등의 상황을 오인할 우려가 있고, 너무 길게 잡으면 장애시나 write시에 문제가 생길 수도 있습니다.
흔히 하는 실수가 session timeout과 connection timeout을 동일하게 여기는 것인데, session timeout = connection time * 호스트수 입니다. 만약 클러스터의가 다섯 대의 주키퍼로 구성되어 있고 connection timeout을 2초로 하고 싶다면, session timeout을 10초로 설정해야 합니다.

7. log level, log directory
특별한 이유가 아니라면 LOG 레벨을 DEBUG나 INFO로 설정하지 마세요. 그리고 가능한 로그 파일은 주키퍼 데이터가 저장되는 하드디스크가 아닌 별도의 하드디스크에 저장하는 것이 좋습니다.

8. do test, test, test
서비스에 투입하기 전에 반드시 주키퍼 클러스터를 테스트해볼 것을 권장합니다. 이전 구성한 주키퍼 클러스터와 똑같은 서버에 똑같은 설정으로 새로운 클러스터를 구성했다고 하더라도, 네트워크가 바뀌었다는 이유만으로 문제가 되는 경우가 있습니다.
https://github.com/phunt/zk-smoketest 에서는 주키퍼 클러스터를 테스트해볼 수 있는 도구를 제공합니다.

9. don’t rely on zookeeper too much
아직 주키퍼는 완전히 안정화되었다고 말하기가 어렵습니다. 아직까지도 약간의 불안정한 요소만으로도 원하지 않는 결과를 전달하는 경우가 많습니다. 전적으로 주키퍼에 의존하지 않도록 하세요. 정말 주키퍼에 의존할 수 밖에 없다면 두 개의 클러스터를 사용하는 것도 나쁘지 않습니다. 어플리케이션 차원에서의 이중 검사(dual-checking) 또한 권장됩니다.