14장. Streamlit 화면과 Docker 컨테이너
프로젝트 1 — 온프레미스 LLM 서빙 서비스
13장 프로젝트의 사내 규정 질의 API 를 서비스 폴더로 옮기고, 직원이 쓰는 채팅 화면을 붙여, 모델 서버(Ollama), API, 화면의 세 서비스로 된 온프레미스(회사 안 서버) LLM 서비스를 꾸려 보세요. compose 파일까지 쓰고, 이 PC 에서는 세 서비스를 각각 띄워 끝에서 끝까지 동작하는지 확인합니다. 회사, 규정, 직원 이름은 지어낸 것입니다.
| 파일 | 생김새 |
|---|---|
company_rules.txt | 7장의 솔내기술 사내 규정집. 조항 30개 |
company_rules_qa.jsonl | 질문, 정답열쇠(답이 여럿이면 세로줄 | 로 나눔), 근거조항. 25줄 |
요구사항은 다섯 가지입니다.
out_service/api/main.py에 규정 질의 API 를 둡니다. 키(RULES_API_KEYS), Ollama 주소(OLLAMA_URL), 규정 파일 경로를 환경 변수로 받습니다.out_service/ui/app.py에 채팅 화면을 둡니다. API 주소(API_URL)와 화면이 쓸 API 키(UI_API_KEY)를 환경 변수로 받고, 답 아래에 근거 조항을 작게 보입니다. 401, 429, 연결 실패를 알기 쉬운 말로 알립니다.- 서비스마다
requirements.txt,Dockerfile을 두고,compose.yaml에 ollama, api, ui 세 서비스를 적습니다. 키는 파일에 적지 않고.env에서 읽게 합니다. - 이 PC 에서 API 를 띄우고, 화면을
AppTest로 열어 질문 셋을 물어 답과 근거 조항을 확인합니다. - 틀린 키를 가진 화면이 401 을 알기 쉽게 알리는지 확인합니다.
단계별 힌트입니다.
- API 코드는 13장 프로젝트의
answer(),who()를 가져오되, 하드코딩된 주소와 키를os.environ으로 바꿉니다. - 화면은 실습 2의 채팅 화면에서
chat()대신 API 의/ask를 부르게 바꿉니다. - compose 안에서 API 는
http://ollama:11434, 화면은http://api:8014로 부릅니다. 이 PC 에서는 둘 다127.0.0.1입니다. - Ollama 컨테이너는 처음에 모델이 없습니다. compose 로 띄운 뒤
docker compose exec ollama ollama pull qwen2.5:3b로 받거나, 볼륨에 미리 넣어 둡니다(폐쇄망이면 11장처럼 반입). - API 의 임베딩 모델은 처음 뜰 때 불러 오므로 몇 초가 걸립니다.
/health가 답할 때까지 기다립니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
공개된 규정이나 FAQ 로 같은 서비스를 꾸려 볼 수 있습니다. 내려받기 전에 이용 약관을 확인합니다.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| 공공기관의 내부 규정 | 국가법령정보센터(law.go.kr)의 행정규칙, 공공기관 경영정보 공개시스템(alio.go.kr) | 조항 단위의 규정 |
| 민원 FAQ | 공공데이터포털(data.go.kr), 각 기관 누리집 | 질문-답 꼴의 시험 묶음으로 쓸 수 있다 |
| 컨테이너 이미지 | Docker Hub 의 공식 이미지(python, ollama/ollama) | 이미지의 라이선스와 출처를 확인하고, 폐쇄망에는 반입 절차를 거친다 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 out_service 아래에 API 의 main.py 를, 2단계(마젠타)에서 채팅 화면의 ui/app.py 를 씁니다. 3단계(다홍)는 compose.yaml 과 .env.example 을 쓰고, 4단계(빨강)는 uvicorn 으로 API 를 띄운 뒤 AppTest 로 화면에서 물으며, 5단계(짙은 청록)는 틀린 키로 연 화면을 확인하고 API 를 끕니다.
결과 보기 (예시 풀이)
1단계. API 서비스 폴더 만들기
import json, os, secrets, shutil, subprocess, sys, time, urllib.request
from importlib.metadata import version
from pathlib import Path
SERVICE = Path("out_service")
shutil.rmtree(SERVICE, ignore_errors=True)
(SERVICE / "api").mkdir(parents=True)
(SERVICE / "ui").mkdir(parents=True)
shutil.copy("company_rules.txt", SERVICE / "api")
(SERVICE / "api" / "main.py").write_text('''"""사내 규정 질의 API(13차시 프로젝트를 설정 가능하게 바꾼 것)"""
import hashlib, hmac, json, os, re, time
import urllib.request
from collections import defaultdict
import numpy as np
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer
OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
MODEL = os.environ.get("CHAT_MODEL", "qwen2.5:3b")
RULES = os.environ.get("RULES_FILE", "company_rules.txt")
digest = lambda k: hashlib.sha256(k.encode()).hexdigest()
KEYS = {digest(v["키"]): {"이름": n, "역할": v["역할"]} for n, v in json.loads(os.environ["RULES_API_KEYS"]).items()}
articles = [l.strip() for l in open(RULES, encoding="utf-8") if re.match(r"^제\\d+조\\(", l)]
titles = [re.match(r"^제\\d+조\\((.+?)\\)", a).group(1) for a in articles]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
A = emb.encode(["passage: " + a for a in articles], normalize_embeddings=True)
SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 한두 문장으로 답하세요. 없으면 '규정에서 찾을 수 없습니다'라고 답하세요."
recent = defaultdict(list)
app = FastAPI(title="사내 규정 질의 API")
header = APIKeyHeader(name="X-API-Key", auto_error=False)
def who(key: str | None = Security(header)):
user = next((u for h, u in KEYS.items() if key and hmac.compare_digest(h, digest(key))), None)
if user is None:
raise HTTPException(status_code=401, detail="API 키가 없거나 맞지 않습니다")
now = time.time()
recent[user["이름"]] = [t for t in recent[user["이름"]] if now - t < 60]
if len(recent[user["이름"]]) >= 10:
raise HTTPException(status_code=429, detail="1분에 10번까지 부를 수 있습니다")
recent[user["이름"]].append(now)
return user
def llm(messages):
body = {"model": MODEL, "messages": messages, "stream": False, "options": {"temperature": 0, "seed": 42}}
req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read())["message"]["content"].strip()
class Ask(BaseModel):
질문: str = Field(min_length=2, max_length=200)
@app.get("/health")
def health():
return {"상태": "ok", "조항": len(articles), "모델": MODEL}
@app.post("/ask")
def ask(body: Ask, user=Depends(who)):
start = time.perf_counter()
q = emb.encode("query: " + body.질문, normalize_embeddings=True)
top = np.argsort(-(A @ q))[:3]
text = llm([{"role": "system", "content": SYSTEM},
{"role": "user", "content": "[조항]\\n" + "\\n".join(articles[i] for i in top) + "\\n\\n[질문]\\n" + body.질문}])
return {"답": text, "근거조항": [titles[i] for i in top], "걸린ms": round((time.perf_counter() - start) * 1000)}
''', encoding="utf-8")
pkgs = ["fastapi", "uvicorn", "numpy", "sentence-transformers"]
(SERVICE / "api" / "requirements.txt").write_text("\n".join(f"{p}=={version(p)}" for p in pkgs) + "\n", encoding="utf-8")
(SERVICE / "api" / "Dockerfile").write_text("""FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8014
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8014"]
""", encoding="utf-8")
print(sorted(p.name for p in (SERVICE / "api").iterdir()))
print((SERVICE / "api" / "requirements.txt").read_text(encoding="utf-8"))['Dockerfile', 'company_rules.txt', 'main.py', 'requirements.txt']
fastapi==0.162.0
uvicorn==0.54.0
numpy==2.5.4
sentence-transformers==6.2.02단계. 채팅 화면 서비스 폴더 만들기
(SERVICE / "ui" / "app.py").write_text('''"""사내 규정 채팅 화면"""
import json
import os
import urllib.error
import urllib.request
import streamlit as st
API = os.environ.get("API_URL", "http://127.0.0.1:8014")
KEY = os.environ.get("UI_API_KEY", "") # 화면 서버만 아는 키. 브라우저로는 보내지 않는다
MESSAGES = {401: "화면의 API 키가 맞지 않습니다. 관리자에게 알려 주세요.",
429: "질문이 너무 잦습니다. 1분 뒤에 다시 물어 주세요.",
422: "질문은 2글자에서 200글자 사이로 써 주세요."}
def ask(question):
req = urllib.request.Request(API + "/ask", data=json.dumps({"질문": question}, ensure_ascii=False).encode(),
headers={"Content-Type": "application/json", "X-API-Key": KEY})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read())
st.title("솔내기술 규정 도우미")
st.caption("사내 규정집에서 찾아 답합니다. 회사와 규정은 연습용으로 지어낸 것입니다.")
if "messages" not in st.session_state:
st.session_state.messages = []
for m in st.session_state.messages:
with st.chat_message(m["role"]):
st.write(m["content"])
if m.get("근거"):
st.caption("근거 조항: " + ", ".join(m["근거"]))
if prompt := st.chat_input("규정을 물어보세요"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.write(prompt)
with st.chat_message("assistant"):
try:
with st.spinner("규정을 찾는 중"):
result = ask(prompt)
except urllib.error.HTTPError as e:
st.error(MESSAGES.get(e.code, f"요청이 거절됐습니다({e.code})."))
st.stop()
except OSError:
st.error("규정 API 에 연결할 수 없습니다. 잠시 뒤에 다시 해 주세요.")
st.stop()
st.write(result["답"])
st.caption("근거 조항: " + ", ".join(result["근거조항"]))
st.session_state.messages.append({"role": "assistant", "content": result["답"], "근거": result["근거조항"]})
''', encoding="utf-8")
(SERVICE / "ui" / "requirements.txt").write_text(f"streamlit=={version('streamlit')}\n", encoding="utf-8")
(SERVICE / "ui" / "Dockerfile").write_text("""FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8501
CMD ["streamlit", "run", "app.py", "--server.port", "8501", "--server.address", "0.0.0.0", "--server.headless", "true", "--browser.gatherUsageStats", "false"]
""", encoding="utf-8")
print(sorted(p.name for p in (SERVICE / "ui").iterdir()))['Dockerfile', 'app.py', 'requirements.txt']3단계. compose 와 비밀 파일의 꼴 쓰기
(SERVICE / "compose.yaml").write_text("""services:
ollama:
image: ollama/ollama
volumes: ["ollama:/root/.ollama"]
api:
build: ./api
environment:
OLLAMA_URL: http://ollama:11434
RULES_API_KEYS: ${RULES_API_KEYS}
depends_on: [ollama]
ui:
build: ./ui
ports: ["8501:8501"]
environment:
API_URL: http://api:8014
UI_API_KEY: ${UI_API_KEY}
depends_on: [api]
volumes:
ollama: {}
""", encoding="utf-8")
# .env 는 비밀을 담으므로 저장소에 올리지 않는다. 꼴만 보이는 견본을 둔다
(SERVICE / ".env.example").write_text('RULES_API_KEYS={"화면": {"키": "발급받은_인증키", "역할": "직원"}}\nUI_API_KEY=발급받은_인증키\n', encoding="utf-8")
import yaml
compose = yaml.safe_load((SERVICE / "compose.yaml").read_text(encoding="utf-8"))
print({n: {"ports": s.get("ports"), "env": sorted(s.get("environment", {}))} for n, s in compose["services"].items()})
print("compose 에 비밀 원문이 있나:", "키\":" in (SERVICE / "compose.yaml").read_text(encoding="utf-8")){'ollama': {'ports': None, 'env': []}, 'api': {'ports': None, 'env': ['OLLAMA_URL', 'RULES_API_KEYS']}, 'ui': {'ports': ['8501:8501'], 'env': ['API_URL', 'UI_API_KEY']}}
compose 에 비밀 원문이 있나: False4단계. 이 PC 에서 API 를 띄우고 화면으로 묻기
from streamlit.testing.v1 import AppTest
# Ollama 모델을 내렸다 다시 올린다(10차시)
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
data=json.dumps({"model": "qwen2.5:3b", "keep_alive": 0}).encode())).read()
ui_key = secrets.token_urlsafe(24) # 시험용 키를 그 자리에서 만든다
env = dict(os.environ, RULES_API_KEYS=json.dumps({"화면": {"키": ui_key, "역할": "직원"}}, ensure_ascii=False))
api = subprocess.Popen([sys.executable, "-m", "uvicorn", "main:app", "--port", "8014", "--log-level", "warning"],
cwd=SERVICE / "api", env=env, stderr=subprocess.DEVNULL)
t = time.perf_counter()
for _ in range(120):
try:
print(urllib.request.urlopen("http://127.0.0.1:8014/health").read().decode(), f"· {time.perf_counter() - t:.1f}초 만에 준비")
break
except OSError:
time.sleep(0.5)
os.environ.update(API_URL="http://127.0.0.1:8014", UI_API_KEY=ui_key)
at = AppTest.from_file(str((SERVICE / "ui" / "app.py").resolve()), default_timeout=180).run()
qa = [json.loads(line) for line in open("company_rules_qa.jsonl", encoding="utf-8")][8:11]
for item in qa:
at.chat_input[0].set_value(item["질문"]).run()
reply = at.chat_message[-1]
ok = any(k in reply.markdown[0].value.replace(" ", "") for k in item["정답열쇠"].replace(" ", "").split("|"))
print("O" if ok else "X", item["질문"], "→", reply.markdown[0].value, "|", reply.caption[0].value){"상태":"ok","조항":30,"모델":"qwen2.5:3b"} · 31.4초 만에 준비
X 부산 출장 가면 호텔비는 얼마까지 나와요? → 부산 출장 가면 호텔비는 실제 쓴 만큼 줍니다. 서울과 광역시 외 지역으로 갈 때는 8만원까지입니다. | 근거 조항: 국내 출장비, 해외 출장비, 출장 보고
O 해외 출장의 하루 일비는 얼마예요? → 해외 출장의 하루 일비는 60달러입니다. | 근거 조항: 해외 출장비, 국내 출장비, 출장 보고
O 출장 다녀와서 보고서는 언제까지 내요? → 출장 다녀와서 보고서는 출장에서 돌아온 뒤 5영업일 안에 내야합니다. | 근거 조항: 출장 보고, 국내 출장비, 인사 평가5단계. 틀린 키로 연 화면과 끄기
os.environ["UI_API_KEY"] = "wrong-key-0000"
bad = AppTest.from_file(str((SERVICE / "ui" / "app.py").resolve()), default_timeout=60).run()
bad.chat_input[0].set_value("연차는 며칠이에요?").run()
print("틀린 키:", [e.value for e in bad.error])
api.terminate()
api.wait(timeout=10)
down = AppTest.from_file(str((SERVICE / "ui" / "app.py").resolve()), default_timeout=60).run()
down.chat_input[0].set_value("연차는 며칠이에요?").run()
print("API 꺼짐:", [e.value for e in down.error])
print(sorted(p.relative_to(SERVICE).as_posix() for p in SERVICE.rglob("*") if p.is_file()))틀린 키: ['화면의 API 키가 맞지 않습니다. 관리자에게 알려 주세요.']
API 꺼짐: ['규정 API 에 연결할 수 없습니다. 잠시 뒤에 다시 해 주세요.']
['.env.example', 'api/Dockerfile', 'api/company_rules.txt', 'api/main.py', 'api/requirements.txt', 'compose.yaml', 'ui/Dockerfile', 'ui/app.py', 'ui/requirements.txt']아래는 이 서비스를 compose 로 띄우는 명령입니다. 이 PC 에서 실행하지 않은 안내 코드이고, out_service 폴더에서 .env.example 을 .env 로 복사해 키를 채운 뒤 실행합니다.
docker compose up -d --build
docker compose exec ollama ollama pull qwen2.5:3b
docker compose ps예상 출력(실행하지 않고 유추한 예시)입니다. api 이미지에는 sentence-transformers 와 torch 가 들어가 크고 오래 걸립니다. 숫자는 둥글게 적었습니다.
[+] Building 400.0s (17/17) FINISHED
[+] Running 5/5
✔ Network out_service_default Created
✔ Volume "out_service_ollama" Created
✔ Container out_service-ollama-1 Started
✔ Container out_service-api-1 Started
✔ Container out_service-ui-1 Started
pulling manifest
pulling 5ee4f07cdb9b... 100% ▕████████████████▏ 1.9 GB
success
NAME SERVICE STATUS PORTS
out_service-api-1 api Up 2 minutes
out_service-ollama-1 ollama Up 2 minutes 11434/tcp
out_service-ui-1 ui Up 2 minutes 0.0.0.0:8501->8501/tcp서비스 폴더에는 API 와 화면의 코드, 판을 적은 패키지 목록, Dockerfile, compose 파일, 비밀의 꼴만 보이는 .env.example 이 모였고, compose 파일에는 키 원문이 들어가지 않았습니다. 이 PC 에서 API 는 임베딩 모델을 불러오느라 31.4초 만에 준비됐습니다. 화면으로 출장 규정 셋을 물어 둘을 맞혔고, 답 아래에는 근거 조항이 작게 붙었습니다. 틀린 "부산 출장 호텔비"는 국내 출장비 조항을 1위로 찾고도 "그 밖의 지역은 8만원"으로 답했습니다. 부산이 광역시(10만원)라는 것을 작은 모델이 잇지 못한 것으로, 검색이 아니라 추론의 실수입니다. 틀린 키를 가진 화면은 "화면의 API 키가 맞지 않습니다", API 가 꺼진 화면은 "규정 API 에 연결할 수 없습니다"를 알기 쉬운 말로 보였습니다. compose 로 세 컨테이너를 띄우는 부분은 Docker 가 없어 실행하지 않았고, 이미지 크기와 빌드 시간은 예상 출력으로만 적었습니다. 실제로 띄울 때는 Ollama 컨테이너에 모델을 받는 것, API 이미지에 torch 가 들어가 커지는 것, 그리고 API 가 임베딩 모델을 처음 내려받을 때 인터넷이 필요하다는 것(폐쇄망이면 11장처럼 미리 넣는다)을 챙깁니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 사내 규정 안내 창구를 세 칸짜리 사무실로 차리는 일과 같습니다. 먼저 규정집을 찾아 답하는 상담실을 꾸리고(1단계), 직원이 앉아 묻는 접수 창구를 꾸립니다(2단계). 세 칸의 배치도와 열쇠 보관함의 빈 양식을 쓰고(3단계), 상담실을 열어 창구에서 출장 규정 셋을 물어봅니다(4단계). 끝으로 틀린 출입증으로 와 보고, 상담실 문을 닫은 뒤에도 물어봅니다(5단계). 여기서 상담실이 규정 질의 API, 접수 창구가 채팅 화면, 세 번째 칸이 모델 서버 Ollama, 출입증이 API 키, 열쇠 보관함 양식이 .env.example 입니다. 비유와 다른 점은, 세 칸을 실제 건물(compose 의 컨테이너)로 세우지는 않았고 이 PC 의 Ollama 를 그대로 썼다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. API 폴더 | Dockerfile, company_rules.txt, main.py, requirements.txt | 상담실 폴더에 코드, 규정집, 패키지 목록, Dockerfile 이 모였습니다. sentence-transformers==6.2.0 이 든 것은 규정을 찾을 때 e5 임베딩 모델을 쓰기 때문입니다 |
| 2. 화면 폴더 | ['Dockerfile', 'app.py', 'requirements.txt'] | 창구 폴더에는 화면 코드와 streamlit 하나만 들어갔습니다 |
| 3. compose | ollama, api, ui 와 환경 변수 이름 | 세 서비스 가운데 PC 바깥으로 문(포트)을 연 것은 ui 의 8501 하나입니다. api 와 ollama 는 안쪽에서만 서로 부릅니다 |
| 3. 비밀 검사 | compose 에 비밀 원문이 있나: False | compose 파일에는 키가 없고, ${UI_API_KEY} 처럼 .env 에서 채울 자리만 있습니다 |
| 4. API 준비 | "조항":30 · 31.4초 만에 준비 | API 가 규정 30조항을 읽고 임베딩 모델을 불러 준비를 마쳤습니다. 이 PC 에서 잰 시간입니다 |
| 4. 부산 호텔비 | X · 8만원으로 답함 | 국내 출장비 조항을 1위로 찾고도, 부산이 광역시(10만원)라는 것을 잇지 못했습니다. 찾기가 아니라 추론의 실수입니다 |
| 4. 해외 일비, 보고서 | O 둘 | 60달러, 5영업일로 맞혔고, 답 아래에 근거 조항이 붙었습니다 |
| 5. 틀린 키 | 화면의 API 키가 맞지 않습니다. … | API 가 401(키가 맞지 않아 거절한다는 응답 번호)로 거절한 것을 화면이 알아듣기 쉬운 말로 바꿔 보였습니다 |
| 5. API 꺼짐 | 규정 API 에 연결할 수 없습니다. … | 연결 실패도 알기 쉬운 말로 알렸습니다 |
| 5. 파일 목록 | 파일 9개 | 서비스 폴더에 코드, 규정집, 패키지 목록, Dockerfile, compose, .env.example 이 모였고, 진짜 키를 담는 .env 는 없습니다 |
| 예상 출력 | 모델 1.9 GB 받기, 세 컨테이너 시작 | 이 PC 에서 실행하지 않고 유추한 예시입니다 |
한 줄로 줄이면, 모델 서버, API, 화면을 나눠 꾸리고 이 PC 에서 끝에서 끝까지 이어 본 프로젝트입니다. 키는 파일에 남지 않았고, 거절과 연결 실패는 사용자가 알아듣는 말로 바뀌었습니다. 다만 질문 셋으로는 답의 품질을 말할 수 없습니다. 맞힘 둘은 열쇠 낱말이 들어 있는지만 본 채점입니다. compose 로 세 컨테이너를 띄우는 부분은 Docker 가 없어 확인하지 못했습니다.