본문 바로가기

wif LiNoUz/SERVER

하나의 메모리 누수를 잡기까지 / 대용량 세션을 위한 로드밸런서


출처 :  http://d2.naver.com/helloworld/1326256                     

        http://d2.naver.com/helloworld/605418


하나의 메모리 누수를 잡기까지

웹 서비스는 멀티 스레드 환경이기 때문에 항상 신중하게 코드를 작성해야 합니다. 부주의하게 수정한 소스 코드 때문에 메모리 누수가 일어나 애플리케이션이 응답하지 않는 503 오류가 발생한 경험이 있습니다. 이 글에서는 503 오류를 처리하고 그 원인을 찾아 메모리 누수 문제를 해결한 사례를 이야기하겠습니다.

장애 징조 - 상반기의 장애 처리 일지

잠자리에 들기 전에 늘 휴대폰을 챙긴다. 배터리가 충분한지, 매너 모드를 해제했는지 확인하고 손이 잘 닿는 위치에 휴대폰을 놓는다. 아침에 잠을 깨우는 알람이 울리기 전에 다른 연락이 없기를 바라며 잠이 든다. 서비스를 운영하다 보니 장애 관제 시스템에서 보내는 장애 메시지를 놓치지 않도록 신경을 곤두세운다. 잠을 푹 자는 건지는 모르겠다.

1월 4일 오후 11시 24분, 간헐적으로 503 오류가 발생한다는 알림을 받았다. 서비스에 투입한 장비 가운데 한 대에서 메모리 용량이 부족하다고 한다. 로그를 분석하니 메모리 부족(out of memory) 오류다. 로컬 장비에서 캐시를 운영하지만 힙 영역의 크기를 크게 설정하지 않은 것이 문제로 보인다. 힙 영역의 크기를 늘렸다.

4월 12일 오전 7시 47분, 또다시 간헐적으로 503 오류가 발생한다는 알림이다. 일찍 출근해 있었기에 Tomcat을 다시 시작해 우선 서비스가 동작하게 했다. 스레드 덤프(thread dump)를 저장하지 않아 로그만으로는 정확한 원인을 파악하기 힘들다. 다음부터는 스레드 덤프를 저장하기로 했다. 3개월 동안 아무 문제가 없었는데 원인이 무엇일까?

5월 11일 오후 8시 45분, 간헐적 503 오류다. 통합 로그 시스템에서 먼저 오류를 알리는 문자 메시지를 보내기 시작했다. 야근을 하던 개발자가 조치를 취했다. 이번에는 정확한 원인을 파악할 수 있지 않을까 기대했다. 로그 파일 분석으로는 데이터베이스의 커넥션 풀(connection pool)이 모자라는 곳에서 문제가 시작된 것으로 보인다. DBCP(database connection pool) 라이브러리의 문제라고 결론을 내렸다. 사내에서 수정한 commons-dbcp-1.2.2로 DBCP 라이브러리를 교체했다. 추가로 Apache Commons Pool 라이브러리도 1.4 버전으로 업그레이드했다. 다른 서비스에도 같은 문제가 있어 이번 기회에 DBCP에 관련된 문제를 전체 개발 조직에 공유했다.

모니터링 개선

나 혼자 죽을 수 없다

웹 서버에 문제가 생기면 L4 수준에서 자동으로 제외돼 여러 대의 서버를 사용해도 큰 문제가 없다. 하지만 Tomcat에 문제가 생겼을 때는 문제가 생긴 서비스가 L4 수준에서 자동으로 제외되지 않아 사용자 요청 중 일정 비율은 제대로 처리되지 않는 문제가 생긴다. 그래서 서비스를 출시할 때부터 Tomcat은 별도의 URL로 모니터링하게 만들어 Tomcat에 문제가 생기면 모니터링 시스템에서 확인할 수 있다. 장애 관리 시스템의 기록을 보면 응답 모니터링 시스템에서 장애가 확인되었다고 하니까 모니터링이 잘 된다고 보면 될 것 같다(그 이후로 L7 수준에서 장애를 확인하도록 해 Tomcat이 정지되면 자동으로 인프라 담당 부서에 문의해 서비스가 사용하는 L4 스위치에 여유가 있는 경우 L7 수준에서 서버를 전환한다. 또 다른 방법은 mod_jk에서 로드 밸런서를 사용하는 방법이다.).

혹시 장애 관리 시스템에서 알리기 전에 서비스의 문제를 알 수 있는 방법은 없을까? 모니터링에서 실패를 조금이라도 먼저 감지하면 조치가 빠르지 않을까? 방법을 찾다 모니터링 시스템에서 개발자에게 문자 메시지를 보낼 수 있는 것을 알았다. 웹 서버나 Tomcat뿐만 아니라 CPU 사용량이 어느 정도 이상 올라가도 알려 줄 수 있다고 한다.

먼저 알고 먼저 처리할 수단이 있으면 마다할 이유가 없다. CPU 사용량까지 포함해 문제가 발생하면 개발자가 문자 메시지를 먼저 받게 인프라 담당 부서에 요청했다. 이때 우리 팀 개발자가 모든 팀원이 문자 메시지를 받도록 인프라 담당 부서에 요청했다. 마치 "(장애 담당자인) 나 혼자 죽을 수 없다"라고 마음 속으로 외치는 소리가 들리는 듯하다.

문자 메시지 스트레스

6월 16일부터 모니터링 개선의 효과를 보기 시작했다. 오후 10시 38분에 7번 기계의 CPU 사용량이 비정상적으로 높아진 것을 통합 모니터링 시스템에서 문자 메시지로 알려 주었다. TV를 보면서 맥주를 한잔하고 있었는데, 인프라 담당 부서에서 먼저 조치를 취하고 메일을 보내 주었다.

문자 메시지가 팀원 모두에게 가면 어떤 일이 생길까? 38명의 목격자가 있는 가운데 한 명이 살해당할 때 아무도 도움을 안 주지 않았다는 글을 읽은 적이 있다. 그 이유는 책임이 분산되기 때문이라고 한다. 즉, '모두 문자 메시지를 받으니 누군가 처리하겠지'라고 생각할 수 있는 것이다. 다행히도 우리 팀은 자발적인 팀이고 모두 책임 의식이 강하다. 우리는 거의 모든 팀원들이 문자 메시지를 받으면 회사의 시스템에 로그인한다.

6월21일 일요일 오후에 다시 문자 메시지가 왔다. 이번에는 8번 기계다. CPU 사용량이 올라갔다가 다시 안정화되었다. 그러나 다음날 새벽 1시에 다시 CPU 사용량이 100%까지 올라갔고 결국 해당 기계를 서비스에서 제외했다.

모니터링 개선 덕에 서비스에 문제가 생기기 전에 미리 서비스에 지장이 없도록 조치할 수 있었다. 그러나 계속해서 문자 메시지에 시달리면서 살 수는 없었다. 문자 메시지가 주는 스트레스도 만만치 않다.

문제 원인 찾기

이제 문제의 원인을 제거할 때다. 서비스에서 제외된 장비의 로그를 분석하기 시작했다. 스레드 덤프 분석 도구인 Samurai로 스레드 덤프를 분석했더니 교착 상태(deadlock)도 없고 모두 유휴 상태(idle)이거나 실행 중인 상태였다. 그리고PMAT(pattern modeling and analysis tool)로 가비지 컬렉션(garbage collection) 상태를 그래프로 그렸다.

image-3

그림 1 가비지 컬렉션 상태 그래프

PMAT와 -verbose 옵션 PMAT는 -verbose:gc 옵션으로 JVM을 실행할 때 출력되는 가비지 컬렉션 로그를 파싱해서 그래프로 보여 주는 도구다. 힙 메모리의 사용 패턴을 분석해서 적절한 설정값을 추천하는 기능도 제공한다. -verbose 옵션에 관해서는 "Garbage Collection 모니터링 방법"을 참고한다.

특정 시점 이후로는 가비지 컬렉션 이후에도 남는 메모리가 계속 줄어든다. 결국 메모리 누수가 원인이다. 몇 달에 한 번 발생하는 문제이기 때문에 개발 장비나 테스트 장비에서는 발생하지 않는다. 결국은 실제로 운영하는 서버에서 확인할 수밖에 없는데 어떤 서버에서 발생할지 모르고, 그렇다고 모든 서버에 프로파일러를 설치할 수는 없는 노릇이다. 메모리 누수가 발생하기 시작하는 장비를 찾아야 한다. 팀에서는 다음 메일과 같이 간단하면서도 효과적인 방법을 고안했다.

아래처럼 웹 서버에서 메일을 발송하도록 스크립트를 만들었습니다. 보시면 대략 아시겠지만 01 ~ 12번 장비까지 최근 Full GC 발생 후 Old Generation의 추이고요. 

현재 힙 메모리의 최대 용량은 1,398,144K까지 가능하고 가비지 컬렉션 후에는 대략 10% 이하가 유지되므로 아래 결과는 정상입니다.

혹시 아래 예처럼 가비지 컬렉션 후에도 최대 용량에 육박하는 장비가 발견된다면 조치가 필요한 것입니다.
[Full 1394849K -> 1350000K (1398144K)]
[Full 1397258K -> 1351000K (1398144K)]
[Full 1395333K -> 1352000K (1398144K)]
[Full 1396319K -> 1353000K (1398144K)]
[Full 1395599K -> 1354000K (1398144K)]
메모리 누수 상태에서도 이틀 이상은 유지되니깐 매일 오전 9시에 동작시키도록 할게요.

감사합니다.

그리고 6월 26일에 드디어 꼬리를 잡았다.

보낸 사람: 개발자 J 

2번 장비 낌새가 좀 이상하군요.
[Full 1397073K -> 116835K (1398144K)]
[Full 1395193K -> 142874K (1398144K)]
[Full 1395081K -> 146435K (1398144K)]
[Full 1395310K -> 374266K (1398144K)]
[Full 1395357K -> 629070K(1398144K)]

장비를 서비스에서 제외시키고 동작 중인 Tomcat으로 원인 분석을 시작했다.

문제 해결

히스토그램 비교

우선 힙 메모리의 히스토그램을 이용해 문제를 해결할 방법을 찾았다. 메모리 누수를 잡기 위해서 프로파일러를 사용하더라도 객체(object)의 변동을 추적하고 힙 영역의 내용을 볼 수밖에 없다. jmap으로 히스토그램을 받아서 정상 상태의 히스토그램과 비교해 증분이 많은 객체를 중점적으로 보면 원인을 찾을 수 있을 것이라고 가정했다. 객체의 개수와 크기를 비교해 CSV(comma separated values) 파일로 저장하는 프로그램을 만들었다. 실제 객체는 힙 덤프(heap dump)로 볼 생각이었다. 여기에 동원한 다음과 같은 도구는 모두 무료로 JDK 6에 포함돼 있다.

  • jmap: 힙 덤프나 히스토그램을 출력하는 프로그램
  • jhat: 힙 덤프를 이용해 각 객체를 볼 수 있는 프로그램
  • VisualVM: 실행되고 있는 JVM의 힙 내용을 볼 수 있는 프로그램

다음은 히스토그램의 일부다.

$ jmap -histro:live 8825 | more
num    # instances    #bytes    class name  
----------------------------------------------
1:    3062256    677810312    [C  
2:    3176949    76246776        java.lang.String  
3:    29959    32072704        [I  
4:    380080    27365760        xxx.xxx.common.model.xxxx  
5:    100476    12792648        <constMethodKlass>  
6:    113714    11254840        [Ljava.lang.Object;  
7:    453459    11883016        java.util.HashMap$Entry  
8:    100476    8043896        <methodKlass>  
9:    16052        6917504        [B  
10:    412877    6606032        java.lang.Integer  
11:    141665    6546672        <symbolKlazss>  
12:    10410    5282344        <const PoolKlass>  
13:    12577    4451720        [Ljava.util.HashMap$Entry;  
14:    10410    4351624        <instanceKlassKlass>  
15:    9166    3134352        <constantPoolCacheKlass>  
16:    105039    2520936        java.util.ArrayList  
17:    58627    1876064        xx.xxx.xxx.xxxListEntry  
18:    58027    1856864        java.util.LinkedHashMap$Entry  
19:    21897    1751760        java.lang.reflect.Method  

다음은 비교 결과를 Excel로 정리한 화면이다.

image-5

그림 2 힙 메모리의 히스토그램 비교

Excel로 결과를 보면 숫자 혹은 크기 변동에 따라 정렬할 수 있어서 문제를 찾기 쉬우리라 예상했다. 그러나 히스토그램을 비교하려면 어느 정도 시간 이상 실행되고 있는 서버의 히스토그램을 확보해야 하는데, 서비스 중인 서버에서 받지를 못했고 숫자만 봐서는 정확히 집어내기가 힘들었다. 어쩔 수 없이 히스토그램을 이용하는 방법은 다음 기회(다음 기회가 없기를 바란다)에 다시 시도하기로 했다.

힙 덤프 분석

히스토그램 비교 대신 문제가 발생한 서버의 힙 메모리 덤프를 받아서 분석을 시작했다. 힙 덤프 파일의 크기는 약 858MB 정도였는데, JDK에 포함된 jhat으로는 3시간이 지나도록 응답이 없어 분석을 진행할 수 없었다.

분석을 도와 줄 도구로 Eclipse의 Memory Analyzer(MAT)를 추천받아 사용했다. Memory Analyzer는 용량이 큰 힙 덤프 파일을 매우 빠른 속도로 탐색할 수 있고 Eclipse 플러그인 형태로도 제공된다. 실제로 Memory Analyzer를 사용했을 때 858MB의 힙 덤프 파일을 1분 이내로 읽어 분석을 진행할 수 있었다.

image-7

그림 3 Memory Analyzer의 힙 덤프 분석 결과

Memory Analyzer를 이용해서 가장 많은 메모리를 차지하고 있는 객체를 찾을 수 있었다. 약 717MB 정도 차지하고 있던 이 객체는 java.util.LinkedHashMap으로 만든 LRU(least recently used) 캐시였다. 캐시 엔트리 한 개의 크기는 약 20 ~ 40KB 정도고 최대 100개의 엔트리를 저장할 수 있다. 일정 시간이 지나거나 오랫동안 사용하지 않은 객체는 삭제하기 때문에 이론적으로는 40MB 이상의 메모리를 사용할 수 없었다.

LinkedHashMap 내부에 있는 table이라는 이름의 배열(array)에 데이터를 저장하는데, table[795]에 저장된 객체가 대부분의 용량을 차지하고 있음을 쉽게 알 수 있었다. 그래서 이 객체의 내부를 살펴보았더니 다음과 같았다.

LinkedHashMap은 LinkedList로 구현한 HashMap이며 맵에 저장한 엔트리를 before와 after라는 이름의 레퍼런스를 사용해 순서대로 엮는다. 따라서 before와 after 레퍼런스에 연결된 객체는 LinkedHashMap의 엔트리여야 하므로 내부의 table 배열에 존재하거나 다른 엔트리의 next 레퍼런스에 연결돼야 한다(next 레퍼런스는 해시의 버킷 연결을 의미한다). 그러나 table[795]에 저장된 객체의 before 레퍼런스에 연결된 객체는 table 배열에 존재하지 않았고 다른 엔트리의 next 레퍼런스에 연결되지도 않았다. 이것은 이 객체가 HashMap의 엔트리가 아님을 의미한다.

HashMap의 엔트리가 아닌 객체는 HashMap에서 지워야 하는데 어떠한 이유로 before 레퍼런스를 정리하지 못해 아직 남아 있기 때문에 가비지 컬렉터(garbage collector)가 객체를 정리하지 못한 상황이라고 볼 수 있었다. 그럼 왜 before 레퍼런스를 정리할 수 없었을까? 이런 문제는 대부분 멀티 스레드(multi thread) 환경에서 스레드 세이프(thread-safe)를 고려하지 않은 코드를 사용했을 때 발생한다는 것을 경험으로 알고 있었다. before 레퍼런스가 변경되는 시점의 코드를 찾아서 어떤 실수가 있었는지 확인하는 작업을 진행했다.

다음은 LinkedHashMap 클래스의 소스 코드 중 일부다.

/**
* Inserts this entry before the speicifed existing entry in the list.
*/
private void addBefore(Entry<K,V> exsitingEntry){  
    after = existingEntry;
    before = exsitingEntry.before;
    before.after = this;
    after.before = this;
}


/**
* This method is invoked by the superclass whenever the value
* of a pre-existing entry is rady by Map.get or modifed by Map.set.
* If the enclosing Map is access-ordered, it moves the entry
* to the end of the list; otherwise, ti does nothing.
*/
void recordAccess(HashMap<K,V> m) {  
    LinkedHashMap<K,V> lm = (LinkedHashMap <K,V>) m;
    if (lm.accessOrder) {
        lm.modCount++;
        remove();
        addBefore(lm.header);
    }

}

....

LinkedHashMap 클래스 내부에서 before 레퍼런스를 변경할 때는 addBefore()라는 메서드를 호출하며, addBefore() 메서드는 recordAccess() 메서드에서 호출한다. recordAccess() 메서드는 LinkedHashMap.get() 메서드와 LinkedHashMap.put() 메서드에서 사용한다. 따라서, get() 메서드와 put() 메서드를 호출하는 부분은 스레드 세이프를 고려해 작성해야 한다.

Collection 객체를 다룰 때 put() 메서드를 사용할 때는 스레드 세이프를 고려해 syncronized 블록을 사용하지만, get() 메서드를 다룰 때는 생략하는 경우가 있다. 위 코드를 확인하는 순간 가장 먼저 든 생각은 '내가 get() 메서드'를 사용할 때 synchronized 블록을 사용했던가?'였다. 작성한 코드를 찾아보니 다음과 같았다.

...
public V get(K key) {  
    LRUCacheEntry<V> entry = null;
    V result = null;
    if (key !=null ) {
        entry = cache.get(key);
        if ( entry != null ) {
            if (lifetime > 0 && (System.currentTimeMills() - entry.getEntranceTime() > lifetime )
                synchronized (cache) {
                    cache.remove(key);
                }    
            } else {
                result = entry.getEntry();
            }

        }
    }
    return result;
}

우려했던 대로 LinkedHashMap.get() 메서드를 호출할 때 syncronized 블록을 사용하지 않았다. 따라서 메모리 누수의 원인을 다음과 같이 추정할 수 있었다.

  1. 여러 스레드가 동시에 get() 메서드를 호출한다.
  2. LinkedHashMap 내부에서 before 레퍼런스와 after 레퍼런스를 변경할 때 레퍼런스가 올바르게 연결되지 않는다.
  3. 오동작으로 의도하지 않은 레퍼런스가 남은 객체는 가비지 컬렉터가 처리할 수 없으므로 메모리를 계속 차지하게 된다.
  4. 이와 같은 일이 반복되면 차지하는 메모리가 점점 증가하게 되고 결국 메모리 부족 오류가 발생한다.

문제의 코드를 스레드 세이프를 고려해 수정한 다음 서비스에 반영했다. 이후 지속적으로 메모리 상황을 관찰한 결과 메모리 누수 현상은 완전히 사라졌다.

마치며

가장 어이없었던 일은 이 다음에 일어났다. LRUCache 클래스는 전부터 사용하던 오래된 코드였기 때문에 이러한 버그가 있다는 사실을 현재 담당 부서에 알리고 수정한 코드를 보내야 했다. 하지만 서둘러 예전 소스를 체크아웃하고 해당 클래스의 소스 코드를 확인하는 순간 뒤통수를 한 대 얻어맞은 기분이었다. 예전 코드는 스레드 세이프를 고려해 synchronized 블록을 사용해서 작성돼 있었다!

정리하자면 이렇다. 예전에 작성한 코드를 재사용하면서 자세히 알아보지도 않고 '이거 왜 get() 메서드를 synchronized 블록으로 감싸지? 속도만 느려지잖아? 누군가 실력이 모자라 이렇게 했군. 아우 창피해' 뭐 이런 생각으로 가볍게 수정했던 것이다. 웹 서비스는 멀티 스레드 환경이기 때문에 항상 신중하게 코드를 작성해야 하는데 가볍게 생각한 부분이 큰 문제를 불러 일으켰다. 개인적으로는 좋은 경험이었지만 팀원들과 관련 부서, 서비스를 이용하는 고객에게 불편을 끼쳐 대단히 죄송스러웠다. 속된 말로 좀 많이 '쪽팔렸다'.

오랜 시간이 지났지만 이때를 생각하면 여전히 창피하다. 대단할 것 없는 작은 사례지만 부디 동료들이 '쪽팔리는' 경험을 줄이는 데 도움이 되었으면 하는 마음을 담아서 이 고해성사 같은 글을 마무리한다



























http://d2.naver.com/helloworld/605418








대용량 세션을 위한 로드밸런서

대용량 서비스를 운영하려면 부하 분산은 필수입니다. 대용량 트래픽을 장애 없이 처리하려면 여러 대의 서버에 적절히 트래픽을 분배해야 합니다. 기존에는 세션 서버를 위한 로드밸런서로 DNS와 L4를 이용했으나 여러 가지 제약이 있어 네이버의 요구 사항을 충족하는 로드밸런서를 직접 개발하게 되었습니다.

이 글에서는 로드밸런서 개발 동기와 아키텍처를 살펴보겠습니다.

기존 로드밸런서의 제약 사항

DNS(Domain Name System)

DNS는 도메인 이름을 IP 주소로 변환하는 기술이다. 하나의 도메인 이름을 라운드로빈(Round Robin) 방식으로 여러 개의 IP 주소로 변환한다면 이것만으로도 쉽게 부하 분산이 가능하다.

그러나 여기에는 두 가지 단점이 있다.

첫째, 대부분의 클라이언트에서는 DNS 서버의 부하를 줄이고 성능을 향상하기 위해 일정 시간 동안 캐싱하기 때문에 부하 분산이 균등하게 되지 않는다.

둘째, 특정 서버에 장애가 발생하더라도 장애 여부가 감지되지 않아 서비스에서 해당 서버를 제거할 수 없다. 이것을 보완하기 위해 health check로 장애를 감지하여 DNS 서버에서 제거할 수 있지만, 모든 DNS 서버에 적용되는 데에 상당히 시간이 소요될 뿐만 아니라 클라이언트의 캐싱 때문에 서비스에서 바로 제거되지는 않는다.

L4

L4는 IP 주소와 포트를 기반으로 로드밸런싱하는 고가의 하드웨어로, 웬만한 서비스에서는 이것만으로도 부하 분산을 처리하기에 충분하다.

여기에서 주의해야 할 점은 L4는 VIP(virtual IP) 단위로만 로드밸런싱하기 때문에 반드시 하나의 VIP에 연결된 서버의 수가 비슷해야 한다는 것이다. 만일 서버 중 몇 대에 문제가 생긴다면 동일한 VIP에 연결된 다른 서버 역시 연달아 부하가 발생하여 더욱 심각한 문제가 발생할 수 있다.

또한 L4의 스펙상 최대 세션 수는 존재하나 세션을 맺는 시나리오에 따라 최대 성능이 다르기 때문에 최대 세션 용량에 도달하지 않았음에도 추가로 세션을 연결할 수 없는 문제가 발생하기도 한다.

마지막으로 통신사 장애 등으로 세션이 비정상적으로 종료된 경우 세션 서버에서는 클라이언트와 세션이 종료되고 정상적으로 다시 연결되었지만, L4에서는 세션 종료처리가 제대로 되지 않아 두 개의 세션을 동시에 유지하고 있어 L4의 한계 용량을 초과한 적도 있다.

개발 요구 사항

그동안의 경험을 통해 장애 대응에 필요한 기능과 운영 중 개선이 필요하다고 느꼈던 사항을 정리하면 다음과 같다.

클라이언트 접속 제한

세션 서버에 장애가 발생하면, 클라이언트는 로드밸런서에 접속할 때 새로 정의된 프로토콜을 이용한다. 새로 정의된 프로토콜은 세션 서버와 통신을 제한하여 장애 대응 시간을 줄일 수 있다.

세션 서버가 비정상적인 상황에서 클라이언트가 지속적으로 접속을 시도한다면 통신사의 네트워크 용량을 초과하여 심각한 문제를 일으킬 수 있다. 기존에는 응답 시간을 최소화하기 위해서 세션 서버와 세션을 항상 유지하려고 최대한 노력했으나 망 부하라는 부작용이 발생했다. 그래서 현재는 로드밸런서와 통신이 되지 않으면 클라이언트 자체적으로 재접속 타이머가 동작하여 망 부하를 최소화하고 있다.

L4 증설 시점 예측

최대 세션 수를 예측할 수 있으며, 앞으로 사용자가 늘어날 것에 대비하여 사전에 세션 서버를 증설할 수 있어야 한다. L4는 일반적으로 세션 유지가 필요하지 않은 웹 서버나 세션 수가 적은 서버의 트래픽 분산에 주로 사용한다. 앞에서 설명한 것처럼 L4의 스펙상 최대 세션 수는 존재하지만, 세션을 맺는 시나리오에 따라 최대 성능이 다르기 때문에 정확한 성능을 알려면 직접 테스트해야 한다. 그러나 수천만 세션을 직접 테스트하기란 현실적으로 불가능하다. 그래서 실제로 문제가 발생하고 나서야 L4 최대 용량이 얼마인지 정확히 파악할 수 있다. 이런 이유로 서비스의 최대 세션 수를 예측할 수 있고 장애 발생 시 아는 범위 내에서 대처할 수 있는 로드밸런서를 개발하고 싶었다.

서버 단위 로드밸런스

특정 서버에 부하가 몰리는 것을 막기 위해서는 VIP 단위가 아닌 세션 서버 단위의 로드밸런싱 기능이 필요하다. 그러나 운영을 하다 보면 VIP당 서버 수를 항상 동일하게 유지하는 것이 쉽지 않다. 그래서 서버 단위로 로드밸런싱을 하려고 한다.

다양한 로드밸런싱 알고리즘

상황에 따라 적절한 로드밸런싱 알고리즘을 사용한다. 가장 간단한 방법으로는 가용한 모든 서버에 라운드로빈으로 로드밸런싱하는 것이다. 이런 경우 서버 재시작 등으로 인하여 세션 수의 불균형이 발생하면 다시 고르게 분배되는 데 상당한 시간이 소요된다. 그러나 Weight Least Connection 등의 알고리즘을 사용하여 세션 수가 적은 서버에 가중치를 두어 트래픽을 분산한다면 세션 수의 불균형이 발생하더라도 금방 고르게 세션이 분산되므로 세션 서버의 배포 등의 작업을 쉽게 할 수 있다.

그렇다고 해서 무조건 그 서버에 트래픽을 몰아주면 해당 서버의 순간 TPS(transaction per second)가 높아 그대로 장애로 직결될 수 있음을 기억해야 한다.

세션 서버 배포 시간 단축

불필요한 세션 서버를 서비스에서 빠르게 제거할 수 있어야 한다. DNS에서 VIP를 삭제하여도 클라이언트에서 VIP 주소를 캐싱하고 있다면 몇 달이 지나도록 서버에 트래픽이 계속해서 유입될 수 있다. 따라서 서비스에서 제거하려고 DNS에서 세션 서버를 제거해도 세션 서버의 세션이 모두 끊어지기 전까지는 서비스에서 제거할 수 없다. 이런 DNS를 사용하지 않고 직접 개발한 로드밸런서를 통해 세션 서버의 주소를 요청한다면 이 문제는 해결할 수 있다.

전체적인 구조도

로드밸런서를 구성하는 모듈의 역할은 다음과 같다. 다양한 분산 알고리즘을 가진 조회 서버(lookup server)와 세션 서버를 관리하는 서버 매니저(server manager)로 나눌 수 있다. 그리고 세션 서버의 목록을 저장하기 위해 ZooKeeper를 사용했고, 세션 수 등을 비롯한 세션 서버 정보를 저장하기 위해 다양한 컬렉션을 제공하는 Redis를 사용했다. 그리고 단말과 1:1 세션을 맺고 있는 세션 서버가 있다.

각 모듈의 기능에 대해 좀 더 알아보면 다음과 같다.

loadbalancer1

그림 1 서버 구조도

클라이언트

클라이언트는 세션 서버 주소를 얻기 위해 조회 서버에 접속하여, 접속 가능한 세션 서버의 주소를 가져온다. 세션 서버의 주소를 정상적으로 수신하면 해당 세션 서버로 연결을 요청한다. 이 때 발생할 수 있는 상황은 크게 세 가지가 있다.

첫째, 모든 세션 서버의 장애로 정상적으로 동작하는 세션 서버가 없다면 조회 서버는 세션 서버가 없다는 메시지와 함께 일정 시간 후 다시 접속하라고 응답한다. 이런 경우 백오프 타임 후에 재접속을 시도한다.

둘째, 단말의 네트워크가 비정상이거나 통신사의 장애로 네트워크가 정상적으로 동작하지 않아 조회 서버와 통신하는 것조차 불가능하다면 클라이언트는 자체적으로 타이머를 두어 네트워크 부하를 최소화한다. 이런 경우 응답 시간 최소화보다는 네트워크 망 부하 최소화를 위해서 모든 클라이언트들의 타이머 값이 서로 다르도록 백오프 알고리즘을 통해 타이머 값을 설정한다.

백오프 알고리즘은 GCM(Google Cloud Messaging for Android)에서 사용되고 있는 알고리즘을 참고했다. 기준이 되는 백오프 타임이 있고 이는 최대 백오프 타임(MAX_BACKOFF_MS)을 초과하기 전까지는 두 배씩 증가한다. 실제 클라이언트에서 조회 서버에 접속하기 위해 재시도할 때마다 적용되는 백오프 타임은 기준이 되는 백오프 타임에 일정 수식을 사용하여 구한다.

Random sRandom = new Random();


int backoffTimeMs = getBackoff();  
int nextAttempt = backoffTimeMs / 2 + sRandom.nextInt(backoffTimeMs);


setAlarmManager(nextAttempt); // 실제 적용된 백오프 타임


if (backoffTimeMs < MAX_BACKOFF_MS) {  
    setBackoff(backoffTimeMs * 2); // 다음 백오프 타임을 구하기 위한 currentBackoffTime set
}

셋째, 정상적으로 세션 서버의 주소를 수신하여 세션 서버에 접속을 시도하더라도 접속이 원활하지 않을 수 있다. 이런 경우 일정 횟수 이상 재접속을 시도해도 세션 서버와 접속이 되지 않으면 해당 세션 서버에 장애가 발생했다고 판단한다.

이런 비정상적인 경우 처음부터 로직을 다시 수행해야 하므로 비용이 많이 든다. 이런 비용을 줄이기 위해서 조회 서버로부터 두 개의 세션 서버 주소를 받고 첫 번째 세션 서버에 접속되지 않는다면 두 번째 세션 서버 주소(Alternative IP)에 접속을 시도하여 비용을 최소화한다.

세션 서버

세션 서버에 추가된 기능은 주기적으로 세션 수를 서버 매니저에게 알려주는 기능과 서버 매니저의 L7 health check 요청에 응답하는 기능이다.

서버 매니저로부터 주기적으로 유입되는 health check 요청을 적절히 처리하여 세션 서버의 정상 동작 여부를 알려준다. 서버 매니저는 일정 시간 동안 health check에 실패하면 해당 세션 서버를 서버 목록에서 제거하기 때문에 자신의 상태를 정확히 전달해야 한다. 응답할 때 자신의 현재 세션 수를 서버 매니저에게 알려준다.

조회 서버

DNS 역할을 하는 Lookup 프로토콜 제공

클라이언트가 요청할 때 어떤 세션 서버에 접속해야 하는지 알려 주는 기능을 제공하며, 세션 서버의 배포 등으로 인하여 서버 목록이 변경된 경우 서버 매니저로부터 새로운 서버 목록을 수신하여 업데이트한다.

분배 알고리즘 변경

서버 매니저를 통해서 운영 중에 부하 분산 알고리즘을 변경할 수 있으며, 라운드로빈이나 세션 수를 고려한 Weight Least Connection 등 다양한 부하 분산 알고리즘을 사용할 수 있다. 현재 기본으로 사용하고 있는 Weight Least Connection 알고리즘은 세션 서버의 세션 수를 고려하여 세션 수가 적은 서버에 부하 분산 시 우선순위를 준다.

복수 지역(multi region) 지원

클라이언트가 보낸 MCC(mobile country code, 모바일 국가 코드)를 기반으로 지역을 선택하여 어떤 세션 서버에 접속해야 하는지 알려 준다. MCC와 지역 매핑 정보는 서버 매니저를 통해서 운영 중 동적으로 변경할 수 있다.

높은 TPS

서비스별로 수천만 명의 사용자가 Lookup 프로토콜을 요청하기 때문에 기존 L4만큼의 높은 처리량이 요구된다. Java 비동기-네트워크 프레임워크인 Netty를 이용하여 구현했으며, Lookup 인스턴스당 약 20,000TPS 이상의 트래픽을 처리할 수 있다.

암호화

클라이언트와 주고받는 모든 패킷은 암호화해서 보안을 강화했다.

서버 매니저

복수 서비스, 복수 지역 지원

한 세트의 서버 매니저는 여러 개의 서비스를, 그리고 각 서비스는 여러 지역을 지원한다. 예를 들면 LINE이라는 서비스와 그 아래의 Korea, China 등 다양한 지역을 지원한다. 지역별로 다른 세션 서버 그룹을 구성한 경우도 지원하고, 굳이 지역을 구분할 필요 없는 서비스는 Common으로 정의된 공통 세션 서버 그룹으로 처리한다.

만약 특정 지역의 모든 서버에 장애가 발생하면 다른 지역의 서버 그룹에서 해당 지역을 처리할 수 있도록 지역 페일오버(failover)도 가능하다.

서비스별 알고리즘 적용 가능

서비스별로 다른 분배 알고리즘을 적용할 수 있다. ZooKeeper는 서비스별로 분배 알고리즘을 관리한다. 알고리즘이 변경되면 조회 서버로 변경된 알고리즘을 전달한다.

세션 서버 정보 관리

서버 매니저(master)에서 주기적으로 세션 서버에 세션 수를 요청한다. 응답받은 세션 수를 Redis에 저장하고, 서버 매니저(slave)들은 Redis를 주기적으로 가져가 데이터를 동기화한다. 모든 서버 매니저는 세션 서버 정보를 가지고 있으며, 조회 서버가 서버 정보를 요청하면 자신의 데이터를 기반으로 서버 정보를 전달한다.

주기적인 health check

서버 매니저(master)는 운영 중인 모든 세션 서버에 주기적으로 health check 요청을 보낸다. 일정 횟수 이상 health check에 실패하면 ZooKeeper에 저장된 해당 서버의 상태를 suspended로 변경하고, 조회 서버에게 해당 서버를 서비스에서 제거하라고 알린다.

ZooKeeper에 저장하는 세션 서버의 상태는 다음과 같다.

loadbalancer2

그림 2 세션 서버 상태도

  • shutdown: 세션 서버의 인스턴스가 종료된 상태 또는 정상적으로 서비스할 수 없는 상태
  • starting: 세션 서버를 시작하고 있는 상태
  • standby: 세션 서버가 정상적인 상태이나 아직 서비스에 투입 전이라 클라이언트의 접속이 없는 상태
  • running: 세션 서버가 정상적이고 서비스에 투입하여 정상적으로 운영 중인 상태
  • suspending: 세션 서버가 오동작하여 서비스에서 제거하고 있는 상태
  • suspended: 세션 서버가 오동작하여 잠시 서비스에 투입하지 않은 상태
  • resuming: 세션 서버가 정상적이어서 다시 서비스에 투입하고 있는 상태
  • shuttingdown: 세션 서버가 종료하고 있는 상태

위의 여러 가지 상태 중 조회 서버에게 세션 서버가 서비스 중이라고 알려 주는 상태는 running 상태이고, 그 외의 상태는 모두 서비스에 아직 투입 전이라고 알려 준다. 현재 모든 상태가 구현되지는 않았다. 운영에 무리가 없는 상태까지 구현해서 오픈하고, 추후 확장 가능성을 열어 놓은 상태이다.

HA 구성

서버 매니저는 최소 두 대 이상(한 대의 master와 한 대 이상의 slave)으로 구성된다. master의 역할은 세션 서버의 health check와 ZooKeeper에 세션 서버의 상태 업데이트이다. 그리고 지역이나 세션 서버의 목록이 변경되면 조회 서버에게 알려 주는 역할을 한다.

slave의 역할은 ZooKeeper와 Redis의 데이터를 지속적으로 동기화하여, 조회 서버의 지속적인 요청에 응답하는 것이다.

장애 대응 시나리오

로드밸런서를 개발하게 된 가장 큰 동기는 장애에 유연하게 대응하는 로드밸런서의 필요성이었다. 통신사에 일시적으로 장애가 발생하여 수천만 사용자의 세션이 끊어지면 장애 시간 동안 단말이 지속적으로 재접속을 요청하여 트래픽이 평소 대비 100배 이상 급증하고 그로 인해 망 용량 초과가 발생한다. 그리고 장애에서 복구 완료 후에는 단말이 일시적으로 접속을 요청하여 서버 용량 초과가 발생한다. 이와 같은 장애가 발생했을 때 이를 처리할 수 있는 로드밸런서 개발이 목표였다.

구조를 설계할 때 일부 모듈의 장애가 전체 장애로 이어지지 않게 하기 위해 최대한 노력했고, 통신사 장애 및 IDC 등의 장애가 발생해도 망 부하를 최소화하고 더 쉽게 장애에 대응할 수 있게 설계했다. 각 모듈에 장애가 발생했을 때 어떻게 처리하는지 알아보자.

loadbalancer3

그림 3 장애 대응 시나리오

1. 세션 서버 장애 시

세션 서버의 장애는 주기적으로 health check를 하는 서버 매니저가 가장 먼저 감지한다. health check에 실패하면 서버 매니저는 변경된 세션 서버 목록을 조회 서버에 다시 전달한다. 조회 서버는 새로 받은 서버 목록으로 로드밸런싱한다.

클라이언트는 연결되어 있던 세션 서버와의 세션이 끊어지면 두 번째 세션 서버(alternative IP)에 접속을 요청한다. 두 번째 세션 서버도 접속되지 않으면 조회 서버에 새로운 세션 서버의 주소를 요청한다.

모든 세션 서버의 장애로 정상적으로 동작하는 세션 서버가 하나도 없다면 조회 서버는 세션 서버가 없다는 메시지와 함께 일정 시간 후 다시 접속하라고 응답한다. 이런 경우 백오프 타임 후에 조회 서버에 재접속을 시도한다.

2. 조회 서버 장애 시

조회 서버 중 일부에 장애가 발생해도 총 조회 서버의 서버 용량이 초과하지 않는 한 정상으로 동작한다.

조회 서버 전체에 장애가 발생하면 클라이언트는 백오프 타임 후에 재접속을 시도한다. 실제로 약 100만 개의 실제 세션으로 테스트를 진행해 보았는데, 조회 서버가 평소에 요청받는 만큼의 세션이 세션 서버에서 감소하고, 조회 서버 복구 직후에는 클라이언트가 백오프 주기에 따라 동작하며 일시적으로 요청량이 증가한다. 테스트 결과 10분 정도의 전체 장애가 발생했을 때 20분 이내에 모든 세션이 복구 완료된다.

3. 서버 매니저 장애 시

서버 매니저(master)에 장애가 발생하면 slave 중 하나가 master로 전환된다. 서버 매니저(master)가 ZooKeeper에 특정 znode를 ephemeral node로 생성하고, slave는 그 znode를 지속적으로 모니터링한다. 해당 znode가 사라지면 서버 매니저(master)가 비정상이라고 판단하여, slave 중 하나가 master로 전환하는 형태로 구현했다.

서버 매니저 전체에 장애가 발생하면 조회 서버는 실시간 세션 서버의 세션 수를 알 수가 없다. 이런 경우 조회 서버는 일시적으로 분배 알고리즘을 라운드로빈으로 동작한다. 서버 매니저 장애가 해결되면 다시 Weight Least Connection으로 동작한다.

마치며

지금까지 DNS와 L4라는 부하 분산 기술을 소개하고, 세션 서버를 위한 로드밸런서를 개발하게 된 배경, 전체적인 구성도, 그리고 모듈별 기능과 장애 시 대응 시나리오를 소개했다. 어느 정도 성능이 필요한 데이터 저장소는 지난 2년 동안 운영하며 장애를 일으키지 않은 Redis를, 서버 정보 관리 등의 분산 데이터 동기화가 필요한 부분에는 ZooKeeper를 사용하고, 단말의 높은 트래픽을 처리하기 위해서는 Netty를 사용했다. 추후 개발이나 아키텍처 구성 시 이러한 기술이 도움이 되길 바란다.

참고 자료










'wif LiNoUz > SERVER' 카테고리의 다른 글

윈도우 2012 서버 초기 구성  (0) 2016.01.22
윈도우 2008 환경설정  (0) 2014.08.12
성능모니터링  (0) 2014.05.14
배치 예제  (0) 2014.05.01
배치파일 명령어 모음  (0) 2014.05.01