이번 게시글에서는 컨테이너 크기를 줄이기 위한 방안을 살펴보고, 파이썬 컨테이너 이미지 빌드가 가지는 특징을 살펴보도록 하겠습니다.
지난 글에서 빌드된 이미지의 크기를 다시 살펴볼까요?
$ docker images python-hello
REPOSITORY TAG IMAGE ID CREATED SIZE
python-hello latest 746136719e90 3 minutes ago 1.46GB
1.46GB인 데는 분명 이유가 있을 겁니다. 그러면 latest
라는 태그가 무엇을 의미하는지부터 살펴볼까요?
Docker Hub으로 다시 들어가봐서, 어떤 컨테이너가 있나 살펴봅시다.
뭔가 되게 많군요. 숫자는 파이썬 버전같은데, -
뒤의 값은 뭔지 알아봅시다.
이 값들은 각각 Simple tags, Shared tags 인데요. 이 값은 아래와 같습니다.
simple tags
: 태그로 기재된 리눅스 배포판 혹은 윈도우즈에서만 동작을 보장하는 컨테이너 이미지 입니다. 이 값을 사용하면 해당 태그에 명시된 플랫폼(리눅스나 윈도우즈 등)의 하나의 이미지 정보(이미지의 구성 정보, 레이어 정보, 실행 환경 등을 담은 매니페스트)에 연결됩니다. 이 경우, 명확히 지정한 플랫폼의 이미지만 사용할 수 있습니다.
shared tags
: 여러 운영체제(리눅스 및 다양한 윈도우즈 버전)와 아키텍처에서 동작하는 컨테이너 이미지를 나타냅니다. 이 값을 Docker Engine이 구동되는 환경(운영체제, 아키텍처 등)에 맞는 적절한 이미지를 자동으로 선택해서 pull하게 됩니다. 예를 들어, mongo:4.0 같은 shared tag를 사용하면 Docker Engine이 현재 실행 중인 호스트의 운영체제와 아키텍처에 맞는 이미지를 알아서 가져옵니다.
도커 매니페스트는 컨테이너 이미지에 대한 상세 정보를 담고 있는 메타데이터입니다. 여기에는:
등이 포함됩니다. 도커는 이 매니페스트를 읽어 해당 이미지가 현재 환경에서 실행 가능한지, 어떤 레이어들이 필요한지 등을 파악합니다.
도커 이미지 크기를 줄이기 위해서는 아래와 같은 과정을 처리할 수 있습니다.
.dockerignore
파일 작성RUN
레이어 최적화앞서 말씀드린 latest
는 shared tags 입니다. 구동하려는 호스트에 맞게 구동될 수는 있지만, 최적화는 다른 문제죠. 이럴 땐 최소한의 내용만을 컨테이너를 통한 배포를 수행할 때는 최소한의 내용만 맞추고 배포하는 것이 보다 나은 접근입니다.
대표적인 베이스 이미지는 Debian linux
기반의 slim
과 Alpine linux
기반의 alpine
이 있습니다.
glibc
를 사용합니다. 이는 거의 대부분의 라이브러리와 호환됩니다.busybox
를 이용합니다musl
libc를 사용합니다. 경량버전의 라이브러리를 추구하는 것이 목표이므로, 일부 라이브러리와 호환되지 않을 수도 있습니다.그러면 이 둘을 사용해서 각각 빌드해볼까요?
Dockerfile
을 제작합니다.FROM python:3.12-slim # slim은 Debian slim 이미지를 의미합니다.
WORKDIR /app
COPY hello.py .
CMD ["python", "hello.py"]
ocker build -t python-hello:slim .
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
python-hello slim 2f648897463c 5 seconds ago 212MB
python-hello latest 746136719e90 12 days ago 1.46GB
Dockerfile
을 제작합니다.FROM python:3.12-alpine # alpine은 Alpine 이미지를 의미합니다.
WORKDIR /app
COPY hello.py .
CMD ["python", "hello.py"]
ocker build -t python-hello:alpine .
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
python-hello slim 2f648897463c 5 seconds ago 212MB
python-hello alpine 37a9f605a036 3 minutes ago 78.4MB
python-hello latest 746136719e90 12 days ago 1.46GB
멀티-스테이지는 하나의 Dockerfile
에서 다양한 스테이지로 나누어서 레이어를 쌓는 개념을 의미합니다. 그렇다면 어떤 스테이지가 있는지 살펴볼까요?
그렇다면 멀티-스테이지로 빌드를 수행해볼까요?
이번 예시부터는 이 GitHub repo를 사용해보겠습니다.
그렇다면, 멀티-스테이지 없이 헬로글또를 찍어볼까요?
FROM python:3.12-slim
ARG POETRY_VERSION=1.6.1
ARG APPLICATION_SERVER_PORT=8000
ENV PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_VERSION=${POETRY_VERSION} \
POETRY_HOME="/opt/poetry" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/application_root \
POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_CACHE_DIR="/application_root/.cache" \
VIRTUAL_ENVIRONMENT_PATH="/application_root/.venv" \
APPLICATION_SERVER_PORT=$APPLICATION_SERVER_PORT \
PATH="/opt/poetry/bin:$PATH"
# 빌드 도구 설치
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
build-essential
# Poetry 설치
RUN python -m venv ${POETRY_HOME}
RUN ${POETRY_HOME}/bin/pip install -U pip setuptools
RUN ${POETRY_HOME}/bin/pip install "poetry==${POETRY_VERSION}"
# 사용자 생성
RUN groupadd -g 1001 python_application && \
useradd -r -u 1001 -g python_application python_application
WORKDIR ${PYTHONPATH}
COPY . ${PYTHONPATH}
RUN chown python_application:python_application ${PYTHONPATH}
RUN mkdir ${POETRY_CACHE_DIR} && chown python_application:python_application ${POETRY_CACHE_DIR}
EXPOSE ${APPLICATION_SERVER_PORT}
COPY ./docker/dev/entrypoint /entrypoint
RUN sed -i 's/\r$//g' /entrypoint
RUN chmod +x /entrypoint
USER 1001
RUN poetry install --without dev
ENTRYPOINT ["/entrypoint"]
이걸로 만든 이미지의 크기를 살펴봅시다.
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
python-hello slim-without-multi f4be58bb8080 38 seconds ago 700MB
반대로 멀티-스테이지를 추가해서 헬로글또를 찍어봅시다.
빌드 스테이지는 실제 필요한 내용들만을 설치합니다.
### ### ### ### ### ### ###
# STAGE 0. Prerequisites #
### ### ### ### ### ### ###
ARG OFFICIAL_PYTHON_IMAGE=3.12
FROM python:${OFFICIAL_PYTHON_IMAGE}-slim AS build-stage # debian slim 이미지를 빌드 스테이지로 설정합시다.
ARG POETRY_VERSION=1.6.1 # 패키지 매니저로 poetry v1.6.1를 씁니다.
ENV PIP_NO_CACHE_DIR=off \ # 최적화 옵션입니다. 자세한 것은 위의 GitHub 링크를 확인해주세요!
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_VERSION=${POETRY_VERSION} \
POETRY_HOME="/opt/poetry"
RUN apt-get update \
&& apt-get install --no-install-recommends -y build-essential \ # 빌드에 필요한 핵심 내용들만 설치합니다.
&& rm -rf /var/lib/apt/lists/*
# https://python-poetry.org/docs/#installing-manually
RUN python -m venv ${POETRY_HOME} # 가상환경을 구성하고, poetry를 설치합니다.
RUN ${POETRY_HOME}/bin/pip install -U pip setuptools
RUN ${POETRY_HOME}/bin/pip install "poetry==${POETRY_VERSION}"
### ### ### ### ###
# STAGE 1. 'run' #
### ### ### ### ###
FROM python:${OFFICIAL_PYTHON_IMAGE}-slim AS run-stage
ENV PATH="/opt/poetry/bin:$PATH" # 환경변수를 추가하여, poetry를 쓸 수 있게 구성합니다.
COPY /opt/poetry /opt/poetry/ # build-stage에서 필요한 내용만 가져옵니다.
entrypoint
파일을 별도로 두고 이 파일을 entrypoint 로 구성합니다.### ### ### ### ### ###
# STAGE 2. 'service' #
### ### ### ### ### ###
FROM run-stage AS service-stage
ARG APPLICATION_SERVER_PORT=8000
# https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUNBUFFERED
ENV PYTHONUNBUFFERED=1 \
# https://docs.python.org/3/using/cmdline.html#envvar-PYTHONDONTWRITEBYTECODE
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/application_root \
# https://python-poetry.org/docs/configuration/#virtualenvsin-project
POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_CACHE_DIR="/application_root/.cache" \
VIRTUAL_ENVIRONMENT_PATH="/application_root/.venv" \
APPLICATION_SERVER_PORT=$APPLICATION_SERVER_PORT
# 가상환경이 먼저 실행될 수 있도록 PATH의 앞에 추가
# https://docs.python.org/3/library/venv.html#how-venvs-work
ENV PATH="$VIRTUAL_ENVIRONMENT_PATH/bin:$PATH"
# 권한분리를 위해 처리
RUN groupadd -g 1001 python_application && \
useradd -r -u 1001 -g python_application python_application
# WORKDIR를 애플리케이션 루트 디렉터리로 지정
# https://www.uvicorn.org/settings/#development
# https://docs.docker.com/engine/reference/builder/#workdir
WORKDIR ${PYTHONPATH}
COPY . ${PYTHONPATH}
RUN chown python_application:python_application ${PYTHONPATH}
# poetry 캐시 디렉터리를 만들고, 권한을 부여함. 링크 참조
# https://python-poetry.org/docs/configuration/#cache-directory
RUN mkdir ${POETRY_CACHE_DIR} && chown python_application:python_application ${POETRY_CACHE_DIR}
# API 엔드포인트에 대해 문서화를 수행
# https://docs.docker.com/engine/reference/builder/#expose
EXPOSE ${APPLICATION_SERVER_PORT}
# ENTRYPOINT로 사용할 파일에 대해 복사 후 내용 구성
COPY ./docker/dev/entrypoint /entrypoint
RUN sed -i 's/\r$//g' /entrypoint
RUN chmod +x /entrypoint
# 컨테이너 구동 시에 부여하는 권한을 명시함
USER 1001
# poetry를 이용하여 의존성 설치
RUN poetry install --without dev
# Entrypoint 로 추가한 파일을 사용.
# https://www.docker.com/blog/docker-best-practices-choosing-between-run-cmd-and-entrypoint/
ENTRYPOINT ["/entrypoint"]
이렇게 되면 용량은 얼마나 줄어들까요?
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
python-hello slim-multi 4f8e4848dc55 27 minutes ago 324MB
절반이나 줄어들었군요!
멀티-스테이지는 빌드타임이 얼마나 걸리는지 살펴볼까요? 이번에는 외부 패키지를 추가로 설치해서 테스트 해봅시다.
설치 대상은 pandas
와 matplotlib
입니다.
docker build -t python-hello:slim-multi-2 --no-cache .
[+] Building 45.6s (20/20) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 3.53kB 0.0s
=> [internal] load metadata for docker.io/library/python:3.12-slim 2.6s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 87.08kB 0.0s
=> [build-stage 1/5] FROM docker.io/library/python:3.12-slim@sha256:032c52613401895aa3d418a4c563d2d05f993bc3ecc065c8f4e2280978acd249 5.6s
(생략)
=> [build-stage 2/5] RUN apt-get update && apt-get install --no-install-recommends -y build-essential && rm -rf /var/lib/apt/lists/* 12.4s
=> [build-stage 3/5] RUN python -m venv /opt/poetry 1.9s
=> [build-stage 4/5] RUN /opt/poetry/bin/pip install -U pip setuptools 1.1s
=> [build-stage 5/5] RUN /opt/poetry/bin/pip install "poetry==1.6.1" 5.1s
=> [run-stage 2/2] COPY --from=build-stage /opt/poetry /opt/poetry/ 0.2s
=> [service-stage 1/9] RUN groupadd -g 1001 python_application && useradd -r -u 1001 -g python_application python_application 0.2s
=> [service-stage 2/9] WORKDIR /application_root 0.0s
=> [service-stage 3/9] COPY . /application_root 0.0s
=> [service-stage 4/9] RUN chown python_application:python_application /application_root 0.1s
=> [service-stage 5/9] RUN mkdir /application_root/.cache && chown python_application:python_application /application_root/.cache 0.1s
=> [service-stage 6/9] COPY entrypoint /entrypoint 0.0s
=> [service-stage 7/9] RUN sed -i 's/\r$//g' /entrypoint 0.1s
=> [service-stage 8/9] RUN chmod +x /entrypoint 0.1s
=> [service-stage 9/9] RUN poetry install --without dev 7.4s
=> exporting to image 8.3s
=> => exporting layers 6.5s
=> => exporting manifest sha256:2e1a026e67ea60a968a576838692cc7661011085b146d3b94bff86e21c10fedd 0.0s
=> => exporting config sha256:f7b5913e1edb9f4566cd1bf1c1f32e0baa757f9fae90fea096917d7241a0fd6d 0.0s
=> => exporting attestation manifest sha256:6482e53034c92e947f0c26372d5a0fc24e24d510ec9a4a5c5ff6901310ef5263 0.0s
=> => exporting manifest list sha256:a23299c55409341354951e7931f8bf1f38137fd21391853773255b17062c1c0f 0.0s
=> => naming to docker.io/library/python-hello:slim-multi-2 0.0s
=> => unpacking to docker.io/library/python-hello:slim-multi-2 1.8s
그렇다면, alpine 베이스 이미지로 빌드를 수행해봅시다.
docker build -t python-hello:alpine-multi-2 --no-cache .
[+] Building 90.4s (21/21) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 2.18kB 0.0s
=> [internal] load metadata for docker.io/library/python:3.12-alpine 1.4s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 85.72kB 0.0s
=> [build-stage 1/5] FROM docker.io/library/python:3.12-alpine@sha256:38e179a0f0436c97ecc76bcd378d7293ab3ee79e4b8c440fdc7113670cb6e204 2.3s
(생략)
=> [build-stage 2/5] RUN apk add --no-cache gcc python3-dev 12.1s
=> [build-stage 3/5] RUN python -m venv /opt/poetry 2.2s
=> [build-stage 4/5] RUN /opt/poetry/bin/pip install -U pip setuptools 1.3s
=> [build-stage 5/5] RUN /opt/poetry/bin/pip install "poetry==1.6.1" 5.7s
=> [run-stage 2/2] COPY --from=build-stage /opt/poetry /opt/poetry/ 0.2s
=> [service-stage 1/10] RUN addgroup -g 1001 -S python_application && adduser -S -u 1001 -G python_application python_application 0.1s
=> [service-stage 2/10] WORKDIR /application_root 0.0s
=> [service-stage 3/10] COPY . /application_root 0.0s
=> [service-stage 4/10] RUN chown python_application:python_application /application_root 0.1s
=> [service-stage 5/10] RUN mkdir /application_root/.cache && chown python_application:python_application /application_root/.cache 0.2s
=> [service-stage 6/10] COPY entrypoint /entrypoint 0.0s
=> [service-stage 7/10] RUN sed -i 's/\r$//' /entrypoint 0.2s
=> [service-stage 8/10] RUN chmod +x /entrypoint 0.1s
=> [service-stage 9/10] RUN apk add --no-cache g++ openssl 8.2s
=> [service-stage 10/10] RUN poetry install --without dev 44.2s
=> exporting to image 11.8s```
=> => exporting layers 9.2s
=> => exporting manifest sha256:6b7bcd80340bf252c06947fde85a4fb53012c3d8bb07ff6dfd3a8b6fe6d1022c 0.0s
=> => exporting config sha256:58c4058d992779af8b0e3aa67ceabd655d234d871e38af0e6996155a56ed953e 0.0s
=> => exporting attestation manifest sha256:e88ed3098ce10fbce9ed2452c82ada1f5ccccb7c9d094b6f2da495754249db26 0.0s
=> => exporting manifest list sha256:b0b8cc043f1436abf29e9b45bcbcf618d0838de9d1678086c9b2b5acc1541e9b 0.0s
=> => naming to docker.io/library/python-hello:alpine-multi-2 0.0s
=> => unpacking to docker.io/library/python-hello:alpine-multi-2 2.6s
빌드 시간과 크기에 대해 한번 살펴보죠.
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
python-hello alpine-multi-2 b0b8cc043f14 2 minutes ago 1.13GB
python-hello slim-multi-2 a23299c55409 3 minutes ago 755MB
베이스이미지 | 빌드된 이미지 크기 | 소요시간(단위: 초) |
---|---|---|
debian slim | 755 MB |
45.6 |
alpine | 1.13 GB |
90.4 |
그렇다면 alpine 베이스 이미지는 사용할 수 없는 걸까요? 왜 그런건지 알아보기에 앞서, 앞서 알아봅시다.
그렇다면 앞서 alpine linux 배포판과 debian 배포판의 차이를 알아봅시다. 이 글에서 살펴볼 둘의 주요 차이점은 C 라이브러리 구현체의 선택입니다. 각각의 특징을 살펴보겠습니다:
glibc
(Debian 계열)glibc
기준으로 빌드되어있으니, 안정성과 호환성이 검증되어 있습니다.musl
(Alpine Linux)과거에는 Alpine Linux에서 많은 파이썬 패키지를 설치할 때 소스로부터 직접 빌드해야 했고, 이는 컨테이너 빌드 시간을 길어지게 하는 주요 요소였습니다. 하지만 Python 패키징 생태계의 발전으로 상황이 크게 개선되었습니다.
다양한 배포판에서 파이썬 패키지를 바로 설치할 수 있도록 하기 위해, 아래와 같은 PEP가 지속적으로 제안되어 왔고 또 받아들여졌습니다.
musl
기반 배포판 공식 지원PEP 656이 accept 되며, musl
기반의 빌드가 이루어진 파이썬 패키지도 직접 다운로드 받게 되어, 이를 지원하고 빌드한 라이브러리라면 누구나 쓸 수 있게 되었습니다.
manylinux는 “Python wheels that work on any linux (almost).” 라는 목표를 가지고 다양한 배포판을 타겟으로 빌드하기 위해 시작된 프로젝트 입니다. 원하는 리눅스 배포판에서 라이브러리를 빌드없이 직접 다운로드 받아 쓸 수 있도록 규약을 잡기 시작한 것이라고 이해할 수 있지요(manylinux 링크에서 발췌하였습니다).
manylinux
---
Older archives: https://groups.google.com/forum/#!forum/manylinux-discuss
The goal of the manylinux project is to provide a convenient way to distribute binary Python extensions as wheels on Linux. This effort has produced PEP 513 (manylinux1), PEP 571 (manylinux2010), PEP 599 (manylinux2014), PEP 600 (manylinux_x_y) and PEP 656 (musllinux_x_y).
Alpine linux와 관련해서는 PEP 656(Platform Tag for Linux Distributions Using Musl)은 위 PEP의 연장선상이라 할 수 있지요. 이 표준이 받아들여지면서, musl
기반의 빌드된 wheel을 배포자가 업데이트 하면 사용자가 즉시 설치할 수 있게 된 것입니다. 이로 인해 alpine linux 기반의 베이스 이미지를 선택해도 빌드 시간 단축 및 의존성 문제를 해결할 수 있게 된 겁니다.
이로인해 Alpine 베이스 파이썬 컨테이너를 선택하더라도 빌드 시간 단축과 이미지 크기 최적화의 이점을 누릴 수 있게 되었습니다. 특히 PEP 656 도입 이후에는 많은 패키지가 musllinux wheel을 제공하고 있고, 코어 로직이 Rust로 짜여진 파이썬 패키지들은 크로스플랫폼을 쉽게 지원하기 때문에 이런 부분에서도 선택에 제약이 크게 없습니다.
그렇지만 여전히 의문입니다. 그러면 언제, 어떤 시기에 적절한 베이스 이미지를 선택하면 될까요? 무턱대고 고르기에는 아무래도 의심이 듭니다. 간단하게나마 있을법한 일을 검증해보고 적절한지 예시 데이터를 보면 더욱 안심이겠죠. 그래서 다음장에는 아래의 상황에서 어떤 베이스 이미지가 좋은지 함께 살펴볼 예정입니다.
긴 글 읽어주셔서 감사합니다.
pip
등으로 설치할 수 있는 외부 모듈의 모음을 의미합니다. 쉽게 말하자면 파이썬 라이브러리라고 할 수 있지요.↩