Docker - Оптимизация ваших образов
Кеширование слоёв
Некоторые инструкции Dockerfile создают новые read-only слои, а некоторые не создают.
Создают слои:
FROM- базовый слойRUN- запуск команды для подготовки образаCOPY,ADD- копирование файлов в образ
Не создают слои:
WORKDIR- установка рабочего каталогаCMD,ENTRYPOINT- команда при запуске контейнераENV- переменные окруженияEXPOSE- объявление о необходимости открыть портUSER- смена пользователя
Пример:
# Слой 1: базовый образ.
FROM python:3.11-slim
# Установка рабочего каталога. Слой не создаётся.
WORKDIR /app
# Слой 2: копирование файла.
COPY requirements.txt .
# Слой 3: установка зависимостей.
RUN pip install -r requirements.txt
# Слой 4: копирование кода.
COPY app.py .
# Подготовка команды при запуске контейнера. Слой не создаётся.
CMD ["python", "app.py"]
При сборке контейнера слои кешируются. А при пере-сборки часть слоёв, которые не изменились, берутся из кеша. Как только один слой изменился, все последующие слои пересобираются. Предыдущие слои берутся из кеша. Из этого следует правило -> Всё что изменяется реже, нужно прописывать выше. Например, зависимости изменяются реже чем основной код, поэтому их установку нужно прописать выше чем копирование файла с кодом.
Вот как это работает. При изменении кода app.py и пересборки образа зависимости будут взяты из кеша, а не будут устанавливаться (не будет выполняться pip install -r requirements.txt). А это очень сильно ускоряет сборку.
Несколько команд в одном RUN
Некоторые действия можно делать в одной инструкции RUN, что создаст 1 слой. Например:
RUN apt update && apt install -y curl && rm -rf /var/lib/apt/lists/*
- Здесь мы ещё и кеш пакетов удаляем из образа, чтобы его уменьшить.
Образ рекомендую делать как можно меньше, для ускорения развёртывания. Например вот так можно избавится от кеша pip:
RUN pip install --no-cache-dir -r requirements.txt
И так, подведём промежуточный итог:
- То что меняется чаще должно быть ниже. Например, код ниже чем зависимости.
- По возможности можно объединять команды в одном
RUN. - Старайтесь удалять ненужные данные (кеш apt, или pip) чтобы уменьшить образ.
Файл .dockerignore
Для того, чтобы в образ не попали ненужные файлы используют файл .dockerignore.
Например, мы копируем в образ не отдельные файлы:
COPY requirements.txt .
COPY app.py .
А копируем весь каталог (контекст):
COPY . .
В этом случае нужно некоторые файлы исключить с помощью .dockerignore. В этот файл обычно помещают:
__pycache__
.env
.git/
.gitignore
*.log
Даже если вы, при сборке образа, копируете в него отдельные файлы, желательно всё равно использовать .dockerignore - это хорошая привычка. Возможно, после вас кто-нибудь решит переделать образ и использует COPY . .. Или вы сами в спешке или по невнимательности сделаете это.
Базовые образы
Выбор базового образа напрямую влияет на размер итогового Docker-образа, скорость доставки и количество потенциальных уязвимостей. Образы debian или ubuntu (и основанные на них) заметно больше по размеру, чем alpine, поэтому контейнеры на их основе дольше скачиваются и содержат больше пакетов, которые нужно обновлять и проверять на CVE.
Alpine Linux - минималистичный дистрибутив, популярный в мире контейнеров. В нём используется:
shвместоbash;apkвместоapt;- библиотека musl libc вместо glibc.
Последний пункт важен на практике: из-за musl некоторые готовые бинарники и зависимости могут не работать “из коробки”, и Alpine не всегда подходит как универсальная замена Debian или Ubuntu.
В качестве компромисса часто используют slim-образы, например debian:bookworm-slim, python:3.11-slim или node:*-slim. Они значительно меньше полноценных образов, но при этом сохраняют совместимость с glibc и привычным набором инструментов.
Важно помнить, что самый маленький базовый образ не всегда лучший выбор. Alpine хорошо подходит для простых сервисов и runtime-контейнеров, но для сборки или сложных приложений иногда разумнее выбрать slim-варианты Debian или Ubuntu.
Практика
Проверим на практике сколько может весить образ созданный на python:3.11-slim и на python:3.11-alpine. Создадим рабочий каталог docker_slim_alpine, все файлы будем создавать в нём.
Возьмём приложение app.py из предыдущего урока:
from flask import Flask
# Для работы с переменными
import os
app = Flask(__name__)
# Читаем переменные окружения
greeting = os.getenv("GREETING", "Hello")
name = os.getenv("NAME", "World")
@app.route('/')
def hello():
return f"""
<h1>{greeting}, {name}!</h1>
<a href="/file">Файл</a>
"""
@app.route('/file')
# Чтение из файла
def file():
try:
with open("data.txt", "r") as f:
content = f.read()
except:
content = "Файл data.txt не найден"
return f"""
<h1>Live reload!</h1>
<p>{content}</p>
"""
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
Для него создадим - requirements.txt:
Flask==2.3.3
Создадим файл, который будем читать - data.txt:
Text from a file located on the host!
И файл с переменными .env:
GREETING=Bonjour
NAME=Elena
Создадим два образа.
- Первый основан на Debian -
Dockerfile-debian:
# Базовый образ
FROM python:3.11-slim
# Объявим порт
EXPOSE 5000
# Рабочий каталог
WORKDIR /app
# Копируем и устанавливаем зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копируем приложение и файл для чтения
COPY app.py .
COPY data.txt .
# Запускаем приложение при старте контейнера
CMD ["python", "app.py"]
- Второй основан на Alpine -
Dockerfile-alpine:
# Базовый минимум (то-еть образ)
FROM python:3.11-alpine
# Объявим порт
EXPOSE 5000
# Рабочий каталог
WORKDIR /app
# Копируем и устанавливаем зависимости
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копируем приложение и файл для чтения
COPY app.py .
COPY data.txt .
# Запускаем приложение при старте контейнера
CMD ["python", "app.py"]
Создадим .dockerignore. В нём исключим наши Docker-файлы и файл с переменными:
Dockerfile-*
.env
Соберём оба образа указав конкретные файлы для сборки, для чего используем опцию -f:
$ docker build -f Dockerfile-debian -t myapp-debian .
$ docker build -f Dockerfile-alpine -t myapp-alpine .
И наконец, сравним размеры получившихся образов:
$ docker images | grep myapp
myapp-alpine:latest 4a324d690d2f 69.7MB 0B
myapp-debian:latest 1f802bbd86e9 140MB 0B
- alpine - вдвое меньше.
Запустим контейнер из образа myapp-alpine и проверим что всё работает:
$ docker run -d -p 5000:5000 --name myapp-alpine myapp-alpine
$curl localhost:5000
<h1>Hello, World!</h1>
<a href="/file">Файл</a>
$ curl localhost:5000/file
<h1>Text from a file located on the host!</h1>
Чтобы не засорять систему удалим контейнер:
$ docker stop myapp-alpine
$ docker rm myapp-alpine
Многоступенчатая сборка
При сборке приложения часто нужны дополнительные инструменты: компиляторы, пакетные менеджеры и т.п. Но в финальном образе они не нужны, нужен только скомпилированный бинарник или другой результат.
Решение: разделить процесс на этапы:
- Сборка (builder) - с полным окружением:
- Финальный образ (runtime) - только то, что нужно для запуска.
Ниже мы рассмотрим ключевые моменты multi-stage bild (многоступенчатой сборки):
- именование стадий;
- копирование между стадиями.
Подготовим рабочий каталог docker_multi_stage. Для multi-stage выберем язык Go, так как он компилирует результат в один бинарник и поэтому идеально подходит для этого.
Напишем приложение hello.go:
package main
import "fmt"
func main() {
fmt.Println("Hello from Go in Docker!")
}
Напишем Dockerfile:
# Этап 1: сборка
FROM golang:1.21 AS builder
WORKDIR /app
# Создаём go.mod программно
RUN echo 'module hello' > go.mod && echo 'go 1.21' >> go.mod
# Копируем исходник
COPY hello.go .
# Собираем приложение
RUN go build -o hello .
# Этап 2: минимальный образ
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/hello .
CMD ["./hello"]
AS builder- это именование стадий;COPY --from=builder /app/hello .- это копирование скомпилированного приложения из первой стадии во вторую.
Собираем образ:
$ docker build -t go-hello .
Проверяем, запустив контейнер без опции -d (чтобы видеть вывод), но с опцией --rm (чтобы удалить контейнер сразу после выполнения):
$ docker run --rm go-hello
Hello from Go in Docker!
Итог
Из статьи вы узнали про следующие методы оптимизации образов Docker:
- То что меняется чаще размещаем ниже в
Dockerfile. Например, код ниже чем зависимости. - Уменьшаем количество слоёв, объединяя команды в одном
RUN. - Стараемся удалять ненужные данные из образа (кеш apt / pip), чтобы его уменьшить.
- Используем
.dockerignore, чтобы в образ не попали ненужные файлы. - По возможности используем более маленькие образы.
- Если приложение нужно предварительно компилировать, то используем многоступенчатую сборку:
- Сборка в одном контейнере;
- Runtime в другом.
Если понравилась статья, подпишись на мой канал в VK или Telegram .