6.3. Python Dockerfile
Цели
После этого материала вы сможете:
- построить
Dockerfileдля Python-приложения от наивного варианта к production-ready, измеряя каждый шаг; - объяснить вклад каждого изменения в размер образа и время пересборки;
- назвать восемь обязательных требований к Python-образу и проверить каждое командой;
- написать эталонный шаблон и адаптировать его под свой проект;
- обосновать каждую строку
Dockerfileпри code review.
Предварительные знания
- 6.1. Выбор base image;
- 6.2. Управление зависимостями;
- Раздел 05. Dockerfile — весь раздел.
Этот урок сводит воедино материал раздела 05 и первых двух уроков раздела 06.
Ключевые термины
| Термин | Объяснение |
|---|---|
наивная сборка | Работающий, но неоптимизированный Dockerfile |
итерация | Одно изменение с измеримым эффектом |
эталонный шаблон | Проверенная заготовка, адаптируемая под проект |
regression | Ухудшение, внесённое оптимизацией |
Теория
Восемь требований
Прежде чем оптимизировать, зафиксируем целевое состояние. Production-образ Python-приложения должен удовлетворять восьми требованиям — каждое проверяется командой.
| № | Требование | Проверка |
|---|---|---|
| 1 | Изменение кода не пересобирает зависимости | замер времени пересборки |
| 2 | Нет инструментов сборки в финальном образе | command -v gcc |
| 3 | Приложение работает от non-root с фиксированным UID | id -u |
| 4 | Вывод сразу попадает в docker logs | docker logs сразу после старта |
| 5 | docker stop завершает приложение за доли секунды с кодом 0 | time docker stop |
| 6 | Секретов нет ни в слоях, ни в истории | docker history, поиск в слоях |
| 7 | Версии зависимостей закреплены | наличие lock-файла |
| 8 | Контекст сборки минимален | размер transferring context |
Дальше — путь от варианта, не удовлетворяющего ни одному, к варианту, удовлетворяющему всем.
Порядок оптимизации
Изменения вносятся по одному, с измерением. Это не педантизм: без измерения легко внести «оптимизацию», которая ничего не даёт или ломает что-то другое.
Порядок выбран по соотношению «эффект к усилию»:
v0 наивный базовая точка
│
├─ v1 .dockerignore контекст, время, безопасность
│
├─ v2 порядок COPY время пересборки — главный выигрыш
│
├─ v3 slim + закреплённые версии размер, воспроизводимость
│
├─ v4 multi-stage размер, безопасность
│
├─ v5 non-root безопасность
│
├─ v6 переменные окружения логи, размер
│
└─ v7 сигналы и healthcheck эксплуатационная пригодность
Внутренний механизм
Почему порядок изменений имеет значение
Некоторые оптимизации взаимодействуют. Например, multi-stage без venv требует знания путей установки; .dockerignore без правильного порядка COPY даёт меньший эффект, чем мог бы.
Измерение после каждого шага показывает фактический вклад, а не предполагаемый. На практике распределение обычно такое:
| Изменение | Влияние на время | Влияние на размер |
|---|---|---|
Порядок COPY | решающее | нет |
.dockerignore | заметное | заметное |
slim вместо полного | небольшое | решающее |
| Multi-stage | небольшое | решающее (при компиляции) |
| Non-root, переменные, сигналы | нет | небольшое |
Последняя строка важна: три требования из восьми не влияют ни на время, ни на размер. Они влияют на то, работает ли приложение правильно.
Команды и примеры
Подготовка проекта
Возьмём реалистичное приложение с компилируемой зависимостью.
mkdir -p /tmp/pydocker/app /tmp/pydocker/tests && cd /tmp/pydocker
cat > app/__init__.py <<'PY'
"""Демонстрационный сервис."""
PY
cat > app/main.py <<'PY'
"""Приложение с обработкой сигналов и выводом в stdout."""
import os
import signal
import sys
import time
running = True
def handle_shutdown(signum, _frame):
global running
print(f"получен {signal.Signals(signum).name}, завершаюсь", flush=True)
running = False
def main() -> int:
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)
import psycopg2 # компилируемая зависимость
print(f"старт: PID={os.getpid()}, UID={os.getuid()}", flush=True)
print(f"psycopg2 {psycopg2.__version__.split()[0]}", flush=True)
n = 0
while running:
n += 1
print(f"работаю, итерация {n}", flush=True)
time.sleep(1)
print("завершено штатно", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > tests/test_app.py <<'PY'
def test_import():
import psycopg2 # noqa: F401
def test_arithmetic():
assert sum(range(4)) == 6
PY
cat > requirements.txt <<'EOF'
psycopg2==2.9.11
EOF
# имитация реального проекта: тяжёлые каталоги и секрет
mkdir -p .git/objects .venv/lib
dd if=/dev/urandom of=.git/objects/pack.bin bs=1M count=30 status=none
dd if=/dev/urandom of=.venv/lib/big.so bs=1M count=45 status=none
echo "DB_PASSWORD=never-commit-this" > .env
echo "структура проекта:"
du -sh . .git .venv app 2>/dev/null | sed 's/^/ /'
структура проекта:
76M .
31M .git
46M .venv
12K app
Полезного — 12 килобайт. Остальное не должно попадать в образ.
v0: наивный вариант
cat > Dockerfile.v0 <<'EOF'
FROM python:3.13
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y libpq-dev
RUN pip install -r requirements.txt
CMD python app/main.py
EOF
s="$(date +%s)"
docker build -q -f Dockerfile.v0 -t py:v0 . > /dev/null
e="$(date +%s)"
echo "первая сборка: $((e-s)) c, размер: $(docker images py:v0 --format '{{.Size}}')"
docker run --rm py:v0 timeout 2 python app/main.py 2>&1 | head -2
первая сборка: 68 c, размер: 1.14GB
старт: PID=1, UID=0
psycopg2 2.9.11
Работает. Теперь проверим по восьми требованиям:
check_all() {
local tag="$1"
echo "── проверка $tag ──"
printf ' 1. пересборка при изменении кода: '
echo "# изменено $(date +%s)" >> app/main.py
local s e
s="$(date +%s.%N)"
docker build -q -f "Dockerfile.${tag#py:}" -t "$tag" . > /dev/null 2>&1
e="$(date +%s.%N)"
awk -v a="$s" -v b="$e" 'BEGIN{printf "%.1f c\n", b-a}'
printf ' 2. gcc в образе: '
docker run --rm --entrypoint sh "$tag" -c 'command -v gcc >/dev/null && echo "ЕСТЬ" || echo "нет"' 2>/dev/null
printf ' 3. UID процесса: '
docker run --rm --entrypoint id "$tag" -u 2>/dev/null
printf ' 4. размер образа: '
docker images "$tag" --format '{{.Size}}'
printf ' 6. секрет .env в образе: '
docker run --rm --entrypoint sh "$tag" -c 'test -f /app/.env && echo "ЕСТЬ" || echo "нет"' 2>/dev/null
}
check_all py:v0
── проверка py:v0 ──
1. пересборка при изменении кода: 51.3 c
2. gcc в образе: ЕСТЬ
3. UID процесса: 0
4. размер образа: 1.14GB
6. секрет .env в образе: ЕСТЬ
Не выполнено ни одно требование. Секрет в образе, работа от root, полтора гигабайта, минута на пересборку.
v1: .dockerignore
cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.py[cod]
.pytest_cache
.env
.env.*
Dockerfile*
.dockerignore
EOF
# tests намеренно НЕ исключён: на шаге v7 появится стадия test,
# которая копирует этот каталог, а .dockerignore — один фильтр
# на всю сборку (урок 5.1)
cp Dockerfile.v0 Dockerfile.v1
docker build -f Dockerfile.v1 -t py:v1 . 2>&1 | grep 'transferring context' | tail -1
check_all py:v1
=> => transferring context: 14.21kB
── проверка py:v1 ──
1. пересборка при изменении кода: 38.7 c
2. gcc в образе: ЕСТЬ
3. UID процесса: 0
4. размер образа: 1.06GB
6. секрет .env в образе: нет
Контекст: 76 MB → 14 KB. Секрет больше не попадает в образ — требование 6 выполнено. Размер сократился на 80 MB, время — на 13 секунд.
v2: порядок COPY
cat > Dockerfile.v2 <<'EOF'
FROM python:3.13
WORKDIR /app
RUN apt-get update && apt-get install -y libpq-dev
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app/ ./app/
CMD python app/main.py
EOF
docker build -q -f Dockerfile.v2 -t py:v2 . > /dev/null
check_all py:v2
── проверка py:v2 ──
1. пересборка при изменении кода: 0.9 c
2. gcc в образе: ЕСТЬ
3. UID процесса: 0
4. размер образа: 1.06GB
6. секрет .env в образе: нет
38.7 → 0.9 секунды. Главный выигрыш всей серии, полученный перестановкой двух строк. Требование 1 выполнено.
v3: slim и закреплённые версии
docker run --rm -v "$PWD/requirements.txt:/r.txt:ro" python:3.13-slim sh -c '
apt-get update -qq && apt-get install -y -qq gcc libpq-dev python3-dev >/dev/null 2>&1
pip install -q -r /r.txt && pip freeze
' | sort > requirements.lock
cat requirements.lock
cat > Dockerfile.v3 <<'EOF'
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
COPY app/ ./app/
CMD python app/main.py
EOF
docker build -q -f Dockerfile.v3 -t py:v3 . > /dev/null
check_all py:v3
psycopg2==2.9.11
── проверка py:v3 ──
1. пересборка при изменении кода: 0.8 c
2. gcc в образе: ЕСТЬ
3. UID процесса: 0
4. размер образа: 412MB
6. секрет .env в образе: нет
1.06 GB → 412 MB. Смена базы дала 650 MB. Требование 7 выполнено — версии закреплены.
Компилятор всё ещё в образе: он нужен для сборки psycopg2.
v4: multi-stage
cat > Dockerfile.v4 <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.lock
FROM python:3.13-slim
WORKDIR /app
# только runtime-библиотека, без заголовков и компилятора
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY app/ ./app/
CMD python app/main.py
EOF
docker build -q -f Dockerfile.v4 -t py:v4 . > /dev/null
check_all py:v4
── проверка py:v4 ──
1. пересборка при изменении кода: 0.7 c
2. gcc в образе: нет
3. UID процесса: 0
4. размер образа: 158MB
6. секрет .env в образе: нет
412 → 158 MB, компилятор исчез. Требование 2 выполнено.
Обратите внимание на libpq5 в финальной стадии: без него приложение упало бы с ImportError: libpq.so.5 (урок 6.2).
v5: non-root
cat > Dockerfile.v5 <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.lock
FROM base AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --chown=appuser:appuser app/ ./app/
USER 10001:10001
CMD python app/main.py
EOF
docker build -q -f Dockerfile.v5 -t py:v5 . > /dev/null
check_all py:v5
── проверка py:v5 ──
1. пересборка при изменении кода: 0.7 c
2. gcc в образе: нет
3. UID процесса: 10001
4. размер образа: 158MB
6. секрет .env в образе: нет
Требование 3 выполнено. Появилась общая стадия base — она устраняет дублирование WORKDIR и создания пользователя.
Пользователь создаётся в base, но USER переключается только в runtime: стадии builder нужны права root для apt-get.
v6: переменные окружения
Проверим требование 4 на текущем варианте:
docker run -d --name buf-test py:v5 > /dev/null
sleep 1
echo "логи через 1 секунду после старта:"
docker logs buf-test 2>&1 | wc -l
docker rm -f buf-test > /dev/null
логи через 1 секунду после старта:
2
Здесь вывод виден, потому что в коде везде стоит flush=True. Но полагаться на это нельзя — сторонние библиотеки его не используют.
cat > Dockerfile.v6 <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
# PYTHONUNBUFFERED: вывод не буферизуется при перенаправлении
# PYTHONDONTWRITEBYTECODE: не создавать .pyc в примонтированных каталогах
# PIP_DISABLE_PIP_VERSION_CHECK: убрать лишний вывод в логах сборки
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.lock
FROM base AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
COPY --chown=appuser:appuser app/ ./app/
USER 10001:10001
CMD python app/main.py
EOF
docker build -q -f Dockerfile.v6 -t py:v6 . > /dev/null
# доказательство: убираем flush=True и сравниваем
docker run --rm --entrypoint python py:v6 -c "
import time
print('без flush, с PYTHONUNBUFFERED')
time.sleep(0.2)
" 2>&1 | head -1
без flush, с PYTHONUNBUFFERED
Требование 4 выполнено — теперь независимо от flush=True в коде.
Обратите внимание: PATH перенесён в стадию base, потому что нужен и builder, и runtime.
v7: сигналы и healthcheck
Проверим требование 5:
docker run -d --name sig-test py:v6 > /dev/null
sleep 2
s="$(date +%s.%N)"; docker stop sig-test > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.1f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect sig-test --format '{{.State.ExitCode}}')"
docker logs sig-test 2>&1 | tail -2
docker rm sig-test > /dev/null
время stop: 10.3 c, код: 137
работаю, итерация 2
Десять секунд и код 137 — при том, что обработчик SIGTERM в коде есть. Причина в shell form: CMD python app/main.py делает PID 1 оболочкой (урок 5.4).
cat > Dockerfile.v7 <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.lock
FROM builder AS test
RUN --mount=type=cache,target=/root/.cache/pip pip install pytest==9.1.1
COPY app/ ./app/
COPY tests/ ./tests/
RUN pytest -q tests/
FROM base AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
COPY --chown=appuser:appuser app/ ./app/
USER 10001:10001
# exec form: приложение становится PID 1 и получает SIGTERM напрямую
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.v7 -t py:v7 . > /dev/null
docker run -d --name sig-ok py:v7 > /dev/null
sleep 2
s="$(date +%s.%N)"; docker stop sig-ok > /dev/null; e="$(date +%s.%N)"
printf 'время stop: %.1f c, код: %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect sig-ok --format '{{.State.ExitCode}}')"
docker logs sig-ok 2>&1 | tail -2
docker rm sig-ok > /dev/null
время stop: 0.3 c, код: 0
получен SIGTERM, завершаюсь
завершено штатно
Требование 5 выполнено. Одно слово — квадратные скобки — изменило 10.3 секунды на 0.3.
Заодно добавлена стадия test, останавливающая сборку при падении тестов:
docker build -q --target test -f Dockerfile.v7 -t py:test . > /dev/null && echo "тесты прошли"
Итоговое сравнение
echo "версия размер пересборка gcc UID секрет"
echo "──────────────────────────────────────────────────────"
for v in v0 v1 v2 v3 v4 v5 v6 v7; do
size="$(docker images py:$v --format '{{.Size}}' 2>/dev/null)"
[ -z "$size" ] && continue
gcc="$(docker run --rm --entrypoint sh py:$v -c 'command -v gcc >/dev/null && echo да || echo нет' 2>/dev/null)"
uid="$(docker run --rm --entrypoint id py:$v -u 2>/dev/null)"
sec="$(docker run --rm --entrypoint sh py:$v -c 'test -f /app/.env && echo да || echo нет' 2>/dev/null)"
printf '%-7s %-9s %-11s %-5s %-7s %s\n' "$v" "$size" "-" "$gcc" "$uid" "$sec"
done
версия размер пересборка gcc UID секрет
──────────────────────────────────────────────────────
v0 1.14GB - да 0 да
v1 1.06GB - да 0 нет
v2 1.06GB - да 0 нет
v3 412MB - да 0 нет
v4 158MB - да → нет 0 нет
v5 158MB - нет 10001 нет
v6 158MB - нет 10001 нет
v7 158MB - нет 10001 нет
Сводка по всем восьми требованиям для финального варианта:
echo "═══ py:v7 — проверка всех восьми требований ═══"
echo "print('# проверка кэша')" >> app/main.py
s="$(date +%s.%N)"; docker build -q -f Dockerfile.v7 -t py:v7 . > /dev/null; e="$(date +%s.%N)"
printf '1. пересборка при изменении кода: %.1f c\n' "$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
printf '2. gcc отсутствует: %s\n' \
"$(docker run --rm --entrypoint sh py:v7 -c 'command -v gcc >/dev/null && echo НЕТ || echo да')"
printf '3. UID процесса: %s\n' "$(docker run --rm --entrypoint id py:v7 -u)"
docker run -d --name final py:v7 > /dev/null; sleep 1
printf '4. логи сразу видны: %s строк\n' "$(docker logs final 2>&1 | wc -l)"
s="$(date +%s.%N)"; docker stop final > /dev/null; e="$(date +%s.%N)"
printf '5. stop: %.1f c, код %s\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker inspect final --format '{{.State.ExitCode}}')"
docker rm final > /dev/null
printf '6. секретов в истории: %s\n' \
"$(docker history --no-trunc py:v7 | grep -ci 'password' || echo 0)"
printf '7. версии закреплены: %s\n' \
"$([ -f requirements.lock ] && echo да || echo нет)"
printf '8. размер контекста: %s\n' \
"$(docker build -f Dockerfile.v7 -t py:v7 . 2>&1 | grep -oP 'transferring context: \K[0-9.]+kB' | tail -1)"
═══ py:v7 — проверка всех восьми требований ═══
1. пересборка при изменении кода: 0.7 c
2. gcc отсутствует: да
3. UID процесса: 10001
4. логи сразу видны: 2 строк
5. stop: 0.3 c, код 0
6. секретов в истории: 0
7. версии закреплены: да
8. размер контекста: 14.21kB
Все восемь требований выполнены.
Итог пути: 1.14 GB → 158 MB (в 7 раз), 51 → 0.7 секунды пересборки (в 73 раза).
Уборка
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=py:*') 2>/dev/null || true
rm -rf /tmp/pydocker
Эталонный шаблон
Заготовка для типового Python-сервиса. Адаптируйте под проект, не копируйте вслепую.
# syntax=docker/dockerfile:1
# ═══ Общая база: настройки и пользователь ═══
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=0 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
# ═══ Сборка: компиляторы остаются здесь ═══
FROM base AS builder
# -dev пакеты нужны только для компиляции расширений
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
# Зависимости отдельно от кода: слой переиспользуется
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.lock
# ═══ Проверки: в финальный образ не входят ═══
FROM builder AS test
RUN --mount=type=cache,target=/root/.cache/pip \
pip install pytest==9.1.1 ruff==0.16.0
COPY app/ ./app/
COPY tests/ ./tests/
RUN ruff check app/
RUN pytest -q tests/
# ═══ Финальный образ ═══
FROM base AS runtime
# Только runtime-библиотеки: без заголовков и компилятора
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
COPY --chown=appuser:appuser app/ ./app/
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --start-interval=2s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status==200 else 1)"
# exec form: приложение является PID 1 и получает SIGTERM
CMD ["python", "-m", "app.main"]
Сопутствующий .dockerignore:
.git
.gitignore
.venv
venv
__pycache__
*.py[cod]
.pytest_cache
.mypy_cache
.ruff_cache
.coverage
htmlcov
.env
.env.*
!.env.example
*.pem
*.key
Dockerfile*
compose*.yaml
.dockerignore
docs
*.md
!README.md
.github
Почему в списке нет tests. Соблазн исключить каталог с тестами велик — в итоговый образ им попадать незачем. Но .dockerignore — один фильтр на всю сборку, включая стадию test, которая копирует tests/ (строка COPY tests/ ./tests/ выше). Исключение сделало бы docker build --target test невозможным:
ERROR: failed to compute cache key: "/tests": not found
В итоговый образ тесты и так не попадают: стадия runtime копирует только app/. Подробнее — урок 5.1.
Рабочие варианты этого шаблона для разных типов приложений — в resources/examples/.
Практическое упражнение
Задание. Возьмите собственный Python-проект (или создайте макет) и проведите его через ту же последовательность, измеряя каждый шаг.
Требуется:
- Составить таблицу из восьми требований и отметить исходное состояние.
- Вносить по одному изменению, после каждого измеряя размер и время пересборки.
- Зафиксировать вклад каждого изменения отдельно.
- Довести все восемь требований до выполнения.
- Объяснить, какое изменение дало наибольший эффект и почему.
Оформите результат таблицей «версия → изменение → размер → время → выполненные требования».
Подсказки
Подсказка 1
Автоматизируйте измерение функцией: она собирает образ, меняет одну строку кода, пересобирает и печатает время.
Подсказка 2
Не вносите два изменения сразу — иначе непонятно, чей вклад.
Подсказка 3
Требования 4 и 5 проверяются только запуском container, а не осмотром образа.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/usr/bin/env bash
# optimize-python-image.sh — пошаговая оптимизация с измерением каждого шага.
set -uo pipefail
WORK="$(mktemp -d)"
trap 'docker rmi -f $(docker images -q --filter "reference=opt:*") >/dev/null 2>&1 || true;
rm -rf "$WORK"' EXIT
cd "$WORK"
# ── Макет проекта ──
mkdir -p app tests .git/objects .venv/lib
cat > app/__init__.py <<'PY'
"""Пакет приложения."""
PY
cat > app/main.py <<'PY'
import os
import signal
import sys
import time
running = True
def handle(signum, _frame):
global running
print(f"получен {signal.Signals(signum).name}", flush=True)
running = False
def main() -> int:
signal.signal(signal.SIGTERM, handle)
import psycopg2
print(f"UID={os.getuid()}, psycopg2 {psycopg2.__version__.split()[0]}", flush=True)
while running:
time.sleep(0.5)
print("завершено штатно", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > tests/test_app.py <<'PY'
def test_import():
import psycopg2 # noqa: F401
PY
echo "psycopg2==2.9.11" > requirements.lock
dd if=/dev/urandom of=.git/objects/pack bs=1M count=25 status=none
dd if=/dev/urandom of=.venv/lib/big.so bs=1M count=40 status=none
echo "SECRET=do-not-leak" > .env
# ── Измерение ──
measure() {
local df="$1" tag="$2" label="$3"
docker build -q -f "$df" -t "$tag" . > /dev/null 2>&1 || {
printf '%-28s СБОРКА УПАЛА\n' "$label"; return; }
# время пересборки после изменения кода
echo "# touch $RANDOM" >> app/main.py
local s e rebuild
s="$(date +%s.%N)"
docker build -q -f "$df" -t "$tag" . > /dev/null 2>&1
e="$(date +%s.%N)"
rebuild="$(awk -v a="$s" -v b="$e" 'BEGIN{printf "%.1f", b-a}')"
local size gcc uid secret
size="$(docker images "$tag" --format '{{.Size}}')"
gcc="$(docker run --rm --entrypoint sh "$tag" -c \
'command -v gcc >/dev/null && echo да || echo нет' 2>/dev/null)"
uid="$(docker run --rm --entrypoint id "$tag" -u 2>/dev/null)"
secret="$(docker run --rm --entrypoint sh "$tag" -c \
'test -f /app/.env && echo да || echo нет' 2>/dev/null)"
# сигналы: запуск и остановка
local stopt code
docker run -d --name "probe-$$" "$tag" > /dev/null 2>&1
sleep 1.5
s="$(date +%s.%N)"; docker stop "probe-$$" > /dev/null 2>&1; e="$(date +%s.%N)"
stopt="$(awk -v a="$s" -v b="$e" 'BEGIN{printf "%.1f", b-a}')"
code="$(docker inspect "probe-$$" --format '{{.State.ExitCode}}' 2>/dev/null)"
docker rm -f "probe-$$" > /dev/null 2>&1
printf '%-28s %-8s %-7s %-5s %-7s %-7s %ss/%s\n' \
"$label" "$size" "${rebuild}c" "$gcc" "$uid" "$secret" "$stopt" "$code"
}
printf '%-28s %-8s %-7s %-5s %-7s %-7s %s\n' \
ВАРИАНТ РАЗМЕР ПЕРЕСБ GCC UID СЕКРЕТ 'STOP/КОД'
printf '%s\n' "──────────────────────────────────────────────────────────────────────────────"
# v0: наивный
cat > D0 <<'EOF'
FROM python:3.13
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y libpq-dev
RUN pip install -r requirements.lock
CMD python app/main.py
EOF
measure D0 opt:v0 "v0 наивный"
# v1: + .dockerignore
cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.py[cod]
.env
.env.*
Dockerfile*
D[0-9]
.dockerignore
tests
EOF
cp D0 D1
measure D1 opt:v1 "v1 +.dockerignore"
# v2: + порядок COPY
cat > D2 <<'EOF'
FROM python:3.13
WORKDIR /app
RUN apt-get update && apt-get install -y libpq-dev
COPY requirements.lock .
RUN pip install -r requirements.lock
COPY app/ ./app/
CMD python app/main.py
EOF
measure D2 opt:v2 "v2 +порядок COPY"
# v3: + slim
cat > D3 <<'EOF'
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
COPY app/ ./app/
CMD python app/main.py
EOF
measure D3 opt:v3 "v3 +slim"
# v4: + multi-stage
cat > D4 <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.lock
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY app/ ./app/
CMD python app/main.py
EOF
measure D4 opt:v4 "v4 +multi-stage"
# v5: + non-root + ENV + exec form
cat > D5 <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
FROM base AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.lock
FROM base AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
COPY --chown=appuser:appuser app/ ./app/
USER 10001:10001
CMD ["python", "app/main.py"]
EOF
measure D5 opt:v5 "v5 +non-root/ENV/exec"
echo
cat <<'SUMMARY'
═══ Вклад каждого изменения ═══
.dockerignore контекст 65 MB → 12 KB; секрет перестал попадать в образ
порядок COPY ГЛАВНЫЙ выигрыш по времени: изменение кода больше
не пересобирает зависимости
slim ГЛАВНЫЙ выигрыш по размеру: минус ~650 MB инструментов
multi-stage минус компилятор из финального образа
non-root безопасность; на размер и время не влияет
ENV логи видны сразу независимо от flush=True в коде
exec form stop: 10 c / код 137 → 0.3 c / код 0
═══ Наибольший эффект ═══
По времени: порядок COPY. Причина — каскадная инвалидация: при
COPY . . перед установкой любое изменение файла
инвалидирует слой с зависимостями и все последующие.
По размеру: переход на slim. Полный образ несёт buildpack-deps —
компиляторы и заголовки десятков библиотек, не нужные
при работе приложения.
По корректности: exec form. Изменение выглядит косметическим —
квадратные скобки, — но определяет, получит ли
приложение SIGTERM вообще.
SUMMARY
Ожидаемый вывод:
ВАРИАНТ РАЗМЕР ПЕРЕСБ GCC UID СЕКРЕТ STOP/КОД
──────────────────────────────────────────────────────────────────────────────
v0 наивный 1.14GB 48.2c да 0 да 10.3s/137
v1 +.dockerignore 1.06GB 36.1c да 0 нет 10.3s/137
v2 +порядок COPY 1.06GB 0.9c да 0 нет 10.3s/137
v3 +slim 412MB 0.8c да 0 нет 10.3s/137
v4 +multi-stage 158MB 0.7c нет 0 нет 10.3s/137
v5 +non-root/ENV/exec 158MB 0.7c нет 10001 нет 0.3s/0
Разбор таблицы.
Столбец «ПЕРЕСБ» показывает разрыв между v1 и v2: 36 секунд против 0.9. Это единственное изменение в этой строке — перестановка COPY.
Столбец «РАЗМЕР» показывает два скачка: v2→v3 (смена базы) и v3→v4 (multi-stage). Первый больше, но второй убирает компилятор, что важнее для безопасности.
Столбец «STOP/КОД» не меняется до самого конца и меняется резко. Это иллюстрирует важный момент: оптимизация размера и скорости не улучшает корректность. Приложение с обработчиком SIGTERM в коде вело себя одинаково плохо во всех вариантах, пока не изменилась форма записи CMD.
Практический вывод для code review. Первые четыре изменения заметны в метриках и о них помнят. Последние три — non-root, ENV, exec form — не влияют ни на размер, ни на время, поэтому их регулярно пропускают. Именно они отвечают за то, что приложение работает правильно.
Проверка результата
mkdir -p /tmp/vpd/app && cd /tmp/vpd
printf 'import time\nprint("работает", flush=True)\ntime.sleep(30)\n' > app/main.py
printf 'FROM python:3.13-slim\nENV PYTHONUNBUFFERED=1\nRUN useradd -u 10001 -m appuser\nWORKDIR /app\nCOPY --chown=appuser:appuser app/ ./app/\nUSER 10001:10001\nCMD ["python","app/main.py"]\n' > Dockerfile
docker build -q -t vpd:1 . > /dev/null
docker run -d --name vpd vpd:1 > /dev/null; sleep 1
docker logs vpd
docker exec vpd id -u
docker rm -f vpd > /dev/null; docker rmi vpd:1 > /dev/null; cd /tmp && rm -rf /tmp/vpd
Ожидается вывод работает и UID 10001.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Оптимизация без измерения | Кажется, что эффект очевиден | Замерять до и после каждого изменения |
| Несколько изменений сразу | Быстрее | Непонятно, чей вклад; вносить по одному |
Забыт .dockerignore | Не задумывались | Секреты в образе, медленная сборка |
COPY . . перед установкой | Порядок кажется неважным | Каскадная инвалидация: 36 c против 0.9 |
Полный образ вместо slim | Значение по умолчанию | ~650 MB лишних инструментов |
| Забыты runtime-библиотеки при multi-stage | В стадии сборки они были | ImportError: libpq.so.5 |
USER в стадии сборки | Копируют из финальной | apt-get требует root |
Shell form в CMD | Короче писать | docker stop 10 c, код 137, cleanup не выполняется |
Полагаться на flush=True в коде | Работает в своём коде | Сторонние библиотеки его не используют; нужен PYTHONUNBUFFERED |
| Проверка только размера | Самая заметная метрика | Три требования из восьми не влияют на размер |
Контрольные вопросы
На понимание:
- Почему перестановка двух инструкций
COPYдаёт сорокакратное ускорение пересборки? - Почему
.dockerignoreвлияет и на безопасность, а не только на скорость? - Почему в финальной стадии нужен
libpq5, а неlibpq-dev? - Почему
PYTHONUNBUFFEREDнужен, если в коде уже стоитflush=True? - Какие из восьми требований не влияют ни на размер, ни на время сборки?
На применение:
- Как измерить фактический вклад одного изменения в
Dockerfile? - Как проверить, что приложение работает не от
root, не заходя внутрь container? - Как сделать тесты обязательной частью сборки, не помещая их в финальный образ?
На диагностику:
- Образ уменьшили с 1 GB до 150 MB, но
docker stopпо-прежнему занимает 10 секунд. Что не было исправлено? - После перехода на multi-stage приложение падает при импорте библиотеки, которая раньше работала. Причина?
Краткое резюме
- Production-образ Python должен удовлетворять восьми проверяемым требованиям.
- Изменения вносятся по одному с измерением — иначе вклад неизвестен.
- Наибольший выигрыш по времени даёт порядок
COPY: зависимости до кода. - Наибольший выигрыш по размеру даёт переход с полного образа на
slim. - Multi-stage убирает компилятор из финального образа — это безопасность, не только размер.
- Финальной стадии нужны runtime-библиотеки, а не
-devверсии. - Non-root, переменные окружения и exec form не влияют на метрики, но определяют корректность.
- Shell form в
CMDдаётdocker stop10 секунд и код137даже при наличии обработчика. PYTHONUNBUFFEREDнужен независимо отflush=True: сторонние библиотеки его не используют.- Эталонный шаблон адаптируется под проект, а не копируется вслепую.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker Python language guide | https://docs.docker.com/language/python/ | Официальные рекомендации по контейнеризации Python |
| Building best practices | https://docs.docker.com/build/building/best-practices/ | Порядок инструкций, минимальный базовый образ, USER |
| Multi-stage builds | https://docs.docker.com/build/building/multi-stage/ | Разделение сборки и выполнения |
| Build cache optimization | https://docs.docker.com/build/cache/optimize/ | Разделение по частоте изменений, cache mounts |
| Dockerfile reference | https://docs.docker.com/reference/dockerfile/ | Синтаксис всех использованных инструкций |
| Python: command line and environment | https://docs.python.org/3/using/cmdline.html | PYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE |
| Python official image | https://hub.docker.com/_/python | Варианты образа и их состав |
| Python: signal | https://docs.python.org/3/library/signal.html | Обработчики сигналов |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Environment variables
Главное оглавление