Python Docker 멀티스테이지 빌드로 이미지 크기 80% 줄이기

목차

FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

이 Dockerfile은 동작한다. FastAPI 앱이 정상적으로 뜨고 API도 문제없이 응답한다. 문제는 docker images를 찍어보는 순간 나타난다.

REPOSITORY    TAG       SIZE
my-fastapi    latest    1.23GB

엔드포인트 10개짜리 API 서버 이미지가 1.2GB다. 화요일 오후에 팀 슬랙에서 DevOps 담당자가 "staging 배포 왜 이렇게 느려요?"라고 메시지를 올린 걸 보고 나서야 이 숫자를 처음 확인했다. M1 Mac iTerm2에서 docker images를 찍어보고 좀 당황했다. ECR에 push하는 데 3분, pull에 2분, CI/CD 파이프라인 전체가 8분을 넘기고 있었다. 서비스 자체는 간단한 API인데 배포 한 번에 8분이 날아가니, 하루 5~6번 배포하는 팀에서 이건 무시할 수 없는 수준이었다. Python Docker 멀티스테이지 빌드 최적화를 적용해서 이 이미지를 187MB까지 줄인 과정을 정리한다.

Python Docker 이미지가 뚱뚱해지는 구조

근본적인 원인은 베이스 이미지에 있다. python:3.12는 Debian Bookworm 위에 시스템 라이브러리, C 컴파일러, Python 인터프리터를 전부 올린 풀 이미지다. 압축 해제 기준 약 1GB. 개발에 필요한 건 거의 다 들어 있지만, 프로덕션 서버에서 gcc를 쓸 일은 없다.

여기에 pip install을 실행하면 pip 캐시, 빌드 아티팩트, C 확장 컴파일에 필요한 헤더 파일까지 전부 이미지 안에 남는다. 설치가 끝난 뒤에도 이것들은 그대로 쌓여 있다.

Docker 이미지는 레이어 구조로 되어 있다. 각 RUN, COPY 명령이 하나의 레이어를 만들고, 한번 만들어진 레이어는 수정할 수 없다. "마지막에 rm -rf /tmp/*를 실행하면 되지 않나?"라고 생각할 수 있는데, 그렇게 해도 이미지 크기는 줄지 않는다. 삭제 명령은 "이 파일이 없다"는 마스크를 새 레이어에 추가할 뿐이다. 원본 레이어의 바이트는 그대로 남는다.

docker history my-fastapi를 실행하면 레이어별 크기를 확인할 수 있다. 이걸로 보면 COPY . . 레이어에 .git 폴더, __pycache__, .venv 같은 것들이 통째로 들어가 있는 경우가 보인다. Docker 데몬은 docker build 실행 시 현재 디렉토리 전체를 빌드 컨텍스트로 전송하기 때문이다. .dockerignore가 없으면 프로젝트 루트의 모든 파일이 전송 대상이 된다. .git 폴더 하나만 200MB가 넘는 프로젝트도 드물지 않다.

docker system df 명령으로 Docker가 차지하고 있는 전체 디스크 공간도 확인해보면 좋다. 이미지가 10개 이상 쌓여 있으면 로컬 디스크에서 50GB 이상 잡아먹고 있을 수 있다. 여기서부터 위기감이 생긴다.

.dockerignore가 먼저다

이미지 크기를 줄이겠다고 바로 멀티스테이지 빌드부터 만지는 건 순서가 잘못된 거다. 가장 먼저 해야 할 일은 .dockerignore 파일 하나 만드는 것이다.

문법은 .gitignore와 거의 같다. 프로젝트 루트에 생성하면 되고, 여기에 적힌 파일과 디렉토리는 빌드 컨텍스트에서 완전히 제외된다. COPY 명령이 실행될 때 아예 존재하지 않는 것처럼 처리된다.

# 버전 관리
.git
.gitignore

# Python 캐시
__pycache__
*.pyc
*.pyo
.pytest_cache

# 가상환경 — 이름이 다를 수 있으니 전부 잡는다
.venv
venv
env

# IDE 설정
.vscode
.idea
*.swp

# 테스트/문서 — 프로덕션 이미지에 불필요
tests/
docs/
*.md

# Docker 자기 참조 방지
Dockerfile*
docker-compose*
.dockerignore

# 환경 변수 — .env.local 같은 변형도 잡아야 한다
.env*

이것만으로 빌드 컨텍스트가 수백 MB 줄어드는 프로젝트가 흔하다. 빌드 시작 시 터미널에 찍히는 Sending build context to Docker daemon 메시지의 크기를 비교해보면 체감이 된다. 기존에 350MB 넘게 전송되던 게 15MB로 줄어드는 식이다.

놓치기 쉬운 항목이 몇 가지 있다. .env는 거의 다들 넣는데, .env.local, .env.development 같은 변형 파일을 빠뜨리는 경우가 많다. .env* 패턴으로 한 번에 잡는 게 안전하다. Python 프로젝트인데 node_modules가 있는 경우도 의외로 많다. 프론트엔드 빌드를 같은 레포에서 관리하는 모노레포 구조에서는 node_modules가 500MB를 넘기기도 하는데, 백엔드 Docker 이미지에 이게 들어갈 이유는 없다.

.dockerignore 없이 멀티스테이지 빌드만 적용하면, 빌드 컨텍스트 전송 자체가 여전히 느리다. 최종 이미지 크기는 줄어도 빌드 시간은 그대로인 셈이다.

멀티스테이지 빌드의 원리

.dockerignore로 빌드 컨텍스트를 정리했더라도 이미지 크기에는 한계가 있다. 근본적인 문제는 빌드 도구와 런타임 환경이 분리되지 않는다는 데 있다.

단일 스테이지 빌드에서는 하나의 FROM 이미지 위에 모든 작업이 쌓인다. psycopg2를 설치하려면 gcc가 필요하고, numpy를 빌드하려면 C 컴파일러와 헤더 파일이 있어야 한다. 설치가 끝난 뒤에는 이 도구들을 쓸 일이 없지만, 같은 레이어에 기록된 이상 이미지에서 분리할 방법이 없다.

멀티스테이지 빌드는 Dockerfile에 FROM을 여러 번 선언하는 것이다. 각 FROM이 독립된 빌드 환경을 만든다. 첫 번째 스테이지에서 컴파일을 끝내고, COPY --from=빌더스테이지 명령으로 결과물만 깨끗한 런타임 이미지로 복사하는 구조다. gcc, python-dev, libpq-dev 같은 빌드 전용 패키지는 첫 번째 스테이지에만 존재한다. 최종 이미지에는 포함되지 않는다.

Go나 Rust 같은 컴파일 언어에서는 멀티스테이지가 이미 표준이다. 바이너리 하나만 뽑아서 scratch 이미지에 넣으면 끝이니까. Python은 인터프리터가 필요해서 scratch까지는 못 가지만, 빌드 도구를 분리하는 원리는 동일하다.

이 구조가 Python에서 특히 유용한 이유는 C 확장 패키지 때문이다. psycopg2, cryptography, pandas 같은 패키지는 설치 시 네이티브 코드를 컴파일해야 하고, 빌드 도구 없이는 설치 자체가 안 된다. 실행할 때는 컴파일된 .so 파일만 있으면 되니까, 빌드에만 필요한 것과 실행에 필요한 것을 분리하는 게 핵심이다.

실전 Dockerfile 구성

아래는 실제 FastAPI 프로젝트에 적용한 멀티스테이지 Dockerfile이다.

# ===== 빌드 스테이지 =====
FROM python:3.12-slim AS builder

# C 확장 패키지 컴파일에 필요한 도구만 설치
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# 의존성 파일만 먼저 복사해서 레이어 캐시를 극대화한다
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ===== 런타임 스테이지 =====
FROM python:3.12-slim

# .pyc 생성 방지, 출력 버퍼링 비활성화
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# 런타임에 필요한 공유 라이브러리만 설치
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# 빌드 결과물만 복사
COPY --from=builder /install /usr/local

COPY . .

# 보안: 비root 사용자로 실행
RUN useradd -m appuser
USER appuser

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

여기서 --prefix=/install이 핵심이다. pip이 패키지를 /install 경로에 설치하게 해서, 런타임 스테이지에서 이 경로만 /usr/local로 통째로 복사한다. pip 캐시, 빌드 중간 파일, 컴파일러 아티팩트는 빌드 스테이지에만 남고 최종 이미지에는 들어가지 않는다.

requirements.txt를 소스코드보다 먼저 COPY하는 순서는 의도적이다. Docker 레이어 캐시를 활용하기 위해서다. 소스 코드를 수정해도 requirements.txt가 바뀌지 않았으면 pip install 레이어는 캐시에서 재사용된다. 의존성이 50개 이상인 프로젝트에서 이 차이가 크다. 매 빌드마다 pip install이 처음부터 돌아가면 2~3분씩 날아간다.

PYTHONDONTWRITEBYTECODE=1.pyc 바이트코드 파일 생성을 막는다. 컨테이너 안에서 .pyc를 캐시해봐야 의미가 없고 이미지 크기만 늘린다. PYTHONUNBUFFERED=1은 stdout/stderr 버퍼링을 끄는 설정인데, 이게 없으면 컨테이너 로그에 출력이 지연되거나 누락될 수 있다. 이 두 환경 변수는 Python Docker 이미지에서 거의 관례적으로 넣는 것이다.

빌드 스테이지의 베이스 이미지에도 slim을 쓴 이유가 있다. 최종 이미지 크기에는 영향이 없다 — 마지막 FROM부터 새로 시작하니까. 그런데 CI/CD 환경에서는 빌드 스테이지 이미지도 pull해야 한다. GitHub Actions나 Jenkins에서 풀 이미지를 pull하면 그것만 1GB다. slim이면 150MB 수준이라 파이프라인 시작이 빨라진다.

이 Dockerfile로 빌드한 결과:

REPOSITORY    TAG       SIZE
my-fastapi    latest    187MB

1.23GB에서 187MB로, 약 85% 감소다. CI/CD 파이프라인 전체 시간도 8분에서 2분대로 내려왔다. ECR push/pull 시간이 비례해서 줄어든 덕이 크다.

베이스 이미지: slim vs alpine

결론부터 말하면 대부분의 경우 slim을 쓰면 된다.

python:3.12-alpine은 이미지 크기가 50MB 수준이라 숫자만 보면 매력적이다. 문제는 C 확장 패키지에서 터진다. Alpine은 glibc 대신 musl libc를 사용하는데, PyPI에 올라간 대부분의 wheel은 glibc 기반이다. Alpine에서는 사전 빌드된 wheel을 사용할 수 없고 소스에서 직접 컴파일해야 한다.

직접 겪은 케이스다. alpine 기반으로 Dockerfile을 바꾸고 psycopg2를 설치하려 했더니 error: command 'gcc' not found가 떴다. apk add gcc musl-dev postgresql-dev를 추가하고 다시 빌드했는데, 빌드 시간이 체감상 3배 이상 늘어났다. 최종 이미지 크기도 slim 기반 멀티스테이지와 비교했을 때 오히려 더 컸다. 의존성 컴파일을 위해 추가된 패키지들이 이미지 크기를 밀어올린 것이다.

psycopg2-binary를 쓰면 해결되지 않냐는 의견도 있다. 다만 psycopg2 공식 문서에서는 프로덕션 환경에 psycopg2-binary 사용을 권장하지 않는다(출처: psycopg2 공식 문서 Install, 2026년 3월 기준). 런타임 시 시스템의 libpq와 번들된 라이브러리 사이에서 버전 충돌이 발생할 수 있다는 이유다.

순수 Python 패키지만 사용하는 프로젝트라면 alpine이 좋은 선택이 될 수 있다. C 확장이 하나라도 끼는 순간 slim이 안전하다.

빌드할 때 자주 터지는 에러들

멀티스테이지 빌드를 처음 세팅하면 비슷한 에러를 반복해서 만나게 된다. 미리 알아두면 헤매는 시간을 줄일 수 있다.

libpq.so.5를 못 찾는 에러

ImportError: libpq.so.5: cannot open shared object file: No such file or directory

가장 흔한 실수다. 빌드 스테이지에서 libpq-dev로 psycopg2를 컴파일해놓고, 런타임 스테이지에 런타임 라이브러리(libpq5)를 넣지 않은 것이다. -dev 패키지는 컴파일용 헤더 파일이 포함된 개발 패키지이고, 실행 시에는 -dev가 빠진 런타임 패키지만 있으면 된다. 같은 패턴으로 libssl-dev는 런타임에 libssl3, libffi-devlibffi8이 필요하다. 빌드용과 런타임용이 다르다는 걸 인지하는 게 먼저다.

COPY –from 경로를 못 찾는 에러

COPY failed: stat /var/lib/docker/tmp/docker-builder.../install: not found

--prefix=/install 경로를 잘못 지정했거나, 빌드 스테이지에서 pip install이 실패했는데 에러를 놓친 경우다. BuildKit을 쓰면 스테이지 실패 시 명확한 에러 메시지가 나오지만, 레거시 빌더에서는 조용히 넘어갈 때가 있다. 디버깅할 때는 빌드 스테이지에 RUN ls -la /install/lib/python3.12/site-packages/를 임시로 넣어서 패키지가 실제로 설치됐는지 확인하는 게 빠르다.

레이어 캐시가 안 먹히는 문제

requirements.txt를 먼저 COPY하는 패턴을 빠뜨리면, 소스 코드 한 줄 수정에도 pip install이 처음부터 다시 돌아간다. 의존성이 많은 프로젝트에서 이건 빌드 한 번에 3~4분씩 추가된다.

Docker BuildKit의 --mount=type=cache,target=/root/.cache/pip 옵션을 쓰면 pip 캐시를 빌드 간에 유지할 수 있다. Docker 23.0 이상에서 BuildKit이 기본 활성화되어 있고(출처: Docker 공식 문서 Build with BuildKit, 2026년 3월 기준), 이 캐시 마운트 기능도 같이 사용할 수 있다. 다만 이건 아직 프로덕션 CI 환경에서 직접 적용해보지 않아서 안정성까지는 장담을 못 하겠다. 로컬 빌드에서는 체감상 2배 정도 빨라지는 느낌이다.

비root 실행 관련

위 Dockerfile에 useraddUSER 지시어를 넣은 건 보안 때문이다. 컨테이너가 root로 실행되면 컨테이너 탈출 취약점 발생 시 호스트 시스템까지 위험해진다. Kubernetes 환경에서는 Pod Security Standard의 restricted 프로파일이 non-root 실행을 강제하는 경우가 많다(출처: Kubernetes Pod Security Standards, v1.30 기준). 이미지 크기와는 별개의 문제지만, 멀티스테이지 Dockerfile을 새로 작성하는 김에 같이 챙기는 게 좋다.

멀티스테이지, 언제 필요한가

모든 Python 프로젝트에 멀티스테이지 빌드가 필요한 건 아니다.

requirements.txt에 C 확장 패키지가 없고 순수 Python 의존성만 쓴다면, 단일 스테이지 + slim + .dockerignore 조합으로 충분하다. 이 조합이면 이미지가 200~300MB 수준에서 끝나는데, 여기서 멀티스테이지를 추가해도 줄어드는 폭이 크지 않다. Dockerfile 복잡도만 올라간다.

멀티스테이지가 필요한 조건은 명확하다. psycopg2, numpy, pandas, cryptography 같은 컴파일 의존성이 하나라도 requirements.txt에 들어있으면 빌드 도구를 분리하는 게 맞다. CI/CD 파이프라인에서 이미지 빌드-푸시-풀 시간이 전체 배포 시간의 절반 이상을 차지하기 시작하면, 그때가 도입 시점이다.

개발 환경 전용 이미지라면 크기 최적화에 시간을 쓸 이유가 없다. 프로덕션 이미지와 개발용 이미지를 분리하는 전략이 현실적이다. 팀에서는 프로덕션만 멀티스테이지로 빌드하고, 개발용은 docker-compose에서 볼륨 마운트로 소스를 직접 연결해서 이미지 빌드 자체를 생략하고 있다.

CI/CD 파이프라인에서 배포 속도가 거슬리기 시작한 시점이 Python Docker 멀티스테이지 빌드 최적화를 도입할 때다.

Chiko IT
Chiko IT

Platform Engineer. Python, AI, Infra에 관심이 많습니다.