Dockerfile и контекст сборки
Чтение и практика ~30 мин
Ситуация
Приложение работает у вас на машине. Коллеге нужно запустить его у себя, а потом — на сервере. «Поставь Python 3.12, скопируй файл, создай папку, запусти от такого-то пользователя» — инструкция, которую легко выполнить по-разному. Dockerfile записывает её так, что результат одинаков везде.
Главная мысль
Dockerfile — рецепт образа: базовый образ и шаги поверх него. Каждый шаг, меняющий файлы, даёт слой. Docker запоминает слои и при повторной сборке берёт готовый слой из кеша, пока шаг и всё, что до него, не изменились. Как только один шаг изменился — он и все шаги после него выполняются заново.
Отсюда правило: то, что меняется редко, — в начале Dockerfile, то, что часто (код), — в конце.
Новые слова
- Dockerfile — текстовый файл с инструкциями сборки.
- Контекст сборки — папка, которую
docker buildпередаёт сборщику (точка.в конце команды). ИнструкцияCOPYможет взять файлы только из контекста. - Слой — сохранённый результат шага сборки; образ — стопка слоёв.
- Кеш сборки — сохранённые слои прошлых сборок.
- Тег — имя и версия образа:
rpd-notes:1. .dockerignore— список того, что не передавать в контекст сборки.
Скачиваемые файлы ниже повторяют листинги главы. Начните с кода в тексте; решение упражнения откройте после своей попытки.
Проект
Папка проекта — ~/docker-course/rpd-notes. В этой главе в ней два файла: приложение app/server.py и Dockerfile.
mkdir -p ~/docker-course/rpd-notes/app
cd ~/docker-course/rpd-notes
Приложение «Заметки» — один файл на Python только со стандартной библиотекой. Читать его целиком не обязательно:
важно, что настройки приходят из переменных окружения (PORT, DATA_DIR, GREETING…), журнал пишется в stdout
с немедленным сбросом, SIGTERM обрабатывается, а при недоступной папке данных приложение сразу завершается
с понятным сообщением. Создайте файл app/server.py с содержимым ниже (в nano или через cat > app/server.py <<'EOF' …
EOF). Тот же файл лежит в project/app/server.py.
Листинг app/server.py (144 строки)
"""«Заметки» — учебное приложение курса «Docker: контейнеры и образы».
Только стандартная библиотека Python 3.12. Настройки — переменные окружения:
HOST адрес, на котором слушать (по умолчанию 0.0.0.0 — все адреса контейнера)
PORT порт (по умолчанию 8000)
DATA_DIR папка с данными (по умолчанию /data)
GREETING заголовок главной страницы (по умолчанию «Заметки»)
STARTUP_DELAY сколько секунд «прогреваться» после запуска; всё это время /health отвечает 503
Адреса:
GET / главная: заголовок и число заметок
GET /notes все заметки, по одной в строке
POST /notes сохранить заметку (тело запроса — текст, до 1000 символов)
GET /health 200 ok — готово; 503 — ещё прогревается, нет доступа к данным или включено обслуживание
"""
import json
import os
import signal
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = os.environ.get("HOST", "0.0.0.0")
DATA_DIR = os.environ.get("DATA_DIR", "/data")
GREETING = os.environ.get("GREETING", "Заметки")
NOTES = os.path.join(DATA_DIR, "notes.json")
MAINTENANCE = os.path.join(DATA_DIR, "maintenance")
def log(message):
print(f"notes: {message}", flush=True)
try:
PORT = int(os.environ.get("PORT", "8000"))
if not 1 <= PORT <= 65535:
raise ValueError
except ValueError:
log(f"неверный PORT: {os.environ.get('PORT')!r} (нужно число от 1 до 65535)")
sys.exit(2)
try:
DELAY = float(os.environ.get("STARTUP_DELAY", "0"))
except ValueError:
DELAY = 0.0
try:
os.makedirs(DATA_DIR, exist_ok=True)
probe = os.path.join(DATA_DIR, ".write-test")
with open(probe, "w") as f:
f.write("ok")
os.remove(probe)
except OSError as e:
log(f"не могу писать в {DATA_DIR}: {e.strerror}")
sys.exit(1)
started = time.monotonic()
lock = threading.Lock()
def load():
try:
with open(NOTES, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save(notes):
tmp = NOTES + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(notes, f, ensure_ascii=False)
os.replace(tmp, NOTES)
def ready():
if time.monotonic() - started < DELAY:
return False, "ещё прогревается"
if os.path.exists(MAINTENANCE):
return False, "включено обслуживание"
if not os.access(DATA_DIR, os.W_OK):
return False, "нет доступа к данным"
return True, "ok"
class Handler(BaseHTTPRequestHandler):
server_version = "notes/1.0"
def reply(self, status, text):
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("X-App", "rpd-notes")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
log(f"{self.command} {self.path} {status}")
def do_GET(self):
if self.path == "/":
return self.reply(200, f"{GREETING}\nЗаметок: {len(load())}\n")
if self.path == "/notes":
return self.reply(200, "".join(f"{n}\n" for n in load()))
if self.path == "/health":
ok, why = ready()
return self.reply(200 if ok else 503, why + "\n")
return self.reply(404, "Не найдено\n")
do_HEAD = do_GET
def do_POST(self):
if self.path != "/notes":
return self.reply(404, "Не найдено\n")
length = int(self.headers.get("Content-Length") or 0)
text = self.rfile.read(min(length, 4000)).decode("utf-8", "replace").strip()
if not text or len(text) > 1000:
return self.reply(400, "Заметка — от 1 до 1000 символов\n")
try:
with lock:
notes = load()
notes.append(text)
save(notes)
except OSError as e:
return self.reply(507, f"Не удалось сохранить: {e.strerror}\n")
return self.reply(201, f"Сохранено. Заметок: {len(notes)}\n")
def log_message(self, *args):
pass
server = ThreadingHTTPServer((HOST, PORT), Handler)
def stop(signum, frame):
log("получен сигнал остановки, завершаю работу")
threading.Thread(target=server.shutdown).start()
signal.signal(signal.SIGTERM, stop)
log(f"слушаю {HOST}:{PORT}, данные в {DATA_DIR}")
server.serve_forever()
log("остановлено")
Теперь Dockerfile в корне проекта (project/Dockerfile):
# Образ приложения «Заметки»
FROM python:3.12-slim
# Рабочая папка внутри образа: дальше пути считаются от неё
WORKDIR /app
# Папка для данных, владелец — непривилегированный пользователь 10001
RUN mkdir /data && chown 10001:10001 /data
# Код приложения — последним: он меняется чаще всего
COPY app/server.py ./
# Дальше всё, включая запуск, — от пользователя 10001, а не от root
USER 10001:10001
ENV PORT=8000 DATA_DIR=/data
EXPOSE 8000
CMD ["python3", "server.py"]
Построчно:
FROM— базовый образ; всё остальное ляжет поверх него.WORKDIR /app— создать папку и сделать её текущей для следующих шагов и для запуска.RUN— выполнить команду при сборке; результат станет слоем. Здесь — папка для данных, отданная пользователю10001.COPY app/server.py ./— скопировать файл из контекста в/app.USER 10001:10001— дальше, и при запуске контейнера, работать не от root. Пользователя с таким номером в образе нет — это нормально: процессу нужен только номер.ENV— переменные окружения по умолчанию; при запуске их можно переопределить.EXPOSE 8000— пометка «приложение слушает порт 8000». Сам по себе порт не открывает (глава 4).CMD— команда запуска контейнера. Форма с квадратными скобками запускаетpython3напрямую, без оболочки, поэтомуSIGTERMотdocker stopдоходит до приложения.
Разбор: собрать, запустить, пересобрать
Сборка
cd ~/docker-course/rpd-notes
docker build -t rpd-notes:1 .
Значимые строки вывода (BuildKit; номера шагов #N и время у вас другие):
#4 [internal] load build context
#4 transferring context: 5.23kB done
#5 [1/4] FROM docker.io/library/python:3.12-slim@sha256:…
#6 [2/4] WORKDIR /app
#7 [3/4] RUN mkdir /data && chown 10001:10001 /data
#8 [4/4] COPY app/server.py ./
#9 naming to docker.io/library/rpd-notes:1 done
[1/4]…[4/4] — четыре шага, меняющих файлы. -t rpd-notes:1 — тег. Точка — контекст: текущая папка.
Запуск
docker run -d --name rpd-app rpd-notes:1
sleep 1
docker logs rpd-app
notes: слушаю 0.0.0.0:8000, данные в /data
docker exec rpd-app id
uid=10001 gid=10001 groups=10001
Процесс работает не от root — это сделала строка USER. Проверим ответ изнутри:
docker exec rpd-app python3 -c "import urllib.request; print(urllib.request.urlopen('http://localhost:8000/').read().decode())"
Заметки
Заметок: 0
Предскажите, сколько займёт остановка — вспомните главу 2.
time docker stop rpd-app
docker logs rpd-app
rpd-app
real 0m0.603s
notes: слушаю 0.0.0.0:8000, данные в /data
notes: GET / 200
notes: получен сигнал остановки, завершаю работу
notes: остановлено
Меньше секунды и код 0 (docker ps -a → Exited (0)): приложение обработало SIGTERM.
docker rm rpd-app
Кеш: что пересобирается
Поменяем код — например, добавим комментарий первой строкой — и соберём снова:
sed -i '1i # версия 2' app/server.py
docker build -t rpd-notes:2 .
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] RUN mkdir /data && chown 10001:10001 /data
#7 CACHED
#8 [4/4] COPY app/server.py ./
#8 DONE 0.0s
WORKDIR и RUN взяты из кеша (CACHED), заново выполнен только COPY — файл изменился. Если бы COPY стоял
до RUN, каждое изменение кода заново выполняло бы и RUN — на больших проектах с установкой пакетов это
минуты на каждую сборку.
Слои образа:
docker image history rpd-notes:2 --format 'table {{.CreatedBy}}\t{{.Size}}'
CREATED BY SIZE
CMD ["python3" "server.py"] 0B
EXPOSE [8000/tcp] 0B
ENV PORT=8000 DATA_DIR=/data 0B
USER 10001:10001 0B
COPY app/server.py ./ # buildkit 16.4kB
RUN /bin/sh -c mkdir /data && chown 10001:10… 20.5kB
WORKDIR /app 8.19kB
…
Ниже — слои базового образа. Инструкции без файлов (CMD, ENV…) дают слои размером 0B: они меняют только
настройки.
Контекст сборки и .dockerignore
В строке transferring context: 5.23kB — сколько данных получил сборщик. BuildKit передаёт из контекста только то,
что нужно инструкциям COPY. Пока вы копируете конкретный файл, в сборку не попадёт ничего лишнего. Но частая
привычка — COPY . . («скопировать всё») — передаст и скопирует в образ всю папку: журналы, резервные копии
данных, историю Git и файл .env с паролями. Файл .dockerignore в корне контекста перечисляет, что исключить
всегда (синтаксис похож на .gitignore). В проекте он такой (скачать как dockerignore.txt; сохраните под именем .dockerignore):
# Не отправлять в сборку: настройки с паролями, данные, историю Git, журналы
.env
data/
.git/
*.log
Чем это грозит без него — в практике.
Практика: пароль, который попал в образ
Подготовьте ситуацию в папке проекта. Это учебные данные — ненастоящий пароль и пустой файл на 30 МБ:
cd ~/docker-course/rpd-notes
printf 'GREETING=Заметки\nADMIN_PASSWORD=учебный-пароль-не-настоящий\n' > .env
mkdir -p data && head -c 30M /dev/zero > data/backup.tar
rm -f .dockerignore
sed -i 's|^COPY app/server.py ./|COPY . ./|' Dockerfile
docker build -t rpd-notes:dev .
- Найдите в выводе сборки, сколько данных ушло в контекст.
- Проверьте, что лежит в
/appвнутри образа и попал ли туда.env. Используйте одноразовый контейнер:docker run --rm rpd-notes:dev …. - Исправьте так, чтобы ни
.env, ниdata/не могли попасть в образ, даже если кто-то снова напишетCOPY . .. Верните точечныйCOPY. - Пересоберите и убедитесь, что в
/appтолькоserver.py. Удалите образrpd-notes:dev.
Признаки успеха: в выводе сборки — transferring context в байтах, а не в мегабайтах; docker run --rm rpd-notes:dev ls -a /app
показывает только server.py; .env и data/ остались в папке проекта, но не в образе.
Подсказка 1
Строка с размером контекста — transferring context: рядом с load build context. Посмотреть файлы образа:
docker run --rm rpd-notes:dev ls -a /app.
Подсказка 2
Два независимых способа защиты: файл .dockerignore (что вообще не передавать) и точечный COPY (что именно
копировать). Нужны оба.
Подсказка 3
Создайте .dockerignore из листинга выше; в Dockerfile верните COPY app/server.py ./
(sed -i 's|^COPY . ./|COPY app/server.py ./|' Dockerfile), затем docker build -t rpd-notes:dev ..
Решение — в answers/03-dockerfile.md.
Если результат другой
failed to compute cache key: … "/app/server.py": not found. Путь вCOPYсчитается от контекста, а не от Dockerfile или/. Проверьте, что вы в~/docker-course/rpd-notesи что файл лежит вapp/server.py.- Каждая сборка выполняет
RUNзаново. Шаг с часто меняющимися файлами стоит раньше него. Поменяйте порядок: редкое — выше, код — ниже. docker image lsпоказывает другие столбцы. Формат зависит от версии Docker (в 29.x с хранилищем containerd —DISK USAGEиCONTENT SIZE, в более старых —SIZE). Смысл тот же.- Убрали
.envиз образа новой сборкой и считаете, что пароль в безопасности. Если образ с паролем уже куда-то отправлен или передан, пароль считается раскрытым: его меняют. Удаление файла в новом слое не стирает его из старого.
Проверка понимания
- Вы поменяли только строку
ENV. Какие шаги выполнятся при следующей сборке, а какие возьмутся из кеша? - Зачем нужен
.dockerignore, еслиCOPYи так копирует один файл?
Ответы — в answers/03-dockerfile.md.
Дальше: 4. Порты и сети.
Проверка автора и источники
- Воспроизведено: сборка, запуск, кеш после изменения кода, история слоёв, утечка
.envприCOPY . ., размер контекста до и после.dockerignore— Docker Engine 29.8.2 с BuildKit, образ-заменительpython:3.12-slim(Python 3.12.3). СтрокаFROM … @sha256:…и размеры слоёв базового образа у настоящегоpython:3.12-slimбудут другими; строки загрузки базового образа при первой сборке не проверены. - Наблюдение «BuildKit передаёт только файлы, нужные
COPY»: при точечномCOPYконтекст был 5,23 КБ при лежащем рядом файле на 30 МБ; приCOPY . ./— 31,48 МБ. - Источники: Dockerfile reference (
FROM,WORKDIR,RUN,COPY,USER,ENV,EXPOSE,CMD, exec-форма), Build cache, Build context и .dockerignore, Dockerfile best practices.
Разбор и ответы — откройте после своей попытки
Практика
Папка ~/docker-course/rpd-notes.
1. Контекст. В выводе сборки:
#5 transferring context: 31.48MB 0.3s done
С COPY . ./ сборщику нужна вся папка — и он получил её целиком, вместе с data/backup.tar.
2. Что в образе.
docker run --rm rpd-notes:dev ls -a /app
.
..
.env
Dockerfile
app
data
docker run --rm rpd-notes:dev cat /app/.env
GREETING=Заметки
ADMIN_PASSWORD=учебный-пароль-не-настоящий
Пароль — в образе. Любой, кто получит образ, прочитает его одной командой.
3. Исправление — две независимые защиты:
cat > .dockerignore <<'IGNORE'
# Не отправлять в сборку: настройки с паролями, данные, историю Git, журналы
.env
data/
.git/
*.log
IGNORE
sed -i 's|^COPY . ./|COPY app/server.py ./|' Dockerfile
4. Проверка.
docker build -t rpd-notes:dev .
docker run --rm rpd-notes:dev ls -a /app
#4 transferring context: 58B 0.1s done
.
..
server.py
docker rmi rpd-notes:dev
.env и data/ остались в папке проекта (ls -a) — их никто не удалял, они просто не попадают в сборку.
Если бы это был настоящий пароль и образ уже ушёл в реестр или к коллеге, правильное действие — сменить пароль.
Воспроизведено: Docker Engine 29.8.2, BuildKit, образ-заменитель python:3.12-slim.
Проверка понимания
- Шаги до
ENV(FROM,WORKDIR,RUN,COPY) возьмутся из кеша. Изменённая инструкция и всё после неё (EXPOSE,CMD) создадутся заново — они быстрые и без файлов. COPYзащищает только пока его не поменяли..dockerignoreисключает файлы из контекста для любых инструкций: кто-то напишетCOPY . .—.envвсё равно не попадёт. Это защита «по умолчанию» на случай ошибки.
Прочитали и разобрались? Отметьте урок — прогресс сохранится в этом браузере.