Copy & Run

Docker로 OpenSearch와 Ollama 띄워 비용 없는 AI-SIEM 만들기

OpenSearch와 로컬 LLM인 Ollama를 Docker로 띄우고, ml-commons 커넥터로 둘을 연결합니다. 로그에서 찾은 브루트포스 경보를 LLM이 세 줄로 분류하는 데까지 확인합니다.

실습 노트 · 2026-10-15 발행 · 9분 · 2026-10 확인

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 값은 참고용이며 환경에 따라 다릅니다.

SIEM·SOAR 데이터 품질과 자동화 과정의 일부입니다

이 실습은 교육에서 참가자마다 독립된 환경으로 진행합니다. 팀 교육으로 구성이 필요하면 문의해 주세요.

과정 보기교육 문의

같은 카테고리의 글