엘라스틱서치 클러스터 직접 구성하기 - 로그가 재산이다

리모트몬스터

Photo by Angie Muldowney on Unsplash

리모트몬스터는 다양한 형태의 로그를 수집해 점수를 매겨 통화/방송의 품질을 측정합니다. 📈

예를 들어, 어느 구간에서 패킷 손실이 많이 일어나는지 혹은 어떤 네트워크 상황에서 실패율이 높은지 등 다양한 지표를 모델링하고 개선 점을 도출합니다. 즉 로그의 손실은 저희의 비즈니스 인사이트의 손실에 직결됩니다. 때문에 어떻게든 엘라스틱서치 같은 로그 저장소의 안정성을 확보 하는데 목숨을 걸어야 합니다. 서비스가 안정적인 상태에서 남는건 로그 밖에 없거든요 :)

이제부터 제가 리모트몬스터에서 싱글 노드로 운영하던 엘라스틱서치를 멀티 클러스터 형태로 전환시킨 경험을 토대로 클러스터 구축 방법을 간략히 풀어 나가려고 합니다.

원론적인 질문부터 시작하면 클러스터를 구성하는 이유는 뭘 까요? 싱글 노드로 잘 돌아가던 엘라스틱서치를 굳이 멀티 클러스터로 업그레이드 시킨 이유가 뭘 까요?

첫째로 다중 가용영역에 클러스터를 구축시켜 가용성을 향상 시킬 수 있습니다. 만약 싱글 노드로 돌아가던 엘라스틱서치가 종료되거나 가용 영역에 문제가 생기면 어떻게 될까요? 엘라스틱서치 서비스 전체가 종료 될 겁니다.

만약에 A, B, C 가용 영역에 클러스터를 배치 시켰다면? 특정 클러스터가 죽으면 나머지 B, C 클러스터가 돌아가고 A 클러스터는 재 시작하면 그만입니다. 클러스터를 이용한 고가용성 확보 이론은 Set up a cluster for high availability 에서 확인해주세요.

둘째로 롤링 업그레이드(Rolling upgrade)를 통한 무중단 버전 업그레이드를 가능케합니다. 만약 싱글 노드로 돌고 있다면 버전 업그레이드를 위해 재 시작 하는 동안 다운 타임이 생기게 됩니다. 그러나 클러스터 환경에선 A, B, C 노드를 순차적으로 재 시작하기 때문에 다운 타임 없이 버전 업그레이드가 가능합니다.

셋째로 확장에 용이합니다. 서비스가 성장해 로그의 양이 늘어나면 어떻게 해야할까요? 만약 싱글 노드로 운영한다면 인스턴스를 잠시 중단한 뒤 수직적 확장을 고려 해야 할 겁니다. 하지만 클러스터를 운영하고 있다면 어떠한 다운 타임 없이 새로운 클러스터를 구축하고 이를 클러스터 그룹에 할당 해주면 그만입니다. (수평적 확장)

프로젝트 바로가기: https://github.com/dangen-effy/elasticsearch-cluster-docker-compose

클러스터 직접 구축하기

클러스터를 구축하는 방법은 두 가지가 있습니다.

Ansible, AWS CloudFormation 혹은 Helm 을 이용해서 비교적 쉽게 구축하기

Docker Compose 혹은 바이너리 파일로 직접 구축하기 (조금 노가다)

저는 후자를 선택 했습니다. 전자는 한 단계 더 추상화 돼있기 때문에 엘라스틱서치 클러스터를 직접 올려본 경험 없이 진행하다보면 문제가 생겼을때 내가 어느 구간에서 삽질하고 있는지 디버깅하기 힘들것 같았습니다. 반면에 Docker Compose 혹은 바이너리로 한땀 한땀 옵션을 넣어가며 인스턴스에 클러스터를 구축하면 어떤 옵션이 중요하고 필수 필드인지, 어떤 원리로 돌아가는지 파악 할 수 있거든요.

하지만 앞으로 한 번 더 구축하려고 하면 Helm 쓰려고요.

1. 다중 가용영역 확보하기

먼저 3 가지 이상의 각기 다른 가용영역에 인스턴스 자원을 확보해주세요.

2. 클러스터 구축하기

이제 docker-compose.yml 파일을 작성 해봅시다. 우리가 띄울 서비스는

Elastic Node* 3

Nginx * 3

Kibana * 1

입니다. Nginx 는 엘라스틱서치로 리버스 프록시(80 => 9200)를 담당하고 VPC 내부에서만 접근 가능하도록 합시다.

클러스터 토폴로지

도커 컴포즈 전체 보기

version: '2.1'
services:
  elastic01:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.5.1
    container_name: es01
    environment:
      - node.name=es01-node
      - cluster.name=es-docker-cluster
      - cluster.initial_master_nodes=es03-node,es01-node,es02-node
      - discovery.seed_hosts=es01.example.com:9300,es02.example.com:9300,es03.example.com:9300
      - bootstrap.memory_lock=true
      - 'ES_JAVA_OPTS=-Xms4g -Xmx4g'
      - network.host=0.0.0.0
      - node.master=true
      - node.data=true
      - discovery.zen.minimum_master_nodes=1
      - http.port=9200
      - transport.tcp.port=9300
      - network.publish_host=${INTERNAL_HOST}
      - xpack.security.enabled=true
      - ELASTIC_PASSWORD=my_password
      - path.repo=/usr/share/elasticsearch/backup
    
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - data01:/usr/share/elasticsearch/data
      - backup:/usr/share/elasticsearch/backup
    extra_hosts:
      - 'es01.example.com:172.26.2.66'
      - 'es02.example.com:172.26.4.100'
      - 'es03.example.com:172.31.6.133'
    ports:
      - 9300:9300
    networks:
      - elastic
    restart: always
​
  elastic02:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.5.1
    container_name: es02
    environment:
      - node.name=es02-node
      - cluster.name=es-docker-cluster
      - cluster.initial_master_nodes=es03-node,es01-node,es02-node
      - discovery.seed_hosts=es01.example.com:9300,es02.example.com:9300,es03.example.com:9300
      - bootstrap.memory_lock=true
      - 'ES_JAVA_OPTS=-Xms4g -Xmx4g'
      - network.host=0.0.0.0
      - node.master=true
      - node.data=true
      - discovery.zen.minimum_master_nodes=1
      - http.port=9200
      - transport.tcp.port=9300
      - network.publish_host=${INTERNAL_HOST}
      - xpack.security.enabled=true
      - ELASTIC_PASSWORD=my_password
      - path.repo=/usr/share/elasticsearch/backup
    
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - data02:/usr/share/elasticsearch/data
      - backup:/usr/share/elasticsearch/backup
    extra_hosts:
      - 'es01.example.com:172.26.2.66'
      - 'es02.example.com:172.26.4.100'
      - 'es03.example.com:172.31.6.133'
    ports:
      - 9300:9300
    networks:
      - elastic
    restart: always
​
  elastic03:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.5.1
    container_name: es03
    environment:
      - node.name=es03-node
      - cluster.name=es-docker-cluster
      - cluster.initial_master_nodes=es03-node,es01-node,es02-node
      - discovery.seed_hosts=es01.example.com:9300,es02.example.com:9300,es03.example.com:9300
      - bootstrap.memory_lock=true
      - 'ES_JAVA_OPTS=-Xms4g -Xmx4g'
      - network.host=0.0.0.0
      - node.master=true
      - node.data=true
      - discovery.zen.minimum_master_nodes=1
      - http.port=9200
      - transport.tcp.port=9300
      - network.publish_host=${INTERNAL_HOST}
      - xpack.security.enabled=true
      - ELASTIC_PASSWORD=my_password
      - path.repo=/usr/share/elasticsearch/backup
​
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - data03:/usr/share/elasticsearch/data
      - backup:/usr/share/elasticsearch/backup
    extra_hosts:
      - 'es01.example.com:172.26.2.66'
      - 'es02.example.com:172.26.4.100'
      - 'es03.example.com:172.31.6.133'
    ports:
      - 9300:9300
    networks:
      - elastic
    restart: always
    
  kibana:
    image: docker.elastic.co/kibana/kibana:7.5.1
    container_name: kibana7
    volumes:
      - ./kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml
    ports:
      - 5601:5601
    restart: always
​
  elasticsearch-node1-proxy:
    image: nginx:latest
    container_name: elasticsearch-proxy-nginx
    ports:
      - 80:80
    volumes:
      - ./proxy/elasticsearch-node1-nginx.conf:/etc/nginx/nginx.conf
    networks:
      - elastic
​
  elasticsearch-node2-proxy:
    image: nginx:latest
    container_name: elasticsearch-proxy-nginx
    ports:
      - 80:80
    volumes:
      - ./proxy/elasticsearch-node2-nginx.conf:/etc/nginx/nginx.conf
    networks:
      - elastic
      
  elasticsearch-node3-proxy:
    image: nginx:latest
    container_name: elasticsearch-proxy-nginx
    ports:
      - 80:80
    volumes:
      - ./proxy/elasticsearch-node3-nginx.conf:/etc/nginx/nginx.conf
    networks:
      - elastic
​
volumes:
  data01:
    driver: local
  data02:
    driver: local
  data03:
    driver: local
  backup:
    driver: local
​
networks:
  elastic:
    driver: bridge

node.name: 노드에 할당 하는 고유한 이름입니다. cluster.initial_master_nodes 옵션에서 이 필드를 참조하므로 잘 부여해주세요.

cluster.name: 클러스터 그룹에 붙여지는 이름입니다. 같은 클러스터 그룹으로 엘라스틱 노드가 묶이기 위해서는 동일한 이름을 공유 해야합니다. 따라서 모든 노드에 es-docker-cluster 라는 값을 할당 해줬습니다.

cluster.initial_master_nodes: 클러스터를 가동시키면 '부트스트래핑' 단계를 거칩니다. 이 단계에서 마스터 노드로 투표 가능한 후보가 누가 있는지 결정하는데 이 값을 참조합니다.

discovery.seed_hosts: 노드간 디스커버리 단계에서 참조 가능 한 호스트의 시드를 제공합니다. 더보기

'ES_JAVA_OPTS=-Xms4g -Xmx4g': JVM이 사용 가능한 힙 사이즈를 결정합니다. 메모리 스펙에 따라 다른 값을 가집니다. 더보기

node.master node.data: 해당 노드가 맡을 수 있는 역할을 지정합니다. 이 포스팅은 master 노드와 data 노드만 사용하고 있지만, 엘라스틱 클러스터는 역할에 따라 노드를 세분화 해서 구축 가능합니다. 더보기

network.publish_host: 노드 <=> 노드 간 통신을(9300 포트) 하기 위해 퍼블리싱 할 호스트 주소를 할당합니다. 노드 간 통신은 VPC 외부에서 참조 할 일이 없으므로 인스턴스의 내부 IP 로 할당 하는 것을 추천합니다. ${INTERNAL_HOST} 를 적절히 교체 해주세요.

path.repo: 엘라스틱서치는 repository 라는 백업 저장소 개념을 사용합니다. API 호출로 repository에 스냅샷을 디스크에 저장하거나 클라우드 스토리지에 저장하고 복원 할 수 있습니다. 더보기

이제 도커 컴포즈 옵션을 살펴봅시다.

Elastic 도커 컴포즈 살펴보기

volume

엘라스틱서치는 기본적으로 Stateful 애플리케이션입니다. 컨테이너의 수명이 다해도 데이터를 남기기 위해 Named Volume 타입으로 볼륨을 정의해줍시다. 이제 1번 노드의 데이터는 도커의 data01 볼륨에 쌓이게됩니다. 또한 스냅샷을 남기기 위해 path.repo 옵션에서 가리키고 있는 /usr/share/elasticsearch/backup 폴더도 Named Volume 으로 지정합니다.

volumes:
      - data01:/usr/share/elasticsearch/data
      - backup:/usr/share/elasticsearch/backup
volumes:
      - data02:/usr/share/elasticsearch/data
      - backup:/usr/share/elasticsearch/backup
volumes:
      - data03:/usr/share/elasticsearch/data
      - backup:/usr/share/elasticsearch/backup
      
[...]
volumes:
  data01:
    driver: local
  data02:
    driver: local
  data03:
    driver: local
  backup:
    driver: local

extra_hosts

노드 간 디스커버리를 위해 호스트를 등록해줍니다. 덕분에 discovery.seed_hosts 필드에서 es01.example.com 를 내부 아이피로써 참조 할 수 있습니다.

extra_hosts:
      - 'es01.example.com:172.26.2.66'
      - 'es02.example.com:172.26.4.100'
      - 'es03.example.com:172.31.6.133'

networks

엘라스틱 클러스터와 Nginx 의 컨테이너간 네트워크를 개방해야 하므로 Bridge 네트워크로 묶어줍니다.

networks:
      - elastic
      
[...]
networks:
  elastic:
    driver: bridge

Nginx 도커 컴포즈 살펴보기

volumes

Nginx 콘픽을 넣어줘야 하므로 Bind Mount 를 해줍시다.

volumes:
      - ./proxy/elasticsearch-node1-nginx.conf:/etc/nginx/nginx.conf

Nginx 콘픽

엘라스틱서치는 외부 IP 접근을 허용 할 필요가 없으므로 server_name 을 ${INTERNAL_HOST} 로 제한해줍시다. elasticsearch-node2-nginx.conf, elasticsearch-node3-nginx.conf 도 값을 변경해주세요.

user  nginx;
worker_processes  1;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;
events {                     
    worker_connections  1024;
}
http {
upstream elasticsearch-container-host {
       server elastic01:9200;
    }
server {
        listen 80;
server_name ${INTERNAL_HOST}; # 내부 아이피로만 접근시 요청 허용
location / {
            proxy_pass http://elasticsearch-container-host;
proxy_read_timeout 90000;
            proxy_http_version 1.1;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header Upgrade $http_upgrade;
        }
    }
sendfile        on;                                                                
    keepalive_timeout  65;                                                                      
    include /etc/nginx/conf.d/*.conf;           
}

Kibana 도커 컴포즈 살펴보기

volumes

키바나도 콘픽 파일이 필요하기 때문에 Bind Mount 시켜줍시다.

volumes:
      - ./kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml

kibana.yml

server.name: kibana
server.port: 5601
server.host: '0.0.0.0'
elasticsearch.username: elastic
elasticsearch.password: my_password
# Elasticsearch Nginx 가 80 -> 9200 프록싱을 하기때문에 포트 없이 씀
elasticsearch.hosts: [ 'http://172.26.2.66', '172.26.4.100', '172.31.6.133' ]
xpack.security.enabled: true
server.cors: true
server.cors.origin: ['*']

이제 모든 세팅은 끝났습니다. 한 번 정리해볼까요?

$ tree .
├── docker-compose.yml
├── kibana
│   └── config
│       └── kibana.yml
└── proxy
    ├── elasticsearch-node1-nginx.conf
    ├── elasticsearch-node2-nginx.conf
    └── elasticsearch-node3-nginx.conf

현 구조의 한계점

클러스터의 핵심은 수평적 확장입니다. 하지만 지금은 인스턴스의 상태에 따라 콘픽 파일에 값을 직접 하드코딩 했기 때문에 자동화된 수평적 확장은 불가능합니다.

가령 들어오는 로그량이 증가 했을때 새 인스턴스를 한 대 띄우고 클러스터 그룹에 새 노드를 할당 하려면 어떤 작업을 해야 할까요? 새롭게 띄운 인스턴스의 내부 아이피와 노드의 이름을 알아야 하므로 cluster.initial_master_nodes 와 discovery.seed_hosts 에 새 노드의 정보를 추가해줘야 합니다. 또한 키바나 콘픽의 elasticsearch.hosts 필드도 수정해줘야겠죠.

즉 서비스 디스커버리를 사람이 손 수 해줘야 한다는 것입니다. 이는 보통 귀찮은 작업이 아닐뿐더러 실수를 유발하겠죠. 이를 해결하려면 서비스 등록에 자유로운 Kubernetes 같은 컨테이너 오케스트레이션 툴을 사용해야합니다.

기업문화 엿볼 때, 더팀스

로그인

/