Python Docker 이미지 최적화: From 1.2GB to 75MB

Python Docker 이미지 최적화: From 1.2GB to 75MB
작아져라 얍!

Introduction

Annotation AI, Docker 최적화 사례

Annotation AI의 여러 서비스들은 의존성을 패키징하여 Docker 이미지로 만들어 배포하고 있습니다. 이번 글에서는 FastAPI를 이용한 Docker 이미지 최적화 경험을 예시로 소개하며, Docker 이미지를 최적화하는 방법에 대해 설명하겠습니다.

최적화하지 않은 초기 Dockerfile

FROM python:3.8
RUN apt-get update && apt-get install -y make curl vim
WORKDIR backend-service

COPY requirements.txt .
COPY Makefile .
RUN pip install -r requirements.txt
COPY . .

CMD ["make", "run-server"]

위 예제는 FastAPI를 패키징하는 Dockerfile입니다. python:3.8 이미지를 base image로 사용하여 의존성을 설치한 후 서버를 실행합니다. 특정 배포 시점마다 매번 Docker image를 빌드하고 있는데, 빌드 속도가 너무 느리다는 피드백이 있어 Docker image를 최적화 하여 빌드 속도를 개선해보려 합니다.

최적화 이전에, 현재 빌드된 Docker image와 사용 중인 base image의 용량을 먼저 확인하겠습니다. 아래 결과와 같이 현재 빌드된 이미지는 약 1.21GB를 차지하고 있으며, Base image는 913MB를 차지하고 있습니다.

$ docker images

REPOSITORY              TAG            IMAGE ID       CREATED             SIZE
backend-service        latest         28763e8659a8   12 seconds ago      1.21GB
...
python                  3.8            51a078947558   3 weeks ago         913MB

Dockerfile 최적화 방법

느린 빌드 속도를 개선하기 위해서는 Docker 이미지를 보다 가볍게 만들어야 합니다. Docker 이미지의 최적화 방법을 찾아보기에 앞서,  대략적인 정보를 얻기 위하여 Chatgpt에게 Docker image를 어떻게 하면 최적화 할 수 있는지 물어보았습니다.

💡
Q. How can I optimize my docker image? Give me some checklists and references.
  1. 좀 더 가벼운 base image를 사용해라.
  2. Layer의 수를 줄여라.
  3. Caching을 적극적으로 사용해라.
  4. 필요없는 파일들 지워라.
  5. ADD 대신에 COPY 를 사용해라.
  6. CMD 대신에 ENTRYPOINT 를 사용해라.
  7. Multi-stage build를 사용 할 것.
  8. .dockerignore 파일을 사용 할 것.

답변으로부터 얻은 키워드(Base-image, Multi-stage build 등)를 기반으로 추가적인 조사를 수행했습니다. 결론적으로 Docker 공식 문서의 best-practice와  위에서 제안한 몇가지 방법을 적용하여 이미지 최적화를 진행해보겠습니다. 사용한 방법은 다음과 같습니다.

  1. 적절한 Base image의 선정
  2. 필요없는 packages의 제거
  3. Multi-stage 빌드
  4. layer 수의 최소화
  5. cache 제거

Optimize Dockerfile

최초 이미지 스펙 및 적용한 최적화 방법

FROM python:3.8
RUN apt-get update && apt-get install -y make curl vim
WORKDIR <Service>

COPY requirements.txt .
COPY Makefile .
RUN pip install -r requirements.txt
COPY . .

CMD ["make", "run-server"]

최초의 Dockerfile은 위와 같이 구성되어 있었습니다. python 3.8을 Base image로 사용하며, 서버를 실행하기 위한 기본 dependency를 설치하고, 최종적으로 서버를 실행하는 것으로 구성되어 있습니다. 이 파일을 빌드하면 약 1.21GB 크기의 이미지가 생성됩니다. 이제 이 이미지의 크기를 최적화하기 위해 먼저 위에서 언급한 1, 2, 3 방법을 적용해보겠습니다.

  1. 적절한 Base image의 선정
  2. 필요없는 packages의 제거
  3. Multi-stage 빌드

Python Docker Base image 선정 및 필요없는 Package 제거

먼저 적절한 base image를 선정해보려 합니다. 이를 먼저 결정한 이유는 base image에 따라 package 설치 부분과 Multi-stage 부분이 달라질수도 있기 때문입니다. 예를들어 Base 이미지의 종류에 따라 사용되는 OS가 다를 수 있습니다. 만약 OS가 다르다면 우리가 설치하고자 하는 package를 설치 할 수 없을수도 있습니다.

Base image tag

Python docker official images 를 보면 굉장히 여러 종류의 Base image를 지원하는것을 확인 할 수 있습니다. Tag에 따라 Base image의 종류가 나뉩니다. 아래 그림은 Base image로 사용되는 일부 tag를 보여줍니다.

Base image의 Tag에는 각각의 의미가 있습니다. 자주 사용되는 몇 가지에 대해서 설명해드리겠습니다.

  • Slim : Base를 실행하기 위한 최소한의 것만 설치되어 있는 image 라는것을 나타냅니다. 예를들어 python-slim의 경우 python을 실행하기 위한 최소한의 것들만 설치되어있습니다.
  • bullseye / buster /  stretch  / jessie  : Debian 계열 os를 사용하는 이미지들입니다. 버전에 Debian 계열 OS의 버전 정보에 따라 tag name이 달라집니다.
  • bullseye : Debian 계열 11 버전대 OS (현재 stable 버전)
  • buster : Debian 계열 10 버전대 OS (구 stable 버전)
  • stretch : Debian 계열 9 버전대 OS
  • jessie : Debian 계열 8 버전대 OS
  • Alpine : Alpine-linux os를 사용하는 image 입니다.. alpine-linux는 5MB 안팎의 매우 경량화된 Linux os 입니다. 해당 OS는 C lib를 사용하지 않고 Musl이라는것을 사용하는데, 이 때문에 C-디펜던시 문제 / 디버깅이 어렵다는 등의 문제가 발생 할 수 있습니다.

Python-slim 이미지

여러 Base image가 있지만, 이중에서 slimAlpine 두 Base image 중 하나를 선택하여 최적화를 진행하고자 하였습니다. 다음은 slim, Alpine image를 pull 하였을 때의 용량입니다.

REPOSITORY                                         TAG            IMAGE ID       CREATED          SIZE
python                                             3.8            51a078947558   3 weeks ago      913MB
python                                             3.8-slim       61afbf515f15   3 weeks ago      124MB
python                                             3.8-alpine     201f0ed8f699   7 days ago       48.2MB

두 이미지 중 어느 것을 사용하더라도 기존 Base image인 Python3.8의 913MB보다 용량이 작습니다. Python-slim은 124MB이고 Alpine은 48.2MB의 용량을 가지고 있습니다. 결과만 보면 Alpine이 더 매력적으로 보이지만, Alpine은 경량화된 OS인 alpine-linux를 사용하기 때문에 Side-effect가 발생할 수 있습니다. 예를 들어 필수적인 패키지의 설치를 지원하지 않을 수도 있습니다. 따라서, Python-slim 이미지를 먼저 이용하여 최적화를 진행한 후, Alpine 이미지에 대한 최적화를 시도해보려고 합니다.

FROM python:3.8-slim
RUN apt-get update && apt-get install -y make curl vim
WORKDIR <Service>

COPY requirements.txt .
COPY Makefile .
RUN pip install -r requirements.txt
COPY . .

CMD ["make", "run-server"]

Python-slim base 이미지를 이용하여 빌드 한 결과입니다. 기존 1.21GB → 647MB 로 약 1/2 가량의 용량이 줄어듦을 확인 할 수 있습니다.

REPOSITORY                                         TAG            IMAGE ID       CREATED              SIZE
backend-service                                   latest         28763e8659a8     seconds ago      1.21GB
backend-service-slim                              latest         0b8e4b1b8f0c     seconds ago        647MB

사용하지 않는 패키지 제거하기

이전에 확인한 Python-slim base image의 용량은 124MB였습니다. 그러나 빌드 후, 이미지의 크기는 647MB입니다. 이는 즉, base image와 별개로 여러 패키지들이 설치되어 용량이 늘어났다는 의미입니다. 따라서 최적화를 위해 필수적인 패키지를 제외하고는 모두 제거하려고 합니다. 예를 들어 실제 배포시에는 필요없지만, 디버깅 및 개발과정에서 사용하고 있는 패키지들이 있을 수 있습니다. 이러한 경우 배포용과 개발용으로 패키지를 나누고 배포용 패키지들만 설치하도록 합니다.

먼저 curl과 vim을 사용하지 않음을 확인하고, Dockerfile 내부에서 지웠습니다. 그리고 사용하지 않는 python dependencies를 삭제합니다. 다음은 기존 설치하고 있던 dependencies 입니다. 개발시에만 사용되는 패키지들(Formatter, Linter 등)이 일부 포함되어 있습니다.

pre-commit == 2.17.0

# setup
fastapi == 0.79.0
uvicorn == 0.18.2
psycopg2 == 2.8.6
numpy == 1.23.5 

# formatter
isort == 5.10.1                 # imports
black == 22.3.0                 # coding style

# linter
pylint              == 2.12.2   # python static code analysis
mypy                == 0.931    # type check
flake8              == 3.8.4    # PyFlakes + pycodestyle + Ned Batchelder’s McCabe script
flake8-docstrings   == 1.6.0    # pydocstyle tool to flake8
flake8-annotations  == 2.7.0    # PEP 3107-style function annotations
flake8-builtins     == 1.5.3    # check python builtins being used as variables or parameters
flake8-bugbear      == 22.1.11  # find likely bugs and design problems

# pytest for linting and unit test
pytest          == 6.2.5
pytest-pylint   == 0.18.0
pytest-flake8   == 1.0.7
pytest-mypy     == 0.8.0
pytest-cov      == 3.0.0        # coverage reports
python-dotenv   == 0.21.0

# converter
xmltodict       == 0.13.0 

개발시에만 사용되는 패키지들을 모두 지우면 다음의 패키지들만 남습니다.

# setup
fastapi == 0.79.0
uvicorn == 0.18.2
psycopg2 == 2.8.6
python-dotenv   == 0.21.0

# converter
xmltodict       == 0.13.0 

필요없는 패키지를 제외하고 필수적인 패키지만 남겨 빌드하였을 때, 크기가 647MB에서 395MB까지 줄어들게 됩니다.

REPOSITORY                                         TAG            IMAGE ID       CREATED              SIZE
backend-service-slim                               latest         0b8e4b1b8f0c    seconds ago        647MB
backend-service-slim-essential                     latest         3b0a04ca5bb2    seconds ago        395MB

Python-alpine

Alpine 이미지도 테스트해줍니다. Alpine에서 지원하지 않는 패키지들도 있으니, 이부분 주의하여 테스트를 진행합니다. Alpine 이미지로 빌드시 기존 395MB에서 325MB 까지 줄어들었음을 확인 할 수 있습니다.

REPOSITORY                                         TAG            IMAGE ID       CREATED              SIZE
backend-service-slim-alpine-essential              latest         b0bbf2507cf5   10 minutes ago      325MB

의아한 점이 있습니다. Alpine base image의 크기는 48.2MB인데, 필수 패키지를 포함하여 빌드하면 325MB까지 증가합니다. 최적화가 제대로 이루어지지 않은 것 같은 느낌을 받습니다.

Multi-stage 빌드

Multi-stage 빌드란?

Multi-stage 빌드는 하나의 Dockerfile 내 여러 단계로 구성 된 이미지를 생성 하는 기능입니다. 각 단계별로 필요한 파일만을 포함시키기 때문에 최종 사용되는 이미지의 크기를 줄일 수 있습니다.

예를 들어 설치, 빌드, 배포의 세단계의 Multi-stage 빌드를 할 수 있습니다. 이 경우에는 Dependency를 다운로드하고 설치하는 설치 단계와 설치된 Dependency를 이용하여 빌드를 하는 빌드 단계, 최종적으로 빌드된 결과를 복사하여 사용하는 배포 단계로 나뉘어 집니다. 결과적으로 세개의 이미지로 나뉘어 빌드가 되고, 최종 사용하는 배포 단계의 이미지는 이전 단계의 결과를 이용만 하므로, 이미지의 크기를 줄일 수 있습니다.

Multi-stage 적용 사례

다음은 Multi-stage을 이용하여 구성한 Dockerfile 입니다. Base image로 python:3.8-alpine 이미지를 사용하였고, Dependency를 설치하는 builder 와 이를 이용하는 deployer 로 나누어 빌드합니다.

FROM python:3.8-alpine AS builder

RUN apk update && apk add --no-cache make && apk add --no-cache libpq-dev g++

WORKDIR /app
COPY requirements* ./
RUN pip install --no-cache-dir -r requirements-prod.txt
COPY Makefile . 
COPY src ./src


FROM python:3.8-alpine AS deployer
COPY --from=builder /usr/local/lib/python3.8/site-packages /usr/local/lib/python3.8/site-packages
COPY --from=builder /app /app
RUN apk update && apk add --no-cache make
WORKDIR /app

CMD ["make", "run-server"]

이를 이용하여 빌드를 하게 되면 최종적으로 75.7MB 의 빌드된 이미지를 얻게됩니다.

REPOSITORY                                         TAG            IMAGE ID       CREATED              SIZE
backend-service-optimized                          latest         57aa097d0125   About a minute ago   75.7MB

맺음말

  • 적절한 Base image의 선정, 필요없는 패키지들의 제거, 그리고 Multi-stage 빌드를 통하여 간단히 기존 이미지의 크기를 줄일 수 있음을 알아보았습니다.
  • 최초 1.17GB 크기의 이미지를 75.7MB 로 줄일 수 있었습니다.

Reference