OpenSearch에 샘플 보안 로그를 넣고, 로컬에서 도는 LLM(Ollama의 qwen2.5:3b)을 OpenSearch ml-commons에 모델로 등록합니다. 로그를 집계해 의심 IP를 찾고, 그 결과를 _predict API로 LLM에 넘겨 공격 유형, 심각도, 권고를 받아 봅니다. 외부 API 키가 필요 없고 전부 내 PC 안에서 끝납니다. 모델 라이선스는 연구용(qwen-research)이므로 업무 용도로 쓰기 전에 확인합니다.
이 글의 스택은 보안 플러그인(TLS, 인증)을 끈 실습용 구성이므로 운영 클러스터에는 쓰지 않습니다. 포트는 로컬 호스트에만 연결했습니다. 인증을 끈 이 구성을 인터넷이나 공용 네트워크에 노출하지 않습니다. 공격 신호를 흉내 낸 로그도 직접 만든 실습 환경에만 넣습니다.
준비물
| 항목 | 내용 |
|---|---|
| Docker | Docker Desktop 또는 Docker Engine, Compose v2 |
| OpenSearch | 2.19.0 (이미지 opensearchproject/opensearch:2.19.0) |
| Ollama | ollama/ollama:latest (확인한 버전은 0.33.2) |
| 모델 | qwen2.5:3b (내려받는 크기 약 1.9GB) |
| 메모리 | Docker에 할당된 메모리가 6GB 이상이어야 합니다(참고용 값이며 환경에 따라 다릅니다). OpenSearch 힙은 1GB로 설정 |
| 소요 시간 | 설정과 실행 약 30분, 이미지와 모델 내려받는 시간은 회선에 따라 별도 |
리눅스 호스트에서는 먼저 sudo sysctl -w vm.max_map_count=262144 를 실행합니다. Docker Desktop은 대개 필요 없습니다. 3단계부터는 jq가 없는 Windows도 같은 명령을 쓸 수 있도록 curl, jq, python3가 들어 있는 tools 컨테이너 안에서 실행합니다.
1. 컨테이너 세 개 띄우기
작업 폴더에 docker-compose.yml 을 만듭니다. OpenSearch, Ollama, 도구 컨테이너가 같은 네트워크에 놓이고, 컨테이너끼리는 서비스 이름(opensearch, ollama)으로 서로를 부릅니다.
mkdir ai-siem && cd ai-siem
cat > docker-compose.yml <<'YML'
services:
opensearch:
image: opensearchproject/opensearch:2.19.0
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
- DISABLE_INSTALL_DEMO_CONFIG=true
- DISABLE_SECURITY_PLUGIN=true # 실습 전용: 인증과 TLS를 끕니다
ulimits:
memlock: { soft: -1, hard: -1 }
nofile: { soft: 65536, hard: 65536 }
ports: ["127.0.0.1:9200:9200"]
volumes: [opensearch-data:/usr/share/opensearch/data]
ollama:
image: ollama/ollama:latest
ports: ["127.0.0.1:11434:11434"]
volumes: [ollama-data:/root/.ollama]
tools:
image: nicolaka/netshoot:latest
command: ["sleep", "infinity"]
volumes:
opensearch-data:
ollama-data:
YML
docker compose up -d
docker compose ps --format '{{.Service}} {{.Status}}'
기대 출력은 다음과 같습니다. 이 compose에는 healthcheck가 없어 상태가 Up 까지만 나옵니다.
ollama Up 8 seconds
opensearch Up 8 seconds
tools Up 8 seconds
2. 모델 내려받고 헬스 확인
모델은 Ollama 컨테이너 안에서 받습니다. 호스트에 Ollama가 이미 설치돼 있어도 컨테이너에 받으면 모델 파일이 섞이지 않습니다.
docker compose exec ollama ollama pull qwen2.5:3b
docker compose exec tools curl -s http://opensearch:9200/_cluster/health
pull 이 끝나면 마지막에 success 가 나옵니다. 헬스 응답에서 확인할 값은 status 와 number_of_nodes 입니다.
"status":"yellow", "number_of_nodes":1
노드가 하나라 복제본을 둘 곳이 없어 yellow 로 나옵니다. 이 실습에서는 정상입니다.
3. OpenSearch에 Ollama를 모델로 등록
tools 컨테이너에 들어가서 실행합니다. 아래 3~5단계 명령은 모두 이 셸 안에서 실행합니다. 먼저 ml-commons가 ollama:11434 로 나가는 요청을 허용하도록 클러스터 설정을 바꿉니다.
docker compose exec tools bash
OS=http://opensearch:9200
curl -s -XPUT $OS/_cluster/settings -H 'Content-Type: application/json' -d '{
"persistent": {
"plugins.ml_commons.only_run_on_ml_node": false,
"plugins.ml_commons.connector.private_ip_enabled": true,
"plugins.ml_commons.trusted_connector_endpoints_regex": ["^http://ollama:11434/.*$"]
}
}'
{"acknowledged":true,"persistent":{"plugins":{"ml_commons":{"connector":{"private_ip_enabled":"true"},"only_run_on_ml_node":"false","trusted_connector_endpoints_regex":["^http://ollama:11434/.*$"]}}},"transient":{}}
다음은 커넥터, 모델 등록, 배포입니다. Ollama의 OpenAI 호환 주소(/v1/chat/completions)를 부르도록 커넥터를 만듭니다. 인증이 없어도 credential 은 반드시 있어야 하므로 더미 값을 넣습니다.
CONNECTOR_ID=$(curl -s -XPOST $OS/_plugins/_ml/connectors/_create -H 'Content-Type: application/json' -d '{
"name": "Ollama Chat",
"version": "1",
"protocol": "http",
"credential": { "key": "ollama-no-auth" },
"parameters": { "endpoint": "ollama:11434", "model": "qwen2.5:3b" },
"actions": [{
"action_type": "predict",
"method": "POST",
"url": "http://${parameters.endpoint}/v1/chat/completions",
"headers": { "Content-Type": "application/json" },
"request_body": "{ \"model\": \"${parameters.model}\", \"messages\": ${parameters.messages}, \"temperature\": 0 }"
}]
}' | jq -r .connector_id)
MODEL_ID=$(curl -s -XPOST $OS/_plugins/_ml/models/_register -H 'Content-Type: application/json' \
-d "{\"name\":\"ollama-chat\",\"function_name\":\"remote\",\"connector_id\":\"$CONNECTOR_ID\"}" | jq -r .model_id)
curl -s -XPOST $OS/_plugins/_ml/models/$MODEL_ID/_deploy
sleep 5
curl -s $OS/_plugins/_ml/models/$MODEL_ID | jq -r .model_state
temperature 를 0으로 고정해 두면 같은 입력에 같은 답이 나와 결과를 비교하기 쉽습니다.
{"task_id":"...","task_type":"DEPLOY_MODEL","status":"COMPLETED"}
DEPLOYED
task_id 값은 실행할 때마다 다릅니다.
4. 샘플 로그 넣고 의심 IP 찾기
브루트포스 흔적 120건, 정상 접속 40건, SQL 인젝션 시도 1건을 security-web 인덱스에 넣습니다. IP는 모두 사설 대역입니다.
python3 - <<'PY'
import json, urllib.request
docs = []
for i in range(120):
docs.append({"@timestamp": "2026-10-15T09:%02d:%02dZ" % (i // 60, i % 60), "source.ip": "10.13.37.7",
"url.path": "/login", "http.response.status_code": 401, "user_agent.original": "python-requests/2.31"})
for i in range(40):
docs.append({"@timestamp": "2026-10-15T09:%02d:00Z" % i, "source.ip": "192.168.10.%d" % (i + 2),
"url.path": "/index.html", "http.response.status_code": 200, "user_agent.original": "Mozilla/5.0"})
docs.append({"@timestamp": "2026-10-15T09:30:00Z", "source.ip": "192.168.10.99", "url.path": "/api/items",
"url.query": "id=1 UNION SELECT username,password FROM users",
"http.response.status_code": 200, "user_agent.original": "sqlmap/1.7"})
body = "".join(json.dumps({"index": {"_index": "security-web"}}) + "\n" + json.dumps(d) + "\n" for d in docs)
req = urllib.request.Request("http://opensearch:9200/_bulk?refresh=true", body.encode(), {"Content-Type": "application/x-ndjson"})
print(json.load(urllib.request.urlopen(req))["errors"])
PY
False
False 는 bulk 요청에 실패한 문서가 없다는 뜻입니다. 이제 401 응답이 가장 많은 IP를 집계합니다. 동적 매핑으로 만들어진 문자열 필드는 .keyword 를 붙여야 집계할 수 있습니다.
curl -s $OS/security-web/_search -H 'Content-Type: application/json' -d '{
"size": 0,
"query": { "term": { "http.response.status_code": 401 } },
"aggs": { "by_ip": { "terms": { "field": "source.ip.keyword", "size": 3 } } }
}' | jq -c '.aggregations.by_ip.buckets'
[{"key":"10.13.37.7","doc_count":120}]
5. 집계 결과를 LLM에 넘겨 분류하기
집계에서 나온 IP와 건수를 한 문장으로 만들어 LLM에 보냅니다. 3B 모델은 예시 두 개와 판단 기준을 함께 주고, 출력을 세 줄로 제한해야 답이 안정적입니다. 한글 본문은 jq -n 으로 JSON을 만들어 파일로 보내면 인코딩 문제를 피할 수 있습니다.
EVENT=$(curl -s $OS/security-web/_search -H 'Content-Type: application/json' -d '{
"size": 0,
"query": { "term": { "http.response.status_code": 401 } },
"aggs": { "by_ip": { "terms": { "field": "source.ip.keyword", "size": 1 } } }
}' | jq -r '.aggregations.by_ip.buckets[0] | "\(.key) 가 /login 에 401 을 \(.doc_count)회 발생, UA=python-requests/2.31"')
echo "$EVENT"
jq -n --arg event "$EVENT" '{parameters: {messages: [
{role: "system", content: "너는 SOC 분석가다. 한국어 3줄로 답한다. 1)공격유형 2)심각도(상/중/하) 3)권고. 공격유형은 [브루트포스,SQLi,XSS,스캐닝,정상] 중 하나. 신호: /login 401 다수=브루트포스, UNION SELECT나 UA=sqlmap=SQLi, <script>=XSS, 경로 404 다수=스캐닝, 2xx 정상응답만=정상."},
{role: "user", content: "10.20.30.40 가 2분간 /login 에 POST 200건, 대부분 401, UA=hydra."},
{role: "assistant", content: "공격유형: 브루트포스\n심각도: 상\n권고: 해당 IP 차단 + 로그인 rate limit."},
{role: "user", content: "192.168.10.5 가 /index.html, /a.css GET 12건 모두 200."},
{role: "assistant", content: "공격유형: 정상\n심각도: 하\n권고: 조치 불필요."},
{role: "user", content: $event}]}}' > /tmp/body.json
curl -s -XPOST $OS/_plugins/_ml/models/$MODEL_ID/_predict -H 'Content-Type: application/json' -d @/tmp/body.json \
| jq -r '.inference_results[0].output[0].dataAsMap.choices[0].message.content'
처음 호출은 모델을 메모리에 올리느라 오래 걸릴 수 있습니다.
10.13.37.7 가 /login 에 401 을 120회 발생, UA=python-requests/2.31
공격유형: 브루트포스
심각도: 상
권고: 해당 IP를 차단하여 보안 강화.
권고 문장의 표현은 모델과 입력에 따라 달라지며, 위 출력의 "보안 강화"는 구체적인 조치가 아니므로 정확한 권고로 볼 수 없습니다. 공격유형과 심각도가 맞는지를 기준으로 확인합니다.
자주 막히는 곳
Connector credential is null or empty list: 인증이 없는 Ollama여도 credential 필드가 필요합니다. 위 예시처럼 아무 값이나 넣습니다.
Connector version is null: 커넥터 본문에 "version": "1" 이 빠졌습니다.
Connector URL is not matching the trusted connector endpoint regex, URL is: http://...: 3단계의 trusted_connector_endpoints_regex 설정이 적용되지 않았거나, 커넥터의 호스트가 정규식과 다릅니다. 컨테이너 안에서는 localhost 가 아니라 ollama:11434 를 씁니다.
OpenSearch 컨테이너가 바로 종료됨: 메모리 또는 vm.max_map_count 부족인 경우가 많습니다. docker compose logs opensearch 로 종료 사유를 먼저 봅니다.
이어서 해 볼 것
- 다른 공격 분류: 같은 프롬프트에
UA=sqlmap/1.7과UNION SELECT쿼리를 적은 문장을 넣어 SQLi로 분류되는지 봅니다. 공격유형은 SQLi, 심각도는 상으로 분류되지만 권고 문장은 부정확할 수 있습니다. - 모델 교체: 메모리가 부족하면
qwen2.5:1.5b(약 1GB), 여유가 있으면qwen2.5:7b(약 5GB)로 커넥터의model값만 바꿔 같은 입력의 답을 비교합니다.llama3.2:3b는 한국어 출력이 불안정할 수 있습니다. 모델 크기는 참고용 값이며 환경에 따라 다릅니다. - OpenSearch Dashboards 추가:
opensearchproject/opensearch-dashboards:2.19.0을 같은 네트워크에 붙이면 Discover와 Anomaly Detection을 쓸 수 있습니다. Assistant 챗 연결은 ml-commons 에이전트 스키마가 버전마다 달라, 쓰는 버전의 공식 문서를 확인해야 합니다.
확인 범위
OpenSearch 2.19.0과 Ollama, qwen2.5:3b를 Docker로 실제 띄워 위 명령을 실행했고, 인용한 출력과 오류 문구는 그 결과입니다. 다만 Dashboards는 이 글의 compose에 넣지 않았고, Anomaly Detection과 Assistant 챗은 실행하지 않았습니다. 모델 크기, 메모리 요구량, vm.max_map_count 값은 참고용이며 환경에 따라 다릅니다.