Введение


Когда я взялся за контейнеризацию моего сервиса для поиска в блоге, мне пришлось пройти множество итераций при работе с Dockerfile, так я учился создавать образ. Контейнеризация как таковая прошла легко, но я хотел создать максимально компактный и эффективный образ, и этот процесс оказался немного более затейливым, чем я ожидал. Далее хотел бы немного подробнее рассказать, чему научился, пока писал именно такой файл Dockerfile, в котором особое внимание уделяется размеру готового образа.

Я для сравнения покажу различные варианты Dockerfile, а в конце этой статьи приведу таблицу, в которой будет показано, образы какого размера получаются из каждого файла. Так вам будет проще оценить, каково влияние от каждого варианта оптимизации.

Пример сервиса


Прежде, чем разбирать примеры Dockerfile, давайте немного углубимся в устройство отдельного образа. В качестве примера разберём небольшой сервис на Python. Работая с ним, мы сможем ничего дополнительно не искать по файлам, а просто просматривать итерации Dockerfile. Этот веб-сервис основан на gunicorn (Falcon API), а с функциональной точки зрения он очень прост – говорит “hi”.

Зависимости


Для начала настроим все зависимости Python, которые нам понадобятся.

requirements.txt
Plain-Text-Markdown-Extention @ git+https://github.com/kostyachum/python-markdown-plain-text.git#egg=plain-text-markdown-extention
falcon
gunicorn

Готов сделать вид, что “Plain Text Markdown Extension” является для этого сервиса необходимым, пусть на самом деле это и не так. На этом примере я просто демонстрирую, что нам нужен pip, чтобы установить зависимость из GitHub. Так мы добьёмся, что git будет обязательно подтянута в образ в качестве сборочной зависимости. Поэтому притворимся, что нам это было необходимо, поскольку таким образом мы сымитируем реалистичную практическую ситуацию.

Сервис


Переходим к нашему супер-простому демонстрационному сервису.
ex_serv.py

#!/usr/bin/env python

import falcon

class ExResource:

    def on_get(self, req, resp):
        resp.status = falcon.HTTP_200
        resp.text = "hi"

app = falcon.App()
exr = ExResource()
app.add_route('/', exr)

Сервис говорит “Hi”!

Оптимизации


В данном случае наиважнейшая задача – правильно подобрать базовый образ, так как именно от его размера, в конечном счёте, более всего зависит, каков будет размер готового образа.
Вторая задача, немного менее важная – разделить сборочный и релизный образ на две стадии, которые заключены в одном файле Dockerfile. Очень важно, чтобы в Dockerfile уместились как сборочный, так и релизный образ.

Подбор базового образа


Когда я приступил к разработке сервиса, я использовал в качестве базового образ “python:3.11”, так как счёл это наиболее логичным. Я ведь пишу приложение на Python, поэтому образ на Python идеально для него подойдёт. Однако этот образ предполагает полную установку на Debian и, следовательно, он достаточно велик.

Затем я попробовал образ “python:3.11-slim” – тоже на основе Debian, но более тонкий. Он значительно меньше прежнего, но всё равно больше, чем я готов допустить.

Далее я попробовал “python:3.11-alpine”, который оказался довольно хорош. На его основе получился компактный готовый образ. Но мне казалось, что на самом деле его можно сделать ещё меньше.

Поэтому я попытался взять “alpine:latest” и установить Python самостоятельно. Это оказалось интересным, поскольку без разделения образа на сборочную и релизную часть, то итоговый образ получается крупнее, чем вариант с “python:3.11-alpine”. Но при разделении на сборочную и релизную часть образ получался, наоборот, меньше.

Сравнение единого образа и образа с разделением на сборочный и релизный


Давайте рассмотрим, что нужно сделать, чтобы подготовить единый и разделённый образ.

Единый образ


Когда мы создаём единый образ, всё в него устанавливается и в нём же остаётся. Речь, в частности, о сборочных зависимостях, например, git. Получается очень простой Dockerfile, но при этом в образе остаются такие вещи, которые не требуются непосредственно для запуска сервиса.

Да, это очень простой пример, но, если обратить внимание на раздел, где описан образа, то видно, насколько сильно влияет на установку такая деталь как git. Дело преимущественно в том, насколько много зависимостей подтягивает сама git.

Разделённый образ


При создании разделённого образа сначала подготавливается его «сборочная» часть, устанавливающая все зависимости – в том числе, сборочные. Затем уже внутри самого этого образа «выстраивается» само приложение. Но мы имеем дело с простым приложением на Python, поэтому в нашем случае сборочный этап сам по себе не так важен.

Как только приложение «собрано», создаётся второй образ, «релизный», и зависимости копируются в него из «сборочного» образа. Наш сервис копируется точно по такому же принципу. В «релизном» образе определяется вся конфигурация, которую мы хотим предоставить. Например, здесь определяется тот порт, который сервис будет слушать по умолчанию, а также определяется команда, которая будет запускать gunicorn.

Наконец, на определённом этапе сборочного процесса Docker сам отбрасывает «сборочный» образ, поскольку до конца сохраняется только последний образ, определённый в файле Dockerfile.

Разделённый образ устроен несколько сложнее. Но в нём в релизную часть образа не подтягивается ни git, ни зависимости, ни сам файл requirements.txt. Разница от этого более ощутима, чем может показаться – даже в таком маленьком и простом проекте, как наш.

Варианты Dockerfile


python:3.11

Единый образ

FROM python:3.11
EXPOSE 80
WORKDIR /app

COPY ./requirements.txt .

RUN apt-get install -y git
RUN pip3 install --no-cache-dir -r requirements.txt

COPY ./ex_serv.py .

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]

Разделённый образ

FROM python:3.11 AS build
WORKDIR /app

COPY ./requirements.txt .

RUN apt-get install -y git

RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

RUN pip3 install --no-cache-dir -r requirements.txt
RUN pip3 uninstall -y pip setuptools packaging

FROM python:3.11 AS release
EXPOSE 80
WORKDIR /app

COPY ./ex_serv.py .

COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]

python:3.11-slim

Для подготовки тонкого образа потребуется выполнить команду slim apt-get update, которая заполняет список файлов, используемый менеджером пакетов. В противном случае мы получим ошибку, когда попытаемся установить git.

Единый образ

FROM python:3.11-slim
EXPOSE 80
WORKDIR /app

COPY ./requirements.txt .

RUN apt-get update
RUN apt-get install -y git
RUN pip3 install --no-cache-dir -r requirements.txt

COPY ./ex_serv.py .

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]

Разделённый образ

FROM python:3.11-slim AS build
WORKDIR /app

COPY ./requirements.txt .

RUN apt-get update
RUN apt-get install -y git

RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

RUN pip3 install --no-cache-dir -r requirements.txt
RUN pip3 uninstall -y pip setuptools packaging

FROM python:3.11-slim AS release
EXPOSE 80
WORKDIR /app

COPY ./ex_serv.py .

COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]

python:3.11-alpine

Единый образ

FROM python:3.11-alpine
EXPOSE 80
WORKDIR /app

COPY ./requirements.txt .

RUN apk add --no-cache git
RUN pip3 install --no-cache-dir -r requirements.txt

COPY ./ex_serv.py .

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]

Разделённый образ

FROM python:3.11-alpine AS build
WORKDIR /app

COPY ./requirements.txt .

RUN apk add --no-cache git

RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

RUN pip3 install --no-cache-dir -r requirements.txt
RUN pip3 uninstall -y pip setuptools packaging

FROM python:3.11-alpine AS release
EXPOSE 80
WORKDIR /app

COPY ./ex_serv.py .

COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]

alpine:latest

Единый образ

FROM alpine:latest
EXPOSE 80
WORKDIR /app

COPY ./requirements.txt .

RUN apk add --no-cache git python3 py3-pip

RUN pip3 install --no-cache-dir -r requirements.txt

COPY ./ex_serv.py .

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]

Разделённый образ

FROM alpine:latest AS build
WORKDIR /app

COPY ./requirements.txt .

RUN apk add --no-cache git python3 py3-pip

RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

RUN pip3 install --no-cache-dir -r requirements.txt
RUN pip3 uninstall -y pip setuptools packaging

FROM alpine:latest AS release
EXPOSE 80
WORKDIR /app

RUN apk add --no-cache python3

COPY ./ex_serv.py .

COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

VOLUME /data

CMD ["gunicorn", "--bind", "0.0.0.0:80", "ex_serv:app" ]


Размеры образов



image

Заключение


Оба образа Alpine получились (с отрывом) самыми компактными. При этом, они значительно меньше, чем базовые образы Debian. Если у вас не предусматривается какой-либо функционал, который не будет работать с Alpine Linux, то именно Alpine следует брать за основу.

Кроме того, рекомендую прописывать в Dockerfile двухэтапную процедуру сборки и релиза. В данном примере разница в размерах не так велика, но она будет быстро нарастать по мере того, чем больше зависимостей вам придётся включать в образ. Например, образ быстро увеличивается, когда мы включаем в него пакеты *-dev для библиотек и всю цепь инструментов clang.

Комментарии (4)


  1. navferty
    30.08.2024 14:02
    +10

    Понимаю что это перевод, но всё же: в итоговом докерфайле для билд-образа сначала копируется список зависимостей самого проекта (COPY ./requirements.txt .), а потом устанавливаются нужные инструменты (git, pip):

    FROM alpine:latest AS build
    WORKDIR /app
    
    COPY ./requirements.txt .
    
    RUN apk add --no-cache git python3 py3-pip
    
    RUN python3 -m venv /opt/venv
    ENV PATH="/opt/venv/bin:$PATH"
    
    RUN pip3 install --no-cache-dir -r requirements.txt
    RUN pip3 uninstall -y pip setuptools packaging
    
    FROM alpine:latest AS release
    # ...

    Это значит, что при каждом изменении в файле requirements.txt не будет работать кэш для последующих слоёв. Если переставить строки местами - сначала устанавливать нужные вещи, которые не зависят непосредственно от содержимого requirements.txt, а потом уже копировать его - можно ещё ускорить билд.


    1. IvanZaycev0717
      30.08.2024 14:02
      +2

      сюда можно еще "колёса" прикрутить в билд, чтобы быстрее работало

      RUN pip wheel --no-cache-dir --no-deps --wheel-dir ./wheels -r requirements.txt

      А в релизе уже

      COPY --from=build ./wheels /wheels
      COPY --from=build ./requirements.txt .
      RUN pip install --upgrade pip
      RUN pip install --no-cache /wheels/*

      Колёса (Wheel-файлы) значительно ускоряют установку, так как они уже скомпилированы


      1. onegreyonewhite
        30.08.2024 14:02

        Так себе совет, потому что COPY сделает слой с колёсами, которые нужны только при установке. Правильнее будет сделать через mount, но тогда задействуется buildkit. Хотя это не проблема для большинства ситуаций, я считаю.


  1. saboteur_kiev
    30.08.2024 14:02
    +3

    Вроде как есть привычный термин - не "разделенный образ", а multi-stage.
    Ну и это известно, что каждая команда порождает слой, поэтому их надо минимизировать, упихивать в одну строку.

    RUN pip3 uninstall -y pip setuptools packaging
    
    FROM python:3.11-slim AS release

    Вот этот кусок кода вообще не понял - зачем uninstall, если начинаем новый образ?