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

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

실습 5 — 컨테이너에 넣을 준비

Docker 이미지를 만들려면 서비스마다 폴더 하나에 코드, 모델 파일, 패키지 목록(requirements.txt), Dockerfile, .dockerignore 를 모아야 합니다. 이 준비는 Docker 없이 할 수 있으므로 실제로 만들고 확인합니다. 결과는 out_deploy/api, out_deploy/ui 폴더입니다. 실습 3을 먼저 실행해야 합니다.

1단계. 서비스마다 폴더 만들기

Python
import shutil
from pathlib import Path

DEPLOY = Path("out_deploy")
shutil.rmtree(DEPLOY, ignore_errors=True)
(DEPLOY / "api").mkdir(parents=True)
(DEPLOY / "ui").mkdir(parents=True)
shutil.copy("out_delay_api.py", DEPLOY / "api" / "main.py")            # API 코드는 main.py 로
shutil.copy("out_delay_model.joblib", DEPLOY / "api")                  # 모델 파일도 함께
shutil.copy("out_app_api.py", DEPLOY / "ui" / "app.py")                # 화면은 app.py 로. 모델은 넣지 않는다
for p in sorted(DEPLOY.rglob("*")):
    print(p.as_posix(), f"{p.stat().st_size:,}바이트" if p.is_file() else "")
출력
out_deploy/api
out_deploy/api/main.py 2,096바이트
out_deploy/api/out_delay_model.joblib 4,462바이트
out_deploy/ui
out_deploy/ui/app.py 2,254바이트

2단계. 코드의 import 로 패키지 목록 만들기

Python
import ast, sys
from importlib.metadata import packages_distributions, version


def imports_of(path):
    """파이썬 파일을 읽어 import 하는 맨 위 모듈 이름들을 모은다(실행하지 않고 문법만 읽는다)"""
    tree = ast.parse(Path(path).read_text(encoding="utf-8"))
    names = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            names |= {a.name.split(".")[0] for a in node.names}
        elif isinstance(node, ast.ImportFrom) and node.module:
            names.add(node.module.split(".")[0])
    return names - set(sys.stdlib_module_names)                  # 표준 라이브러리는 설치할 필요가 없다


dist = packages_distributions()                                   # 모듈 이름 → 패키지(배포판) 이름
found = {}
for service in ("api", "ui"):
    mods = imports_of(DEPLOY / service / ("main.py" if service == "api" else "app.py"))
    found[service] = sorted({dist[m][0] for m in mods})
    print(service, "의 import:", sorted(mods), "→ 패키지:", found[service])
출력
api 의 import: ['fastapi', 'joblib', 'pandas', 'pydantic'] → 패키지: ['fastapi', 'joblib', 'pandas', 'pydantic']
ui 의 import: ['streamlit'] → 패키지: ['streamlit']

3단계. 빠진 패키지를 채우고 판을 적기

Python
# import 에는 없지만 실행에 필요한 것: 서버를 띄우는 uvicorn, joblib 파일을 풀 때 필요한 scikit-learn
extra = {"api": ["uvicorn", "scikit-learn"], "ui": []}
for service in ("api", "ui"):
    names = sorted(set(found[service]) | set(extra[service]), key=str.lower)
    lines = [f"{n}=={version(n)}" for n in names]
    (DEPLOY / service / "requirements.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"--- {service}/requirements.txt", *lines, sep="\n")
출력
--- api/requirements.txt
fastapi==0.162.0
joblib==1.6.0
pandas==3.1.0
pydantic==2.14.1
scikit-learn==1.9.1
uvicorn==0.54.0
--- ui/requirements.txt
streamlit==1.66.1

4단계. Dockerfile 과 .dockerignore 쓰기

Python
DOCKERFILE = {
    "api": """FROM python:3.14-slim
WORKDIR /app
# 패키지 목록만 먼저 복사해 설치한다. 코드만 바뀌면 이 층을 다시 쓰지 않는다
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8013
# 컨테이너 바깥에서 연결할 수 있게 0.0.0.0 으로 띄운다
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8013"]
""",
    "ui": """FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV API_URL=http://api:8013
EXPOSE 8501
CMD ["streamlit", "run", "app.py", "--server.port", "8501", "--server.address", "0.0.0.0", "--server.headless", "true", "--browser.gatherUsageStats", "false"]
""",
}
IGNORE = "__pycache__/\n*.pyc\n.venv/\n*.log\n.env\n"            # 이미지에 넣지 않을 것. .env 에는 비밀이 들기 쉽다
for service, text in DOCKERFILE.items():
    (DEPLOY / service / "Dockerfile").write_text(text, encoding="utf-8")
    (DEPLOY / service / ".dockerignore").write_text(IGNORE, encoding="utf-8")
    files = sorted(p.name for p in (DEPLOY / service).iterdir())
    size = sum(p.stat().st_size for p in (DEPLOY / service).iterdir())
    print(service, files, f"· 빌드에 보낼 파일 {size / 1024:.1f}KB")
출력
api ['.dockerignore', 'Dockerfile', 'main.py', 'out_delay_model.joblib', 'requirements.txt'] · 빌드에 보낼 파일 6.9KB
ui ['.dockerignore', 'Dockerfile', 'app.py', 'requirements.txt'] · 빌드에 보낼 파일 2.6KB

아래는 이 폴더로 이미지를 만들고 띄우는 Docker 명령입니다. 교안을 만든 PC 에는 Docker 가 없어서 이 PC 에서 실행하지 않은 안내 코드입니다. out_deploy 폴더에서 실행합니다.

출력
docker build -t delay-api:1 ./api
docker build -t delay-ui:1 ./ui
docker images delay-*
docker network create delay-net
docker run -d --name api --network delay-net -p 8013:8013 delay-api:1
docker run -d --name ui --network delay-net -p 8501:8501 -e API_URL=http://api:8013 delay-ui:1
curl http://127.0.0.1:8013/health

예상 출력(실행하지 않고 유추한 예시)입니다. 이미지의 크기와 빌드 시간은 PC 와 인터넷 속도에 따라 다르며, 숫자는 둥글게 적었습니다. 컨테이너 번호는 실행마다 다른 글자가 나옵니다.

출력
[+] Building 95.0s (10/10) FINISHED
 => [1/5] FROM docker.io/library/python:3.14-slim
 => [3/5] RUN pip install --no-cache-dir -r requirements.txt
 => exporting to image
 => => naming to docker.io/library/delay-api:1
[+] Building 60.0s (10/10) FINISHED
 => => naming to docker.io/library/delay-ui:1
REPOSITORY   TAG   IMAGE ID       CREATED          SIZE
delay-api    1     (실행마다 다름)  1 minute ago     약 500MB
delay-ui     1     (실행마다 다름)  1 minute ago     약 450MB
(네트워크 번호)
(컨테이너 번호)
(컨테이너 번호)
{"상태":"ok","판":"delay-lr-1"}

이 실습이 뜻하는 것

이 실습은 이삿짐 상자를 꾸리는 일과 같습니다. 방마다 상자를 따로 마련해 물건을 담고(1단계), 쓰는 물건의 목록을 적습니다(2단계). 목록에서 빠진 것(전원선처럼 눈에 덜 띄는 것)을 더하고 모델 번호까지 적은 뒤(3단계), 상자 겉에 푸는 순서를 적은 안내문과 "넣지 말 것" 쪽지를 붙입니다(4단계). 여기서 상자가 서비스 폴더(out_deploy/api, out_deploy/ui), 물건 목록이 requirements.txt, 안내문이 Dockerfile, 쪽지가 .dockerignore 입니다. 비유와 다른 점은, 이번에는 상자를 꾸리기만 하고 실제로 실어 보내지는(이미지를 만들지는) 않았다는 것입니다.

단계출력뜻
1. 폴더api: main.py 2,096바이트, 모델 4,462바이트 · ui: app.py 2,254바이트API 상자에는 코드와 모델이, 화면 상자에는 화면 코드만 들어갔습니다
2. import 읽기api: fastapi, joblib, pandas, pydantic · ui: streamlit코드를 실행하지 않고 읽어서 찾은 패키지입니다
3. 패키지 목록scikit-learn==1.9.1, uvicorn==0.54.0 등import 에는 없던 둘을 더하고, 이 PC 의 판을 == 로 못 박았습니다. 다른 PC 에서도 같은 판이 깔리게 하려는 것입니다
4. 빌드에 보낼 파일api 6.9KB, ui 2.6KB상자에 든 파일은 작습니다. KB(킬로바이트)는 1,024바이트입니다
예상 출력이미지 약 500MB, 약 450MB이 PC 에서 실행하지 않고 유추한 예시입니다. 이미지가 커지는 까닭이 파이썬과 패키지라는 뜻으로만 읽습니다

한 줄로 줄이면, Docker 없이 할 수 있는 이삿짐 꾸리기를 끝낸 실습입니다. 서비스마다 폴더, 판을 적은 패키지 목록, Dockerfile 이 갖춰졌고, 실습 6에서 이 두 폴더를 compose 로 묶고 폴더만으로 서비스가 뜨는지 확인합니다. 이미지가 실제로 만들어지는지, 크기와 빌드 시간이 얼마인지는 이 PC 에서 확인하지 못했습니다.

코드 설명

  • 1단계는 서비스마다 폴더를 나눕니다. API 폴더에는 코드와 모델 파일(4,462바이트)을, 화면 폴더에는 화면 코드만 넣습니다. 화면 이미지에 모델과 scikit-learn 이 들어가지 않는 것이 화면과 API 를 나눈 덕입니다.
  • ast.parse 는 파이썬 파일을 실행하지 않고 문법 나무로 읽습니다. 그 안의 import 를 모아 sys.stdlib_module_names(표준 라이브러리 목록)를 빼고, packages_distributions() 로 모듈 이름을 설치할 패키지 이름으로 바꿉니다. API 는 fastapi, joblib, pandas, pydantic, 화면은 streamlit 하나가 나왔습니다.
  • import 만 보면 두 가지가 빠집니다. 서버를 띄우는 uvicorn 은 코드가 아니라 실행 명령에 나오고, scikit-learn 은 import 하지 않지만 joblib 파일을 풀 때 필요합니다. 빠뜨리면 이미지는 만들어져도 컨테이너가 뜰 때 오류가 납니다. 3단계는 둘을 더하고 이 PC 의 판(fastapi 0.162.0, scikit-learn 1.9.1 등)을 == 으로 적었습니다.
  • 4단계의 Dockerfile 은 본문의 순서(기본 이미지, 패키지 목록 먼저 복사해 설치, 코드 복사, 실행 명령) 그대로이고, 서버는 0.0.0.0 으로 띄웁니다. 빌드에 보낼 파일은 API 6.9KB, 화면 2.6KB 로 작습니다. 이미지가 수백 MB 가 되는 것은 파이썬과 패키지 때문입니다(예상 출력). 기본 이미지 python:3.14-slim 은 이 PC 의 파이썬 판에 맞춘 것입니다.
← 이전 절14장 · 실습 4 — 화면과 API 를 실제 서버로 띄우기다음 절 →14장 · 실습 6 — docker compose 로 묶을 준비와 로컬 확인