Infra

[ELK] K8S 환경에 ELK stack 배포하기

eraser 2021. 12. 5. 12:01
반응형

 

 회사에서 Springboot Application의 로그를 모니터링하기 위해 ELK stack을 활용해 모니터링 시스템을 개발했다. 그런ㄷㅔ QA 및 테스트 환경에서의 로그 모니터링이 필요해짐에 따라 해당 시스템을 사내 데이터센터의 클라우드 환경(현재 사내 QA 및 테스트는 사내 데이터 센터 클라우드 환경에서 namespace 및 리소스를 할당 받아 이루어지고 있다)에 배포할 필요성이 생겼다.


 사내 데이터센터 클라우드 환경이 K8S와 거의 동일하기 때문에, docker-compose를 이용해 컨테이너 환경에서 개발했던 것을 쿠버네티스 환경에 올리기만 하면 될 것이라고 생각했는데, 어떻게 구성할 지부터 시작해, 실제 쿠버네티스 오브젝트 매니페스트를 작성하고 배포하는 것이 쉽지 않았기 때문에, 해당 과정을 정리해 두고자 한다.

 

 

GitHub - sirzzang/elk-on-k8s-tutorial: 쿠버네티스 환경에 ELK stack 배포하기

쿠버네티스 환경에 ELK stack 배포하기. Contribute to sirzzang/elk-on-k8s-tutorial development by creating an account on GitHub.

github.com

 

 




 사내 데이터센터 클라우드는 쿠버네티스 API 서버 1.17.8 버전을 사용하는 쿠버네티스 환경이다. 웹에서 YAML 에디터가 제공되기 때문에 해당 에디터를 이용해 오브젝트 매니페스트를 작성하거나, 로컬에서 YAML 파일을 작성한 뒤 kubectl 명령어를 통해 오브젝트를 생성하고 업데이트할 수 있다.

 

 다만, 현재 QA 및 테스트를 위해 할당받은 namespace에서는 노드, persistent volume 등 특정 오브젝트에 대한 접근 권한이 없다. 때문에 오브젝트 생성 후 어떤 파드를 어떤 노드에서 실행할 것인지 할당하는 등의 작업은 불가능하며, 영구 볼륨을 생성하는 것이 아니라, 영구 볼륨 클레임을 생성하여 스토리지를 사용할 수 있다. 스토리지 클래스의 경우, 할당받은 namespace에 기본적으로 제공되는 것들(hdd, ssd 등)이 있어 이것을 사용했다.

 


 

# 구조



배포 시 구조는 다음과 같이 구성했다. Elasticsearch와 Kibana의 경우에는 elastic에서 제공하는 기본 이미지를 사용했고, Logstash는 elastic에서 제공하는 기본 이미지에 로그 파싱을 위해 필요한 플러그인을 설치한 이미지를 생성해 사용했다.

 

 

쿠버네티스를 공부한 지 얼마 되지 않아, 아래와 같이 그림을 그리는 것은 옳지 않을 수 있다.  머릿속으로 생각했을 때 이러 이러한 구조로 그리면 맞겠지 하고 그린 그림일 뿐...

 

 

 

## Elasticsearch

 


 Elasticsearch의 경우, 로그 데이터를 저장하고 있어야 하므로 파드가 내려가도 데이터가 사라지지 않도록 Statefulset 컨트롤러 오브젝트를 생성해 파드를 실행하도록 했다. 그리고 PVC 오브젝트를 생성해 스토리지에 데이터를 저장한다.

 


 다만, Elasticsearch 클러스터는 구성하지 못했다. 즉, replicas를 1로 설정해 Elasticsearch 노드를 1개만 띄웠다. 처음 모니터링 시스템 개발 시 Elasticsearch 클러스터 구성 방법을 파악한 뒤 개발한 것이 아니었기 때문에, 배포 시 이 점을 반영할 수 없었다. 이렇게 한 개의 노드로만 구성했을 때 장애에 대처할 수 없고(예컨대, Elasticsearch 노드에 문제가 발생하면 그 때 발생하는 로그는 적재되지 않게 된다), Elasticsearch의 장점을 활용할 수도 없는 것이기 때문에, 추후 클러스터를 구성하도록 전체 아키텍쳐를 변경하는 것이 필요하다.


 배포된 환경에서 Logstash 노드와 Kibana 노드가 Elasticsearch와 통신할 수 있어야 하므로 NodePort 타입의 서비스 오브젝트를 생성하고, 9200 포트를 열어 주었다. 그림 상에서는 9300 포트도 나와 있는데, 사실 9300 포트의 경우 Elasticsearch를 여러 개의 노드로 구성할 때 노드 간 사용하게 될 포트로, 지금은 1개의 노드로 구성해 필요가 없지만, 추후 여러 노드로 구성할 때를 대비해 지금 단계에서도 열어 두었다.

 

## Logstash, Kibana


 Logstash와 Kibana의 경우 Elasticsearch와 달리 데이터를 저장하거나 상태를 기억해야 할 필요가 없기 때문에, Deployment 컨트롤러 오브젝트로 파드를 실행하도록 했다.

 

 둘 모두 장애가 났을 때 굳이 여러 개의 파드가 떠 있어야 할 필요가 없다고 판단했기 때문에, replicas를 1로 설정했다. Logstash 파드의 경우 장애가 발생했을 때 나면 로그 유실 우려가 있지만, 이런 문제가 발생한다면 TCP로 로그를 바로 쏴 주는 현재 아키텍쳐에서 비롯되는 문제일 것이라 보기 때문에, Logstash 파드를 여러 개 띄우는 것이 아니라 Filebeats 파드를 띄우는 식으로 개선하고자 한다. Kibana 파드의 경우 장애가 발생할 때 대시보드를 조회하지 못할 수는 있다. 다만, Kibana Saved Objects가 모두 Elasticsearch 파드에 bound된 스토리지에 저장되기 때문에 데이터 유실 우려는 없어 가용성을 높일 필요는 없을 것이라 판단했다. (물론, Elasticsearch 파드에 문제가 생긴다면 Kibana Saved Objects도 모두 유실된다..)


Logstash 파드는 Elasticsearch 파드와 통신하면 되므로 NodePort 타입의 서비스 오브젝트를, Kibana 파드는 사내에서 접속해 로그 대시보드를 확인할 수 있어야 하므로 LoadBalancer 타입의 서비스 오브젝트를 생성해 주었다.

 

내용 추가

1. 해당 내용에 대해 세미나를 진행했는데, Kibana 대시보드 접속 설정은 굳이 LoadBalancer 서비스 오브젝트를 붙이지 않고, ingress 오브젝트 단에서 진행해도 될 듯하다는 피드백이 있었다.

2. 추후 서비스 오브젝트에 대해 다시 공부하면서 보니, Logstash 디플로이먼트 오브젝트와 Kibana 디플로이먼트 오브젝트에 연결할 서비스 오브젝트는 굳이 NodePort가 아니었어도 될 듯하다. 클러스터 내에서만 통신해도 되기 때문에, ClusterIP로 해도 된다.

 


 

# 배포


namespace, 스토리지 등 사내 클라우드 환경에서 기본적으로 제공되는 것은 각주로 대체했다.

 

 

## Elasticsearch


NodePort 타입의 Service 오브젝트를 작성한 뒤 적용한다.

apiVersion: v1 
kind: Service
metadata:
  name: elasticsearch
  namespace: # namespace
spec:
  ports:
    - name: http-rest
      protocol: TCP
      port: 9200
      targetPort: 9200
    - name: tcp
      protocol: TCP
      port: 9300
      targetPort: 9300
  selector:
    app: elasticsearch

 


 아래와 같이 Statefulset 오브젝트를 작성한 뒤 적용한다. Statefulset의 VolumeClaimTemplates를 PersistentVolumeClaim 매니페스트로 따로 작성해도 된다.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: elasticsearch
  namespace: # namespace
spec:
  replicas: 1
  selector:
    matchLabels:
      app: elasticsearch
  template:
    metadata:
      labels:
        app: elasticsearch
    spec:
      containers:
        - name: elasticsearch
          image: 'docker.elastic.co/elasticsearch/elasticsearch:7.15.2'
          imagePullPolicy: IfNotPresent
          resources:
            limits:
              cpu: "1"
              memory: 8Gi
            requests:
              cpu: 500m
              memory: 4Gi          
          env:
            - name: ES_JAVA_OPTS
              value: '-Xmx2g -Xms2g'
            - name: discovery.type 
              value: single-node
          ports: 
            - name: http-rest
              containerPort: 9200
              protocol: TCP
            - name: tcp
              containerPort: 9300
              protocol: TCP
          volumeMounts:
            - name: es-data-volume
              mountPath: /usr/share/elasticsearch/data
      restartPolicy: Always
  volumeClaimTemplates:
    - kind: PersistentVolumeClaim
      apiVersion: v1
      metadata:
        name: es-data-volume
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 10Gi
        storageClassName: # storage class
        volumeMode: Filesystem


위의 두 YAML 파일을 elasticsearch.yaml과 같이 하나의 파일로 작성한 뒤 적용해도 된다.

더보기
  • elasticsearch.yaml
apiVersion: v1 
kind: Service
metadata:
  name: elasticsearch
  namespace: # namespace
spec:
  ports:
    - name: http-rest
      protocol: TCP
      port: 9200
      targetPort: 9200
    - name: tcp
      protocol: TCP
      port: 9300
      targetPort: 9300
  selector:
    app: elasticsearch
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: elasticsearch
  namespace: # namespace
spec:
  replicas: 1
  selector:
    matchLabels:
      app: elasticsearch
  template:
    metadata:
      labels:
        app: elasticsearch
    spec:
      containers:
        - name: elasticsearch
          image: 'docker.elastic.co/elasticsearch/elasticsearch:7.15.2'
          imagePullPolicy: IfNotPresent
          resources:
            limits:
              cpu: "1"
              memory: 8Gi
            requests:
              cpu: 500m
              memory: 4Gi          
          env:
            - name: ES_JAVA_OPTS
              value: '-Xmx2g -Xms2g'
            - name: discovery.type 
              value: single-node
          ports: 
            - name: http-rest
              containerPort: 9200
              protocol: TCP
            - name: tcp
              containerPort: 9300
              protocol: TCP
          volumeMounts:
            - name: es-data-volume
              mountPath: /usr/share/elasticsearch/data
      restartPolicy: Always
  volumeClaimTemplates:
    - kind: PersistentVolumeClaim
      apiVersion: v1
      metadata:
        name: es-data-volume
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 10Gi
        storageClassName: # storage class
        volumeMode: Filesystem
  • 적용
kubectl apply -f elasticsearch.yaml

 

 

## Logstash


Logstash 설정 및 파이프라인 작성을 위한 conf 파일을 ConfigMap 오브젝트로 작성한다. filter 부분은 대체.

apiVersion: v1
kind: ConfigMap
metadata:
  name: logstash-config
  namespace: elk-stack
data:
  # configuration for logstash pipeline
  logstash.conf: |
    input {
      tcp {
        port => 5000
      }
    }
    filter {
      json {
        source => "message"
      }
      mutate {
        remove_field => ["@version"]
      }
    }
    output {
      stdout {
        codec => rubydebug
      }
    }
  
  # logstash configuration
  logstash.yml: |
    http.host: "0.0.0.0"
    path.config: /usr/share/logstash/pipeline


NodePort 타입의 서비스 오브젝트를 작성한 뒤 적용한다.

apiVersion: v1
kind: Service
metadata:
  name: logstash
  namespace: # namespace
spec:
  type: NodePort
  ports:
    - name: tcp
      protocol: TCP 
      port: 5000
      targetPort: 5000
  selector:
    app: logstash


Logstash 파드의 Deployment 컨트롤러 오브젝트를 작성한 뒤 적용한다. 만약, ConfigMap을 수정했다면, Deployment를 다시 실행해 주어야 한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: logstash
  namespace: # namespace
spec:
  replicas: 1
  selector:
    matchLabels:
      app: logstash
  template:
    metadata:
      labels:
        app: logstash
    spec:
      volumes:
        - name: logstash-config-volume
          configMap:
            name: logstash-config
            items:
              - key: logstash.yml
                path: logstash.yml
        - name: logstash-pipeline-volume
          configMap:
            name: logstash-config
            items:
              - key: logstash.conf
                path: logstash.conf
      containers:
        - name: logstash
          image: # logstash image from private repository
          imagePullPolicy: Always
          resources:
            limits:
              cpu: 500m
              memory: 4Gi
            requests:
              cpu: 300m
              memory: 2Gi          
          env:
            - name: LS_JAVA_OPTS
              value: '-Xmx1g -Xms1g'
          ports:
            - name: tcp
              containerPort: 5000
              protocol: TCP
          volumeMounts:
            - name: logstash-config-volume
              mountPath: /usr/share/logstash/config
            - name: logstash-pipeline-volume
              mountPath: /usr/share/logstash/pipeline

 

 

## Kibana


Kibana 설정을 위한 파일을 ConfigMap 오브젝트로 작성한다. elasticsearch.hosts 설정의 경우, 쿠버네티스의 DNS 시스템에 따라 elasticsearch라는 파드 이름을 통해 elasticsearch 서비스 오브젝트에 접속할 수 있다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: kibana-config
  namespace: # namespace
data:
  kibana.yml: |
    server.host: 0.0.0.0
    elasticsearch.hosts: ["http://elasticsearch:9200"]


LoadBalancer 타입의 Service 오브젝트를 작성해 적용한다.

apiVersion: v1
kind: Service
metadata:
  name: kibana
  namespace: # namespace
spec:
  ports:
    - name: http
      protocol: TCP
      port: 5601
      targetPort: 5601
  selector:
    app: kibana
  type: LoadBalancer

 


Kibana 파드의 Deployment 컨트롤러 오브젝트를 작성한 뒤 적용한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: kibana
  namespace: # namespace
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kibana
  template:
    metadata:
      labels:
        app: kibana
    spec:
      volumes:
        - name: kibana-config-volume
          items:
            - key: kibana.yml
              path: kibana.yml
      containers:
        - name: waplpay-kibana
          image: 'docker.elastic.co/kibana/kibana:7.15.2'
          imagePullPolicy: IfNotPresent
          ports:
            - name: kibana
              containerPort: 5601
              protocol: TCP
          resources:
            limits:
              cpu: '1'
              memory: 2Gi
            requests:
              cpu: 500m
              memory: 1Gi
          volumeMounts:
            - name: kibana-config-volume
              mountPath: /usr/share/kibana/config
      restartPolicy: Always

 

 



위와 같이 각 오브젝트의 매니페스트를 작성한 뒤 배포하면, 위에서 언급한 구조와 같은 형태의 ELK stack이 클라우드 환경에 배포된다.
아래 사진의 예시와 같이 kubectl get pods, kubectl get statefulsets, kubectl get deployments, kubectl get pvc 등과 같은 명령어를 통해 각 오브젝트의 상태를 확인해 보면 된다.

글 작성 시점에서는 elasticsearch 파드가 생성된 지 14일이 지났다.

 


 

 

# Troubleshooting

 

## OOMKilled 에러


Elasticsearch 파드를 실행하는 과정에서 아래와 같은 OOMKilled 에러를 마주했다.

OOMKilled 후 CrashLoopBackOff 상태를 반복한다


 메모리 관련 에러인 듯하여 찾아 보니, Elasticsearch 컨테이너에 `Xmx2g Xms2g` 옵션을 주어 JVM 힙 메모리 사이즈를 설정했는데, 파드에 할당한 자원 할당량이 2Gi 였기 때문에 발생하는 문제인 듯하였다. 파드에 할당하는 자원 할당량 중 메모리 할당량을 높여 주니 문제가 발생하지 않았다.


 관련해 더 찾다 보니, LINE Engineering에서 프로메테우스를 사용해 메트릭을 모니터링하다 OOMKilled 에러를 겪었다는 글을 발견했다.

 

누가 Kubernetes 클러스터에 있는 나의 사랑스러운 Prometheus 컨테이너를 죽였나! - LINE ENGINEERING

안녕하세요. 이번 글에서는 CrashLoopBackoff 상태에 있는 Prometheus 컨테이너 이슈의 원인을 조사하고 해결하는 과정에서 겪은 흥미로운 경험을 공유하려고 합니다. 사실 이런 현상이 발생하는 원인

engineering.linecorp.com

 

 

## PVC bound 실수


 사실 이건 해결한 문제는 아니지만, 아주 바보 같은 실수를 해서 기록한다.

 

 ELK stack을 작성해 배포한 뒤, kubectl get pvc 명령어를 통해 PVC 상태를 확인했는데, PVC가 bound되어 있지 않음을 발견했다. Grafana 대시보드를 통해 네임스페이스 내 PVC 오브젝트들의 상태를 확인해도 bound되어 있는 Elasticsearch PVC를 찾을 수 없었다.


 알고 보니 Elasticsearch Statefulset 오브젝트 매니페스트를 작성하면서 volumeMounts 부분을 작성하지 않았던 것이었다. volumeMounts 부분을 작성해서 Statefulset 컨트롤러 매니페스트를 다시 작성해서 배포했는데, 그 결과 Elasticsearch 파드가 재기동되면서 이전까지 쌓여 있었던 약 5만 개 가량의 로그 데이터가 사라지고, 인덱스가 새로 매핑되면서 Kibana 파드도 재기동해야 하는 문제가 발생했다. Kibana 서버에 장애가 발생한 것은 아니었지만, 이전 Elasticsearch 파드에 저장되어 있던 Kibana Saved Objects가 사라졌음은 말할 것도 없다...


 처음 배포할 때부터 완벽하게 매니페스트를 작성했더라면 좋았겠지만, 다음에도 이런 상황이 발생한다면 무턱대고 Statefulset을 다시 작성해 배포하는 것이 아니라, 해당 파드에 있는 데이터를 클라우드 환경 내 스토리지를 사용해 저장한 뒤 다시 배포하는 방법을 찾아야 할 것이다.

 

 

 


 


로컬에서 구축한 모니터링 시스템을 클라우드 상의 운영 환경에 올린 것에 의의를 둔 초기 배포였지만, 앞으로 다음과 같은 부분을 더 연구해서 발전시켜 나가야 한다.

  • 구조
    • Elasticsearch 클러스터를 어떻게 구성해야 하며, 구성 시 클라우드 환경 상의 아키텍쳐는 어떻게 달라져야 하는가?
    • 로그 모니터링 시스템에 filebeats 스택이 추가되었을 때, 클라우드 환경 상의 아키텍쳐는 어떻게 달라져야 하는가?
  • 자원 할당
    • 로그 이벤트 1건 당 용량은 얼마나 되며, 로그 발생량에 기초했을 때 Elasticsearch 파드에 bound되는 PVC의 용량은 어떻게 산정해야 하는가?
    • Elasticsearch, Logstash, Kibana 각 파드의 CPU, 메모리 사용량을 Grafana를 통해 관찰했을 때 어느 정도의 추이를 보이며, 이를 기반으로 했을 때, 각 파드의 자원 할당량을 어떻게 최적화할 수 있는가?

 

반응형