14장. Streamlit 화면과 Docker 컨테이너
실습 6 — docker compose 로 묶을 준비와 로컬 확인
화면과 API 를 함께 띄우는 compose.yaml 을 쓰고, 파일이 서로 맞는지(폴더, 포트, 서비스 이름) 코드로 검사합니다. 그다음 compose 가 하는 일을 이 PC 에서 흉내 내, 실습 5의 두 폴더에서 각각 서버를 띄워 서로 이어지는지 확인합니다. 실습 5를 먼저 실행해야 합니다.
1단계. compose.yaml 쓰기
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단계. 파일끼리 맞는지 검사하기
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 에서 흉내 내기
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 → ok4단계. 배포 폴더의 화면으로 예측하고 끄기
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의 약점을 메웁니다.