FROM, RUN, COPY: фундаментальные инструкции
В прошлом уроке мы написали 10-строчный Dockerfile. Теперь разберём, что значит каждая из главных инструкций, какие у них варианты и где разработчики чаще всего ошибаются. Эти три (FROM, RUN, COPY) встречаются почти в каждом Dockerfile и определяют размер образа, скорость сборки и удобство сопровождения.
В этом уроке: выбор базового образа для DE, почему один RUN лучше пяти, COPY vs ADD, и базовый .dockerignore для Python-проекта.
FROM: выбор базового образа
FROM это первая (или почти первая) инструкция в Dockerfile. Она задаёт «фундамент», на котором строится твой образ:
FROM <image>:<tag>
FROM <image>@<digest>
FROM <image>:<tag> AS <stage-name> # для multi-stage (модуль 8)
Для DE есть несколько типичных вариантов:
| Базовый образ | Размер | Когда использовать |
|---|---|---|
python:3.13 | ~1 GB | Никогда — слишком жирный, gcc и dev-tools уже встроены |
python:3.13-slim | ~145 MB | DE-стандарт — Debian-slim с Python, ничего лишнего |
python:3.13-alpine | ~50 MB | Маленький, но musl libc → проблемы с pip wheels (numpy, pandas) |
python:3.13-slim-bookworm | ~145 MB | То же что slim, явно указанная версия Debian (для воспроизводимости) |
debian:12-slim | ~75 MB | Когда нужно ставить Python через apt вручную |
ubuntu:24.04 | ~80 MB | Если команда работает с Ubuntu-specific пакетами |
gcr.io/distroless/python3-debian12 | ~50 MB | Distroless: без shell, минимальная attack surface для prod |
scratch | 0 B | Для статически слинкованных Go/Rust бинарников |
Для большинства DE-задач python:3.13-slim это правильный default. Он:
- Достаточно мал (~145 MB), быстро pull’ится
- Содержит glibc (а не musl), что критично для precompiled wheels — numpy / pandas / scipy не требуют сборки из исходников
- Имеет apt для установки system deps (libpq-dev, build-essential для C-extensions)
- Хорошо документирован, mainstream в индустрии
Alpine привлекателен размером, но проблема в musl libc. Многие Python wheels precompiled под glibc — при pip install на Alpine они либо не находят wheel и собираются из исходников (медленно, требует gcc + dev-headers), либо несовместимы. Pandas на Alpine = boilerplate из 50+ строк apk add. Slim с glibc = pip install pandas работает мгновенно.
# DE-стандартный FROM
FROM python:3.13-slim
# Если нужна конкретная patch-версия для воспроизводимости
FROM python:3.13.1-slim-bookworm
# Pinning через digest для прода
FROM python@sha256:5b7b9c0f6c9e3e8a4f0d8b6a1e2c3d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1
RUN: один RUN это один слой
RUN выполняет команду в новом контейнере поверх предыдущего слоя и фиксирует результат как новый read-only слой. Базовая форма:
RUN <command>
Это shell form — команда выполняется через /bin/sh -c <command>. Альтернативно есть exec form:
RUN ["executable", "param1", "param2"]
Exec form не использует shell, поэтому не делает variable substitution, не интерпретирует &&, |, redirects. На практике для RUN почти всегда используется shell form, exec form чаще для CMD/ENTRYPOINT (урок 06.03).
Главное правило: объединять связанные команды в один RUN. Не делать так:
# Плохо: 5 слоёв, мусор остаётся в нижних
RUN apt-get update
RUN apt-get install -y curl jq vim
RUN curl -L https://example.com/tool > /usr/local/bin/tool
RUN chmod +x /usr/local/bin/tool
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
Правильно:
# Хорошо: 1 слой, apt-cache и lists очищены ДО snapshot'а
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl jq vim && \
curl -L https://example.com/tool -o /usr/local/bin/tool && \
chmod +x /usr/local/bin/tool && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
Это даёт:
- Меньше слоёв (быстрее pull, проще читать docker history)
- Меньший размер (см. модуль 6: rm в следующем слое не удаляет данные из предыдущего)
- Атомарность: либо весь сетап получился, либо весь упал — нет «полусобранных» промежуточных слоёв
Ключевые подсказки для RUN с apt:
--no-install-recommendsне ставит «рекомендованные» пакеты (часто +100MB безполезного)apt-get updateбез install почти всегда ошибка (lists устаревают между слоями)- В конце
rm -rf /var/lib/apt/lists/*убирает apt-cache (10-20MB) set -eux(или-o pipefail) делает RUN падающим на любой ошибке (без этогоRUN cmd1 && cmd2падает только если cmd2 не выполнился — cmd1 может тихо упасть)
COPY vs ADD
Обе инструкции копируют файлы из build context в образ. Разница:
COPY простой — копирует файлы и директории. Поддерживает:
- Wildcards (
COPY *.py /app/) - chown/chmod (
COPY --chown=user:user . /app/) --from=stageдля multi-stage build (модуль 8)--linkдля лучшего кэширования (BuildKit)
# Базовое
COPY requirements.txt /app/
# С wildcards
COPY src/*.py /app/src/
# С правами владельца
COPY --chown=appuser:appgroup ./app /app
# Несколько источников в одну директорию
COPY requirements.txt setup.py /app/
ADD делает то же, но ещё умеет:
- Распаковывать tar-архивы (
ADD app.tar.gz /app/распакует автоматически) - Скачивать URL (
ADD https://example.com/file.tar.gz /tmp/)
# Распакует автоматически
ADD app.tar.gz /app/
# Скачает по URL
ADD https://example.com/binary /usr/local/bin/binary
Правило: для копирования файлов всегда используй COPY. ADD только когда нужна auto-extract tar (но даже там лучше явный RUN tar xzf).
Почему COPY лучше ADD:
- Предсказуемость. ADD автоматически распакует .tar.gz, но не .zip; URL без auth-headers; для git-репо не работает. COPY делает ровно одно — копирует.
- Cache invalidation. ADD URL не имеет хорошего cache key (Docker не знает, изменилось ли содержимое URL без HEAD-запроса).
- Безопасность. ADD URL может неожиданно вытянуть что-то незапиннутое.
Современный workflow:
# Плохо: ADD URL
ADD https://example.com/tool.tar.gz /tmp/
# Хорошо: явный RUN curl с pinned URL и checksum
RUN curl -fsSL https://example.com/tool-v1.2.3.tar.gz -o /tmp/tool.tar.gz && \
echo "abc123... /tmp/tool.tar.gz" | sha256sum -c - && \
tar xzf /tmp/tool.tar.gz -C /opt/ && \
rm /tmp/tool.tar.gz
Hadolint (линтер для Dockerfile) выдаёт DL3020 “Use COPY instead of ADD for files and folders”. В CI это автоматически предотвращает антипаттерны. Подробнее про hadolint — в уроке 07.05.
.dockerignore: что НЕ копировать
Когда ты пишешь COPY . /app, Docker копирует всё содержимое build context’а. Если в проекте лежит:
.git/(история коммитов, иногда 100+ MB)__pycache__/,*.pyc(Python байткод — не нужен в образе, Python пересоздаст).venv/,venv/(виртуальное окружение — не нужно, pip install внутри сделает своё).pytest_cache/,.mypy_cache/(кэши тестов и линтеров)node_modules/(если параллельно есть JS)- Локальные
*.csv/*.parquetс тестовыми данными .envс secret’ами- IDE-файлы:
.vscode/,.idea/
— весь этот мусор попадёт в образ. Раздувает размер, рискует утечкой секретов, и инвалидирует cache при изменении любого мусорного файла.
Решение: файл .dockerignore в корне build context’а:
# .dockerignore (типичный для Python DE-проекта)
# Version control
.git
.gitignore
.gitattributes
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv/
ENV/
env/
.pytest_cache
.mypy_cache
.ruff_cache
.coverage
htmlcov/
*.egg-info/
dist/
build/
# IDE
.vscode
.idea
*.swp
.DS_Store
# Local data
data/
*.parquet
*.csv
*.json.gz
# Secrets (failsafe -- secrets не должны быть в репо вообще)
.env
.env.local
*.pem
*.key
# Docker-specific (избежать рекурсии)
Dockerfile.dev
docker-compose.override.yml
# CI artifacts
.github/
.gitlab-ci.yml
Синтаксис .dockerignore похож на .gitignore:
- Один pattern на строку
#— комментарий*— wildcard для одного компонента пути**— wildcard для нескольких компонентов (**/__pycache__— везде где встретится)!pattern— исключение (например*.pem\n!public.pem)path/(с/) — только директория
После добавления .dockerignore build context уменьшается в разы:
$ docker build -t my-etl:v1 .
=> => transferring context: 1.45kB # с .dockerignore (только нужные файлы)
# vs без .dockerignore:
=> => transferring context: 230MB # включая .git, .venv, data/, ...
Реалистичный Dockerfile для DE
Собираем всё вместе. ETL-сервис, который читает Postgres и пишет в S3:
# Dockerfile
FROM python:3.13-slim
# System deps (для psycopg2-binary, boto3, и т.п.)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpq5 \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Сначала requirements -- кэш сохранится при изменении кода
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Остальной код
COPY . .
# Запуск
CMD ["python", "-m", "etl"]
И сопровождающий .dockerignore:
.git
__pycache__
*.pyc
.venv
.pytest_cache
.mypy_cache
data/
tests/fixtures/
.env
После build:
$ docker build -t my-etl:v1 .
$ docker images my-etl
REPOSITORY TAG IMAGE ID CREATED SIZE
my-etl v1 abc123def456 30 seconds ago 195MB
195MB — отличный размер для Python-ETL с psycopg2 и boto3.
Попробуй сам
Сравни эффект разных стратегий RUN:
mkdir bad-good && cd bad-good
# Плохой Dockerfile -- 5 RUN
cat > Dockerfile.bad <<'EOF'
FROM python:3.13-slim
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y jq
RUN curl -L https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.x86_64.tar.xz -o /tmp/sc.tar.xz
RUN apt-get clean
EOF
# Хороший Dockerfile -- 1 RUN с cleanup
cat > Dockerfile.good <<'EOF'
FROM python:3.13-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends curl jq && \
curl -L https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.x86_64.tar.xz -o /tmp/sc.tar.xz && \
rm /tmp/sc.tar.xz && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
EOF
# Сборка обоих
docker build -f Dockerfile.bad -t example:bad .
docker build -f Dockerfile.good -t example:good .
# Сравнение размеров
docker images example
# REPOSITORY TAG SIZE
# example good ~180MB
# example bad ~220MB
# Сравнение количества слоёв
docker history example:bad | wc -l
docker history example:good | wc -l
cd .. && rm -rf bad-good
docker rmi example:bad example:good