Spring

Spring + ELK로 로그 시스템 구축하기

minturtle 2024. 11. 16. 00:05
반응형

개요

  • Ticketing 프로젝트 진행 중에 로그를 시각화하기 위해서 ELK 스택을 도입하기로 결정하였습니다. 원래는 모니터링 서버(Grafana)에 Loki와 연동하였으나, 모니터링 서버의 성능이 상대적으로 떨어져 정상적으로 로그를 처리하지 못했고, ElasticSearch도 사용하게 된 김에 이를 해결하기 위해 ELK를 도입하기로 결정했습니다.

 

1. Spring 로그 설정

  • 먼저 ELK로 로그를 보내기 위해선, 스프링에서 찍은 로그를 파일로 저장해야합니다. 로그는 LogstashEncoder를 사용하여 JSON 형태로 로그가 저장되도록 설정하였습니다.
<configuration>
    <timestamp key="TODAY" datePattern="yyyyMMdd"/>
    <appender name="LOG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${LOG_FILE}_${TODAY}.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${LOG_FILE}.%d{yyyyMMdd}_%i.log</fileNamePattern>
            <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE}</maxFileSize>
            <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP}</totalSizeCap>
            <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY}</maxHistory>
        </rollingPolicy>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
    </appender>

    <root level="INFO">
        <appender-ref ref="LOG_FILE"/>
    </root>
</configuration>

 

{"@timestamp":"2024-11-08T21:14:04.3193516+09:00","@version":"1","message":"[UcpDYLQ4]|||-> PerformanceReader.findPerformanceEntityByCursor","logger_name":"com.flab.ticketing.common.aop.aspect.LoggingAspect","thread_name":"http-nio-8080-exec-12","level":"INFO","level_value":20000,"traceId":"672e008cbfe531720077d34b580681a0","spanId":"c44823ae293841f0"}
  • 참고로 LOG_PATH, LOG_FILE 등은 설정을 보고 쉽게 yml 파일로 등록할 수도 있습니다.
logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger{36}] - %msg%n"
  file:
    name: ticketing
    path: /app/logs
  logback:
    rollingpolicy:
      max-file-size: 10MB
      total-size-cap: 1GB
      max-history: 30
  • 이렇게 하면 /app/logs 디렉터리에 로그파일이 저장됩니다.

2. Logstash와 Kibana 구축하기

  • 그 후 ElasticSearch, Logstash와 Kibana를 띄울 건데, 편하게 Docker Compose를 사용해서 띄워 보도록 하였습니다.
version: "3.8"

volumes:
  certs:
    driver: local
  esdata01:
    driver: local
  kibanadata:
    driver: local
  logstashdata01:
    driver: local

networks:
  default:
    name: elastic
    external: false

services:
  setup:
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
    volumes:
      - certs:/usr/share/elasticsearch/config/certs
    user: "0"
    command: >
      bash -c '
        if [ x${ELASTIC_PASSWORD} == x ]; then
          echo "Set the ELASTIC_PASSWORD environment variable in the .env file";
          exit 1;
        elif [ x${KIBANA_PASSWORD} == x ]; then
          echo "Set the KIBANA_PASSWORD environment variable in the .env file";
          exit 1;
        fi;
        if [ ! -f config/certs/ca.zip ]; then
          echo "Creating CA";
          bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
          unzip config/certs/ca.zip -d config/certs;
        fi;
        if [ ! -f config/certs/certs.zip ]; then
          echo "Creating certs";
          echo -ne \
          "instances:\n"\
          "  - name: es01\n"\
          "    dns:\n"\
          "      - es01\n"\
          "      - localhost\n"\
          "      - ${MY_DOMAIN_NAME}\n"\
          "    ip:\n"\
          "      - 127.0.0.1\n"\
          "  - name: kibana\n"\
          "    dns:\n"\
          "      - kibana\n"\
          "      - localhost\n"\
          "    ip:\n"\
          "      - 127.0.0.1\n"\
          > config/certs/instances.yml;
          bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key;
          unzip config/certs/certs.zip -d config/certs;
        fi;
        echo "Setting file permissions"
        chown -R root:root config/certs;
        find . -type d -exec chmod 750 \{\} \;;
        find . -type f -exec chmod 640 \{\} \;;
        echo "Waiting for Elasticsearch availability";
        until curl -s --cacert config/certs/ca/ca.crt https://es01:9200 | grep -q "missing authentication credentials"; do sleep 30; done;
        echo "Setting kibana_system password";
        until curl -s -X POST --cacert config/certs/ca/ca.crt -u "elastic:${ELASTIC_PASSWORD}" -H "Content-Type: application/json" https://es01:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done;
        echo "All done!";
      '
    healthcheck:
      test: [ "CMD-SHELL", "[ -f config/certs/es01/es01.crt ]" ]
      interval: 1s
      timeout: 5s
      retries: 120

  es01:
    depends_on:
      setup:
        condition: service_healthy
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
    labels:
      co.elastic.logs/module: elasticsearch
    volumes:
      - certs:/usr/share/elasticsearch/config/certs
      - ./es-data:/usr/share/elasticsearch/data
    ports:
      - ${ES_PORT}:9200
    environment:
      - node.name=es01
      - cluster.name=${CLUSTER_NAME}
      - discovery.type=single-node
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.key=certs/es01/es01.key
      - xpack.security.http.ssl.certificate=certs/es01/es01.crt
      - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.enabled=true
      - xpack.security.transport.ssl.key=certs/es01/es01.key
      - xpack.security.transport.ssl.certificate=certs/es01/es01.crt
      - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
      - xpack.security.transport.ssl.verification_mode=certificate
      - xpack.license.self_generated.type=${LICENSE}
    mem_limit: ${ES_MEM_LIMIT}
    ulimits:
      memlock:
        soft: -1
        hard: -1
    healthcheck:
      test: [ "CMD-SHELL", "curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q 'missing authentication credentials'" ]
      interval: 10s
      timeout: 10s
      retries: 120

  kibana:
    depends_on:
      es01:
        condition: service_healthy
    image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
    labels:
      co.elastic.logs/module: kibana
    volumes:
      - certs:/usr/share/kibana/config/certs
      - kibanadata:/usr/share/kibana/data
    ports:
      - ${KIBANA_PORT}:5601
    environment:
      - SERVERNAME=kibana
      - ELASTICSEARCH_HOSTS=https://es01:9200
      - ELASTICSEARCH_USERNAME=kibana_system
      - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
      - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=config/certs/ca/ca.crt
      - XPACK_SECURITY_ENCRYPTIONKEY=${ENCRYPTION_KEY}
      - XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${ENCRYPTION_KEY}
      - XPACK_REPORTING_ENCRYPTIONKEY=${ENCRYPTION_KEY}
    mem_limit: ${KB_MEM_LIMIT}
    healthcheck:
      test: [ "CMD-SHELL", "curl -s -I http://localhost:5601 | grep -q 'HTTP/1.1 302 Found'" ]
      interval: 10s
      timeout: 10s
      retries: 120

  logstash01:
    depends_on:
      es01:
        condition: service_healthy
      kibana:
        condition: service_healthy
    image: docker.elastic.co/logstash/logstash:${STACK_VERSION}
    labels:
      co.elastic.logs/module: logstash
    user: root
    volumes:
      - certs:/usr/share/logstash/certs
      - logstashdata01:/usr/share/logstash/data
      - "./logstash_ingest_data/:/usr/share/logstash/ingest_data/"
      - "./logstash.conf:/usr/share/logstash/pipeline/logstash.conf:ro"
    ports:
      - "5044:5044"
    environment:
      - xpack.monitoring.enabled=false
      - ELASTIC_USER=elastic
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
      - ELASTIC_HOSTS=https://es01:9200

ELK Docker Compose 작성은 공식 문서 깃허브를 보고 제가 필요한 부분만 변경하였습니다.

각 컨테이너의 역할은 다음과 같습니다

  1. setup : elasticsearch-certutil를 사용하여 CA 인증서를 발급하고, Kibana가 ElasticSearch에 접속할 수 있는 User를 생성하는 역할을 합니다.
    • CA인증서는 자체 서명을 하기 때문에 JAVA 프로그램을 실행할 때 “신뢰되지 않는 인증서” 오류가 나타날 수 있습니다. 이에 대해 저는 인증서만 있으면 무조건 신뢰하도록 코드를 작성하였습니다.
@Configuration
@Profile("prod")
internal class ProductionElasticSearchConfig(
      @Value("\${spring.data.elasticsearch.url}") private val elasticHost: String,
      @Value("\${spring.data.elasticsearch.api-key}") private val elasticApiKey: String
) : ElasticsearchConfiguration() {

     override fun clientConfiguration(): ClientConfiguration {
          val httpHeaders = HttpHeaders()
          httpHeaders.add("Authorization", "ApiKey $elasticApiKey")
          // SSL Context 설정
          val sslContext = SSLContextBuilder.create()
               .loadTrustMaterial(null) { _: Array<X509Certificate>, _: String -> true }
               .build()

         return ClientConfiguration.builder()
              .connectedTo(elasticHost)
              .usingSsl(sslContext)
              .withDefaultHeaders(httpHeaders)
             .build()
      }
}
  • 다음으론 Kibana사용자 생성인데요, 기본 사용자(elastic)으로 접근하도록 하면되지 않냐? 라고 할 수 있는데 ElasticSearch 8 버젼 이상에서 Kibana가 기본 사용자를 사용할 수 없도록 제한되어 있음을 확인할 수 있었습니다.

  1. es01
    • elasticsearch 컨테이너입니다. es-data 폴더를 볼륨으로 걸어 컨테이너를 재실행해도 데이터가 유실되지 않도록 설정되어 있습니다.

 

  1. kibana
    • kibana 컨테이너입니다.

 

  1. logstash01
    • logstash 컨테이너 입니다. logstash는 logstash.conf를 필요로 하는데, 이는 아래와 같습니다.
input {
  beats {
    port => 5044
    host => "0.0.0.0"
  }
}

filter {
  json {
    source => "message"  
    skip_on_invalid_json => true 
  }
}

output {
  elasticsearch {
    index => "logstash-%{+YYYY.MM.dd}"
    hosts=> "${ELASTIC_HOSTS}"
    user=> "${ELASTIC_USER}"
    password=> "${ELASTIC_PASSWORD}"
    cacert=> "certs/ca/ca.crt"
  }
}
  • logstash는 beat가 5044 포트로 데이터를 전송하면, 이를 elasticsearch에 저장합니다.
    - 중간의 filter는 데이터가 JSON 형태로 들어오는 것을 파싱하여 더 보기 편하게 파싱하는 역할을 하는데요, 설명하기가 어려워 예시를 보여드리겠습니다.
/// BEFORE
{
   "something" : "",
   // ...
   "message" : {"level" : "info", "message" : "Tomcat started on port 9090 (http) with context path '/'"}
}

// After
{
	"something": "",
	"level" :"info",
	"message" : "Tomcat started on port 9090 (http) with context path '/'",
}

 

 

 

3. FileBeat 설정하기

  • 그 후 Application 서버로 가서, FileBeat를 설정해 주었습니다.
logging.level: info
logging.to_stderr: true

filebeat.inputs:
- type: filestream
  id: logback-logs
  enabled: true
  paths:
    - ${LOG_PATH}/*.log
  json.keys_under_root: true
  json.add_error_key: true
  json.message_key: message

processors:
  - add_docker_metadata: ~

output.logstash:
  hosts: ["logstash:5044"]
  ssl.enabled: false
  • filebeat는 log 파일을 읽어서, logstash로 보내는 설정입니다.
  • 마지막으로 docker compose로 filebeat를 실행할 수 있도록 설정해 주었습니다.
  filebeat:
    image: docker.elastic.co/beats/filebeat:8.13.4
    container_name: filebeat
    user: root
    volumes:
      - ./logs:/app/logs
      - ./filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
    environment:
      - LOG_PATH=/app/logs
    command:
      - --strict.perms=false
  • 여기서 user를 root로 설정하지 않으면 아래의 오류가 나는 것을 확인할 수 있었습니다.
Exiting: error loading config file: config file ("filebeat.yml") must be owned by the user identifier (uid=0) or root

 

 

Kibana 접속 및 설정

  • 마지막으로 Kibana에 접속해서 설정 해주면 되었는데요, 먼저 Data View를 설정해주어야 합니다. 공식 문서는 여기서 볼 수 있습니다.

  • 저는 logstash.conf에서도 확인할 수 있듯이 logstash-yyyy.MM.dd 와 같이 인덱스를 생성하였기 때문에, index pattern에 logstash-* 로 설정하면 모든 로그 인덱스가 매칭됨을 알 수 있었습니다.
  • 그러면 Kibana Discover에서 로그를 검색하고 확인할 수 있습니다. 공식문서는 여기에서 확인할 수 있습니다.

 

반응형