머신러닝 실전 워크플로 (10) - 컨테이너와 배포: Docker
requirements.txt만으로는 재현되지 않는 실행 환경을 컨테이너가 어떻게 이미지 하나로 고정하는지, 최소 서빙 Dockerfile과 의존성 격리를 중심으로 정리한 10편입니다.
머신러닝 실전 워크플로 시리즈의 10편입니다. 9편의 “서빙: 배치와 온라인, FastAPI”에 이어집니다.
“내 컴퓨터에선 됩니다”
9편에서 모델을 FastAPI로 감싸 예측 요청에 응답하는 앱을 만들었습니다. 이 앱을 내 노트북에서 uvicorn으로 띄우면 잘 돌아갑니다. 문제는 그 앱이 내 노트북을 떠나는 순간입니다. 동료의 맥으로, CI 러너로, 리눅스 서버로 옮기면 같은 코드가 다르게 동작하거나 아예 실행되지 않습니다. 원인은 대개 코드가 아니라 코드가 딛고 선 환경입니다.
8편에서 log_model이 requirements.txt를 함께 남긴다고 했습니다. 파이썬 패키지 버전을 고정하는 이 파일은 필요하지만, 그것만으로는 부족합니다. 같은 requirements.txt를 깐 두 대의 머신도 그 아래 층이 다르면 다르게 굴러갑니다.
- 파이썬 인터프리터: 내 노트북은 3.11.4, 서버는 3.9. 패키지 목록이 같아도 인터프리터가 다르면 동작이 갈립니다.
- OS 시스템 라이브러리: LightGBM은 실행에
libgomp1이 필요합니다. 내 노트북엔 이미 깔려 있지만 갓 만든 리눅스 이미지엔 없어서, 로드하는 순간OSError가 납니다.requirements.txt에는 이런 OS 수준 의존성이 적히지 않습니다. - 그 밖의 상태: 로케일, 타임존, 시스템에 설치된 컴파일러 유무까지 결과를 바꿀 수 있습니다.
“내 컴퓨터에선 됩니다”는 이 보이지 않는 층의 차이가 만드는 문제입니다. 재현성을 패키지 목록 위쪽만 잠가서는 확보할 수 없다는 뜻입니다.
컨테이너가 고정하는 것
컨테이너는 이 보이지 않는 층까지 통째로 이미지 하나에 담습니다. 파이썬 인터프리터, 시스템 라이브러리, 설치한 패키지, 그리고 내 애플리케이션 코드가 하나의 불변 이미지로 굳습니다. 이 이미지는 어디서 실행하든 같은 파일 시스템과 같은 실행 환경을 재현합니다. 내 노트북에서 만든 이미지가 CI에서도, 서버에서도 글자 그대로 동일하게 뜹니다.
이미지를 만드는 방법이 텍스트 파일 하나라는 점이 중요합니다. Dockerfile은 “이 환경을 어떻게 만드는가”를 명령 몇 줄로 적은 레시피입니다. 이 파일을 git에 넣어 버전관리하면, 환경 자체가 코드와 함께 관리되는 산출물이 됩니다. 2편에서 프로젝트 구조와 재현성을 잡았던 원칙이 실행 환경으로 확장되는 셈입니다.
가상머신 대신 컨테이너를 쓰는 이유도 여기서 나옵니다. 가상머신은 OS 커널까지 통째로 복제해 무겁고 부팅이 느립니다. 컨테이너는 호스트 커널을 공유하고 그 위 사용자 공간만 격리하므로, 이미지가 가볍고 몇 초 만에 뜹니다. ML 서빙처럼 같은 이미지를 여러 벌 띄우고 자주 재배포하는 상황에 잘 맞습니다.
최소 서빙 Dockerfile
9편에서 만든 FastAPI 앱을 이미지로 굽습니다. 서빙 이미지의 목표는 하나입니다. 예측 요청에 응답하는 데 필요한 것만 담고, 그 이상은 넣지 않는 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 슬림 베이스: 파이썬 인터프리터만 든 최소 리눅스
FROM python:3.11-slim
# LightGBM 실행에 필요한 OS 라이브러리. 이게 없으면 로드가 깨진다
RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 의존성을 코드보다 먼저 복사해 설치한다. 코드만 바뀔 때 이 층을 캐시로 재사용한다
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드는 마지막에 복사한다
COPY src/ ./src/
EXPOSE 8000
# 0.0.0.0으로 바인딩해야 컨테이너 밖에서 접속된다
CMD ["uvicorn", "src.serving.app:app", "--host", "0.0.0.0", "--port", "8000"]
각 줄이 앞 절의 층 하나씩을 고정합니다. python:3.11-slim이 인터프리터를, apt-get으로 깐 libgomp1이 OS 라이브러리를, pip install이 8편에서 나온 requirements.txt의 패키지를 잠급니다. 손이 닿는 모든 층이 이 파일 안에서 확정됩니다.
순서에는 이유가 있습니다. requirements.txt를 코드보다 먼저 복사하는 것은 도커의 층 캐시 때문입니다. 도커는 각 명령을 층으로 쌓고 바뀌지 않은 층은 재사용합니다. 코드만 고치고 다시 빌드할 때 의존성 설치 층이 그대로 캐시되므로, 매번 무거운 pip install을 다시 하지 않아도 됩니다. 코드를 먼저 복사하면 이 캐시가 매번 깨집니다.
빌드하고 실행하는 명령은 두 줄입니다.
1
2
3
4
5
# 현재 디렉터리의 Dockerfile로 이미지를 빌드하고 이름표를 붙인다
docker build -t taxi-demand-api:latest .
# 8000 포트를 호스트로 열고, .env의 환경변수를 주입해 컨테이너를 띄운다
docker run --rm -p 8000:8000 --env-file .env taxi-demand-api:latest
이미지에 코드를 통째로 복사하기 전에 무엇이 들어갈지 걸러야 합니다. .dockerignore에 data/, .venv/, .git/, 노트북 체크포인트를 적어두면, 수 기가바이트의 데이터와 로컬 가상환경이 이미지에 섞여 들어가는 사고를 막습니다. 이미지는 서빙에 필요한 코드만 담아야 가볍고 안전합니다.
--host 0.0.0.0을 빠뜨리면 컨테이너 안에서만 접속되고 밖에서는 연결이 거부됩니다.uvicorn의 기본값127.0.0.1은 컨테이너 내부 루프백만 가리키기 때문입니다. 컨테이너 안에서 뜨는 웹 서버는0.0.0.0으로 바인딩해야-p로 연 포트가 실제로 통합니다.
의존성 격리
컨테이너의 값어치는 재현성만이 아닙니다. 서비스마다 환경을 따로 가둘 수 있다는 점이 실전에서 더 크게 작용합니다.
ML 프로젝트에는 요구 조건이 서로 다른 조각이 섞여 있습니다. 서빙은 예측에 필요한 최소 패키지만 있으면 되고, 학습은 무거운 프레임워크 전부가 필요합니다. 이 둘을 한 환경에 몰아넣으면 서빙 이미지가 수 기가바이트로 부풀고, 쓰지도 않는 패키지의 취약점까지 떠안습니다. 컨테이너는 서비스마다 이미지를 나눠 이 문제를 끊습니다. 위 서빙 이미지는 torch나 학습용 LightGBM 스택을 담지 않고, MLflow도 추적 클라이언트만 든 경량판(mlflow-skinny)을 씁니다.
서빙 이미지에 학습 스택을 함께 넣지 않습니다. 이미지가 커져 배포와 스케일이 느려지고, 서빙 런타임에 필요 없는 패키지가 늘어난 만큼 공격 표면과 버전 충돌 위험도 커집니다. 이미지 하나에는 그 서비스가 실제로 쓰는 것만 담는 것이 원칙입니다.
격리가 선택이 아니라 필수가 되는 경우도 있습니다. Airflow가 그렇습니다. Airflow는 자체 의존성이 방대하고 버전 고정이 까다로워, 프로젝트 가상환경에 함께 설치하면 다른 패키지와 충돌하기로 유명합니다. 그래서 Airflow는 프로젝트의 pyproject.toml에 넣지 않고 컨테이너로만 구동하는 편이 안전합니다. Airflow의 무거운 의존성 트리가 서빙이나 학습 환경을 건드리지 못하도록, 컨테이너 경계로 완전히 갈라놓는 것입니다. 오케스트레이션 자체는 11편에서 다룹니다.
NYC 택시에서는
이 결정을 실제로 내린 곳이 NYC 택시 파이프라인입니다. 개발환경 세팅 편에서 인프라 서비스(PostgreSQL, MinIO, MLflow, Airflow)를 전부 Docker Compose로 띄우기로 했고, 그중 Airflow는 의존성 충돌 때문에 가상환경에 설치하지 않고 컨테이너로만 구동한다고 못 박았습니다. 이번 편에서 정리한 “왜 컨테이너인가”가 그 프로젝트에서는 스택을 어떻게 나눌지의 구체적인 판단으로 나타납니다.
FastAPI 서빙 편은 이번 편의 Dockerfile을 실제 데이터로 밀어붙인 사례입니다. python:3.11-slim에 libgomp1만 더한 최소 서빙 이미지, 전체 MLflow 대신 mlflow-skinny를 쓴 결정, 그리고 Docker Compose 네트워크 안에서 서비스 이름이 곧 호스트명이 되어 같은 코드가 환경변수만 바꿔 로컬에서도 컨테이너에서도 도는 구조를 그 글에서 코드로 확인할 수 있습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| “내 컴퓨터에선 됩니다” | 인터프리터, OS 라이브러리 등 패키지 아래 층의 차이가 만드는 재현성 문제 |
| requirements.txt의 한계 | 파이썬 패키지만 잠근다. 인터프리터와 시스템 라이브러리는 고정하지 못한다 |
| 컨테이너 | 실행 환경의 모든 층을 불변 이미지 하나로 고정. 어디서든 동일하게 뜬다 |
| Dockerfile | 환경을 만드는 레시피를 텍스트로 적어 git으로 버전관리하는 산출물 |
| 층 캐시 | 의존성을 코드보다 먼저 복사해 설치. 코드만 바뀔 때 pip install을 재사용한다 |
| 0.0.0.0 바인딩 | 컨테이너 안 웹 서버는 0.0.0.0으로 열어야 밖에서 접속된다 |
| 의존성 격리 | 서비스마다 이미지를 나눠 최소 의존성만 담고 충돌을 끊는다 |
| Airflow 컨테이너 격리 | 충돌 심한 의존성은 가상환경 대신 컨테이너로만 구동해 갈라놓는다 |
서빙 앱을 어디서든 같은 방식으로 뜨는 이미지로 굳혔습니다. 다음 편은 이렇게 컨테이너로 나눈 조각들을 정해진 순서와 시각에 자동으로 실행하는 오케스트레이션입니다. Airflow로 파이프라인을 DAG로 엮고, 재실행해도 안전한 멱등성을 봅니다.