Docker — Оптимизация ваших образов

🕒 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

Создадим два образа.

  • Первый основан на DebianDockerfile-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"]
  • Второй основан на AlpineDockerfile-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.

Мы используем cookie-файлы для наилучшего представления нашего сайта. Продолжая использовать этот сайт, вы соглашаетесь с использованием cookie-файлов.
Принять
Отказаться
Политика конфиденциальности