Docker로 ELK(Elasticsearch, Logstash, Kibana)를 띄우는 방법은 찾기 쉽다. docker-elk 저장소를 받아 docker compose up 하면 된다. 막힌 건 그다음이었다. 이미 따로 돌고 있는 애플리케이션의 Docker Compose에 ELK를 합친 사례는 찾지 못했다. 내 경우 그 애플리케이션은 Nginx Proxy Manager였고, 모으려던 로그는 그 앞에서 ModSecurity가 남기는 audit 로그였다.
“Docker 컨테이너로 구성"과 “다른 애플리케이션에 결합”, 이 두 조건을 같이 만족하는 레퍼런스가 없어서 소스를 이것저것 뜯어보며 붙여야 했고, 시간이 제법 들었다. 일은 두 단계로 나눴다. 먼저 docker-elk 옆에 Nginx 컨테이너 하나를 붙여 access 로그가 Kibana까지 가는지 확인하고, 같은 구성을 Nginx Proxy Manager 쪽으로 옮겼다.
두 단계의 핵심은 같았다. 로그를 쓰는 컨테이너와 Logstash가 같은 볼륨을 본다. 이 글에서는 테스트 구성과 통합 구성을 차례로 정리하고, 지금 다시 보니 손봐야 할 설정도 덧붙인다.
docker-elk 옆에 Nginx 한 대를 붙여 먼저 시험했다
출발점은 deviantony/docker-elk다.
git clone https://github.com/deviantony/docker-elk.git
cd docker-elk
각 사용자의 비밀번호는 .env 파일에서 정한다. compose 파일의 ${ELASTIC_PASSWORD}, ${LOGSTASH_INTERNAL_PASSWORD} 같은 값이 여기서 온다.
여기에 web(nginx:latest) 서비스를 추가했다. 기본 페이지에 접속하면 남는 access 로그를 Logstash가 읽어 Elasticsearch로 넘기는 구성이다.
version: '3.7'
services:
# The 'setup' service runs a one-off script which initializes users inside
# Elasticsearch — such as 'logstash_internal' and 'kibana_system' — with the
# values of the passwords defined in the '.env' file. It also creates the
# roles required by some of these users.
#
# This task only needs to be performed once, during the *initial* startup of
# the stack. Any subsequent run will reset the passwords of existing users to
# the values defined inside the '.env' file, and the built-in roles to their
# default permissions.
#
# By default, it is excluded from the services started by 'docker compose up'
# due to the non-default profile it belongs to. To run it, either provide the
# '--profile=setup' CLI flag to Compose commands, or "up" the service by name
# such as 'docker compose up setup'.
setup:
profiles:
- setup
build:
context: setup/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
init: true
volumes:
- ./setup/entrypoint.sh:/entrypoint.sh:ro,Z
- ./setup/lib.sh:/lib.sh:ro,Z
- ./setup/roles:/roles:ro,Z
environment:
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-}
METRICBEAT_INTERNAL_PASSWORD: ${METRICBEAT_INTERNAL_PASSWORD:-}
FILEBEAT_INTERNAL_PASSWORD: ${FILEBEAT_INTERNAL_PASSWORD:-}
HEARTBEAT_INTERNAL_PASSWORD: ${HEARTBEAT_INTERNAL_PASSWORD:-}
MONITORING_INTERNAL_PASSWORD: ${MONITORING_INTERNAL_PASSWORD:-}
BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-}
networks:
- elk
depends_on:
- elasticsearch
- kibana
- logstash
elasticsearch:
build:
context: elasticsearch/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
volumes:
- ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z
- elasticsearch:/usr/share/elasticsearch/data:Z
ports:
- 9200:9200
- 9300:9300
environment:
node.name: elasticsearch
ES_JAVA_OPTS: -Xms512m -Xmx512m
# Bootstrap password.
# Used to initialize the keystore during the initial startup of
# Elasticsearch. Ignored on subsequent runs.
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
# Use single node discovery in order to disable production mode and avoid bootstrap checks.
# see: https://www.elastic.co/guide/en/elasticsearch/reference/current/bootstrap-checks.html
discovery.type: single-node
networks:
- elk
restart: unless-stopped
# 추가한 서비스: Nginx 로그 디렉토리를 web-logs 볼륨으로 내보낸다
web:
image: nginx:latest
volumes:
- web-logs:/var/log/nginx:Z # Nginx 로그 디렉토리 공유
networks:
- elk
depends_on:
- logstash
restart: unless-stopped
ports:
- 8081:80
logstash:
build:
context: logstash/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
volumes:
- ./logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro,Z
- ./logstash/pipeline:/usr/share/logstash/pipeline:ro,Z
- ./logstash/input:/usr/share/logstash/input:rw,Z
- web-logs:/var/log/nginx:Z # 새로운 볼륨 추가 (web과 같은 볼륨)
ports:
- 5044:5044
- 50000:50000/tcp
- 50000:50000/udp
- 9600:9600
environment:
LS_JAVA_OPTS: -Xms256m -Xmx256m
LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
networks:
- elk
depends_on:
- elasticsearch
restart: unless-stopped
kibana:
build:
context: kibana/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
volumes:
- ./kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro,Z
ports:
- 5601:5601
environment:
KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-}
networks:
- elk
depends_on:
- elasticsearch
restart: unless-stopped
networks:
elk:
driver: bridge
volumes:
elasticsearch:
web-logs:
원본에서 바뀐 곳은 web 서비스, Logstash의 web-logs 마운트, 맨 아래 web-logs 볼륨 선언 세 군데다. web과 logstash가 같은 web-logs:/var/log/nginx를 마운트한다는 점만 보면 된다.
docker-elk는 기본값이 trial 라이선스라서 basic으로 바꿨다.
xpack.license.self_generated.type: trial -> basic
Logstash 파이프라인에는 file input을 두 개 뒀다. 하나는 CSV 테스트 파일, 하나는 Nginx access 로그다.
input {
beats {
port => 5044
}
tcp {
port => 50000
}
file {
path => "/usr/share/logstash/input/test-input.csv"
start_position => "beginning"
sincedb_path => "/usr/share/logstash/input/sincedb_file"
mode => "tail"
}
file {
path => "/var/log/nginx/host.access.log" # 새로운 로그 파일 경로
start_position => "beginning"
sincedb_path => "NUL"
}
}
output {
elasticsearch {
hosts => "elasticsearch:9200"
user => "elastic"
password => "${LOGSTASH_INTERNAL_PASSWORD}"
index => "test-input"
}
stdout {
codec => json_lines
}
}
docker-compose up -d
Nginx 이미지는 기본 설정에서 host.access.log가 주석 처리되어 있다. 원래라면 설정 파일을 마운트해서 켜야 하지만, 시험 단계라 컨테이너에 들어가 직접 주석을 풀었다.
docker exec -it docker-elk-web-1 /bin/bash
vi /etc/nginx/conf.d/default.conf
`===============================================
#access_log /var/log/nginx/host.access.log main;
===============================================`
# 위 내용 주석처리('#') 제거 후 저장
service nginx restart
확인 순서는 이렇다.
localhost:8081에 접속해host.access.log에 access 로그가 쌓이는지 본다.Kibana(
localhost:5601) > Stack Management > Index management에서test-input인덱스를 찾는다.Console에서
GET test-input/_search?pretty로 access 로그가 들어왔는지 본다.
로그가 보였다. 여기까지는 금방 끝났다.
로그는 네트워크가 아니라 볼륨으로 건넨다
통합으로 넘어가기 전에 무엇이 동작한 건지 짚어둔다. Logstash file input은 파일을 tail할 뿐이라, 애플리케이션 쪽에는 아무것도 설치하지 않아도 된다. 애플리케이션 컨테이너가 로그를 파일로 남기면 그 디렉토리를 named volume으로 만들어 Logstash 컨테이너에도 마운트한다. 이번 구성에는 Filebeat 같은 수집기를 따로 두지 않았다. Logstash와 Kibana는 같은 bridge 네트워크 안에서 서비스 이름(elasticsearch:9200)으로 Elasticsearch를 부른다.
통합 구성에서는 흐름이 이렇게 된다.
graph LR
NPM["npm_core<br/>Nginx Proxy Manager + ModSecurity"] -- "modsec_audit.log 기록" --> V[("npm_data 볼륨<br/>/data/logs")]
V -- "file input (tail)" --> LS[logstash]
LS -- "elasticsearch:9200" --> ES[elasticsearch]
KB["kibana :5601"] --> ES하나 더 알아둘 것이 setup 서비스다. compose 주석에 적혀 있듯 setup은 logstash_internal, kibana_system 같은 내장 사용자와 롤을 .env의 비밀번호로 만드는 일회성 스크립트다. setup profile에 묶여 있어서 평소 docker compose up으로는 뜨지 않는다. 처음 한 번은 --profile setup을 붙여 올린다. 다시 돌리면 기존 사용자의 비밀번호와 롤이 .env 값으로 초기화된다.
같은 구성을 Nginx Proxy Manager Compose로 옮겼다
docker-elk 디렉토리를 Nginx Proxy Manager 저장소 안 elk/로 옮기고 compose 파일은 docker/에 두었다. 테스트 구성과 비교하면 바뀐 건 대부분 경로와 이름이다.
| 구분 | 테스트 구성 | 통합 구성 |
|---|---|---|
| 로그 출처 | Nginx access 로그 /var/log/nginx/host.access.log | ModSecurity audit 로그 /data/logs/modsec_audit.log |
| 공유 볼륨 | web-logs | npm_data (이름 npm_core_data) |
| 네트워크 | elk | nginx_proxy_manager (이름 npm_network) |
| 인덱스 | test-input | modsecurity-audit-%{+YYYY.MM.dd} |
| build context | elasticsearch/ 등 | ../elk/elasticsearch/ 등 |

주요 파일은 세 개다. 먼저 kibana.yml이다.
server.name: kibana
server.host: 0.0.0.0
elasticsearch.hosts: [ http://elasticsearch:9200 ]
monitoring.ui.container.elasticsearch.enabled: true
monitoring.ui.container.logstash.enabled: true
elasticsearch.username: kibana_system
elasticsearch.password: ${KIBANA_SYSTEM_PASSWORD}
logstash.conf는 ModSecurity audit 로그 하나만 읽는다. start_position => "end"라 이미 쌓인 로그는 건너뛰고 새로 붙는 줄부터 읽는다.
input {
file {
path => "/data/logs/modsec_audit.log"
start_position => "end"
sincedb_path => "/data/logs/.sincedb_modsec_audit"
type => "modsecurity-audit"
}
}
filter {
if [type] == "modsecurity-audit" {
grok {
match => {
"message" => "(?m)%{DATA:modsec_timestamp}.*%{DATA:transaction_id}.*%{GREEDYDATA:raw_data}"
}
}
date {
match => [ "timestamp", "dd/MMM/yyyy:HH:mm:ss Z" ]
}
}
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "modsecurity-audit-%{+YYYY.MM.dd}"
user => "elastic"
password => "${LOGSTASH_INTERNAL_PASSWORD:-}"
ssl => false
ssl_certificate_verification => false
}
}
docker-compose.yml에서 눈여겨볼 곳은 Logstash의 npm_data:/data:rw 한 줄이다. Nginx Proxy Manager(npm)가 쓰는 npm_data 볼륨을 Logstash도 그대로 마운트한다.
# WARNING: This is a DEVELOPMENT docker-compose file, it should not be used for production.
services:
setup:
profiles:
- setup
build:
context: ../elk/setup/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
init: true
volumes:
- ../elk/setup/entrypoint.sh:/entrypoint.sh:ro,Z
- ../elk/setup/lib.sh:/lib.sh:ro,Z
- ../elk/setup/roles:/roles:ro,Z
environment:
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-}
METRICBEAT_INTERNAL_PASSWORD: ${METRICBEAT_INTERNAL_PASSWORD:-}
FILEBEAT_INTERNAL_PASSWORD: ${FILEBEAT_INTERNAL_PASSWORD:-}
HEARTBEAT_INTERNAL_PASSWORD: ${HEARTBEAT_INTERNAL_PASSWORD:-}
MONITORING_INTERNAL_PASSWORD: ${MONITORING_INTERNAL_PASSWORD:-}
BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-}
networks:
- nginx_proxy_manager
depends_on:
- elasticsearch
- kibana
- logstash
npm:
image: nginxproxymanager:dev
..(생략)..
volumes:
- npm_data:/data
- le_data:/etc/letsencrypt
- ../backend:/app
- ../frontend:/app/frontend
- ../global:/app/global
depends_on:
- db
working_dir: /app
db:
..(생략)..
elasticsearch:
build:
context: ../elk/elasticsearch/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
init: true
volumes:
- ../elk/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z
- elasticsearch_data:/usr/share/elasticsearch/data:Z # !
container_name: elasticsearch
environment:
node.name: elasticsearch
ES_JAVA_OPTS: -Xms512m -Xmx512m
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
discovery.type: single-node
ports:
- 9200:9200
networks:
- nginx_proxy_manager
logstash:
build:
context: ../elk/logstash/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
container_name: logstash
volumes:
- ../elk/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro,Z
- ../elk/logstash/pipeline:/usr/share/logstash/pipeline:ro,Z
- npm_data:/data:rw # Nginx Proxy Manager와 같은 볼륨
ports:
- 5044:5044
networks:
- nginx_proxy_manager
depends_on:
- elasticsearch
environment:
LS_JAVA_OPTS: -Xms256m -Xmx256m
LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
kibana:
build:
context: ../elk/kibana/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
container_name: kibana
volumes:
- ../elk/kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro,Z
ports:
- 5601:5601
environment:
KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-}
networks:
- nginx_proxy_manager
depends_on:
- elasticsearch
volumes:
npm_data:
name: npm_core_data
le_data:
name: npm_le_data
db_data:
name: npm_db_data
elasticsearch_data:
driver: local
name: elk_es_data
networks:
nginx_proxy_manager:
name: npm_network
driver: bridge
setup profile을 붙여 한 번에 올렸다
docker compose -f docker-compose.yml --profile setup up



몇 가지 시행착오 끝에 컨테이너가 모두 떴다. Nginx Proxy Manager 관리자 페이지(localhost:3081)에 이어 Kibana(localhost:5601)도 정상으로 열렸다.
Nginx Proxy Manager 관리자 페이지:
http://localhost:3081 → ✅ 정상


Kibana 페이지:
http://localhost:5601 → ✅ 정상


→ ✅ logstash를 통해 modsec log 수집 성공 Logstash를 거쳐 ModSecurity 로그가 Elasticsearch에 쌓이는 것까지 확인했다.
Kibana에서 ModSecurity 로그를 보려면 Data View가 필요하다
모든 서비스가 뜨면 localhost:3080(추후 Nginx Proxy Manager 프록시를 거쳐갈 트래픽 대상 서비스) 접속에 대한 ModSecurity audit 로그가 Elasticsearch에 저장된다. 인덱스에 들어가 있을 뿐이니 Kibana에서 보려면 Data View를 하나 만들어야 한다.
Kibana(
http://localhost:5601)에 접속한다. 초기 계정은 IDelastic, PASSWORDchangeme다.좌측 네비게이션 >
Stack Management>Data view>Create Data View
인덱스 명: (아무 이름)
인덱스 패턴:
modsecurity-audit-*시간 필드:
@timestamp
이제 Discover 메뉴에서 http://localhost:3080 접속 로그를 보고 Visualize 메뉴에서 차트를 구성할 수 있다.
수집은 됐지만 다시 보니 손볼 곳이 있다
돌아가는 것과 맞게 설정된 것은 다르다. 지금 설정을 다시 읽으면 걸리는 곳이 네 군데 있다.
Logstash가
elastic사용자에LOGSTASH_INTERNAL_PASSWORD를 쓴다. 두 설정 모두user => "elastic"인데 비밀번호는logstash_internal용 값이다..env의 비밀번호가 모두 같은 초기값이라 통과한 것으로 보이는데, 비밀번호를 바꾸는 순간 깨진다.user를logstash_internal로 맞추는 게 docker-elk의 의도다.테스트 구성의
sincedb_path => "NUL"은 Windows의 null 장치다. 리눅스 컨테이너에서는NUL이라는 파일이 생길 뿐이다. 읽은 위치를 저장하지 않으려던 거라면/dev/null이 맞다.ModSecurity audit 로그가 줄 단위로 쪼개져 들어간다. Discover 화면을 보면
---VsiPw0KO---Z--같은 섹션 구분선이나 빈message가 문서 하나씩 차지한다. audit 로그는 한 트랜잭션이 여러 줄이라 file input에 multiline codec을 걸어야 한 건으로 묶인다. 파싱도 덜 됐다.date필터는timestamp필드를 찾는데 grok이 만드는 건modsec_timestamp라서, 지금@timestamp는 로그 시각이 아니라 수집 시각이다.ssl_certificate_verification은 deprecated다. 실행 로그에ssl_verification_mode로 바꾸라는 경고가 찍힌다.
정리
ELK를 다른 애플리케이션에 붙이는 일은 결국 로그 볼륨을 같이 마운트하는 일이다. Logstash file input이 그 볼륨의 파일을 tail한다.
작은 Nginx 테스트로 경로를 먼저 확인해 두니, 통합 단계에서 바꿀 것은 대부분 경로, 볼륨, 네트워크 이름이었다.
setup서비스는 사용자와 롤을 만드는 일회성 작업이라 처음 한 번만--profile setup으로 올린다.수집은 됐지만 파싱은 아직이다. multiline 처리와 날짜 필드가 다음 과제다.
같은 조건의 레퍼런스를 찾지 못해 오래 걸린 작업이었다. 아무튼 성공이다.
📎 Reference.