🕒 6 мин.
Оптимизация образов Docker — одна из задач Devops-инженера. В этой статье описаны некоторые методы, которые вы можете применять для оптимизации своих образов 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>Live reload!</h1> <p>Text from a file located on the host!</p>
Чтобы не засорять систему удалим контейнер:
$ 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.