LLM 엔지니어링14장 · Streamlit 화면과 Docker 컨테이너19 / 22← 이전목차다음 →MetaContents

14장. Streamlit 화면과 Docker 컨테이너

실습 6 — docker compose 로 묶을 준비와 로컬 확인

화면과 API 를 함께 띄우는 compose.yaml 을 쓰고, 파일이 서로 맞는지(폴더, 포트, 서비스 이름) 코드로 검사합니다. 그다음 compose 가 하는 일을 이 PC 에서 흉내 내, 실습 5의 두 폴더에서 각각 서버를 띄워 서로 이어지는지 확인합니다. 실습 5를 먼저 실행해야 합니다.

1단계. compose.yaml 쓰기

Python
from pathlib import Path
import yaml

DEPLOY = Path("out_deploy")
compose_text = """services:
  api:
    build: ./api
    ports: ["8013:8013"]
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8013/health')"]
      interval: 10s
  ui:
    build: ./ui
    ports: ["8501:8501"]
    environment:
      API_URL: http://api:8013
    depends_on:
      api:
        condition: service_healthy
"""
(DEPLOY / "compose.yaml").write_text(compose_text, encoding="utf-8")
compose = yaml.safe_load(compose_text)
print({name: svc.get("ports") for name, svc in compose["services"].items()})
출력
{'api': ['8013:8013'], 'ui': ['8501:8501']}

2단계. 파일끼리 맞는지 검사하기

Python
from urllib.parse import urlsplit

problems = []
services = compose["services"]
for name, svc in services.items():
    if "build" in svc and not (DEPLOY / svc["build"] / "Dockerfile").exists():      # build 폴더에 Dockerfile 이 있나
        problems.append(f"{name}: {svc['build']} 에 Dockerfile 이 없다")
host_ports = [p.split(":")[0] for svc in services.values() for p in svc.get("ports", [])]
if len(host_ports) != len(set(host_ports)):                                          # PC 쪽 포트가 겹치나
    problems.append("PC 쪽 포트가 겹친다")
for name, svc in services.items():                                                   # 주소의 호스트 이름이 서비스 이름인가
    for key, value in svc.get("environment", {}).items():
        host = urlsplit(value).hostname
        if value.startswith("http") and host not in services:
            problems.append(f"{name}.{key}: {host} 라는 서비스가 없다")
expose = (DEPLOY / "api" / "Dockerfile").read_text(encoding="utf-8")
print("api Dockerfile 의 포트와 compose 의 포트가 같다:", "--port\", \"8013" in expose and "8013:8013" in services["api"]["ports"])
print("문제:", problems or "없음")
출력
api Dockerfile 의 포트와 compose 의 포트가 같다: True
문제: 없음

3단계. compose 가 하는 일을 이 PC 에서 흉내 내기

Python
import os, subprocess, sys, time, urllib.request

# 두 서비스를 각자의 폴더에서 띄운다. compose 의 서비스 이름(api) 대신 127.0.0.1 을 준다
api = subprocess.Popen([sys.executable, "-m", "uvicorn", "main:app", "--port", "8013", "--log-level", "warning"],
                       cwd=DEPLOY / "api")
ui = subprocess.Popen([sys.executable, "-m", "streamlit", "run", "app.py", "--server.port", "8501", "--server.headless", "true",
                       "--browser.gatherUsageStats", "false"], cwd=DEPLOY / "ui",
                      env=dict(os.environ, API_URL="http://127.0.0.1:8013"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for url in ("http://127.0.0.1:8013/health", "http://127.0.0.1:8501/_stcore/health"):
    for _ in range(60):
        try:
            print(url, "→", urllib.request.urlopen(url, timeout=2).read().decode())
            break
        except OSError:
            time.sleep(0.5)
출력
http://127.0.0.1:8013/health → {"상태":"ok","판":"delay-lr-1"}
http://127.0.0.1:8501/_stcore/health → ok

4단계. 배포 폴더의 화면으로 예측하고 끄기

Python
from streamlit.testing.v1 import AppTest

os.environ["API_URL"] = "http://127.0.0.1:8013"
at = AppTest.from_file(str((DEPLOY / "ui" / "app.py").resolve()), default_timeout=60).run()
at.button[0].click().run()                                     # 기본값 그대로 예측
print(at.caption[0].value, "·", at.metric[0].value)
for proc in (ui, api):
    proc.terminate()
    proc.wait(timeout=10)
print("두 서버 꺼짐")
출력
API 연결됨 · 모델 판 delay-lr-1 · 9.5%
두 서버 꺼짐

아래는 compose 로 두 컨테이너를 함께 띄우는 명령입니다. 이 PC 에서 실행하지 않은 안내 코드이고, out_deploy 폴더에서 실행합니다.

출력
docker compose up -d --build
docker compose ps
docker compose logs ui --tail 3
docker compose down

예상 출력(실행하지 않고 유추한 예시)입니다. 숫자와 시간은 둥글게 적었습니다.

출력
[+] Running 3/3
 ✔ Network out_deploy_default  Created
 ✔ Container out_deploy-api-1  Healthy
 ✔ Container out_deploy-ui-1   Started
NAME               IMAGE            SERVICE   STATUS                    PORTS
out_deploy-api-1   out_deploy-api   api       Up 30 seconds (healthy)   0.0.0.0:8013->8013/tcp
out_deploy-ui-1    out_deploy-ui    ui        Up 20 seconds             0.0.0.0:8501->8501/tcp
ui-1  |   You can now view your Streamlit app in your browser.
ui-1  |   URL: http://0.0.0.0:8501
[+] Running 3/3
 ✔ Container out_deploy-ui-1   Removed
 ✔ Container out_deploy-api-1  Removed
 ✔ Network out_deploy_default  Removed

이 실습이 뜻하는 것

이 실습은 이사 당일의 배치도와 같습니다. 두 상자를 어느 방(포트)에 둘지 배치도를 쓰고(1단계), 배치도와 상자가 서로 맞는지 대조합니다(2단계). 그다음 상자만 풀어 각 방의 전기를 켜 보고(3단계), 화면 방에서 API 방으로 주문을 보내 본 뒤 불을 끕니다(4단계). 여기서 배치도가 compose.yaml, 방 번호가 포트, 방끼리 부르는 이름표가 서비스 이름 api 입니다. 비유와 다른 점은, 실제 이사(compose 로 컨테이너 띄우기)는 하지 않고 이 PC 에서 흉내만 냈다는 것입니다.

단계출력뜻
1. 배치도{'api': ['8013:8013'], 'ui': ['8501:8501']}두 서비스와 포트가 바르게 들어갔습니다. "PC 쪽 번호:컨테이너 쪽 번호"로 읽습니다
2. 대조포트가 같다: True, 문제: 없음Dockerfile 이 제자리에 있고, 포트가 겹치지 않고, API_URL 의 api 가 실제 서비스 이름이었습니다
3. 폴더에서 띄우기{"상태":"ok",…}, ok배포 폴더의 파일만으로 두 서버가 떴습니다. 상자에 빠진 파일이 없다는 뜻입니다
4. 예측API 연결됨 · 모델 판 delay-lr-1 · 9.5%배포 폴더의 화면이 배포 폴더의 API 를 불러, 칸을 건드리지 않은 기본값 주문에 9.5% 를 그렸습니다
4. 끄기두 서버 꺼짐띄운 두 서버를 정리했습니다
예상 출력Healthy, Started실행하지 않고 유추한 예시입니다. api 가 건강해진 뒤에 ui 가 뜨는 차례를 보이려는 것입니다

한 줄로 줄이면, compose 파일을 쓰고 Docker 없이 잡을 수 있는 실수를 코드로 걸러 낸 뒤, 폴더만으로 두 서비스가 서로 이어지는 것까지 확인한 실습입니다. 프로젝트에서는 같은 방식에 모델 서버(Ollama)를 더해 세 서비스로 꾸립니다. compose 가 실제로 컨테이너를 띄우는지, 컨테이너끼리 api 라는 이름으로 찾아가는지는 이 PC 에서 확인하지 못했습니다.

코드 설명

  • 1단계는 compose.yaml 을 쓰고 yaml.safe_load 로 다시 읽어, 두 서비스와 포트(8013, 8501)가 바르게 들어갔는지 봅니다. YAML 은 들여쓰기가 틀리면 다른 뜻이 되므로, 쓴 뒤 읽어 보는 것이 좋습니다.
  • 2단계는 Docker 없이도 잡을 수 있는 실수를 코드로 찾습니다. build 폴더에 Dockerfile 이 있는지, PC 쪽 포트가 겹치지 않는지, API_URL 의 호스트 이름(api)이 실제 서비스 이름인지, Dockerfile 의 포트와 compose 의 포트가 같은지입니다. 이번에는 문제가 없었습니다.
  • 3, 4단계는 compose 대신 두 서버를 각자의 배포 폴더(cwd=)에서 띄웠습니다. 폴더 안의 파일만으로 서비스가 뜨는지 확인하는 것으로, 이미지에 빠진 파일이 있는지 미리 알 수 있습니다. 배포 폴더의 화면이 배포 폴더의 API 를 불러 기본값 주문에 9.5% 를 그렸습니다.
  • depends_on 의 condition: service_healthy 는 api 의 healthcheck 가 성공할 때까지 ui 를 띄우지 않고 기다리게 합니다. 본문에서 본 "먼저 띄우기만 하는" depends_on 의 약점을 메웁니다.
← 이전 절14장 · 실습 5 — 컨테이너에 넣을 준비다음 절 →14장 · 프로젝트 1 — 온프레미스 LLM 서빙 서비스