본문 바로가기
일반IT/AI

GPU Ollama 컨테이너 기반 보안 AI 웹앱 만들기

by gasbugs 2026. 7. 4.
반응형

Flask + Ollama + NVD API로 Local CVE Analyst 만들기

이번 튜토리얼에서는 이미 GPU 기반으로 실행 중인 Ollama 컨테이너를 활용해서 직접 웹 애플리케이션을 만들어보겠습니다.

 

단순히 “LLM에게 질문하는 챗봇”이 아니라, 외부 API에서 실제 데이터를 가져오고, 그 데이터를 로컬 LLM이 분석하도록 구성합니다. 예제로 만들 앱은 Local CVE Analyst입니다.

 

사용자가 CVE ID 또는 키워드를 입력하면 Flask 앱이 NVD API에서 취약점 정보를 조회하고, Ollama에 실행 중인 로컬 LLM이 해당 취약점을 한국어로 요약·분석해주는 구조입니다.

 

Ollama의 /api/chat 엔드포인트는 model, messages를 입력받아 대화형 응답을 생성할 수 있고, 기본적으로 스트리밍 응답을 사용합니다. 튜토리얼에서는 Flask에서 처리하기 쉽게 stream: false를 사용합니다.

 


1. 완성할 앱 구조

이번에 만들 구조는 다음과 같습니다.

사용자 브라우저
    ↓
Flask Web UI
    ↓
Flask Backend
    ├── NVD CVE API 호출
    └── Ollama API 호출
            ↓
      로컬 GPU LLM 응답 생성
            ↓
사용자 화면에 CVE 원본 정보 + AI 분석 결과 출력

핵심은 Flask 서버가 중간에서 두 가지 역할을 한다는 점입니다.

첫째, 사용자가 입력한 CVE ID 또는 키워드로 NVD API를 호출합니다.
둘째, NVD에서 가져온 취약점 정보를 Ollama에게 전달해 분석 결과를 받아옵니다.

 

이번 튜토리얼에서는 LLM이 직접 API를 호출하게 만들지 않고, Flask가 명시적으로 API를 호출한 다음 그 결과를 LLM에게 넘깁니다. 이렇게 하면 초급자도 흐름을 이해하기 쉽고, 실습 중 오류도 줄일 수 있습니다.

 


2. 사전 조건

이 튜토리얼은 다음 상태를 전제로 합니다.

1. Ollama 컨테이너가 이미 실행 중이다.
2. Ollama 컨테이너가 GPU를 사용하도록 구성되어 있다.
3. Ollama API 포트 11434에 접근할 수 있다.
4. 사용할 모델이 이미 pull 되어 있거나, pull 할 수 있다.

 

예를 들어 호스트에서 다음 명령이 정상 동작해야 합니다.

curl http://localhost:11434/api/tags

 

응답 예시는 다음과 비슷합니다.

{
  "models": [
    {
      "name": "llama3.1:latest"
    }
  ]
}

 

간단한 채팅 호출도 확인해봅니다.

curl http://localhost:11434/api/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.1",
    "messages": [
      {
        "role": "user",
        "content": "Ollama가 무엇인지 한 문장으로 설명해줘."
      }
    ],
    "stream": false
  }'

 

stream: false를 사용하면 응답이 여러 조각으로 나뉘지 않고 JSON 하나로 반환되기 때문에 Flask 앱에서 다루기 쉽습니다.


3. 프로젝트 디렉터리 만들기

먼저 프로젝트 디렉터리를 생성합니다.

mkdir ollama-flask-cve-app
cd ollama-flask-cve-app

 

전체 구조는 다음과 같습니다.

ollama-flask-cve-app/
├── app.py
├── requirements.txt
├── Dockerfile
├── docker-compose.app.yml
├── templates/
│   └── index.html
└── static/
    └── style.css

 

Flask는 기본적으로 HTML 템플릿을 templates 디렉터리에서 찾고, render_template() 함수를 사용해 HTML 페이지를 렌더링합니다.


4. Python 패키지 정의

requirements.txt 파일을 만듭니다.

Flask==3.1.1
requests==2.32.4

 

Flask는 웹 UI와 라우팅을 담당하고, requests는 Ollama API와 NVD API를 호출하는 데 사용합니다.


5. Flask 백엔드 작성

이제 app.py를 작성합니다.

import os
import re
from typing import Any, Dict, List, Optional

import requests
from flask import Flask, render_template, request


app = Flask(__name__)


# Ollama 컨테이너가 호스트에서 11434 포트로 노출되어 있으면 localhost 사용
# Flask 앱도 컨테이너로 실행하고 Ollama 컨테이너와 같은 네트워크에 붙이면
# 예: http://lab-ollama:11434/api/chat 또는 http://ollama:11434/api/chat
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434/api/chat")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1")

# NVD CVE API 2.0
NVD_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"

# 선택 사항: NVD API Key
# API Key가 있으면 환경변수로 지정합니다.
# export NVD_API_KEY="your-api-key"
NVD_API_KEY = os.getenv("NVD_API_KEY", "")

# 키워드 검색 시 가져올 최대 CVE 개수
NVD_RESULTS_PER_PAGE = int(os.getenv("NVD_RESULTS_PER_PAGE", "5"))


def is_cve_id(keyword: str) -> bool:
    """
    입력값이 CVE ID 형식인지 확인합니다.
    예: CVE-2024-3094
    """
    return bool(re.fullmatch(r"CVE-\d{4}-\d{4,}", keyword.strip(), re.IGNORECASE))


def call_nvd_api(keyword: str) -> Dict[str, Any]:
    """
    NVD API에서 CVE 정보를 가져옵니다.

    CVE ID가 입력되면 cveIds 파라미터를 사용하고,
    일반 키워드가 입력되면 keywordSearch 파라미터를 사용합니다.
    """
    keyword = keyword.strip()

    if is_cve_id(keyword):
        params = {
            "cveIds": keyword.upper()
        }
    else:
        params = {
            "keywordSearch": keyword,
            "resultsPerPage": NVD_RESULTS_PER_PAGE
        }

    headers = {}

    if NVD_API_KEY:
        headers["apiKey"] = NVD_API_KEY

    response = requests.get(
        NVD_API_URL,
        params=params,
        headers=headers,
        timeout=30
    )

    response.raise_for_status()
    return response.json()


def get_english_description(cve: Dict[str, Any]) -> str:
    """
    CVE 설명 중 영어 설명을 우선 추출합니다.
    """
    descriptions = cve.get("descriptions", [])

    for item in descriptions:
        if item.get("lang") == "en":
            return item.get("value", "")

    if descriptions:
        return descriptions[0].get("value", "")

    return ""


def get_cvss_info(cve: Dict[str, Any]) -> Dict[str, str]:
    """
    CVSS 정보를 추출합니다.
    CVSS v4.0, v3.1, v3.0, v2 순서로 확인합니다.
    """
    metrics = cve.get("metrics", {})

    metric_order = [
        "cvssMetricV40",
        "cvssMetricV31",
        "cvssMetricV30",
        "cvssMetricV2",
    ]

    for metric_key in metric_order:
        metric_items = metrics.get(metric_key)

        if not metric_items:
            continue

        metric = metric_items[0]
        cvss_data = metric.get("cvssData", {})

        return {
            "version": str(cvss_data.get("version", metric_key)),
            "score": str(cvss_data.get("baseScore", "UNKNOWN")),
            "severity": str(
                metric.get("baseSeverity")
                or cvss_data.get("baseSeverity")
                or "UNKNOWN"
            ),
            "vector": str(cvss_data.get("vectorString", "UNKNOWN")),
        }

    return {
        "version": "UNKNOWN",
        "score": "UNKNOWN",
        "severity": "UNKNOWN",
        "vector": "UNKNOWN",
    }


def get_cwe_list(cve: Dict[str, Any]) -> List[str]:
    """
    CWE 목록을 추출합니다.
    """
    weaknesses = cve.get("weaknesses", [])
    cwe_list = []

    for weakness in weaknesses:
        for desc in weakness.get("description", []):
            value = desc.get("value")
            if value:
                cwe_list.append(value)

    return sorted(set(cwe_list))


def get_reference_urls(cve: Dict[str, Any], limit: int = 5) -> List[str]:
    """
    참고 URL을 일부만 추출합니다.
    """
    references = cve.get("references", {}).get("referenceData", [])
    urls = []

    for ref in references[:limit]:
        url = ref.get("url")
        if url:
            urls.append(url)

    return urls


def extract_cve_items(nvd_data: Dict[str, Any]) -> List[Dict[str, Any]]:
    """
    NVD API 응답에서 화면과 프롬프트에 사용할 정보만 추출합니다.
    """
    vulnerabilities = nvd_data.get("vulnerabilities", [])
    result = []

    for item in vulnerabilities:
        cve = item.get("cve", {})
        cvss = get_cvss_info(cve)

        result.append(
            {
                "id": cve.get("id", "UNKNOWN"),
                "published": cve.get("published", "UNKNOWN"),
                "last_modified": cve.get("lastModified", "UNKNOWN"),
                "status": cve.get("vulnStatus", "UNKNOWN"),
                "description": get_english_description(cve),
                "cvss_version": cvss["version"],
                "cvss_score": cvss["score"],
                "severity": cvss["severity"],
                "vector": cvss["vector"],
                "cwe": get_cwe_list(cve),
                "references": get_reference_urls(cve),
            }
        )

    return result


def format_cve_items_for_prompt(cve_items: List[Dict[str, Any]]) -> str:
    """
    Ollama에게 전달할 텍스트를 만듭니다.
    원본 JSON 전체를 넘기지 않고 필요한 필드만 전달합니다.
    """
    if not cve_items:
        return "검색 결과가 없습니다."

    lines = []

    for item in cve_items:
        cwe_text = ", ".join(item["cwe"]) if item["cwe"] else "UNKNOWN"
        ref_text = "\n".join(item["references"]) if item["references"] else "NONE"

        lines.append(
            f"""
CVE ID: {item["id"]}
Published: {item["published"]}
Last Modified: {item["last_modified"]}
Status: {item["status"]}
Severity: {item["severity"]}
CVSS Version: {item["cvss_version"]}
CVSS Score: {item["cvss_score"]}
CVSS Vector: {item["vector"]}
CWE: {cwe_text}
Description:
{item["description"]}

References:
{ref_text}
"""
        )

    return "\n---\n".join(lines)


def ask_ollama(cve_text: str) -> str:
    """
    Ollama API를 호출해 CVE 분석 결과를 생성합니다.
    """
    prompt = f"""
너는 보안 분석가다.
아래 CVE 정보를 바탕으로 한국어로 분석해라.

반드시 지켜야 할 규칙:
1. 제공된 CVE 정보에 근거해서만 설명한다.
2. 원문에 없는 공격 절차를 과장해서 만들지 않는다.
3. 초보자도 이해할 수 있게 설명한다.
4. 운영자 관점의 대응 방안을 제시한다.
5. 출력은 다음 형식을 따른다.

출력 형식:
## 요약
## 취약점 설명
## 악용 가능성
## 위험도 판단
## 운영자 대응 방안
## 참고할 점

CVE 정보:
{cve_text}
"""

    payload = {
        "model": OLLAMA_MODEL,
        "messages": [
            {
                "role": "user",
                "content": prompt
            }
        ],
        "stream": False,
        "options": {
            "temperature": 0.2
        }
    }

    response = requests.post(
        OLLAMA_URL,
        json=payload,
        timeout=180
    )

    response.raise_for_status()
    data = response.json()

    return data.get("message", {}).get("content", "")


@app.route("/", methods=["GET", "POST"])
def index():
    """
    메인 페이지입니다.
    GET 요청이면 검색 폼만 보여주고,
    POST 요청이면 NVD API 조회 후 Ollama 분석 결과를 보여줍니다.
    """
    query = ""
    cve_items = []
    cve_prompt_text = ""
    analysis_result = ""
    error = ""

    if request.method == "POST":
        query = request.form.get("query", "").strip()

        if not query:
            error = "CVE ID 또는 검색 키워드를 입력하세요."
        else:
            try:
                nvd_data = call_nvd_api(query)
                cve_items = extract_cve_items(nvd_data)
                cve_prompt_text = format_cve_items_for_prompt(cve_items)

                if cve_items:
                    analysis_result = ask_ollama(cve_prompt_text)
                else:
                    error = "NVD API에서 검색 결과를 찾지 못했습니다."

            except requests.exceptions.HTTPError as e:
                error = f"HTTP 오류가 발생했습니다: {e}"
            except requests.exceptions.Timeout:
                error = "요청 시간이 초과되었습니다. 모델이 너무 크거나 API 응답이 느릴 수 있습니다."
            except requests.exceptions.RequestException as e:
                error = f"API 호출 중 오류가 발생했습니다: {e}"
            except Exception as e:
                error = f"처리 중 예상하지 못한 오류가 발생했습니다: {e}"

    return render_template(
        "index.html",
        query=query,
        model=OLLAMA_MODEL,
        ollama_url=OLLAMA_URL,
        cve_items=cve_items,
        cve_prompt_text=cve_prompt_text,
        analysis_result=analysis_result,
        error=error,
    )


if __name__ == "__main__":
    app.run(
        host="0.0.0.0",
        port=5000,
        debug=True
    )

 

NVD CVE API 2.0의 기본 URL은 https://services.nvd.nist.gov/rest/json/cves/2.0입니다. 단건 CVE 조회에는 cveIds 파라미터를 사용할 수 있고, 키워드 검색에는 keywordSearch를 사용할 수 있습니다. resultsPerPage와 startIndex는 결과 페이지네이션에 사용됩니다.

 

또한 NVD API Key는 요청 헤더에 apiKey:{key value} 형태로 전달하며, API Key가 없으면 30초당 5회, API Key가 있으면 30초당 50회 제한이 적용됩니다.


6. HTML UI 작성

이제 templates/index.html 파일을 만듭니다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <title>Local CVE Analyst</title>

  <link
    rel="stylesheet"
    href="{{ url_for('static', filename='style.css') }}"
  />
</head>
<body>
  <main class="page">
    <section class="hero">
      <div class="badge">Ollama + Flask + NVD API</div>
      <h1>Local CVE Analyst</h1>
      <p>
        GPU 기반 Ollama 컨테이너와 NVD API를 연동해
        CVE 정보를 로컬 LLM으로 분석하는 보안 AI 웹앱입니다.
      </p>
    </section>

    <section class="panel">
      <form method="POST" class="search-form">
        <label for="query">CVE ID 또는 검색 키워드</label>

        <div class="input-row">
          <input
            id="query"
            name="query"
            type="text"
            value="{{ query }}"
            placeholder="예: CVE-2024-3094 또는 openssl"
            autocomplete="off"
          />

          <button type="submit">
            분석하기
          </button>
        </div>
      </form>

      <div class="runtime-info">
        <span>Model: {{ model }}</span>
        <span>Ollama API: {{ ollama_url }}</span>
      </div>
    </section>

    {% if error %}
    <section class="alert">
      {{ error }}
    </section>
    {% endif %}

    {% if cve_items %}
    <section class="grid">
      <div class="panel">
        <h2>NVD 검색 결과</h2>

        {% for item in cve_items %}
        <article class="cve-card">
          <div class="cve-header">
            <h3>{{ item.id }}</h3>
            <span class="severity">{{ item.severity }}</span>
          </div>

          <dl>
            <div>
              <dt>CVSS</dt>
              <dd>{{ item.cvss_score }} / {{ item.cvss_version }}</dd>
            </div>
            <div>
              <dt>Status</dt>
              <dd>{{ item.status }}</dd>
            </div>
            <div>
              <dt>Published</dt>
              <dd>{{ item.published }}</dd>
            </div>
            <div>
              <dt>Last Modified</dt>
              <dd>{{ item.last_modified }}</dd>
            </div>
          </dl>

          <p class="description">
            {{ item.description }}
          </p>

          {% if item.cwe %}
          <div class="chips">
            {% for cwe in item.cwe %}
            <span>{{ cwe }}</span>
            {% endfor %}
          </div>
          {% endif %}
        </article>
        {% endfor %}
      </div>

      <div class="panel">
        <h2>Ollama 분석 결과</h2>

        {% if analysis_result %}
        <pre class="analysis">{{ analysis_result }}</pre>
        {% else %}
        <p class="muted">분석 결과가 없습니다.</p>
        {% endif %}
      </div>
    </section>

    <section class="panel">
      <h2>LLM에 전달한 요약 데이터</h2>
      <pre class="raw">{{ cve_prompt_text }}</pre>
    </section>
    {% endif %}
  </main>
</body>
</html>

 

여기서 중요한 점은 analysis_result를 safe 처리하지 않는다는 것입니다. LLM 응답이나 외부 API 응답을 HTML로 신뢰하면 XSS 문제가 생길 수 있습니다. 그래서 <pre> 태그와 CSS의 white-space 처리를 사용해 줄바꿈만 자연스럽게 보여줍니다.


7. CSS 작성

static/style.css 파일을 만듭니다.

:root {
  --bg: #f3f4f6;
  --panel: #ffffff;
  --text: #111827;
  --muted: #6b7280;
  --border: #e5e7eb;
  --primary: #111827;
  --primary-hover: #374151;
  --danger-bg: #fee2e2;
  --danger-text: #991b1b;
  --code-bg: #0f172a;
  --code-text: #e5e7eb;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family:
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    "Noto Sans KR",
    Arial,
    sans-serif;
  background: var(--bg);
  color: var(--text);
}

.page {
  width: 1120px;
  max-width: 94%;
  margin: 40px auto;
}

.hero {
  margin-bottom: 24px;
}

.badge {
  display: inline-block;
  padding: 6px 10px;
  margin-bottom: 12px;
  border-radius: 999px;
  background: #e5e7eb;
  color: #374151;
  font-size: 13px;
  font-weight: 700;
}

.hero h1 {
  margin: 0 0 12px;
  font-size: 42px;
  letter-spacing: -0.04em;
}

.hero p {
  margin: 0;
  color: var(--muted);
  font-size: 18px;
  line-height: 1.6;
}

.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 18px;
  padding: 24px;
  margin-bottom: 20px;
  box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
}

.search-form label {
  display: block;
  margin-bottom: 10px;
  font-weight: 800;
}

.input-row {
  display: flex;
  gap: 10px;
}

.input-row input {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  font-size: 16px;
}

.input-row button {
  padding: 0 22px;
  border: none;
  border-radius: 12px;
  background: var(--primary);
  color: white;
  font-size: 16px;
  font-weight: 800;
  cursor: pointer;
}

.input-row button:hover {
  background: var(--primary-hover);
}

.runtime-info {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 14px;
  color: var(--muted);
  font-size: 13px;
}

.runtime-info span {
  padding: 6px 10px;
  background: #f9fafb;
  border: 1px solid var(--border);
  border-radius: 999px;
}

.alert {
  padding: 16px;
  margin-bottom: 20px;
  border-radius: 14px;
  background: var(--danger-bg);
  color: var(--danger-text);
  font-weight: 700;
}

.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  align-items: start;
}

h2 {
  margin-top: 0;
  margin-bottom: 16px;
  font-size: 22px;
}

.cve-card {
  padding: 18px;
  border: 1px solid var(--border);
  border-radius: 14px;
  margin-bottom: 14px;
  background: #f9fafb;
}

.cve-header {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  align-items: center;
  margin-bottom: 12px;
}

.cve-header h3 {
  margin: 0;
}

.severity {
  padding: 5px 9px;
  border-radius: 999px;
  background: #111827;
  color: #ffffff;
  font-size: 12px;
  font-weight: 800;
}

dl {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin: 0 0 14px;
}

dt {
  color: var(--muted);
  font-size: 12px;
}

dd {
  margin: 2px 0 0;
  font-weight: 700;
  font-size: 14px;
}

.description {
  line-height: 1.6;
  color: #374151;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.chips span {
  padding: 5px 8px;
  border-radius: 999px;
  background: #e5e7eb;
  color: #374151;
  font-size: 12px;
  font-weight: 700;
}

pre {
  margin: 0;
  white-space: pre-wrap;
  word-break: break-word;
  line-height: 1.6;
}

.analysis {
  min-height: 300px;
  color: var(--text);
  font-family: inherit;
}

.raw {
  padding: 16px;
  border-radius: 12px;
  background: var(--code-bg);
  color: var(--code-text);
  font-size: 13px;
}

.muted {
  color: var(--muted);
}

@media (max-width: 900px) {
  .grid {
    grid-template-columns: 1fr;
  }

  .input-row {
    flex-direction: column;
  }

  .input-row button {
    padding: 14px 22px;
  }

  .hero h1 {
    font-size: 34px;
  }
}

8. 로컬에서 실행하기

패키지를 설치합니다.

pip install -r requirements.txt

 

Ollama API URL과 모델명을 지정합니다.

export OLLAMA_URL="http://localhost:11434/api/chat"
export OLLAMA_MODEL="llama3.1"

 

NVD API Key가 있다면 선택적으로 지정합니다.

export NVD_API_KEY="your-nvd-api-key"

 

Flask 앱을 실행합니다.

python app.py

 

브라우저에서 다음 주소로 접속합니다.

http://localhost:5000

 

검색창에 다음과 같이 입력해봅니다.

CVE-2024-3094

 

또는 키워드로 검색할 수도 있습니다.

openssl

 

화면에는 크게 세 가지 정보가 표시됩니다.

1. NVD API에서 가져온 CVE 기본 정보
2. Ollama가 생성한 한국어 취약점 분석
3. Ollama에게 실제로 전달한 요약 데이터

9. 컨테이너로 실행하기

Flask 앱도 컨테이너로 실행하고 싶다면 Dockerfile을 작성합니다.

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

 

앱 이미지를 빌드합니다.

docker build -t ollama-flask-cve-app:latest .

 

Ollama 컨테이너가 호스트의 11434 포트를 노출하고 있다면, Docker에서는 상황에 따라 다음 주소를 사용할 수 있습니다.

http://host.docker.internal:11434/api/chat

 

하지만 Linux 서버나 실습 환경에서는 host.docker.internal이 항상 보장되지 않을 수 있습니다. 가장 깔끔한 방식은 Ollama 컨테이너와 Flask 앱 컨테이너를 같은 Docker 네트워크에 넣는 것입니다.

 

예를 들어 Ollama 컨테이너 이름이 lab-ollama라고 가정하겠습니다.

docker network create ollama-net
docker network connect ollama-net lab-ollama

 

이제 Flask 앱 컨테이너를 같은 네트워크에 붙입니다.

docker run --rm -it \
  --name local-cve-analyst \
  --network ollama-net \
  -p 5000:5000 \
  -e OLLAMA_URL="http://lab-ollama:11434/api/chat" \
  -e OLLAMA_MODEL="llama3.1" \
  ollama-flask-cve-app:latest

 

이제 브라우저에서 접속합니다.

http://localhost:5000

10. Docker Compose 예시

이미 Ollama 컨테이너가 별도로 실행 중이라면, 앱만 Compose로 관리할 수도 있습니다.

docker-compose.app.yml 파일을 생성합니다.

services:
  cve-app:
    build: .
    container_name: local-cve-analyst
    ports:
      - "5000:5000"
    environment:
      OLLAMA_URL: "http://lab-ollama:11434/api/chat"
      OLLAMA_MODEL: "llama3.1"
      NVD_RESULTS_PER_PAGE: "5"
      # NVD_API_KEY: "your-nvd-api-key"
    networks:
      - ollama-net

networks:
  ollama-net:
    external: true

 

실행합니다.

docker compose -f docker-compose.app.yml up --build

 

만약 Ollama 컨테이너 이름이 ollama라면 다음처럼 바꾸면 됩니다.

OLLAMA_URL: "http://ollama:11434/api/chat"

 

중요한 점은 Flask 앱이 컨테이너 안에서 실행될 때 localhost는 Ollama 컨테이너가 아니라 Flask 앱 컨테이너 자신을 의미한다는 것입니다. 그래서 앱 컨테이너에서 Ollama 컨테이너로 접근하려면 같은 네트워크에 연결한 뒤 컨테이너 이름을 호스트명처럼 사용하는 방식이 가장 이해하기 쉽습니다.


11. Podman 환경에서 실행하는 경우

실습 스크립트가 Podman 기반이라면 비슷하게 구성할 수 있습니다.

 

Ollama 컨테이너가 lab-ollama라는 이름으로 실행 중이라고 가정합니다.

podman network create ollama-net
podman network connect ollama-net lab-ollama

 

이미지를 빌드합니다.

podman build -t ollama-flask-cve-app:latest .

 

앱 컨테이너를 실행합니다.

podman run --rm -it \
  --name local-cve-analyst \
  --network ollama-net \
  -p 5000:5000 \
  -e OLLAMA_URL="http://lab-ollama:11434/api/chat" \
  -e OLLAMA_MODEL="llama3.1" \
  ollama-flask-cve-app:latest

 

브라우저에서 다음 주소로 접속합니다.

http://localhost:5000

12. 테스트 시나리오

첫 번째 테스트는 CVE ID 단건 조회입니다.

CVE-2024-3094

 

이 경우 Flask 앱은 NVD API에 cveIds=CVE-2024-3094 형태로 요청합니다.

 

두 번째 테스트는 키워드 검색입니다.

openssl

 

이 경우 Flask 앱은 NVD API에 keywordSearch=openssl 형태로 요청합니다.

세 번째 테스트는 존재하지 않는 CVE ID입니다.

 

CVE-2099-0000

 

이 경우 검색 결과가 없다는 메시지가 표시되어야 합니다.


13. 자주 발생하는 오류

13.1 Ollama API 연결 실패

오류 예시는 다음과 같습니다.

Connection refused

 

확인할 내용은 다음과 같습니다.

curl http://localhost:11434/api/tags

 

호스트에서는 되는데 앱 컨테이너 안에서 안 된다면 OLLAMA_URL이 잘못되었을 가능성이 높습니다.

 

Flask 앱을 로컬에서 실행한다면 다음을 사용합니다.

export OLLAMA_URL="http://localhost:11434/api/chat"

 

Flask 앱을 컨테이너에서 실행하고 Ollama 컨테이너 이름이 lab-ollama라면 다음을 사용합니다.

export OLLAMA_URL="http://lab-ollama:11434/api/chat"

13.2 모델이 없다는 오류

Ollama 응답에서 모델이 없다고 나오면 해당 모델을 먼저 pull 해야 합니다.

ollama pull llama3.1

 

Ollama가 컨테이너 안에서 실행 중이라면 다음처럼 실행합니다.

docker exec -it lab-ollama ollama pull llama3.1

 

또는 Podman 환경에서는 다음과 같습니다.

podman exec -it lab-ollama ollama pull llama3.1

13.3 NVD API 403 또는 제한 오류

NVD API는 공개 접근에도 rate limit이 있습니다. API Key가 없으면 30초당 5회, API Key가 있으면 30초당 50회 제한이 적용됩니다.

 

실습 중 여러 번 버튼을 누르면 제한에 걸릴 수 있습니다. 이 경우 잠시 기다렸다가 다시 시도합니다.

 

운영 수준으로 만들려면 다음 기능을 추가하는 것이 좋습니다.

1. NVD API 응답 캐싱
2. 동일 검색어 반복 요청 제한
3. API Key 사용
4. 사용자별 요청 제한

13.4 응답 시간이 너무 오래 걸리는 경우

로컬 LLM 모델이 크거나 GPU 메모리가 부족하면 응답 시간이 오래 걸릴 수 있습니다.

 

이 경우 다음을 조정합니다.

1. 더 작은 모델 사용
2. NVD_RESULTS_PER_PAGE 값을 1~3으로 축소
3. 프롬프트 길이 축소
4. Ollama keep_alive 설정 조정

 

예를 들어 결과 개수를 3개로 줄입니다.

export NVD_RESULTS_PER_PAGE="3"

14. 이 앱의 학습 포인트

이 실습의 핵심은 “LLM 앱은 단순히 채팅창을 붙이는 것이 아니다”라는 점입니다.

 

이번 앱은 다음 개념을 모두 포함합니다.

1. 웹 UI
2. Flask 백엔드
3. 외부 REST API 연동
4. 로컬 LLM API 호출
5. 프롬프트 구성
6. 컨테이너 네트워크
7. 보안 데이터 분석

 

특히 보안 교육에서는 이 구조가 유용합니다. CVE, CWE, CVSS 같은 보안 데이터를 단순히 보여주는 것을 넘어서, LLM이 운영자 관점의 설명으로 바꿔주기 때문입니다.


15. 다음 단계: Tool Calling 구조로 확장하기

이번 튜토리얼에서는 Flask가 먼저 NVD API를 호출하고, 그 결과를 Ollama에게 넘겼습니다.

사용자 입력
    ↓
Flask가 NVD API 호출
    ↓
Flask가 Ollama 호출
    ↓
최종 답변 출력

 

고급 버전에서는 Ollama의 Tool Calling 기능을 사용할 수 있습니다. Ollama는 모델이 도구를 호출하고 그 결과를 답변에 반영하는 tool calling 기능을 지원합니다.

 

고급 구조는 다음과 같습니다.

사용자 입력
    ↓
Ollama가 필요한 도구 판단
    ↓
Flask가 해당 도구 함수 실행
    ↓
도구 실행 결과를 Ollama에 다시 전달
    ↓
최종 답변 출력

 

다만 처음부터 이 구조로 가면 설명이 복잡해집니다. 그래서 입문 튜토리얼에서는 지금처럼 “Flask가 외부 API를 호출하고, Ollama는 분석만 담당하는 구조”가 더 적합합니다.


16. 마무리

이번 튜토리얼에서는 GPU 기반 Ollama 컨테이너가 이미 준비되어 있다는 전제에서, Flask를 사용해 실제 웹 UI가 있는 로컬 LLM 애플리케이션을 만들었습니다.

 

완성된 앱은 다음 흐름으로 동작합니다.

CVE ID 또는 키워드 입력
    ↓
NVD API에서 취약점 정보 조회
    ↓
필요한 필드만 추출
    ↓
Ollama 로컬 LLM에 분석 요청
    ↓
웹 화면에 원본 정보와 AI 분석 결과 출력

 

이 예제는 단순한 챗봇보다 실습 가치가 높습니다. 외부 API와 로컬 LLM을 연결하는 기본 패턴을 보여주기 때문입니다. 이 패턴을 응용하면 GitHub 저장소 분석기, 로그 분석 도우미, 보안 점검 리포트 생성기, 내부 문서 검색형 RAG 앱으로도 확장할 수 있습니다.

반응형