본문으로 건너뛰기

ELK Stack 연동 — Filebeat + Elasticsearch + Kibana

ELK Stack은 로그 수집·저장·시각화를 위한 대표적인 오픈소스 솔루션입니다. Nginx, Tomcat 같은 웹 서버에서 발생하는 수백만 건의 로그를 실시간으로 수집하고, 강력한 검색 기능으로 장애 원인을 빠르게 파악할 수 있습니다. 이 챕터에서는 Docker Compose를 활용해 ELK Stack을 구성하고, Filebeat로 Nginx/Tomcat 로그를 수집해 Kibana 대시보드로 시각화하는 전 과정을 실습합니다.

ELK Stack 구성 요소

ELK Stack은 네 가지 핵심 컴포넌트로 구성됩니다.

Elasticsearch 는 분산 검색 및 분석 엔진입니다. JSON 기반의 RESTful API를 제공하며, 로그 데이터를 인덱스에 저장하고 Lucene 기반의 풀텍스트 검색을 지원합니다. 수평적 확장(샤딩, 레플리카)이 가능해 대용량 로그도 무리 없이 처리합니다.

Logstash 는 데이터 수집·변환·전송 파이프라인입니다. 다양한 입력 플러그인(파일, Kafka, Beats)으로 데이터를 받아 Grok 패턴 등으로 파싱하고, Elasticsearch로 출력합니다. 무거운 편이라 단순 로그 수집에는 Filebeat로 대체하는 경우가 많습니다.

Kibana 는 Elasticsearch 데이터를 웹 UI로 시각화하는 도구입니다. Discover(로그 탐색), Visualize(차트), Dashboard(대시보드 구성), APM(성능 모니터링) 등 다양한 기능을 제공합니다.

Filebeat 는 경량 로그 수집기(Shipper)입니다. 서버에 설치해 로그 파일을 실시간으로 모니터링하고, Logstash 또는 Elasticsearch로 직접 전송합니다. 리소스 사용량이 매우 낮아 프로덕션 서버에 부담 없이 배포할 수 있습니다.

┌─────────────┐    ┌─────────────┐    ┌─────────────────────┐    ┌─────────┐
│ Nginx/ │───▶│ Filebeat │───▶│ Logstash │───▶│ Elastic│
│ Tomcat │ │ (수집) │ │ (파싱/변환) │ │ search │
│ (로그생성) │ └─────────────┘ └─────────────────────┘ │ (저장) │
└─────────────┘ └────┬────┘

┌────▼────┐
│ Kibana │
│ (시각화) │
└─────────┘

Docker Compose로 ELK Stack 구성

전체 스택을 Docker Compose 하나로 구성합니다. .env 파일로 버전과 비밀번호를 관리합니다.

mkdir -p ~/elk-stack/{logstash/pipeline,filebeat,nginx/logs,tomcat/logs}
cd ~/elk-stack

.env 파일 생성:

# .env
ELK_VERSION=8.12.0
ELASTIC_PASSWORD=changeme123!
KIBANA_PASSWORD=changeme123!
ENCRYPTION_KEY=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4

docker-compose.yml 작성:

# docker-compose.yml
version: '3.8'

services:
# ──────────────────────────────────────────────
# Elasticsearch
# ──────────────────────────────────────────────
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:${ELK_VERSION}
container_name: elasticsearch
environment:
- node.name=elasticsearch
- cluster.name=elk-cluster
- discovery.type=single-node
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=false
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- es_data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
networks:
- elk
healthcheck:
test: ["CMD-SHELL", "curl -s -u elastic:${ELASTIC_PASSWORD} http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'"]
interval: 30s
timeout: 10s
retries: 5

# ──────────────────────────────────────────────
# Kibana
# ──────────────────────────────────────────────
kibana:
image: docker.elastic.co/kibana/kibana:${ELK_VERSION}
container_name: kibana
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
- ELASTICSEARCH_USERNAME=kibana_system
- ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
- xpack.encryptedSavedObjects.encryptionKey=${ENCRYPTION_KEY}
ports:
- "5601:5601"
networks:
- elk
depends_on:
elasticsearch:
condition: service_healthy

# ──────────────────────────────────────────────
# Logstash
# ──────────────────────────────────────────────
logstash:
image: docker.elastic.co/logstash/logstash:${ELK_VERSION}
container_name: logstash
environment:
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
- "LS_JAVA_OPTS=-Xms256m -Xmx256m"
volumes:
- ./logstash/pipeline:/usr/share/logstash/pipeline:ro
ports:
- "5044:5044" # Beats input
- "5000:5000" # TCP input
networks:
- elk
depends_on:
elasticsearch:
condition: service_healthy

# ──────────────────────────────────────────────
# Filebeat
# ──────────────────────────────────────────────
filebeat:
image: docker.elastic.co/beats/filebeat:${ELK_VERSION}
container_name: filebeat
user: root
environment:
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
volumes:
- ./filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
- ./nginx/logs:/var/log/nginx:ro
- ./tomcat/logs:/var/log/tomcat:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- filebeat_data:/usr/share/filebeat/data
networks:
- elk
depends_on:
elasticsearch:
condition: service_healthy
command: filebeat -e --strict.perms=false

volumes:
es_data:
driver: local
filebeat_data:
driver: local

networks:
elk:
driver: bridge

Logstash 파이프라인 설정

Nginx 액세스 로그를 파싱하는 Grok 패턴을 정의합니다.

# logstash/pipeline/nginx.conf
input {
beats {
port => 5044
}
}

filter {
# ─── Nginx 액세스 로그 파싱 ───────────────────────────────────────────────
if [fields][log_type] == "nginx_access" {
grok {
match => {
"message" => '%{IPORHOST:client_ip} - %{DATA:user_name} \[%{HTTPDATE:time_local}\] "%{WORD:method} %{DATA:request_uri} HTTP/%{NUMBER:http_version}" %{NUMBER:status_code:int} %{NUMBER:body_bytes_sent:int} "%{DATA:http_referer}" "%{DATA:user_agent}"'
}
tag_on_failure => ["_grokparsefailure_nginx"]
}
# 날짜 파싱
date {
match => ["time_local", "dd/MMM/yyyy:HH:mm:ss Z"]
target => "@timestamp"
}
# User-Agent 파싱
useragent {
source => "user_agent"
target => "ua"
}
# 지리 정보 (GeoIP)
geoip {
source => "client_ip"
target => "geoip"
}
# 상태 코드 범주화
if [status_code] >= 500 {
mutate { add_field => { "status_category" => "5xx_error" } }
} else if [status_code] >= 400 {
mutate { add_field => { "status_category" => "4xx_error" } }
} else if [status_code] >= 300 {
mutate { add_field => { "status_category" => "3xx_redirect" } }
} else {
mutate { add_field => { "status_category" => "2xx_success" } }
}
}

# ─── Tomcat 액세스 로그 파싱 ─────────────────────────────────────────────
if [fields][log_type] == "tomcat_access" {
grok {
match => {
"message" => '%{IPORHOST:client_ip} - %{DATA:user_name} \[%{HTTPDATE:time_local}\] "%{WORD:method} %{DATA:request_uri} HTTP/%{NUMBER:http_version}" %{NUMBER:status_code:int} %{NUMBER:body_bytes_sent:int} %{NUMBER:response_time:int}'
}
tag_on_failure => ["_grokparsefailure_tomcat"]
}
date {
match => ["time_local", "dd/MMM/yyyy:HH:mm:ss Z"]
target => "@timestamp"
}
# 응답시간 ms → s 변환
if [response_time] {
ruby {
code => "event.set('response_time_sec', event.get('response_time').to_f / 1000)"
}
}
}

# ─── Tomcat 에러 로그 파싱 ───────────────────────────────────────────────
if [fields][log_type] == "tomcat_error" {
grok {
match => {
"message" => '%{DATA:time_local} %{LOGLEVEL:log_level} \[%{DATA:thread}\] %{JAVACLASS:logger} %{GREEDYDATA:log_message}'
}
tag_on_failure => ["_grokparsefailure_tomcat_error"]
}
}

# ─── 공통: 불필요 필드 제거 ──────────────────────────────────────────────
mutate {
remove_field => ["agent", "ecs", "input", "log", "host"]
}
}

output {
if [fields][log_type] == "nginx_access" {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
user => "elastic"
password => "${ELASTIC_PASSWORD}"
index => "nginx-access-%{+YYYY.MM.dd}"
template_name => "nginx-access"
}
} else if [fields][log_type] == "tomcat_access" {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
user => "elastic"
password => "${ELASTIC_PASSWORD}"
index => "tomcat-access-%{+YYYY.MM.dd}"
}
} else if [fields][log_type] == "tomcat_error" {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
user => "elastic"
password => "${ELASTIC_PASSWORD}"
index => "tomcat-error-%{+YYYY.MM.dd}"
}
}
# 디버깅용 stdout (프로덕션에서는 제거)
# stdout { codec => rubydebug }
}

Filebeat 설정

Nginx와 Tomcat 로그 파일을 모니터링하는 Filebeat 설정입니다.

# filebeat/filebeat.yml
filebeat.inputs:

# ─── Nginx 액세스 로그 ──────────────────────────────────────────────────
- type: log
id: nginx-access
enabled: true
paths:
- /var/log/nginx/access.log
- /var/log/nginx/*access*.log
fields:
log_type: nginx_access
server: nginx
fields_under_root: false
multiline:
type: pattern
pattern: '^\d+\.\d+\.\d+\.\d+'
negate: true
match: after

# ─── Nginx 에러 로그 ────────────────────────────────────────────────────
- type: log
id: nginx-error
enabled: true
paths:
- /var/log/nginx/error.log
fields:
log_type: nginx_error
server: nginx
fields_under_root: false

# ─── Tomcat 액세스 로그 ─────────────────────────────────────────────────
- type: log
id: tomcat-access
enabled: true
paths:
- /var/log/tomcat/localhost_access_log.*.txt
- /var/log/tomcat/access_log.*
fields:
log_type: tomcat_access
server: tomcat
fields_under_root: false

# ─── Tomcat 에러 로그 (멀티라인: 자바 스택트레이스 처리) ──────────────────
- type: log
id: tomcat-error
enabled: true
paths:
- /var/log/tomcat/catalina.out
fields:
log_type: tomcat_error
server: tomcat
fields_under_root: false
multiline:
type: pattern
# 날짜로 시작하지 않으면 이전 줄에 이어 붙임 (스택트레이스 처리)
pattern: '^\d{2}-[A-Za-z]{3}-\d{4}'
negate: true
match: after
max_lines: 500
timeout: 5s

# ─── 프로세서 ────────────────────────────────────────────────────────────
processors:
- add_host_metadata:
when.not.contains.tags: forwarded
- add_docker_metadata: ~
- drop_fields:
fields: ["agent.ephemeral_id", "agent.hostname", "agent.id", "agent.version"]
ignore_missing: true

# ─── 출력: Logstash로 전송 ───────────────────────────────────────────────
output.logstash:
hosts: ["logstash:5044"]
bulk_max_size: 2048
# 재시도 설정
backoff.init: 1s
backoff.max: 60s

# ─── 로깅 ────────────────────────────────────────────────────────────────
logging.level: info
logging.to_files: true
logging.files:
path: /var/log/filebeat
name: filebeat
keepfiles: 7
permissions: 0644

# ─── 모니터링 (Elasticsearch에 Filebeat 상태 저장) ──────────────────────
monitoring.enabled: false

Elasticsearch 인덱스 템플릿 설정

인덱스가 생성될 때 자동으로 적용되는 매핑 템플릿을 등록합니다.

# Kibana system 사용자 비밀번호 설정 (최초 1회)
curl -X POST "http://localhost:9200/_security/user/kibana_system/_password" \
-H "Content-Type: application/json" \
-u elastic:changeme123! \
-d '{"password": "changeme123!"}'

# Nginx 액세스 로그 인덱스 템플릿 등록
curl -X PUT "http://localhost:9200/_index_template/nginx-access" \
-H "Content-Type: application/json" \
-u elastic:changeme123! \
-d '{
"index_patterns": ["nginx-access-*"],
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"index.lifecycle.name": "nginx-logs-policy",
"index.lifecycle.rollover_alias": "nginx-access"
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"client_ip": { "type": "ip" },
"method": { "type": "keyword" },
"request_uri": { "type": "keyword" },
"status_code": { "type": "integer" },
"status_category": { "type": "keyword" },
"body_bytes_sent": { "type": "long" },
"response_time": { "type": "float" },
"user_agent": { "type": "text" },
"geoip": {
"properties": {
"location": { "type": "geo_point" },
"country_name": { "type": "keyword" },
"city_name": { "type": "keyword" }
}
}
}
}
},
"priority": 200
}'

Index Lifecycle Management (ILM)

오래된 인덱스를 자동으로 롤오버·삭제하는 ILM 정책을 설정합니다.

# ILM 정책 생성
curl -X PUT "http://localhost:9200/_ilm/policy/nginx-logs-policy" \
-H "Content-Type: application/json" \
-u elastic:changeme123! \
-d '{
"policy": {
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"rollover": {
"max_primary_shard_size": "10gb",
"max_age": "1d"
},
"set_priority": { "priority": 100 }
}
},
"warm": {
"min_age": "7d",
"actions": {
"shrink": { "number_of_shards": 1 },
"forcemerge": { "max_num_segments": 1 },
"set_priority": { "priority": 50 }
}
},
"cold": {
"min_age": "30d",
"actions": {
"set_priority": { "priority": 0 },
"freeze": {}
}
},
"delete": {
"min_age": "90d",
"actions": {
"delete": {}
}
}
}
}
}'

스택 시작 및 초기화

cd ~/elk-stack

# 스택 시작 (처음에는 시간이 걸립니다)
docker-compose up -d

# 로그 확인
docker-compose logs -f elasticsearch
docker-compose logs -f logstash
docker-compose logs -f filebeat

# Elasticsearch 상태 확인
curl -u elastic:changeme123! http://localhost:9200/_cluster/health?pretty

# 인덱스 목록 확인 (로그 수집 후)
curl -u elastic:changeme123! http://localhost:9200/_cat/indices?v

# Filebeat 상태 확인
docker-compose exec filebeat filebeat test output
docker-compose exec filebeat filebeat test config

테스트 로그 생성

# Nginx 테스트 로그 생성 (실제 환경에서는 nginx가 생성)
cat >> ~/elk-stack/nginx/logs/access.log << 'EOF'
192.168.1.100 - - [31/Mar/2026:10:00:01 +0900] "GET /api/users HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
192.168.1.101 - - [31/Mar/2026:10:00:02 +0900] "POST /api/login HTTP/1.1" 401 89 "-" "curl/7.68.0"
192.168.1.102 - - [31/Mar/2026:10:00:03 +0900] "GET /static/js/app.js HTTP/1.1" 304 0 "http://example.com/" "Chrome/120"
10.0.0.1 - admin [31/Mar/2026:10:00:04 +0900] "DELETE /api/admin/users/5 HTTP/1.1" 403 45 "-" "Python/requests"
EOF

# 수집 여부 확인
curl -u elastic:changeme123! \
"http://localhost:9200/nginx-access-*/_count?pretty"

Kibana 대시보드 구성

Kibana(http://localhost:5601)에 접속해 대시보드를 구성합니다.

1단계: 데이터 뷰(Data View) 생성

  • Stack Management → Data Views → Create data view
  • Name: Nginx Access Logs
  • Index pattern: nginx-access-*
  • Timestamp field: @timestamp

2단계: Discover로 로그 탐색

  • Kibana → Discover 메뉴
  • 데이터 뷰 선택 후 시간 범위 조정 (Last 15 minutes → Last 24 hours)
  • KQL 쿼리로 필터링:
# 5xx 에러만 보기
status_code >= 500

# 특정 IP의 요청
client_ip: "192.168.1.100"

# POST 요청 중 실패
method: "POST" AND status_code >= 400

# 응답시간 1초 이상
response_time > 1000

3단계: Visualize로 차트 생성

# Bar Chart: HTTP 상태 코드별 요청 수
- Aggregation: Count
- Bucket: Terms → status_category
- 결과: 2xx/4xx/5xx 비율 한눈에 파악

# Line Chart: 시간대별 요청 수
- Y-axis: Count
- X-axis: Date histogram → @timestamp (Auto interval)

# Pie Chart: 상위 URL 경로별 분포
- Aggregation: Count
- Bucket: Terms → request_uri.keyword (Top 10)

4단계: Dashboard 구성

  • Kibana → Dashboard → Create dashboard
  • Add panel → 위에서 만든 차트들을 추가
  • 레이아웃 조정 후 Save as "Nginx Overview"

리소스 요구사항 및 운영 고려사항

구성 요소최소 메모리권장 메모리CPU
Elasticsearch1GB4GB+2 코어+
Logstash512MB1GB+1 코어+
Kibana512MB1GB1 코어
Filebeat50MB100MB낮음

운영 시 주의사항:

  • JVM 힙 설정: Elasticsearch는 가용 메모리의 절반을 JVM 힙에 할당합니다. 8GB 서버라면 -Xms4g -Xmx4g 설정이 최적입니다.
  • 디스크 공간: 일일 로그량의 최소 30배 이상 여유 공간을 확보합니다. ILM로 자동 삭제 정책을 반드시 설정하세요.
  • 샤드 수: 인덱스당 샤드 크기를 10~50GB 수준으로 유지합니다. 과도한 샤드는 클러스터 성능을 저하시킵니다.
  • 보안: 프로덕션 환경에서는 반드시 TLS를 활성화하고, 기본 elastic 계정 비밀번호를 변경하며, 역할 기반 접근 제어(RBAC)를 설정하세요.
  • 백업: snapshot API 또는 Kibana의 Snapshot and Restore 기능으로 정기 백업을 구성합니다.