Zookeeper를 이용한 분산코디네이터 구성
2016-03-18 16:03:35
목차
본격적으로 주키퍼(zookeeper)를 활용 해야 할 때가 왔다. 활용전에 주키퍼의 기본 기능들을 살펴보려 한다.
설치 환경은 다음과 같다.
- VirtualBox
- 주키퍼 클러스터 : 우분투 리눅스 15.10 Server 3대로 구성한다.
- 주키퍼 노드 : 우분투 리눅스 15.10 Server 3대로 구성한다.
아래와 같이 구성한다.
3개의 주키퍼 노드로 주키퍼 클러스터를 만든다. 주키퍼 노드의 이름은
zk-01, zk-02, zk-03이다. 테스트를 위해서 두 개의 노드를 준비했다. 이 두 개의 노두를 하나의 클러스터로 관리하려 한다. 노드의 이름은 각각
node-01, node-02다.
workstation은 내가 작업하는 공간이다. 간단한 와처(watcher) 프로그램과 zkCli.sh 등의 프로그램을 이용해서 주키퍼 클러스터가 제대로 굴러가는지를 테스트한다.
주키퍼 노드에 zookeeper 애플리케이션을 설치했다. 이 작업은 주키퍼 클러스터를 구성하는 모든 노드들에 대해서 수행해야 한다.
# apt-get install zookeeper zookeeperd -y
각 주키퍼 노드에 /etc/hosts를 설정했다.
# cat /etc/hosts
......
192.168.56.50 zk-01
192.168.56.51 zk-02
192.168.56.52 zk-03
......
주키퍼 노드의 myid를 설정했다. zk-01은 1, zk-02는 2, zk-03은 3으로 설정한다. myid는 1에서 255까지의 값을 가질 수 있으며, 전체 주키퍼 클러스터에서 유일한 값이어야 한다.
# cat /var/lib/zookeeper/myid
1
주키퍼 설정파일을 수정한다.
# cat /etc/zookeeper/conf/zoo.cfg
server.1=zk-01:2888:3888
server.2=zk-02:2888:3888
server.3=zk-03:2888:3888
주키퍼 서버를 재시작 한다.
# service zookeeper restart
주키퍼 패키지를 설치하면, zkCli.sh라는 프로그램이 함께 설치된다. 이 프로그램을 이용하면 인터액티브하게 주키퍼 서버에 명령을 내릴 수 있다.
Workstation에서 zkCli.sh를 이용 주키퍼 서버에 연결해 보자.
Workstation에도 zookeeper를 설치해야 한다. 서비스가 목적이 아니므로 zookeeperd는 설치하지 않는다.
# apt-get install zookeeper
패키지를 설치하면
/usr/share/zookeeper/bin 에 zkCli.sh가 복사된다. zkCli.sh를 실행 할 수 있도록 .bashrc에 PATH 환경변수를 설정했다.
# cat ~/.bashrc
......
export PATH=$PATH:/usr/share/zookeeper/bin
......
zkCli.sh를 실행해보자.
# zkCli.sh -server zk-01
[zk: zk-01(CONNECTED) 0]
help 명령을 내리면 사용 할 수 있는 명령들이 나온다. 주키퍼의 기능들을 대략 확인 할 수 있다.
[zk: zk-01(CONNECTED) 2] help
ZooKeeper -server host:port cmd args
connect host:port
get path [watch]
ls path [watch]
set path data [version]
rmr path
delquota [-n|-b] path
quit
printwatches on|off
create [-s] [-e] path data acl
stat path [watch]
close
ls2 path [watch]
history
listquota path
setAcl path acl
getAcl path
sync path
redo cmdno
addauth scheme auth
delete path [version]
setquota -n|-b val path
주키퍼의 활용에 대한 아이디어를 구체화하기 위해서 테스트 시나리오를 만들기로 했다. zkCli.sh를 이용해서 시나리오를 구현하려 한다.
나는 도커 클러스터를 관리하려고 한다.
도커 클러스터는 하나 이상의
도커 인스턴스로 구성이 된다. 각 도커 인스턴스는 도커 컨테이너를 실행하게 된다.
주키퍼를 이용해서 관리할 정보들은 다음과 같다.
- 클러스터에 몇 개의 도커 인스턴스가 있는지
- 클러스터에 총 몇 개의 컨테이너가 실행 중인지
- 각 도커 인스턴스에 몇 개의 컨테이너가 실행 중인지
- 도커 인스턴스가 제대로 작동하고 있는지 확인한다. 문제가 생긴 인스턴스에는 컨테이너를 할당하지 않는다.
이들 정보를 이용해서 어느 클러스터의 어느 인스턴스에 컨테이너를 만들어야 하는지, 어떤 인스턴스를 복구해야 할지를 결정 할 수 있을 것이다.
주키퍼는
디렉터리형식으로 자원을 배치하고 관리한다.
ls명령으로 파일시스템을 탐색하듯 자원을 탐색할 수 있다.
[zk: zk-01(CONNECTED) 3] ls /
[zookeeper]
지금은 단지 하나의 디렉터리만 있다. zookeeper 디렉터리 밑에 있는 자원을 살펴보자.
[zk: zk-01(CONNECTED) 5] ls /zookeeper
[quota]
quota라는 자원이 보인다.
주키퍼는
디렉터리형식으로 자원을 배치하고 관리한다. 도커 클러스터를 관리하기 위한 노드를 만든다. 노드의 이름은
docker-cluster로 했다. docker-cluster는 하나 이상의 클러스터를 관리 할 수 있다. 그래서 0001, 0002 두 개의 노드를 docker-cluster 밑에 추가했다.
[zk: zk-01(CONNECTED) 16] create /docker-cluster docker_cluster
[zk: zk-01(CONNECTED) 17] create /docker-cluster/0001 cluster_0001
[zk: zk-01(CONNECTED) 18] create /docker-cluster/0002 cluster_0002
주키퍼의 데이터관리 시스템은 파일 시스템과 아주 유사하다. 하지만 파일시스템과 다르게 각 노드는 하위 노드 뿐만 아니라
자신과 관련된 데이터를 가질 수 있다. create 명령을 실행 할때 노드의 이름과 노드의 데이터를 설정할 수 있다. 노드에 대한 메타정보로 볼 수 있겠다.
노드는 2가지 타입이 있다.
- Ephemeral : 세션이 끊기면(클라이언트와의 연결이 끊어지면)삭제되는 노드다.
- Persistent : 세션이 끊기더라도 여전히 남아있는 노드다.
각 타입은
Sequential 성질을 가질 수 있다. Sequential을 설정하면 노드 이름뒤에 4바이트 크기의 유일한 숫자를 붙는다. 이 숫자는 0부터 증가를 한다. 따라서 2147483647개의 노드에 대해서 유일한 이름을 줄 수 있다.
/docker-cluster/0001 밑에 mynode 라는 이름으로 시작되는 sequential 노드를 만들어보자.
[zk: 192.168.56.50(CONNECTED) 8] create -s /docker-cluster/0001/mynode node1
Created /docker-cluster/0001/mynode0000000000
[zk: 192.168.56.50(CONNECTED) 9] create -s /docker-cluster/0001/mynode node2
Created /docker-cluster/0001/mynode0000000001
get을 이용해서 노드의 정보를 가져올 수 있다.
[zk: 192.168.56.50(CONNECTED) 10] get /docker-cluster/0001/mynode0000000000
node1
cZxid = 0x30000000b
ctime = Mon Mar 21 00:47:33 KST 2016
mZxid = 0x30000000b
mtime = Mon Mar 21 00:47:33 KST 2016
pZxid = 0x30000000b
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 0
[zk: 192.168.56.50(CONNECTED) 11]
- data : 노드의 데이터를 확인 할 수 있다.
- cZxid : 노드의 생성 id
- mZxid : 노드의 수정 id
- mtime : 노드가 수정된 시간
- ctime : 노드가 만들어진 시간
- dataLength : 노드 데이터의 크기
- dataVersion : 노드 데이터에 대한 버전이다. 데이터가 변경되면 1씩 올라간다.
- numChildren : 노드가 가지고 있는 하위 디렉토리의 갯수
delete 명령을 이용해서 노드를 지울 수 있다.
[zk: 192.168.56.50(CONNECTED) 13] delete /docker-cluster/0001/mynode0000000000
set으로 노드의 데이터를 변경 할 수 있다.
[zk: 192.168.56.50(CONNECTED) 3] set /docker-cluster/0001/node0000000005 newdata
[zk: 192.168.56.50(CONNECTED) 4] get /docker-cluster/0001/node0000000005
newdata
cZxid = 0x30000000f
ctime = Mon Mar 21 00:59:47 KST 2016
mZxid = 0x300000012
......
주키퍼는 각 노드들에 대한 변경이벤트의 발생을 기다릴 수 있다. help를 보면 wath 옵션을 사용 할 수 있는 명령이 있다.
[zk: 192.168.56.50(CONNECTED) 12] help
......
get path [watch]
ls path [watch]
stat path [watch]
......
ls2 path [watch]
......
watch 옵션을 주면 노드에 변경정보가 있을 때, 이를 알려준다.
[zk: 192.168.56.50(CONNECTED) 15] ls /docker-cluster/0001 watch
[mynode0000000004]
다른 터미널에서 zkCli.sh를 이용해서 /docker-cluster/0001/node3를 만들어보자. 아래와 같이 노드 정보가 변경됐다는 이벤트 메시지를 확인 할 수 있을 것이다.
[zk: 192.168.56.50(CONNECTED) 16]
WATCHER::
WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/docker-cluster/0001
주키퍼는
getData(),
getChildren(),
exists()에 대한 watch를 제공한다. 주키퍼의 watch 이벤트는
one-time-trigger방식으로 작동한다. 즉 watch로 이벤트를 가져왔다면, 다시 watch를 수행해야지만 다음 이벤트를 가지고 올 수 있다.
one-time-trigger 방식으로 작동하기 때문에, 다음 watch를 수행하기 전에 발생한 이벤트는 잃어버릴 수 있다. 이를 감안해서 watch전략을 짜야 한다. 예를 들어서 여러 개의 자식 노드를 가지고 있는 노드에 대한 watch는 하지 말자. 동시에 여러 개의 하위 노드들의 값이 변할 수 있는데, 이들을 처리하기가 만만치 않을 것이다.
컨테이너 기반의 SaaS 인프라를 관리하기 위한 분산 코디네이터를 만들어보자. 이 분산 코디네이터는 컨테이너를 실행하는 인스턴스들의 목록을 유지하며, 각 인스턴스의 가용 자원을 모니터링한다. 관리자가 SaaS 애플리케이션 실행을 요청하면, 적당한 인스턴스를 찾아서 컨테이너를 실행하는 일을 한다. 또한 코디네이터는 각 인스턴스가 실행하고 있는 애플리케이션의 목록들도 가지고 있어야 한다.
나는 다음과 같이 분산 코디네이터 환경을 만들기로 했다.
이 환경은 Jsaas-Master과 Jsaas-Slave로 구성된다. Jsaas-Master는 인스턴스를 관리하는 역할을 하며, 주키퍼로 관리를 한다. 가용성을 위해서 3개 이상의 Jsaas-Master로 클러스터를 구성한다. 이 클러스터는 Active-Standby 방식으로 작동한다. 요청의 분산과 가용성을 위해서 Active-Active로 운영하고 싶겠지만 좋은 생각이 아니다.
주키퍼를 데이터베이스로 사용 할 수 있을 것이란 생각에 Active-Active하게 구성할 생각을 한다. 관리하는 모든 인스턴스와 애플리케이션 정보를 주키퍼에 두면, Jsaas-Master들이 모두 공유해서 서비스 할 수 있을 것 처럼 보이기 때문이다. 주키퍼는 Mysql처럼 빠른 읽기/쓰기를 위한 목적으로 만들어진 소프트웨어가 아니다. 빠른 읽기/쓰기가 필요한 정보들을 서로 공유하기 위해서 주키퍼를 사용하는 것은 좋은 생각이 아니다.
그래서 주키퍼는 Jsaas-Master를 관리하는 목적으로만 사용하기로 했다. Jsaas-Slave의 정보들은 Active 상태에 있는 Jsaas-Master만 관리한다. Standby 상태의 Jsaas-Master는 아무런 정보도 가지지 않는데, 어차피 Jsaas-Slave들은 Active 상태의 Jsaas-Master에만 연결할 것이기 때문에 문제될 게 없다.
중요한건 3개의 Jsaas-Master 중에서 리더를 결정하는 것이다. 분산 코디네이터네에서는
bully 알고리즘을 이용해서 리더를 선출하는데, 이 경우 주키퍼를 이용해서 리더를 선출 할 수 있다. 대략 적인 알고리즘은 다음과 같다.
- 각 노드(Jsaas-Master)들은 일련번호를 갖는다. Sequence 타입으로 znode를 만드는 것으로 일련번호를 부여 할 수 있다. 이 일련번호는 계속 증가한다.
- 낮은 일련 번호를 가지는 노드가 리더로 선출될 확률이 높다.
- 처음 주키퍼에 접속한 노드-1은 가장 낮은 일련번호를 가지고 있을 거다.
- 처음에는 노드-1만 있기 때문에 자동으로 리더로 선출 된다.
- 노드-2, 3, 4 가 붙는다. 새로 붙는 노드들은 이미 붙어있는 노드에 누가 리더인지를 묻는다. 이 경우 노드-1이 리더가 되고, 나머지 뒤에 붙은 노드들은 follower가 된다.
- 노드-1이 빠져나갔다고 가정해보자. 리더가 사라졌으니, 새로운 리더를 선출해야 한다. 노드-2,3,4는 watch로 노드-1의 연결이 끊겼다는 것을 알아챈다. 노드-2는 자신의 일련번호가 가장 낮다는 것을 알고 있으므로, 주변 노드에게 내가 리더가 되겠으니 승인 해달라는 메시지를 전송한다.
- 노드-2가 요청 메시지를 늦게보내거나 아예 보내지 못 할 수도 있다. 다른 노드들이 무한정 기다릴 수는 없으므로 일정 시간동안 리더 승인 요청이 없으면, 다른 노드들이 리더 승인 요청 메시지를 보낸다. 만약 노드-2가 이 메시지를 받는다면, 아냐 내가 리더이니 승인해줘라는 응답 메시지를 보내고 리더가 될 수 있을 것이다. 노드-2로 부터 응답이 없다면, 일련번호가 가장 낮은 노드-3이 리더가 된다.
- 노드-1이 다시 주키퍼에 붙으면 새로 (가장 높은)일련번호로 붙는다. 주변 노드에 리더 확인 요청을 보내는 것으로 리더를 확인하고 자신은 follower가 된다.
최적화할 구석이 있지만 위의 방식으로 알고리즘을 대략 구현 할 수 있을 것이다.
실제 구현을 해보려 한다. 단 코드는 만들지 않는다(귀찮아서). 구성은 아래와 같다.
Jsaas-master들이 사용할 자료구조를 설계한다. 이들 마스터는
/jsaas디렉토리 밑에 저장하기로 했다. 노드의 타입은
emphasis,
sequence이다. 노드의 이름은
node.info.json.으로 했다. 마스터는
node.info.json.00000000009 형식의 이름으로 연결된다.
node.info.json. 파일에는 노드의 상세 정보가 들어있다. 마스터들은 이 정보로 다른 마스터들을 확인 할 수 있다.
{
"hostname":"mast-01.joinc.priv",
"ip":"192.168.56.155",
"port":"8081"
}
- hostname : 마스터의 호스트 이름
- ip : 마스터의 IP
- port : 마스터의 Port
테스트용 코드다. 주키퍼에 연결해서 znode를 등록하는 일을 한다.
package main
import (
"encoding/json"
"fmt"
"github.com/samuel/go-zookeeper/zk"
"time"
)
func main() {
cli, _, err := zk.Connect([]string{"192.168.56.155", "192.168.56.156", "192.168.56.157"}, time.Second * 5)
if err != nil {
panic(err)
}
data := struct {
Hostname string `json:"hostname"`
Ip string `json:"ip"`
Port int `json:"port"`
}{
"jsaas-01.joinc.priv",
"192.168.56.155",
8888,
}
jdata, _ := json.Marshal(data)
path, err := cli.Create("/jsaas/node.infojson.", jdata,
zk.FlagSequence|zk.FlagEphemeral,
zk.WorldACL(zk.PermAll))
if err != nil {
panic(err)
}
fmt.Println("Create ", path)
time.Sleep(time.Second * 1000)
}
/jsaas는 미리 만들어 놓았다. 실제 작동하는 코드에서는 jsaas를 검사해서 없으면 새로 만들어야 할 것이다. 그리고 hostname, ip, port 정보를 가지는 구조체를 만들어서
/jsaas/node.info.json. 노드의 데이터로 설정했다. 역시 실제 코드에서는
hostname을 읽어서 자신의 ip를 설정해야 겠으나 테스트인 만큼 (귀찮아서)하드코딩 했다. 이 프로그램을 실행한 결과다.
# go run jsaas-master.go
2016/04/13 19:06:19 Connected to 192.168.56.155:2181
2016/04/13 19:06:19 Authenticated: id=95718048050708488, timeout=5000
Create /jsaas/node.infojson.0000000006
하나 더 실행 했다.
# go run goconnect.go
2016/04/13 19:07:19 Connected to 192.168.56.155:2181
2016/04/13 19:07:19 Authenticated: id=95718048050708489, timeout=5000
Create /jsaas/node.infojson.0000000007
zkCli.sh로 노드의 정보를 확인했다.
[zk: 192.168.56.155(CONNECTED) 0] ls /
[jsaas, zookeeper]
[zk: 192.168.56.155(CONNECTED) 2] ls /jsaas
[node.infojson.0000000007, node.infojson.0000000006]
[zk: 192.168.56.155(CONNECTED) 2] ls /jsaas/
[node.infojson.0000000007, node.infojson.0000000006]
[zk: 192.168.56.155(CONNECTED) 5] get /jsaas/node.infojson.0000000006
{"hostname":"jsaas-01.joinc.priv","ip":"192.168.56.155","port":8888}
cZxid = 0xb00000053
bully 알고리즘은 그룹내 모든 프로세스들이 다른 프로세스들의 정보를 미리 알고 있어야 하기 때문에 동적으로 시스템을 확장하기기 쉽지 않다. 이 일을 주키퍼가 도맡아 처리하기 때문에 쉽게 분산 코디네이터를 만들 수 있다.
주키퍼 구성에서 분산 코디네이터 구현까지 간단히 살펴봤다. 분산 코디네이터 내용의 대부분은
Mesos 아키텍처를 참고했다.