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

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

flume command-line arguments

flume을 실행할 때 명령어 옵션을 줄 수 있는데, 이 옵션에 대한 내용을 문서에서 찾기가 쉽지 않아 별도로 메모해둡니다.
실제 서비스에서 flume을 적용할 때, 보안상 문제가 될 수 있으니 node의 HTTP 서버 설정(-s 옵션 적용)은 반드시 꺼두시기 바랍니다.

master
c – Load config from file
f – Use fresh (empty) flume configs
i – Server id (an integer from 0 up)

node
c – Load initial config from cmdline arg
n – Set node name
s – Do not start local flume status server on node
1 – Make flume node one shot (if closes or errors, exits)
m – Have flume hard exit if in likely GC thrash situation
h – Print help information
v – Print version information

그리고 node와는 달리 master는 HTTP 서버를 끌 수 없습니다.

flume을 master로 작동할 때, main() 함수에서는 다음과 같이 FlumeMaster 인스턴스를 생성하는데,


FlumeMaster config = new FlumeMaster();

FlumeMaster의 기본 생성자 정의는 다음과 같이 정의되어 있고, doHttp를 무조건 true로 호출하게 되어 있습니다.


public FlumeMaster() {
 this(FlumeConfiguration.get(), true);
 }

public FlumeMaster(FlumeConfiguration cfg, boolean doHttp) {
 this.cfg = cfg;
 ...
 this.doHttp = doHttp;

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);
	}

Flume을 사용하여 Apache Log를 HBase에 저장하기 – 테스트

Flume을 사용해서 아파치 로그를 HBase에 저장하는 시도를 해보았습니다. 별도 개발없이 공개된 소스만으로 얼마나 할 수 있는지를 살펴보는 게 주목적이었습니다.

로그 수집을 위한 오픈소스 솔루션으로는 Chukwa나 Scribe 같은 것이 있고 많은 곳에서 유용하게 사용되고 있지만, 개인적으로는 Flume의 아키텍처가 간단하고 좀 더 유연해서 맘에 들더군요. Flume과 Chukwa나 Scribe와의 가장 큰 차이점이라면 수집과 관련된 노드(Agent/Collector 등)를 자유롭게 구성할 수 있다는 것입니다. Chukwa는 시스템의 구성도 제한될 뿐더러 Chukwa를 운영하기 위한 Hadoop이 별도로 필요하고, Scribe는 일단 추가 구현을 해야하는 부분이 많고 기존 자바 환경과 같이 운용하기에는 여러모로 부담이 있었습니다. 이에 비해 Flume은 Source/Sink라는 간단한 모델을 차용하여 요구사항에 따라 제각기 역할의 노드로 이루어진 네트워크 구성을 할 수 있다는 장점이 있습니다.  Flume은 커뮤니티도 잘 형성되어 있을 뿐 아니라 Cloudera에서 계속 지원을 하고 있기도 하죠.

로그를 저장하는 방식으로는 파일로 저장하는 방법도 있지만, 저장보다는 분석이 주목적이기 때문에 RAW 형태의 파일보다는 조금 더 정형화된 구조를 선택하는 것이 좋다고 생각했습니다. 또한 실제 서비스의 아파치 로그를 저장하려면 꽤 많은 용량이 필요하기에 저장소 확장성도 어느 정도 확보가 되어야 하기에 몇 가지 noSQL 솔루션을 검토했습니다.
나름의 거버넌스 결과에 따르면 대용량/선형확장성 측면에서는 Cassandra와 HBase가 우선 후보 대상입니다. 데이터 Write 측면에서는 Cassandra가 더 적합할 수도 있지만, 우선 HBase를 시도한 이유는 다음과 같습니다.

  1. Hadoop 맵리듀스 사용
  2. 요구사항 중 Read Consistency가 필요한 상황이 있음

Flume – HBase 구성

구성은 아래와 같은 환경에서 진행하였습니다.

  • 10개의 Apache Web Server, 각 서버당 1개의 Agent (10개의 물리장비)
  • Collector 노드 1개/Master 노드 1개(1개의 물리장비)

Agent 세팅

각 Apache Web Server에 설치된 Agent 역할의 Flume 세팅은 다음과 같이 했습니다. Agent 노드는 Apache의 AccessLog를 읽어서 collector로 전송해주는 역할을 합니다.

agentX: tailDir("/logs/apachelog/access_.*", true) | agentSink("collector"), 35863)

/logs/apachelog 디렉토리에 있는 파일 중 access_로 시작하는 파일을 tail 하면서, 한 줄 단위의 변경사항을 collectormaster 노드로 전송하라는 의미입니다. 만약 tailDir()의 두 번째 인자를 false로 주면 Flume이 실행될 때마다 파일을 처음부터 읽어들이게 됩니다. Flume을 셧다운시키거나 재시작하는 경우도 종종 있으므로 로그가 처음부터 다시 전송되는 것을 방지하기 위해 두 번째 인자를 true로 하였습니다.

Collector 세팅

Collector는 Agent로부터 전송된 로그를 수신하고, HBase에 저장하는 역할을 합니다.

hbase sink & attr2hbase sink

현재 Flume의 배포판(CDH3u1 기준)에는 hbase 와 attr2hbase 두 가지 sink가 지원됩니다.
기본으로 활성화되어 있지는 않기에 이 두 sink를 사용하려면 flume-site.xml에서 추가 설정이 필요합니다.

...
<property>
<name>flume.plugin.classes</name>
<value>com.cloudera.flume.hbase.HBaseSink,com.cloudera.flume.hbase.Attr2HBaseEventSink</value>
</property>
...

hbase와 attr2hbase는 모두 hbase의 configuration을 사용합니다. Flume의 classpath에 hbase config 파일의 위치를 지정해주는 것이 좋습니다. 두 sink의 차이에 대해 자세한 내용은 이곳을 참고하시기 바랍니다.
저는 split, regex 등의 decorator를 통해 얻은 결과를 다루는게 attr2hbase가 좀 더 편했기에 attr2hbase를 사용했습니다만, 사용성에 따라 어느 것을 선택해도 크게 문제는 없으리라 봅니다. 그리고 필요하다면 별도의 hbase sink를 구현하는 것도 나쁘지 않을 듯 합니다. hbase/attr2hbase 소스를 살펴보시면 아시겠지만 심플하게 구현이 되어 있습니다.

Apache Log Format

아파치 로그는 httpd.conf의 설정에 따라 조금씩 다르겠습니다만, 제가 테스트한 환경은 다음과 같은 형식의 로그를 사용합니다.

12.34.56.789 - - [03/Sep/2011:23:59:49 +0900] "GET /index.html HTTP/1.1" 200 412 8387 "http://yourdomain.com/main.html" "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0;)"

빠른 프로토타이핑을 위해 우선 정규식으로 해당 로그를 처리하기로 하고, 로그의 주요 항목(IP, URL, DateTime, ContentSize, Browser 등)을 컬럼으로 추출해냅니다. 또한 로그원본 확보를 위해 원본은 별도의 컬럼(logbody)으로 추출합니다.

Collector 형식

Agent로부터 전송된 아파치 로그 문자열을 split, regexAll 등의 decorator를 사용하여 가공을 합니다.
attr2hbase는 특정 prefix를 가진 attribute를 hbase에 저장하도록 되어 있습니다.
만약 prefix값이 “2hb_”라면, “2hb_name” 이라는 attribute의 값은 hbase의 “name”이라는 컬럼에 저장이 됩니다. 그리고 row key는 이름이  “2hb_”인 attribute의 값이 됩니다.

  • split(“^$”, 0, “2hb_logbody”)
    로그원본 문자열 전체를 2hb_logbody라는 attribute에 저장하기 위해 약간의 꼼수를 사용한 것입니다.
  • regexAll(“^([\\d.]+) (\\S+) …. “, name1, name2, name3, … )
    아파치 로그의 각 항목을 정규식을 사용해서 추출합니다.
  • format(“%{nanos}:”) split(“:”, 0, “2hb_”)
    nano초 값을 row key로 사용하려고 하는데, 이것도 약간의 꼼수가 사용되었습니다.
    value() decorator가 EL을 지원하지 않기 때문에 value(“2hb_”, “%{nanos}”)와 같은 방식을 사용할 수 없습니다.
    따라서 우선 전체 문자열을 “%{nano}:” 바꾼 후 “:”으로 split 한 값을 얻어내어 “2hb_”에 저장합니다.
  • attr2hbase( “accesslog”, “prop”, “”, “2hb_”, “1000”, “false” );
    accesslog 테이블의 prop 컬럼패밀리에 attribute 들을 저장합니다. prefix는 “2hb_” 입니다.

Collector 노드의 설정 형식은 다음과 같습니다.

collector: collectorSource(35863) | split("^$",0,"2hb_logbody")
 regexAll("^([\\d.]+) (\\S+) (\\S+) \\[([\\w:/]+\\s[+\\-]\\d{4})\\] \"(.+?)\" (\\d{3}) (\\d+) (\\d+) \"([^\"]+)\" \"([^\"]+)\"", "2hb_ip", "2hb_a1", "2hb_b1", "2hb_date", "2hb_url", "2hb_status", "2hb_contentsize", "2hb_c1", "2hb_browser")
 format("%{nanos}:")
 split(":", 0, "2hb_")
 attr2hbase( "accesslog", "prop", "", "2hb_", "1000", "false" );

Flume master에서 각 agent와 collector에 설정 명령을 내리고 나서 설정에 이상이 없다면 아파치 로그가 HBase에 쌓이게 됩니다.

더 고민해봐야 할 것들

위와 같이 세팅하고 며칠동안 아파치 로그를 수집해보니 어느 정도는 예상했던 문제들이 더욱 뚜렷하게 드러나더군요.

  1. 높은 CPU 사용률
    아파치 서버 한 대당 tailDir()이 약 800개의 파일을 모니터링하고 있는데, 이는 CPU에 상당히 부담을 주고 있습니다. 서버 한 대당  평균 메모리 사용률이 꾸준히 30~60% 사이를 유지하는 편입니다. 사실 이 정도면 실제 서비스에서는 심각한 수준입니다. 또한 서비스의 트리팩이 증가하여 서버의 부하가 높아지면 로그 Agent는 시스템의 리소스에 따라 로그 처리 작업을 줄이거나 중지할 필요가 있습니다. 로그량이 증가한다고 해서 같이 처리량을 올리면 이는 곧 시스템 다운으로 이어질 가능성이 있죠.
    따라서 실제 서비스에서는 모니터링 대상 파일 개수를 줄이고 지연처리/비동기 처리 방식을 적용할 필요가 있습니다.
  2. 설정의 복잡함
    Flume에서 다양한 종류의 decorator와 sink를 제공하고는 있지만 하나씩 뜯어보면 아직 개선할 점이 많습니다. 또한 이 테스트에서 보이듯이 간단한 정규식 처리를 하는 데에도 collector 노드의 설정이 꽤 복잡해집니다. 다중 tier로 Flume 노드들이 배치되면 데이터 흐름을 따라가는 것도 쉽지는 않을 것이기에 대응이 필요하다는 생각은 듭니다.
    다행히 Flume은 decorator/sink 확장을 제공하므로 운용 환경에 맞게 임의의 decorator/sink를 구현하는 것도 좋으리라 봅니다. 아래와 같이 아파치로그에 특화된 source를 별도로 구현하는 것도 괜찮겠네요.

    flumenode: sourceApache("/log/access_.*", AccessLogFormat) | console("avrojson")
    

참고자료

Using Flume to Collect Apache 2 Web Server Logs
http://www.cloudera.com/blog/2010/09/using-flume-to-collect-apache-2-web-server-logs/

Flume User Guide
http://archive.cloudera.com/cdh/3/flume/UserGuide/index.html

Flume and HBase Integration
http://blog.sematext.com/2011/07/28/flume-and-hbase-integration/