정말 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를 무조건 쓰지 말라는 것은 조금 지나친 가이드가 아닌가 생각을 해봅니다.

Advertisements

[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) 또한 권장됩니다.

zookeeper Socket Linger Time 관련 이슈

zookeeper 3.3.3 이하 버전에서는 서버와 연결을 담당하는 소켓의 Linger Time을 무조건 2초로 지정하고 있습니다.
이 값을 2초로 지정한 원인은 잘 모르겠습니다만, 아마 네트워크 응답이 느린 상황을 대비한 게 아닐까 싶습니다.

	public NIOServerCnxn(ZooKeeperServer zk, SocketChannel sock,
			SelectionKey sk, Factory factory) throws IOException {
		this.zk = zk;
		this.sock = sock;
		this.sk = sk;
		this.factory = factory;
		sock.socket().setTcpNoDelay(true);
		sock.socket().setSoLinger(true, 2);
		InetAddress addr = ((InetSocketAddress) sock.socket()
				.getRemoteSocketAddress()).getAddress();
		authInfo.add(new Id("ip", addr.getHostAddress()));
		sk.interestOps(SelectionKey.OP_READ);
	}

위와 같은 linger 지정이 문제가 되는 이유는 zookeeper 서버가 클라이언트와의 연결을 종료할 때 비동기가 아니라 동기화 방식으로 세션을 닫으며, 만약 동시에 여러 개의 세션이 닫히는 상황이라면 ‘세션개수 x linger time’만큼 지연이 발생할 수 있기 때문입니다.

public void close() {
synchronized(factory.cnxns){
...

많은 경우, zookeeper 클라이언트와 서버는 같은 네트워크상에 있기 때문에 linger time이 문제가 되는 상황은 거의 없습니다. 설령 linger time이 지정되어 있다고 해도 비동기 방식으로 연결을 종료한다면 역시 문제가 되지 않습니다.
그러나 현재 zookeeper 구조에서 비동기식 방식으로 연결을 닫도록 소스를 수정하는 것은 아무래도 구조상 큰 작업으로 보입니다. (zookeeper 버전이 올라가면서 수정되기를 기다려야 할 것 같네요.)
참고로 zookeeper 3.3.4에서는 soLinger() 부분이 다음과 같이 linger time을 사용하지 않도록 변경되었습니다.

	public NIOServerCnxn(ZooKeeperServer zk, SocketChannel sock,
			SelectionKey sk, Factory factory) throws IOException {
		this.zk = zk;
		this.sock = sock;
		this.sk = sk;
		this.factory = factory;
		sock.socket().setTcpNoDelay(true);
		sock.socket().setSoLinger(false, -1);
		InetAddress addr = ((InetSocketAddress) sock.socket()
				.getRemoteSocketAddress()).getAddress();
		authInfo.add(new Id("ip", addr.getHostAddress()));
		sk.interestOps(SelectionKey.OP_READ);
	}