[Перевод] Как оптимизировать размер контейнерного образа в Docker

Введение


Когда я взялся за контейнеризацию моего сервиса для поиска в блоге, мне пришлось пройти множество итераций при работе с 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. Получается очень простой makes 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.

© Habrahabr.ru