문제 제기
교적 간단한 기능만 하는 마이크로서비스의 Docker 이미지가 180MB에 달하고 Runtime 에는 Idle 상태에서 메모리를 120MB 를 사용하고 있었습니다. Python alpine 도 아니고 slim 이미지 기반인데 왜 이렇게 메모리를 비효율적으로 쓸까? 호기심이 생겼고 문제를 분석했습니다.
맥락 요약 (이전 글 안본 분들을 위해)
프로젝트 구조
현재 프로젝트의 디렉터리 구조는 아래와 같이 packages 에 공통모듈을 정의하고 services 에서는 해당 공통모듈을 끌어다가 FastAPI 와 gRPC 서버를 돌리는 코드가 위치해 있습니다. 그리고 프로젝트 root 에 pyproject.toml, packges 안의 모든 모듈에 pyproject.toml 이, services 안의 모든 서비스에 pyproject.toml 이 위치해 있습니다.
Dockerfile
개별 서비스마다 하나의 Dockerfile 이 존재하며 하나의 Dockerfile 은 아래의 형태를 따르고 있습니다.
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# 작업 디렉토리
WORKDIR /app
# 필요한 파일들만 복사
ADD pyproject.toml /app/pyproject.toml
ADD uv.lock /app/uv.lock
ADD packages /app/packages
ADD services/a_service /app/services/a_service
RUN uv sync --all-packages
WORKDIR /app/services/a_service
ENV PYTHONPATH=/app:/app/services/a_service
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--workers 2"]
원인 분석
1. Entrypoint 에서 uv run 재호출
uv 를 이용해서 sync 한 상태에서 다시 Docker Entrypoint 에서 uv 를 호출하고 있습니다. uv run 명령어는 "어떤 Python 명령이나 스크립트를 실행할 때, 그 실행이 항상 알맞은 Python 가상환경 안에서 이뤄지도록 보장" 해줍니다. 즉 단순히 Python 코드 실행보다 더 많은 일을 하게 됩니다. 예를들어 필요한 라이브러리 설치와 같은 것입니다.
결국 Entrypoint 에서 uv run 을 시행하면 기존 package 를 remove 하고 다시 add 하는 동작이 일어납니다. 이러한 행위는 예상치 못한 결과를 만들 수 있습니다.
2. Build vs Run 경계 모호
uv 는 Python 실행 도구가 아니라 패키지 관리 도구 입니다. 그런데 uv sync 를 통해서 이미 프로젝트 환경에 필요한 의존성을 모두 설치한 뒤에도 uv 를 계속 들고 있습니다. 굳이 그럴 필요가 있을까요?
Build (Python 에서 Build 는 의존성 설치 과정) 를 끝마치면 uv 를 없애는게 Docker 이미지 크기를 줄이는 방법일 것입니다.
해결전략
1. .venv PATH 주입
아래와 같이 Docker 내부 SYSTEM PATH 에 .venv 를 지정함으로써 uv run 을 사용하지 않아도 직접 uvicorn 을 호출할 수 있습니다. 이를 이용하면 Entrypoint 에서 굳이 uv run 을 호출하지 않아도 됩니다.
# .venv PATH 주입
ENV PATH="/app/.venv/bin:$PATH"
2. Multi-stage Build
Builder Stage 와 Runtime Stage 를 나누면 uv 를 없앤 Docker 이미지를 만들 수 있습니다.
- Builder Stage: uv 로 .venv 완성 후 /app 복사
- Runtime Stage: 슬림 이미지 + /venv 만 사용, uv 삭제
3. 최종 Dockerfile
# Builder stage
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
ENV UV_NO_PYTHON_DOWNLOADS=1
WORKDIR /app
ADD pyproject.toml /app/pyproject.toml
ADD packages /app/packages
ADD services/a_service services/a_service
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --all-packages --no-install-project --no-dev
# Runtime stage
FROM python:3.12-slim
RUN groupadd -r app && useradd -r -g app app
COPY --from=builder --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH=/app:/app/services/a_service
USER app
WORKDIR /app/services/a_service
EXPOSE 8000 50051
CMD ["uvicorn", "app.main:app", "--workers 2"]
결과적으로 Docker 이미지 크기를 20%~30%정도 줄일 수 있었고, Idle 상태의 Docker 컨테이너의 메모리 사용량도 50%가량 줄일 수 있었습니다.
삽질: uv sync --all-packages 를 사용한게 문제는 아닌가?
가설
"모든 workspace 를 설치하니 이미지가 커지나?"
"`uv sync --all-packages` 를 호출하면 Workspace 의 모든 가상환경에서 해당 프로젝트의 모든 라이브러리를 '프로젝트 환경'에 설치하고 Monorepo 사이즈가 커짐에 따라 자연스럽게 Docker 이미지와 컨테이너의 메모리가 사용량도 커질 수 있다" 는 가설이 있었습니다.
증명
이 가설은 틀렸는데, 그 이유는 uv sync 의 동작 방식 때문입니다.
- 공식문서에는 Syncing은 lockfile(uv.lock) 에 근거하여 packages 일부를 프로젝트 환경에 설치한다고 합니다. (Syncing is the process of installing a subset of packages from the lockfile into the project environment)
- 그리고 또 다른 공식문서를 통해는 uv sync 명령어를 실행하는 동안 lockfile 이 만들어진다는 것을 알 수 있습니다. (The lockfile is automatically created and updated during uv invocations that use the project environment, i.e., uv sync and uv run.)
즉 uv sync --all-packages 를 통해 프로젝트 환경에 라이브러리를 만든다면 아래의 동작이 일어나는 것입니다.
- uv sync --all-packages 를 입력한다.
- 모든 Workspace의 pyproject.toml을 돌아다니며 uv.lock 파일과 비교하여 uv.lock 파일을 업데이트.
- 만약 uv.lock 파일이 없다면 현재 소스코드에 근거하여 새롭게 uv.lock 파일을 만든다.
- uv.lock 파일에 따라서 프로젝트 환경에 필요한 라이브러리를 설치한다.
제가 실행한 테스트에서 uv.lock 을 포함한 상태에서 Dockerfile 을 빌드했을때와 uv.lock을 포함하지 않고 Dockerfile 을 빌드했을때의 사이즈가 동일했기 때문에 2번의 소스코드와 비교하여 uv.lock 파일을 "업데이트"하는 과정에는 존재하지 않는 내용은 삭제하는 과정을 포함하는 것으로 보입니다.
이러한 과정 때문에 uv.lock 파일이 있는 경우와 없는 경우에 최종 Dockerfile 의 크기에 유의미한 차이가 없으며 결과적으로 Runtime(Docker 컨테이너)에서도 메모리 사용량에 차이가 없는 것으로 보입니다.
차차리 다른 관점에서 uv.lock 파일이 없을때 비교, 업데이트하는 과정이 없어서 이미지 빌드 속도가 조금 더 빠를 수 있겠다는 생각은 듭니다.
'탐구 생활 > Python Monorepo' 카테고리의 다른 글
| Python Monorepo - namespace 활용하기 (0) | 2025.06.14 |
|---|---|
| Python MSA를 위한 Monorepo 구성기 — 왜 uv를 선택했는가 (9) | 2025.06.01 |
