탐구 생활/Python Monorepo

Python Monorepo - uv Docker 다이어트하기

개발프로브 2025. 6. 29. 18:13

문제 제기

교적 간단한 기능만 하는 마이크로서비스의 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 하는 동작이 일어납니다. 이러한 행위는 예상치 못한 결과를 만들 수 있습니다.

게다가 uv sync 에서 --frozen 옵션을 이용한 뒤 다시 uv run 을 호출하면 어떤 이유에선지는 모르지만, Docker 컨테이너 Idel 상태에서의 메모리 사용량이 증가하는 현상이 나타납니다.

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 를 통해 프로젝트 환경에 라이브러리를 만든다면 아래의 동작이 일어나는 것입니다.

  1. uv sync --all-packages 를 입력한다.
  2. 모든 Workspace의 pyproject.toml을 돌아다니며 uv.lock 파일과 비교하여 uv.lock 파일을 업데이트.
  3. 만약 uv.lock 파일이 없다면 현재 소스코드에 근거하여 새롭게 uv.lock 파일을 만든다.
  4. uv.lock 파일에 따라서 프로젝트 환경에 필요한 라이브러리를 설치한다.

제가 실행한 테스트에서 uv.lock 을 포함한 상태에서 Dockerfile 을 빌드했을때와 uv.lock을 포함하지 않고 Dockerfile 을 빌드했을때의 사이즈가 동일했기 때문에 2번의 소스코드와 비교하여 uv.lock 파일을 "업데이트"하는 과정에는 존재하지 않는 내용은 삭제하는 과정을 포함하는 것으로 보입니다.

 

이러한 과정 때문에 uv.lock 파일이 있는 경우와 없는 경우에 최종 Dockerfile 의 크기에 유의미한 차이가 없으며 결과적으로 Runtime(Docker 컨테이너)에서도 메모리 사용량에 차이가 없는 것으로 보입니다.

 

차차리 다른 관점에서 uv.lock 파일이 없을때 비교, 업데이트하는 과정이 없어서 이미지 빌드 속도가 조금 더 빠를 수 있겠다는 생각은 듭니다.