Введение
Когда я взялся за контейнеризацию моего сервиса для поиска в блоге, мне пришлось пройти множество итераций при работе с 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](https://habrastorage.org/webt/x5/83/q2/x583q2mkiktoy23fra0gojabvou.jpeg)
Заключение
Оба образа Alpine получились (с отрывом) самыми компактными. При этом, они значительно меньше, чем базовые образы Debian. Если у вас не предусматривается какой-либо функционал, который не будет работать с Alpine Linux, то именно Alpine следует брать за основу.
Кроме того, рекомендую прописывать в Dockerfile двухэтапную процедуру сборки и релиза. В данном примере разница в размерах не так велика, но она будет быстро нарастать по мере того, чем больше зависимостей вам придётся включать в образ. Например, образ быстро увеличивается, когда мы включаем в него пакеты *-dev для библиотек и всю цепь инструментов clang.
Комментарии (4)
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, если начинаем новый образ?
navferty
Понимаю что это перевод, но всё же: в итоговом докерфайле для билд-образа сначала копируется список зависимостей самого проекта (
COPY ./requirements.txt .
), а потом устанавливаются нужные инструменты (git, pip):Это значит, что при каждом изменении в файле requirements.txt не будет работать кэш для последующих слоёв. Если переставить строки местами - сначала устанавливать нужные вещи, которые не зависят непосредственно от содержимого requirements.txt, а потом уже копировать его - можно ещё ускорить билд.
IvanZaycev0717
сюда можно еще "колёса" прикрутить в билд, чтобы быстрее работало
А в релизе уже
Колёса (Wheel-файлы) значительно ускоряют установку, так как они уже скомпилированы
onegreyonewhite
Так себе совет, потому что COPY сделает слой с колёсами, которые нужны только при установке. Правильнее будет сделать через mount, но тогда задействуется buildkit. Хотя это не проблема для большинства ситуаций, я считаю.