top bar

글 목록

2015년 4월 28일 화요일

[Elasticsearch] 클러스터(또는 노드) 재시작하기 - Rolling Restarts

1. 개요



Elasticsearch 클러스터를 운영하다보면, Elasticsearch의 자체의 업그레이드 작업이든, 하드웨어의 교체/업그레이드 작업이든, Elasticsearch 클러스터 전체를 재구동해야하는 경우가 무조건 온다.

이때, 보통 우리가 생각 하듯이 그냥 클러스터 전체를 껐다 키면 되지 않을까? 라는건 조금 저렴한(?) 발상이다. 어쨌든, 각 노드를 하나씩 재구동한다고 했을때, 어떻게 해야할까? 

이제부터 'Rolling Restarts'라는 방식을 설명하겠다.
아래와 같은 클러스터 구조가 있다고 하자.



'amigo' 인덱스를 보면,
노드가 3개, Primary Shard가 5개 Replica Shard가 5개인 구조이다. (굵은 테두리로 되어있는 shard가 Primary Shard 이다) 이때, 'es-node1'을 shutdown 한다고 치자. 그렇게 되면 'amigo' 인덱스의 node1에 있는 0,2,3 replica shard는 어떻게 될까? 

있을 곳을 잃어버린 위의 3개 shard는 '무작위로' 나머지 node2, node3으로 '재배치'(Reallocation)가 된다. 이때 발생하는것은 고 비용의 IO 작업 이다.

그건 그렇다 치자, 해야하는 작업을 마치고 다시 node1을 구동한다. 원래있던 0,2,3 replica shard가 다시 node1로 돌아가게 될까? 

아니다. node2와 node3에 있던 shard들이 무작위로 node1로 옮겨가게되는데, 이때 Elasticsearch는 적절히 각 노드의 균형을 맞춰 shard를 재배치 한다. 이때 발생하는 Rebalance 작업 또한 비용이 상당하다.

게다가 각 shard에 대용량의 데이터가 저장되어있다고 했을때, 이러한 Reallocation, Rebalance 작업은 엄청난 시간이 걸린다 (필자는 노드 하나를 껐다 키고 모든 샤드가 파란색이 될때까지 2시간 이상을 기다린 경험도 있다..)

여기서 필요한게 바로 'Rolling Restarts' 기법이다.

2. Rolling Restarts Steps



이제 각 단계를 살펴보자.

1) 위 그림과 같은 초기 상태이다


가능하면, document가 색인되는것을 중지한다.

2) 아래와 같은 요청으로 shard allocation 옵션을 disable한다
$ curl -XPUT 'localhost:9200/_cluster/settings?pretty=true' -d
{
    "transient" : {
        "cluster.routing.allocation.enable" : "none"
    }
}
이게 Rolling Restarts 의 핵심이다. 이게 왜 핵심인지는 step을 밟아가며 알아보자.

3) 노드를 shutdown 한다.
$ curl -XPOST 'localhost:9200/_cluster/nodes/_local/_shutdown'
자, 2)번에서 allocation을 disable 했다. 이러한 설정상태에서 node를 shutdown하면, 아래와같이 shutdown한 node에 있던 shard들은 재배치 되지 않고 'Unassinged' 상태로 남아있다.




바로 위에 언급했던 '불필요한 I/O작업'을 피할 수 있는것이다.

4) 해야하는 작업을 한다 (유지보수/upgrade)

5) node를 다시 구동하고, 클러스터에 합류하는지 확인한다.
$ sudo service elasticsearch start
 
Starting Elasticsearch...
Waiting for Elasticsearch......
running: PID:25374

6) 2번에서 disable했던 allocation 옵션을 다시 enable한다.
$ curl -XPUT 'localhost:9200/_cluster/settings?pretty=true' -d
{
    "transient" : {
        "cluster.routing.allocation.enable" : "all"
    }
}
자 이렇게 되면 'Unassigned' 상태에있던 shard들이 아래와 같이 재배치 될것이다.


하지만 서론에서 언급한것처럼 무작위로 재배치되어 균형을 맞추는것이 아니라, 원래 node1에 있던 shard들, 즉 'Unassigned' 상태에있던 shard 들만 node1로 옮겨간다.

원래 node1에 있던 shard들이 제자리로 돌아 가는 것이니, Rebalance 작업이 필요없는 것이다!

아래와같이 빠른속도로 재배치가 완료된다.



7) 클러스터가 안정화되면(green 상태) 2번~6번 까지의 작업을 다른 node에 반복한다.


3. 결론



'Rolling Restarts'의 핵심은 shard가 allocation되는것을 disable하는것이다. 간단하지 않은가? 이런 설정을 한뒤에 재구동을 하면, 불필요한 I/O작업과 Rebalance 작업을 하지 않아도 되니, 비용절약과 동시에 재구동후의 빠른 클러스터의 안정을 가져다 준다.

Elasticsearch 운영시에 꼭 알아둬야할 기법인 것같다.

2015년 4월 21일 화요일

[Elasticsearch] VersionConflictEngineException 에 대하여

1. 발단



현재 필자가 담당하고 있는 시스템의 주요 이슈중 하나가, 데이터의 누락이었다.
예를들면 아래와 같다.


물론 위의 화면은, Elasticsearch에 색인된 데이터를 가져와 뿌려준다.
다만, 저런 현상이 보인다는건, 해당 document의 필드값이 이빨이 빠져있다는 거다.

이리저리 뒤져가며 삽질하다가, 아래와 같은 로그를 발견했다.


VersionConflictEngineException... 이게 문제의 원인인 듯 하다.

2. 분석 & 해결과정



elasticsearch 문서에는 이러한 Version Conflict를 아래의 url에서 다루고 있다.


elasticsearch에 색인되어있는 각각의 document는 '_version'이라는 내장 필드를 가지고있다. document가 처음 색인되면 version은 1이다. 이후 update, delete와 같은 작업이 해당 document에 이루어지면, 버전이 1씩 올라간다.

하지만, 이것이 무엇이 문제일까? 이러한 version conflict를 재현해보았다.

1) 먼저 아래와 같은 document를 색인한다.
$ curl -XGET 'localhost:9200/test_index/test_type/1?pretty=true'
{
  "_index" : "test_index",
  "_type" : "test_type",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source":{"name":"john","address":"boston"}
}
테스트용 인덱스와 타입을 만들었고, id를 1로 하여 위와같은 doc하나를 색인하였다.
필드는 'name'과 'address'이며 최초 값은 'john'과 'boston'이다.

2) 두개의 shell script를 준비한다.
## name_update.sh 파일 내용 ##
#!bash 
## name을 업데이트하는 shell script 
curl -XPUT 'localhost:9200/_bulk?pretty=true' --data-binary @requests1.json;

## request1.json 파일 내용
{"update":{"_id":"1","_type":"test_type","_index":"test_index"}}\n
{"doc":{"name":"tom"}}

## address_update.sh 파일 내용 ##
#!bash 
## address를 업데이트하는 shell script 
curl -XPUT 'localhost:9200/_bulk?pretty=true' --data-binary @requests2.json;

## request2.json 파일 내용
{"update":{"_id":"1","_type":"test_type","_index":"test_index"}}\n
{"doc":{"address":"newyork"}}
 _bulk를 이용해 업데이트 요청을 처리하는 shell script이다. 두개의 script파일을 만들었는데, 하나는 name필드를 'tom'으로 업데이트하는 스크립트이고, 또 하나는 address필드를 'newyork'으로 업데이트하는 스크립트이다.

3) Crontab을 사용하여 두개의 스크립트를 '동시에' 실행시킨다.

결과는 어떻게 될까?

name필드는 'john'에서 'tom'으로, address필드는 'boston'에서 'newyork'으로 업데이트
되어야 하는게 우리가 기대하는 결과일 것이다.

하지만, 결과는 아래와 같았다. 해당 doc을 조회해 보자.
$ curl -XGET 'localhost:9200/test_index/test_type/1?pretty=true'
{
  "_index" : "test_index",
  "_type" : "test_type",
  "_id" : "1",
  "_version" : 2,
  "found" : true,
  "_source":{"name":"john","address":"newyork"} <-- name이 최초 버전 그대로 'john'이다!
}
 address필드는 예상되로 'newyork' 으로 업데이트되었지만, name필드는 업데이트되지
않았다. 어째서 이런결과가 발생한 것일까.

name을 업데이트하는 스크립트 수행결과에 아래와 같은실행 로그가 찍혀있었다.

{
  "took" : 2,
  "errors" : true,
  "items" : [ {
    "update" : {
      "_index" : "index1",
      "_type" : "type1",
      "_id" : "5",
      "status" : 409,
      "error" : "VersionConflictEngineException[[index1][1] [type1][5]: 
version conflict, current [2], provided [1]]"
    }
  } ]
}

오오잇! VersionConflictEngineException이 보인다. 실무에서 똑같이 보았던 Exception이다.

원인은 이렇다.

먼저 name필드와 address필드를 업데이트하는 스크립트가 '동시에' 실행되었다는것, 그리고 앞서 말한것 같이 document에는 'version'이라는 개념이 있다는것을 주목해 보자.

그리고 두개의 스크립트를 일련의 '프로세스'로 간주하자. 두개의 프로세스는 각각 version 1의 대상 document에 대해 update를 수행한다. 이때, 밀리세컨드이든, 나노세컨드이든, 간만의 '시간차'로 어느 한 프로세스가 '먼저' update를 수행하게 될 것이다. 그렇게되면 해당 document의 version은 '2' 가 된다.

곧바로 수행되는 다른 프로세스도 version '1'의 document를 업데이트하려고 할 것이다. 하지만? 이미 해당 document의 버전은 version '2'가 되어있다. 두번째 프로세스가 업데이트를 수행하려고 했던 version 1의 document는 'Out of date' 가 되어버린 것이다.

그렇게되면 두번째 프로세스 예를들어 위의 경우처럼 address를 먼저 업데이트하고 두번째로 name을 업데이트 한다고 하면 name은 업데이트를 수행하지 못한다.

이것이 문제이다. 최초에 실무에서 겪었던 데이터 누락의 경우를 보자


위의 경우처럼 데이터가 누락되는 원인은 위의 재현된 사항과 같았다. A행(document)의 경우를 본다면, 최초 D열과 E열은 필드 값이 비어있고, 각각 D열을 업데이트하는 스레드, E열을 업데이트하는 스레드가 돌고있다

각각의 스레드가 A행의 D와 E열을 업데이트하려고(값을채우려고) 시도한다. 위에 재현했던대로 Version Conflict가 발생한다. 그렇게되면 둘중 하나의 값은 유실 되어 버린다

이게 문제다.

3. 해결




이를 해결하는것은 매우매우매우 간단하다. 바로 bulk를 이용하여 update쿼리를 할때, 'retry_on_conflict'라는 옵션을 사용하는것이다. 아래는 자바코드에서 Elasticsearch에 bulk 요청을 하는 예제이다.

// 3. 검색엔진에 인덱싱 요청
indexMessage = new StringBuffer();
indexMessage.append("{\"update\":{\"_index\":\"test_index\"," + 
"\"_type\":\"test_type\",\"_id\":\"")
// version conflict발생시 최대 5번까지 retry 한다.
.append(doNotification.getMsgId()).append("\", \"_retry_on_conflict\":5}}\n");

'retry_on_conflict' 옵션은, 말그대로 Version Conflict가 발생할 때마다 '재시도' 하는 횟수를 말한다. 이렇게되면 충돌이 발생하더라도 다시 업데이트를 시도하여 최종적으로는 업데이트가 정상적으로 이루어 지게 된다
하나의 이슈도 참 심오하다. 그래서 결코 쉽지않은 Elasticsearch이다.