Elasticsearch + Fluent Bit + Kibana 경량 로깅 인프라 구성

SangYeon Min·2025년 8월 30일
0

PROJECT-WHEN-WILL-WE-MEET

목록 보기
12/12
post-thumbnail

Architecture Design

프로젝트의 성능을 테스트하고 Kafka, MySQL, RAVO-SPRING 등의 로그를 분석하기 위해서 초기에는 ELK 스택 도입을 고려하였습니다.

하지만 클러스터를 다중화한다고 하더라도, 최소 4GB의 메모리와 3vCPU가 요구되는 해당 형태의 로깅 인프라는 현재 상황에 적합하지 않다고 고려하였습니다.

프로젝트의 규모가 크지 않기에, 우선적으로 단순 로그 수집 및 전달을 위해서 JVM 위에서 동작하는 Logstash를 사용하기에는 운영 부담이 존재했습니다.

따라서 LogstashFluent Bit으로 교체한 형태의 EFK 아키텍처를 구성하였습니다.

  • (E) Elasticsearch: 강력한 검색과 저장 기능은 그대로 유지합니다.
  • (F) Fluent Bit: Logstash를 대체할 초경량, 고성능 로그 수집기입니다.
  • (K) Kibana를: 로그를 보여줄 대시보드입니다.

EFK Stack

  1. 리소스 효율성
    C언어로 작성된 Fluent BitLogstash 대비 수십 MB 수준의 매우 낮은 메모리 점유율을 가집니다.
  2. Integrated Observability
    기존 Grafana 대시보드에서 동일한 시간대에 발생한 애플리케이션의 에러 로그를 바로 드릴다운하여 확인할 수 있습니다.

EFK 스택을 도입할 때의 기존 ELK 스택에 비한 장점은 위와 같으며

  1. Elasticsearch 인덱스 관리
    데이터 보관 주기에 맞춰 오래된 인덱스를 자동으로 삭제하거나 아카이빙하는 ILM(Index Lifecycle Management) 정책을 반드시 설정해 디스크 용량을 관리해야 합니다.

  2. 로그 처리의 부재
    Fluent Bit은 가벼운 만큼 Logstash가 제공하는 복잡한 데이터 변환이나 외부 API 연동을 통한 데이터 Enrichment 기능은 부족합니다.
    만약 로그에 특별한 처리가 필요하다면, 로그를 생성하는 애플리케이션단에서 구조화된 로그(JSON)를 출력하도록 구현해야 합니다.

주의해야 할 점은 위와 같습니다.


EFK Deployment

[root@master logging]# k create ns logging
namespace/logging created

[root@master logging]# k get ns
NAME              STATUS   AGE
default           Active   148d
kafka             Active   93d
kube-node-lease   Active   148d
kube-public       Active   148d
kube-system       Active   148d
logging           Active   3s
monitoring        Active   142d

우선 위와 같이 logging 네임스페이스를 생성해줍니다.

Elasticsearch

Elasticsearch는 분산형 검색 및 분석 엔진으로, 클러스터, 노드, 샤드, 인덱스, 리플리카, 분석기 등의 핵심 구성 요소를 가지고 있습니다. 이러한 구성 요소들이 상호 작용하며 데이터를 저장, 검색, 분석하는 데 사용됩니다.

  • Cluster:
    여러 노드로 구성된 Elasticsearch의 최상위 단위로, 데이터를 분산 저장하고 검색 요청을 처리합니다.
  • Node:
    Elasticsearch 인스턴스가 실행되는 서버 또는 가상 머신입니다. 각 노드는 클러스터의 일부이며, 데이터를 저장하거나 검색 요청을 처리하는 역할을 담당합니다.
  • Index:
    유사한 특징을 가진 문서들의 논리적인 컬렉션입니다. 예를 들어, 쇼핑몰 데이터의 경우 상품, 주문, 사용자 정보를 각각 다른 인덱스로 구성할 수 있습니다.
  • Shard:
    인덱스는 여러 개의 샤드로 나누어집니다. 샤드는 Lucene 인스턴스이며, 데이터를 저장하고 검색하는 기본 단위입니다.
    • Primary Shard: 데이터의 원본을 저장합니다.
    • Replica Shard: 프라이머리 샤드의 복사본으로, 고가용성을 위해 사용됩니다. 장애 발생 시 레플리카 샤드가 프라이머리 샤드의 역할을 대체합니다.
  • Analyzer:
    텍스트 데이터를 인덱싱하거나 검색하기 전에 처리하는 구성 요소입니다. 토큰화, 필터링, 정규화 등의 작업을 수행합니다.

Data Sharding

데이터 샤딩은 대용량의 데이터를 여러 개의 작은 단위, 즉 Shard로 분할하여 서로 다른 서버에 분산 저장 및 처리하는 기술입니다.
데이터베이스의 Horizontal Scaling을 위한 가장 대표적인 방법 중 하나입니다.

  1. Shard Key 지정
  • 가장 먼저, 데이터를 어떻게 분산시킬지 결정하는 기준이 되는 Shard Key를 선택합니다.
  1. Hash Function 적용
  • 지정된 샤드 키의 각 값(A, B, C, D)을 Hash Function에 입력합니다.
  • 해시 함수는 입력된 값을 특정 규칙에 따라 Hash Value로 변환합니다.
  1. 데이터 분산
  • 해시 함수를 통해 계산된 Hash Values에 따라 원래 테이블의 각 Row가 어느 샤드로 갈지 결정됩니다.
기법설명장점단점
범위 기반 샤딩 (Range Based Sharding)샤딩 키의 범위를 기준으로 데이터를 분할합니다. (예: 우편번호 10000~19999는 A서버, 20000~29999는 B서버)구조가 간단하고 특정 범위의 데이터를 가져오는 쿼리에 효율적입니다.데이터가 특정 범위에 몰릴 경우(Hot Spot) 부하가 집중될 수 있습니다.
해시 기반 샤딩 (Hash Based Sharding)샤딩 키를 해시 함수(Hash Function)에 적용한 결과값을 기준으로 데이터를 분할합니다.데이터를 각 샤드에 비교적 균등하게 분배할 수 있어 트래픽 분산에 유리합니다.특정 범위의 데이터를 조회하기 어렵고, 샤드 개수를 변경할 때 데이터 재정렬이 복잡합니다.
디렉토리 기반 샤딩 (Directory Based Sharding)샤딩 키와 샤드의 매핑 정보를 별도의 조회 테이블(Lookup Table)에 저장하여 관리합니다.샤딩 키를 유연하게 관리하고 동적으로 샤드를 추가/삭제하기 용이합니다.조회 테이블 자체에 병목이 발생할 수 있으며, 관리 포인트가 늘어납니다.

Installation

helm repo add elastic https://helm.elastic.co
helm repo update

Elasticsearch의 공식 Helm 차트를 사용하기 위해 리포지토리를 추가합니다.

# 단일 노드 클러스터로 설정
replicas: 1
minimumMasterNodes: 1

# 리소스 제한 (환경에 맞게 조정 가능)
resources:
  requests:
    cpu: "500m"
    memory: "1Gi"
  limits:
    cpu: "1"
    memory: "1Gi"

# Persistent Volume 설정
volumeClaimTemplate:
  accessModes: [ "ReadWriteOnce" ]
  # `local-path' 스토리지 클래스 지정
  storageClassName: "local-path"
  resources:
    requests:
      # 데이터 저장 공간 (필요시 증설)
      storage: 20Gi 

이후 values.yaml 메타데이터를 위와 같이 조정합니다.
단일 노드 ElasticSearch 클러스터로 설정하고 제한된 리소스 설정을 추가합니다.
또한 local-path 스토리지 클래스를 지정하여 로그 데이터를 저장하도록 설정합니다.

helm install elasticsearch elastic/elasticsearch \
  --namespace logging \
  --version 7.17.3 \
  -f elasticsearch-values.yaml

Helm 차트 버전을 명시하면 예기치 않은 버전 변경으로 인한 문제를 예방할 수 있기에 널리 사용되는 7.17.3 버전을 사용하여 Helm 차트를 설치합니다.

[root@master k8s]# k get all
NAME                         READY   STATUS    RESTARTS   AGE
pod/elasticsearch-master-0   1/1     Running   0          4m23s

NAME                                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)             AGE
service/elasticsearch-master            ClusterIP   10.97.176.38   <none>        9200/TCP,9300/TCP   4m24s
service/elasticsearch-master-headless   ClusterIP   None           <none>        9200/TCP,9300/TCP   4m24s

NAME                                    READY   AGE
statefulset.apps/elasticsearch-master   1/1     4m24s

이후 위와 같이 성장적으로 elasticsearch Pod가 배포된 모습을 볼 수 있습니다.

Kibana

# Kibana가 통신할 Elasticsearch 클러스터의 서비스 주소
elasticsearchHosts: "http://elasticsearch-master.logging.svc.cluster.local:9200"

resources:
  requests:
    cpu: "200m"
    memory: "512Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

# 외부 접근을 위한 NodePort 설정
service:
  type: NodePort
  port: 5601
  nodePort: 30561

Kibana는 Elasticsearch처럼 JVM 기반이 아니라 Node.js 기반이기 때문에 더 적은 리소스를 할당합니다.
또한 Kibana가 통신할 Elasticsearch 클러스터의 서비스 주소를 value.yaml에 명시해줍니다.

helm install kibana elastic/kibana \
  --namespace logging \
  --version 7.17.3 \
  -f kibana-values.yaml

이후 7.17.3 버전임을 명시하고 설치를 진행하면

[root@master k8s]# k get pod
NAME                             READY   STATUS    RESTARTS   AGE
elasticsearch-master-0           1/1     Running   0          16m
kibana-kibana-6546bbf5b5-7l6ts   1/1     Running   0          11m

Pod들이 정상적으로 배포되는 모습을 볼 수 있습니다.

Host nginx proxy

$ sudo semanage port -a -t http_port_t -p tcp 5601

$ vim /etc/nginx/conf.d/k8s-kibana.conf
server {
    listen 5601;
    server_name _;

    location / {
        proxy_pass http://172.20.112.101:30561;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

또한 위와 같이 NodePort로 포워딩해주는 nginx 프록시를 설정해주면

위와 같이 외부에서 Kibana 대시보드에 정상적으로 접근이 가능한 것을 볼 수 있습니다.

Fluent Bit

1. 각 노드에 있는 Fluent Bit Pod는 해당 노드에서 실행되는 다른 모든 애플리케이션 Pod들의 로그를 수집하고 필터링하여 중앙 저장소로 전달하는 경량 로그 수집기(Shipper) 역할을 합니다.
2. 클러스터의 모든 노드에서 수집된 로그는 중앙 로그 저장소인 Elasticsearch로 전송됩니다.
3. 사용자나 개발자는 Kibana를 통해 Elasticsearch에 저장된 로그를 확인합니다.

helm repo add fluent https://fluent.github.io/helm-charts
helm repo update

우선 fluent Helm Repo를 추가해줍니다.

# Kubernetes API 접근을 위한 RBAC 생성
rbac:
  create: true

# DaemonSet으로 배포하여 모든 노드에 Pod가 하나씩 실행되도록 보장
kind: DaemonSet

resources:
  requests:
    cpu: "50m"
    memory: "50Mi"
  limits:
    cpu: "100m"
    memory: "100Mi"

# 마스터 노드의 Taint를 무시하고 파드를 스케줄링하도록 설정
tolerations:
- key: "node-role.kubernetes.io/control-plane"
  operator: "Exists"
  effect: "NoSchedule"
- key: "node-role.kubernetes.io/master"
  operator: "Exists"
  effect: "NoSchedule"

config:
  # INPUT: 컨테이너 로그 수집
  inputs: |
    [INPUT]
        Name              tail
        Tag               kube.*
        Path              /var/log/containers/*.log
        Parser            cri
        DB                /var/log/flb_kube.db
        Mem_Buf_Limit     5MB
        # fluent-bit 자신과 kube-system 네임스페이스 로그는 수집에서 제외
        Exclude_Path      *fluent-bit*,*kube-system*

  # FILTER: Kubernetes 메타데이터(pod, namespace, labels 등) 추가
  filters: |
    [FILTER]
        Name                kubernetes
        Match               kube.*
        Kube_URL            https://kubernetes.default.svc:443
        Kube_CA_File        /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
        Kube_Token_File     /var/run/secrets/kubernetes.io/serviceaccount/token
        Merge_Log           On
        Merge_Log_Key       log_processed

  # OUTPUT: Elasticsearch로 로그 전송
  outputs: |
    [OUTPUT]
        Name                es
        Match               *
        # Elasticsearch 서비스 주소
        Host                elasticsearch-master.logging.svc.cluster.local
        Port                9200
        # 이 설정을 통해 'k8s-log-YYYY.MM.DD' 형식의 일별 인덱스가 생성됨
        Logstash_Format     On
        Logstash_Prefix     k8s-log
        # Index 이름에 '.' 이 들어가는 것을 허용
        Replace_Dots        On
        Retry_Limit         False

이후 위와 같은 value.yaml을 설정하여 준 뒤

helm install fluent-bit fluent/fluent-bit \
  --namespace logging \
  -f fluent-bit-values.yaml

Helm을 통해 이를 설치하면

[root@master logging]# k logs -f fluent-bit-hfc4t
Fluent Bit v4.0.7
* Copyright (C) 2015-2025 The Fluent Bit Authors
* Fluent Bit is a CNCF sub-project under the umbrella of Fluentd
* https://fluentbit.io

______ _                  _    ______ _ _             ___  _____
|  ___| |                | |   | ___ (_) |           /   ||  _  |
| |_  | |_   _  ___ _ __ | |_  | |_/ /_| |_  __   __/ /| || |/' |
|  _| | | | | |/ _ \ '_ \| __| | ___ \ | __| \ \ / / /_| ||  /| |
| |   | | |_| |  __/ | | | |_  | |_/ / | |_   \ V /\___  |\ |_/ /
\_|   |_|\__,_|\___|_| |_|\__| \____/|_|\__|   \_/     |_(_)___/


[2025/08/30 07:39:54] [ info] [fluent bit] version=4.0.7, commit=8c5cbc1bd8, pid=1
[2025/08/30 07:39:54] [ info] [storage] ver=1.5.3, type=memory, sync=normal, checksum=off, max_chunks_up=128
[2025/08/30 07:39:54] [ info] [simd    ] SSE2
[2025/08/30 07:39:54] [ info] [cmetrics] version=1.0.5
[2025/08/30 07:39:54] [ info] [ctraces ] version=0.6.6
[2025/08/30 07:39:54] [ info] [input:tail:tail.0] initializing
[2025/08/30 07:39:54] [ info] [input:tail:tail.0] storage_strategy='memory' (memory only)
[2025/08/30 07:39:54] [ info] [input:tail:tail.0] db: delete unmonitored stale inodes from the database: count=0
[2025/08/30 07:39:54] [ info] [filter:kubernetes:kubernetes.0] https=1 host=kubernetes.default.svc port=443

각 Pod의 로그가 위와 같이 정상적으로 보이는 모습을 볼 수 있으며

[root@master logging]# k get pod -o wide
NAME                READY   STATUS    RESTARTS   AGE    IP              NODE           NOMINATED NODE   READINESS GATES
fluent-bit-gmb6b    1/1     Running   0          24m    10.244.0.107    master         <none>           <none>
fluent-bit-lxxss    1/1     Running   0          15m    10.244.17.12    k8s-worker-1   <none>           <none>
fluent-bit-xrgbz    1/1     Running   0          24m    10.244.12.157   k8s-worker-2   <none>           <none>

모든 Node들에 대해 fluent-bit Pod가 작동하는 모습을 볼 수 있습니다.

Log Rotation Configuration

kubectl port-forward --namespace logging svc/elasticsearch-master 9200:9200

이후 Log Rotation을 적용하기 위해 Master Node에서 Elasticsearch API를 호출하기 위해 kubectl port-forward 명령을 사용합니다.

curl -X PUT "http://localhost:9200/_ilm/policy/k8s_log_policy" -H 'Content-Type: application/json' -d'
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_age": "1d",
            "max_size": "25gb"
          }
        }
      },
      "delete": {
        "min_age": "7d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}
'

우선 curl 명령을 사용하여 k8s_log_policy라는 이름의 ILM 정책을 생성합니다.
위 정책은 7일 후에 delete 단계로 인덱스를 이동시킵니다.

curl -X PUT "http://localhost:9200/_template/k8s_log_template" -H 'Content-Type: application/json' -d'
{
  "index_patterns": ["k8s-log-*"],
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "index.lifecycle.name": "k8s_log_policy",
    "index.lifecycle.rollover_alias": "k8s-log"
  }
}
'

이후 fluent-bitk8s-log-* 패턴으로 생성하는 새로운 인덱스들이 ILM 정책을 자동으로 사용하도록 인덱스 템플릿을 생성하여 일주일 단위의 Log Rotation 설정을 완료할 수 있습니다.

Kibana Log 확인

왼쪽 Stack Management > Index Patterns으로 이동하여 Create index pattern 버튼을 클릭합니다.

ndex pattern name에 k8s-log-*를 입력하고 Next step을 클릭합니다.
이후 Time field로 @timestamp를 선택하고 Create index pattern 클릭하여줍니다.

이후 Discover 탭으로 이동하면 클러스터에서 수집된 모든 로그가 표시되는 것을 알 수 있습니다.

kubernetes.labels.app : "wwwm-spring-be-v2"

라이브 서비스되고 있는 애플리케이션 로그만 보기 위해 위와 같이 애플리케이션 레이블로 필터링을 진행하면

실제로 서비스를 이용하였을 때
kubectl logs로 확인한 로그들을

Kibana에서도 확인할 수 있습니다.

ElasticSearch external access

$ kubectl get svc -n logging elasticsearch-master
NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)             AGE
elasticsearch-master   ClusterIP   10.97.176.38   <none>        9200/TCP,9300/TCP   3h54m

추후 로그들을 외부 애플리케이션에 바로 적용 가능하도록 elasticsearch-master SVC를 확인하고

$ kubectl patch svc elasticsearch-master -n logging -p '{"spec": {"type": "NodePort"}}'

$ kubectl get svc -n logging elasticsearch-master
NAME                   TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)                         AGE
elasticsearch-master   NodePort   10.97.176.38   <none>        9200:32171/TCP,9300:32610/TCP   3h55m

kubectl patch를 통해 "type": "NodePort"로 변경하여줍니다.

# sudo semanage port -a -t http_port_t -p tcp 2171

server {
    listen 2171;
    server_name _;

    location / {
        proxy_pass http://172.20.112.101:32171;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

이후 이전과 유사하게 nginx 프록시를 설정해주면

$ curl -X GET "http://_:2171/k8s-log-*/_search?pretty" -H 'Content-Type: application/json' -d'
{
  "query": {
    "match": {
      "kubernetes.pod_name": "wwwm-spring-be-v2"
    }
  },
  "sort": [
    {
      "@timestamp": {
        "order": "desc"
      }
    }
  ],
  "size": 10
}
'
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0{
  "took" : 307,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 10000,
      "relation" : "gte"
    },
    "max_score" : null,
    "hits" : [
      {
        "_index" : "k8s-log-2025.08.30",
        "_type" : "_doc",
        "_id" : "n0Sj-pgBZwz61aXAx_44",
        "_score" : null,
        "_source" : {
          "@timestamp" : "2025-08-30T11:01:33.565Z",
          "time" : "2025-08-30T07:01:33.565529803-04:00",
          "stream" : "stdout",
          "logtag" : "F",
          "message" : "Hibernate: ",
          "kubernetes" : {
            "pod_name" : "wwwm-spring-be-v2-86f76b65fd-zmzm8",
            "namespace_name" : "default",
            "pod_id" : "852b804c-37a0-4548-8cb6-0df2e9a5ad25",
            "labels" : {
              "app" : "wwwm-spring-be-v2",
              "pod-template-hash" : "86f76b65fd"
            },
            "host" : "master",
            "pod_ip" : "10.244.0.87",
            "container_name" : "wwwm-spring-be-v2",
            "docker_id" : "75cd6d26c37c2cd2da9150e157b676024292a6a8d86fb60c70891425a748e37d",
            "container_hash" : "docker.io/judemin/wwwm-spring-be@sha256:278c9ea2f9c2b545de06d8df5239d92d353dcb22375da1fad1e60c0c26f64fb4",
            "container_image" : "docker.io/judemin/wwwm-spring-be:68a7e54"
          }
        },
        "sort" : [
          1756551693565
        ]
      },
      {
        "_index" : "k8s-log-2025.08.30",
        "_type" : "_doc",
        "_id" : "oESj-pgBZwz61aXAx_44",
        "_score" : null,
        "_source" : {
          "@timestamp" : "2025-08-30T11:01:33.565Z",
          "time" : "2025-08-30T07:01:33.565529803-04:00",
          "stream" : "stdout",
          "logtag" : "F",
          "message" : "    select",
          "kubernetes" : {
            "pod_name" : "wwwm-spring-be-v2-86f76b65fd-zmzm8",
            "namespace_name" : "default",
            "pod_id" : "852b804c-37a0-4548-8cb6-0df2e9a5ad25",
            "labels" : {
              "app" : "wwwm-spring-be-v2",
              "pod-template-hash" : "86f76b65fd"
            },
            "host" : "master",
            "pod_ip" : "10.244.0.87",
            "container_name" : "wwwm-spring-be-v2",
            "docker_id" : "75cd6d26c37c2cd2da9150e157b676024292a6a8d86fb60c70891425a748e37d",
            "container_hash" : "docker.io/judemin/wwwm-spring-be@sha256:278c9ea2f9c2b545de06d8df5239d92d353dcb22375da1fad1e60c0c26f64fb4",
            "container_image" : "docker.io/judemin/wwwm-spring-be:68a7e54"
          }
        },
        "sort" : [
          1756551693565
        ]
      },
      ...

외부에서 curl 명령을 통해 로그를 열람할 수 있는 것을 볼 수 있습니다.
이러한 방법은 로그가 노출되지 않아야 하는 환경에서는 위험하지만 현재는 AIOps, Failover 시스템 구축 등을 위해 임시적으로 포트를 열어두었습니다.

0개의 댓글