Главная/Python внутри Container/Урок

6.3. Python Dockerfile

Цели

После этого материала вы сможете:

  • построить Dockerfile для Python-приложения от наивного варианта к production-ready, измеряя каждый шаг;
  • объяснить вклад каждого изменения в размер образа и время пересборки;
  • назвать восемь обязательных требований к Python-образу и проверить каждое командой;
  • написать эталонный шаблон и адаптировать его под свой проект;
  • обосновать каждую строку Dockerfile при code review.

Предварительные знания

Этот урок сводит воедино материал раздела 05 и первых двух уроков раздела 06.

Ключевые термины

ТерминОбъяснение
наивная сборкаРаботающий, но неоптимизированный Dockerfile
итерацияОдно изменение с измеримым эффектом
эталонный шаблонПроверенная заготовка, адаптируемая под проект
regressionУхудшение, внесённое оптимизацией

Теория

Восемь требований

Прежде чем оптимизировать, зафиксируем целевое состояние. Production-образ Python-приложения должен удовлетворять восьми требованиям — каждое проверяется командой.

ТребованиеПроверка
1Изменение кода не пересобирает зависимостизамер времени пересборки
2Нет инструментов сборки в финальном образеcommand -v gcc
3Приложение работает от non-root с фиксированным UIDid -u
4Вывод сразу попадает в docker logsdocker logs сразу после старта
5docker stop завершает приложение за доли секунды с кодом 0time docker stop
6Секретов нет ни в слоях, ни в историиdocker history, поиск в слоях
7Версии зависимостей закрепленыналичие lock-файла
8Контекст сборки минималенразмер transferring context

Дальше — путь от варианта, не удовлетворяющего ни одному, к варианту, удовлетворяющему всем.

Порядок оптимизации

Изменения вносятся по одному, с измерением. Это не педантизм: без измерения легко внести «оптимизацию», которая ничего не даёт или ломает что-то другое.

Порядок выбран по соотношению «эффект к усилию»:

text
   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, переменные, сигналынетнебольшое

Последняя строка важна: три требования из восьми не влияют ни на время, ни на размер. Они влияют на то, работает ли приложение правильно.


Команды и примеры

Подготовка проекта

Возьмём реалистичное приложение с компилируемой зависимостью.

bash
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/^/  /'
text
структура проекта:
  76M	.
  31M	.git
  46M	.venv
  12K	app

Полезного — 12 килобайт. Остальное не должно попадать в образ.

v0: наивный вариант

bash
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
text
первая сборка: 68 c, размер: 1.14GB
старт: PID=1, UID=0
psycopg2 2.9.11

Работает. Теперь проверим по восьми требованиям:

bash
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
text
── проверка py:v0 ──
  1. пересборка при изменении кода: 51.3 c
  2. gcc в образе:                  ЕСТЬ
  3. UID процесса:                  0
  4. размер образа:                 1.14GB
  6. секрет .env в образе:          ЕСТЬ

Не выполнено ни одно требование. Секрет в образе, работа от root, полтора гигабайта, минута на пересборку.

v1: .dockerignore

bash
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
text
 => => 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

bash
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
text
── проверка py:v2 ──
  1. пересборка при изменении кода: 0.9 c
  2. gcc в образе:                  ЕСТЬ
  3. UID процесса:                  0
  4. размер образа:                 1.06GB
  6. секрет .env в образе:          нет

38.7 → 0.9 секунды. Главный выигрыш всей серии, полученный перестановкой двух строк. Требование 1 выполнено.

v3: slim и закреплённые версии

bash
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
text
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

bash
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
text
── проверка 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

bash
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
text
── проверка 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 на текущем варианте:

bash
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
text
логи через 1 секунду после старта:
2

Здесь вывод виден, потому что в коде везде стоит flush=True. Но полагаться на это нельзя — сторонние библиотеки его не используют.

bash
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
text
без flush, с PYTHONUNBUFFERED

Требование 4 выполнено — теперь независимо от flush=True в коде.

Обратите внимание: PATH перенесён в стадию base, потому что нужен и builder, и runtime.

v7: сигналы и healthcheck

Проверим требование 5:

bash
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
text
время stop: 10.3 c, код: 137
работаю, итерация 2

Десять секунд и код 137 — при том, что обработчик SIGTERM в коде есть. Причина в shell form: CMD python app/main.py делает PID 1 оболочкой (урок 5.4).

bash
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
text
время stop: 0.3 c, код: 0
получен SIGTERM, завершаюсь
завершено штатно

Требование 5 выполнено. Одно слово — квадратные скобки — изменило 10.3 секунды на 0.3.

Заодно добавлена стадия test, останавливающая сборку при падении тестов:

bash
docker build -q --target test -f Dockerfile.v7 -t py:test . > /dev/null && echo "тесты прошли"

Итоговое сравнение

bash
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
text
версия  размер    пересборка  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   нет

Сводка по всем восьми требованиям для финального варианта:

bash
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)"
text
═══ 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 раза).

Уборка

bash
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=py:*') 2>/dev/null || true
rm -rf /tmp/pydocker

Эталонный шаблон

Заготовка для типового Python-сервиса. Адаптируйте под проект, не копируйте вслепую.

dockerfile
# 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:

text
.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 невозможным:

text
ERROR: failed to compute cache key: "/tests": not found

В итоговый образ тесты и так не попадают: стадия runtime копирует только app/. Подробнее — урок 5.1.

Рабочие варианты этого шаблона для разных типов приложений — в resources/examples/.


Практическое упражнение

Задание. Возьмите собственный Python-проект (или создайте макет) и проведите его через ту же последовательность, измеряя каждый шаг.

Требуется:

  1. Составить таблицу из восьми требований и отметить исходное состояние.
  2. Вносить по одному изменению, после каждого измеряя размер и время пересборки.
  3. Зафиксировать вклад каждого изменения отдельно.
  4. Довести все восемь требований до выполнения.
  5. Объяснить, какое изменение дало наибольший эффект и почему.

Оформите результат таблицей «версия → изменение → размер → время → выполненные требования».

Подсказки

Подсказка 1

Автоматизируйте измерение функцией: она собирает образ, меняет одну строку кода, пересобирает и печатает время.

Подсказка 2

Не вносите два изменения сразу — иначе непонятно, чей вклад.

Подсказка 3

Требования 4 и 5 проверяются только запуском container, а не осмотром образа.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/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

Ожидаемый вывод:

text
ВАРИАНТ                      РАЗМЕР   ПЕРЕСБ  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 — не влияют ни на размер, ни на время, поэтому их регулярно пропускают. Именно они отвечают за то, что приложение работает правильно.

Проверка результата

bash
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
Проверка только размераСамая заметная метрикаТри требования из восьми не влияют на размер

Контрольные вопросы

На понимание:

  1. Почему перестановка двух инструкций COPY даёт сорокакратное ускорение пересборки?
  2. Почему .dockerignore влияет и на безопасность, а не только на скорость?
  3. Почему в финальной стадии нужен libpq5, а не libpq-dev?
  4. Почему PYTHONUNBUFFERED нужен, если в коде уже стоит flush=True?
  5. Какие из восьми требований не влияют ни на размер, ни на время сборки?

На применение:

  1. Как измерить фактический вклад одного изменения в Dockerfile?
  2. Как проверить, что приложение работает не от root, не заходя внутрь container?
  3. Как сделать тесты обязательной частью сборки, не помещая их в финальный образ?

На диагностику:

  1. Образ уменьшили с 1 GB до 150 MB, но docker stop по-прежнему занимает 10 секунд. Что не было исправлено?
  2. После перехода на multi-stage приложение падает при импорте библиотеки, которая раньше работала. Причина?

Краткое резюме

  1. Production-образ Python должен удовлетворять восьми проверяемым требованиям.
  2. Изменения вносятся по одному с измерением — иначе вклад неизвестен.
  3. Наибольший выигрыш по времени даёт порядок COPY: зависимости до кода.
  4. Наибольший выигрыш по размеру даёт переход с полного образа на slim.
  5. Multi-stage убирает компилятор из финального образа — это безопасность, не только размер.
  6. Финальной стадии нужны runtime-библиотеки, а не -dev версии.
  7. Non-root, переменные окружения и exec form не влияют на метрики, но определяют корректность.
  8. Shell form в CMD даёт docker stop 10 секунд и код 137 даже при наличии обработчика.
  9. PYTHONUNBUFFERED нужен независимо от flush=True: сторонние библиотеки его не используют.
  10. Эталонный шаблон адаптируется под проект, а не копируется вслепую.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker Python language guidehttps://docs.docker.com/language/python/Официальные рекомендации по контейнеризации Python
Building best practiceshttps://docs.docker.com/build/building/best-practices/Порядок инструкций, минимальный базовый образ, USER
Multi-stage buildshttps://docs.docker.com/build/building/multi-stage/Разделение сборки и выполнения
Build cache optimizationhttps://docs.docker.com/build/cache/optimize/Разделение по частоте изменений, cache mounts
Dockerfile referencehttps://docs.docker.com/reference/dockerfile/Синтаксис всех использованных инструкций
Python: command line and environmenthttps://docs.python.org/3/using/cmdline.htmlPYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE
Python official imagehttps://hub.docker.com/_/pythonВарианты образа и их состав
Python: signalhttps://docs.python.org/3/library/signal.htmlОбработчики сигналов

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Environment variables
Главное оглавление

Markdown на GitHub ↗