Первый Dockerfile: от Python-скрипта до образа
До сих пор мы пользовались чужими образами: pull, run, stop. В этом модуле учимся собирать свои. Dockerfile это текстовый файл с инструкциями, по которому Docker daemon строит образ. Это не магия и не язык программирования — это плоский список команд, каждая из которых создаёт слой (см. модуль 6). В DE-практике Dockerfile это твой главный артефакт: ETL-сервис, Airflow worker, custom Python-инструмент — всё это пакетируется в Dockerfile, версионируется в git, собирается в CI.
В этом уроке: первый Dockerfile для простого Python-скрипта, полный workflow build → run, и что такое build context.
Минимальный Dockerfile для Python
Возьмём простой ETL-скрипт — читает CSV, считает агрегаты, печатает в stdout. Структура проекта:
my-etl/
├── etl.py
├── requirements.txt
└── Dockerfile
# etl.py
import pandas as pd
import sys
if __name__ == "__main__":
df = pd.read_csv(sys.stdin)
print(df.groupby('category').agg({'value': ['count', 'sum', 'mean']}))
# requirements.txt
pandas==2.2.3
# Dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY etl.py .
CMD ["python", "etl.py"]
10 строк, и у нас полноценный образ. Что делает каждая инструкция:
FROM python:3.13-slim— выбираем базовый образ. python:3.13-slim это Debian-slim с уже установленным Python 3.13 (~145MB). Все следующие инструкции применяются поверх него.WORKDIR /app— устанавливает рабочую директорию для всех последующих команд. Если /app не существует — Docker её создаст.COPY requirements.txt .— копирует файл из build context в текущую WORKDIR (то есть /app/requirements.txt). Точка.это «текущая директория WORKDIR».RUN pip install --no-cache-dir -r requirements.txt— выполняет команду внутри слоя.--no-cache-dirне сохранять wheel’ы в ~/.cache/pip (экономит ~50MB).COPY etl.py .— копирует основной код. Отдельно от requirements, чтобы изменения в etl.py не инвалидировали кэш слоя с pip install (см. урок 07.05 best practices).CMD ["python", "etl.py"]— команда по умолчанию приdocker run. Подробно про CMD vs ENTRYPOINT — в уроке 06.03.
docker build: первая сборка
Из директории my-etl/:
$ docker build -t my-etl:v1 .
Что значит каждый кусок:
docker build— команда сборки-t my-etl:v1— tag для собранного образа (имя:версия).— build context: директория, которая отправляется daemon’у как «всё, что доступно для COPY/ADD»
Вывод:
[+] Building 18.5s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 187B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.13-slim 1.8s
=> [internal] load build context 0.1s
=> => transferring context: 1.45kB 0.0s
=> [1/4] FROM docker.io/library/python:3.13-slim@sha256:abc... 0.0s
=> CACHED [2/4] WORKDIR /app 0.0s
=> [3/4] COPY requirements.txt . 0.1s
=> [4/4] RUN pip install --no-cache-dir -r requirements.txt 12.4s
=> [5/5] COPY etl.py . 0.1s
=> exporting to image 0.3s
=> => exporting layers 0.3s
=> => writing image sha256:5e8a7b6c... 0.0s
=> => naming to docker.io/library/my-etl:v1 0.0s
Каждый шаг это инструкция Dockerfile. CACHED означает, что Docker нашёл слой в локальном кэше и использовал его без повторного выполнения (см. урок 07.02 BuildKit cache). При первой сборке CACHED не будет — кэш пуст. При повторной без изменений все шаги станут CACHED, и build займёт секунды.
После build:
$ docker images my-etl
REPOSITORY TAG IMAGE ID CREATED SIZE
my-etl v1 5e8a7b6c4d3f 30 seconds ago 175MB
175MB — почти всё это python:3.13-slim (145MB) + pandas (~30MB).
docker run: запуск собранного образа
Базовый запуск:
$ echo "category,value
a,10
a,20
b,30
b,40" | docker run --rm -i my-etl:v1
# value
# count sum mean
# category
# a 2 30 15.0
# b 2 70 35.0
Что произошло:
- Docker создал контейнер из my-etl:v1
-i(interactive) подключил stdin контейнера к stdin docker run--rmпометил контейнер «удалить после exit»- Внутри запустился
python etl.py, прочитал CSV из stdin, посчитал, напечатал в stdout, exit 0 - Контейнер удалился (
--rm)
Это полный цикл: написали → собрали → запустили. С точки зрения процесса внутри контейнера — он просто читает stdin и пишет stdout, не зная что он внутри Docker.
Build context: что отправляется daemon’у
Когда ты пишешь docker build ., точка это build context — директория, всё содержимое которой передаётся docker daemon’у. Это важно понять, потому что size build context влияет на скорость сборки.
$ docker build -t my-etl:v1 .
=> => transferring context: 1.45kB
В нашем минимальном проекте контекст 1.45 KB. Но если в той же директории лежат:
.git/директория (история коммитов, может быть 100+ MB)node_modules/(если параллельно есть JS-часть, 200+ MB)__pycache__/,.venv/(Python байткод и виртуальное окружение)- Локальные
.parquet/.csvфайлы с тестовыми данными
— весь этот мусор тоже отправится daemon’у. Каждая сборка будет занимать на 200+ MB трафика (если daemon удалённый) или I/O (если локально). Решение: .dockerignore (урок 06.05).
# .dockerignore (типичный)
.git
.venv
__pycache__
*.pyc
node_modules
.pytest_cache
.mypy_cache
data/
*.parquet
*.csv
.DS_Store
Docker не отправляет файлы, попадающие в шаблоны .dockerignore, daemon’у. Это и быстрее, и безопаснее (секреты в .env не попадут в образ случайно).
build context может быть не только локальной директорией. docker build https://github.com/user/repo.git соберёт из git-репо. docker build - < Dockerfile собирает без контекста (можно только FROM + RUN/ENV/CMD, без COPY/ADD). Эти варианты редко нужны в DE.
Build cache: почему повторный build быстрый
При первом build pip install занял 12 секунд (скачивание pandas + numpy + установка). Запустим build второй раз:
$ docker build -t my-etl:v1 .
[+] Building 1.2s (10/10) FINISHED
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY requirements.txt . 0.0s
=> CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt 0.0s
=> CACHED [5/5] COPY etl.py . 0.0s
Всё CACHED, build 1.2 секунды. Почему?
Docker кэширует каждый слой по хэшу его inputs:
- Для
FROM python:3.13-slim— input это image ID базового образа. Не изменился → CACHED. - Для
WORKDIR /app— input это инструкция и предыдущий слой. Не изменилось → CACHED. - Для
COPY requirements.txt .— input это содержимое файла requirements.txt (его MD5 / SHA) + предыдущий слой. Файл не изменился → CACHED. RUN ниже тоже CACHED, потому что зависит от слоя с requirements.txt, который не изменился. - Для
COPY etl.py .— если etl.py не изменился → CACHED.
Если бы мы изменили etl.py, последний слой пересобрался бы, но pip install остался бы кэшированным. Это и есть причина почему в Dockerfile сначала COPY requirements.txt + RUN pip install, потом COPY остального кода: pip install это медленная операция, мы хотим её кэшировать максимально.
# Хорошо: кэш pip install не инвалидируется при изменении etl.py
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Плохо: любое изменение в исходном коде инвалидирует pip install
COPY . .
RUN pip install -r requirements.txt
Первый bash-скрипт: shebang, права и exit code
Полный цикл с pull / push
В реальной DE-команде workflow обычно такой:
# 1. На своей машине: разработка
git clone [email protected]:org/data-pipeline.git
cd data-pipeline
# ... редактируешь код ...
# 2. Build локально для теста
docker build -t data-pipeline:dev .
docker run --rm data-pipeline:dev # запустить и проверить
# 3. Push в shared registry (для коллег и CI)
docker tag data-pipeline:dev ghcr.io/org/data-pipeline:v1.2.3
echo $GITHUB_PAT | docker login ghcr.io -u me --password-stdin
docker push ghcr.io/org/data-pipeline:v1.2.3
# 4. На production / staging:
docker pull ghcr.io/org/data-pipeline:v1.2.3
docker run --restart unless-stopped --name pipeline ghcr.io/org/data-pipeline:v1.2.3
# Или через compose:
# docker compose up -d
В реальной практике build + push выполняется в CI на каждый PR / merge — но это требует знаний из модуля 18 (CI и testcontainers).
Попробуй сам
Собери и запусти свой первый Dockerfile:
# Создаём минимальный проект
mkdir my-first && cd my-first
cat > hello.py <<'EOF'
import os
import sys
print(f"Hello from Docker! Python {sys.version_info.major}.{sys.version_info.minor}")
print(f"Hostname (container id): {os.uname().nodename}")
EOF
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY hello.py .
CMD ["python", "hello.py"]
EOF
# Build
docker build -t my-first:v1 .
# Inspect
docker images my-first
docker history my-first:v1
# Run
docker run --rm my-first:v1
# Hello from Docker! Python 3.13
# Hostname (container id): a1b2c3d4e5f6
# Запусти несколько раз -- hostname (container id) каждый раз новый
docker run --rm my-first:v1
docker run --rm my-first:v1
# Build повторно -- увидишь CACHED
docker build -t my-first:v1 .
# Измени hello.py, build снова -- только последний слой пересоберётся
echo "print('changed!')" >> hello.py
docker build -t my-first:v1 .
# Cleanup
cd .. && rm -rf my-first
docker rmi my-first:v1