Перейти к содержимому
RootPoligon

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 .
  1. Найдите в выводе сборки, сколько данных ушло в контекст.
  2. Проверьте, что лежит в /app внутри образа и попал ли туда .env. Используйте одноразовый контейнер: docker run --rm rpd-notes:dev ….
  3. Исправьте так, чтобы ни .env, ни data/ не могли попасть в образ, даже если кто-то снова напишет COPY . .. Верните точечный COPY.
  4. Пересоберите и убедитесь, что в /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 из образа новой сборкой и считаете, что пароль в безопасности. Если образ с паролем уже куда-то отправлен или передан, пароль считается раскрытым: его меняют. Удаление файла в новом слое не стирает его из старого.

Проверка понимания

  1. Вы поменяли только строку ENV. Какие шаги выполнятся при следующей сборке, а какие возьмутся из кеша?
  2. Зачем нужен .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.

Проверка понимания

  1. Шаги до ENV (FROM, WORKDIR, RUN, COPY) возьмутся из кеша. Изменённая инструкция и всё после неё (EXPOSE, CMD) создадутся заново — они быстрые и без файлов.
  2. COPY защищает только пока его не поменяли. .dockerignore исключает файлы из контекста для любых инструкций: кто-то напишет COPY . . — .env всё равно не попадёт. Это защита «по умолчанию» на случай ошибки.

Прочитали и разобрались? Отметьте урок — прогресс сохранится в этом браузере.