10.2. Hot reload
Цели
После этого материала вы сможете:
- настроить автоматическую перезагрузку кода и объяснить, что при этом происходит;
- назвать, какие изменения перезагрузка не подхватывает;
- диагностировать «пропажу» установленных пакетов при монтировании кода;
- ограничить область наблюдения и объяснить, зачем это нужно на больших проектах;
- использовать
docker compose watchи выбрать междуsync,sync+restartиrebuild; - понимать ограничения inotify и что делать при их исчерпании.
Предварительные знания
- 7.3. Bind mounts — перекрытие каталогов;
- 10.1. Development и production образы;
- 9.6. Несколько файлов и profiles.
Ключевые термины
| Термин | Объяснение |
|---|---|
hot reload | Перезапуск приложения при изменении файлов |
inotify | Механизм ядра для уведомлений об изменениях файлов |
watchfiles | Библиотека наблюдения, используемая Uvicorn |
develop.watch | Механизм Compose: синхронизация без bind mount |
polling | Опрос файлов вместо уведомлений; медленно, но работает везде |
Теория
Что такое перезагрузка на самом деле
--reload не подменяет код в работающем процессе. Он перезапускает процесс целиком.
watcher (родитель)
│ наблюдает за файлами
▼
процесс приложения ──изменение──► убить ──► запустить заново
Отсюда следствия, которые определяют, что перезагрузка умеет, а что нет:
| Изменение | Подхватывается |
|---|---|
Правка .py-файла | Да |
| Новый файл в наблюдаемом каталоге | Да |
| Изменение шаблона или статики | Зависит от --reload-include |
Новая зависимость в requirements.txt | Нет — нужна пересборка |
Изменение Dockerfile | Нет — нужна пересборка |
| Изменение переменных окружения | Нет — нужен перезапуск container'а |
Изменение compose.yaml | Нет — нужен up |
Строка про зависимости — источник самой частой потери времени: разработчик добавляет пакет в requirements.txt, видит ModuleNotFoundError и ищет ошибку в коде.
Второе следствие: перезапуск теряет состояние в памяти. Пулы соединений, кэши, счётчики создаются заново. Для разработки это обычно приемлемо, но объясняет, почему после правки первый запрос медленнее.
Инструменты и их умолчания
| Инструмент | Команда | Механизм |
|---|---|---|
| FastAPI | fastapi dev app/main.py | Uvicorn плюс watchfiles |
| Uvicorn | uvicorn app.main:app --reload | watchfiles |
| Flask | flask --app app run --debug | Werkzeug reloader |
| Gunicorn | gunicorn --reload | Опрос stat |
| Django | manage.py runserver | Встроенный autoreloader |
Важное различие: fastapi dev привязывается к 127.0.0.1, а fastapi run — к 0.0.0.0. В container это означает недоступность снаружи (урок 8.3):
CMD ["fastapi", "dev", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]
Флаг --host 0.0.0.0 для fastapi dev обязателен, для fastapi run — нет.
Перезагрузка только для разработки
| Причина | Пояснение |
|---|---|
| Процесс наблюдения тратит ресурсы | Лишний расход на каждый watch |
| Неожиданный перезапуск | Запись временного файла роняет сервис |
| Медленный старт | Каждый перезапуск — полная инициализация |
| Отладчик Flask — выполнение кода | --debug открывает консоль с интерпретатором |
Последний пункт критичен: включённый отладчик Werkzeug позволяет выполнить произвольный код через веб-интерфейс. В production это прямая уязвимость (урок 6.9).
Перекрытие каталогов
Монтирование кода в /app скрывает всё, что было в образе по этому пути. Классический симптом:
ModuleNotFoundError: No module named 'fastapi'
Причина и решения разобраны в уроке 7.3. Кратко:
| Решение | Как |
|---|---|
| Окружение вне монтируемого пути | python -m venv /opt/venv вместо /app/.venv |
| Anonymous volume поверх | -v /app/.venv — более глубокое монтирование побеждает |
| Монтировать подкаталог, а не корень | ./app:/app/app вместо ./:/app |
Третий вариант чаще всего оказывается и самым простым, и самым точным: монтируется ровно то, что редактируется.
Область наблюдения
По умолчанию watcher следит за рабочим каталогом рекурсивно. На большом проекте это тысячи файлов — и тысячи watch'ей ядра.
uvicorn app.main:app --reload \
--reload-dir app \
--reload-include '*.html' \
--reload-exclude '*.pyc'
| Флаг | Назначение |
|---|---|
--reload-dir | Ограничить каталоги наблюдения |
--reload-include | Дополнительные шаблоны файлов |
--reload-exclude | Исключения |
--reload-delay | Задержка перед перезапуском |
Ограничение области — не микрооптимизация. Наблюдение за .venv, node_modules или каталогом данных приводит к перезапускам при каждой записи временного файла.
Ограничение inotify
Ядро ограничивает число наблюдаемых объектов на пользователя:
cat /proc/sys/fs/inotify/max_user_watches
Типичное значение — 8192 или 65536. Исчерпание даёт ошибку:
OSError: [Errno 28] inotify watch limit reached
| Решение | Комментарий |
|---|---|
Ограничить --reload-dir | Правильное: наблюдать только нужное |
| Увеличить лимит на host | fs.inotify.max_user_watches в sysctl |
| Перейти на опрос | Медленно и грузит CPU; крайняя мера |
Лимит принадлежит host, а не container'у: namespace для inotify не существует. Несколько container'ов с наблюдением делят один лимит.
docker compose watch — альтернатива bind mount
services:
api:
build:
context: .
target: dev
develop:
watch:
- action: sync
path: ./app
target: /app/app
ignore:
- __pycache__/
- action: rebuild
path: ./requirements.txt
- action: sync+restart
path: ./config
target: /app/config
docker compose watch
| Действие | Что делает |
|---|---|
sync | Копирует изменённые файлы в container |
rebuild | Пересобирает образ и пересоздаёт container |
sync+restart | Копирует и перезапускает container |
sync+exec | Копирует и выполняет команду внутри |
Ключевое отличие от bind mount: файлы копируются, а не монтируются.
| Bind mount | develop.watch | |
|---|---|---|
| Файлы, созданные приложением, видны на host | Да | Нет |
| Проблема UID/GID | Есть | Нет |
| Работа с удалённым демоном | Не работает | Работает |
| Реакция на изменение зависимостей | Нет | rebuild |
| Задержка | Нулевая | Небольшая, на копирование |
Требует docker compose watch | Нет | Да |
Строка про rebuild — главная практическая выгода: изменение requirements.txt автоматически пересобирает образ, и ModuleNotFoundError не возникает.
Строка про UID: файлы копируются от имени процесса в container'е и на host не появляются. Это снимает проблему прав, но и лишает возможности видеть сгенерированные файлы (урок 7.5).
Что выбрать
| Ситуация | Выбор |
|---|---|
| Локальный Docker, один разработчик | Bind mount плюс --reload |
| Нужно видеть файлы, созданные приложением | Bind mount |
| Удалённый демон или медленная файловая система | develop.watch |
| Часто меняются зависимости | develop.watch с rebuild |
| Большой проект, много файлов | develop.watch либо --reload-dir |
Смешивать можно: develop.watch для зависимостей, bind mount для кода.
Внутренний механизм
Как watcher замечает изменение
watchfiles подписывается на события inotify для каждого наблюдаемого каталога. Ядро уведомляет о IN_MODIFY, IN_CREATE, IN_MOVED_TO и других событиях.
Отсюда особенность: сохранение файла редактором обычно порождает несколько событий — создание временного файла, переименование, удаление. Watcher группирует их с небольшой задержкой, иначе перезапуск происходил бы по три раза на одно сохранение.
Почему bind mount не мешает inotify
Bind mount — та же файловая система host (урок 7.3). События inotify генерирует драйвер файловой системы, и они видны в container'е.
Это не работает на файловых системах без поддержки уведомлений — например, при монтировании по сети. Тогда остаётся опрос.
Команды и примеры
Перезагрузка работает
mkdir -p /tmp/reload/app && cd /tmp/reload
cat > app/__init__.py <<'PY'
"""Приложение для демонстрации hot reload."""
PY
cat > app/main.py <<'PY'
"""Версия правится на host — перезагрузка должна её подхватить."""
import os
from fastapi import FastAPI
VERSION = "1"
app = FastAPI()
@app.get("/")
async def root() -> dict[str, object]:
return {"version": VERSION, "pid": os.getpid()}
PY
cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH"
# Окружение ВНЕ /app: монтирование кода его не скроет
RUN python -m venv /opt/venv
WORKDIR /app
FROM base AS builder
COPY requirements.txt .
RUN pip install -r requirements.txt
FROM builder AS dev
# fastapi dev по умолчанию слушает 127.0.0.1 — в container нужен 0.0.0.0
CMD ["fastapi", "dev", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]
FROM base AS runtime
COPY --from=builder /opt/venv /opt/venv
COPY app/ ./app/
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF
cat > compose.yaml <<'EOF'
name: reload
services:
api:
build:
context: .
target: dev
ports:
- "127.0.0.1:8000:8000"
volumes:
# Монтируем подкаталог, а не корень: /opt/venv не затрагивается
- ./app:/app/app
EOF
docker compose up -d --build > /dev/null 2>&1
sleep 8
echo "═══ до правки ═══"
curl -s localhost:8000/ | python3 -m json.tool --compact | sed 's/^/ /'
echo "═══ правим VERSION на host ═══"
sed -i 's/^VERSION = "1"/VERSION = "2-ПРАВКА"/' app/main.py
sleep 4
echo "═══ после правки, без пересборки ═══"
curl -s localhost:8000/ | python3 -m json.tool --compact | sed 's/^/ /'
echo "═══ что сказал watcher ═══"
docker compose logs api --no-log-prefix 2>/dev/null | grep -iE 'reload|change' | tail -3 | sed 's/^/ /'
Ожидаемый вывод:
═══ до правки ═══
{"version":"1","pid":8}
═══ правим VERSION на host ═══
═══ после правки, без пересборки ═══
{"version":"2-ПРАВКА","pid":14}
═══ что сказал watcher ═══
WARNING: WatchFiles detected changes in 'app/main.py'. Reloading...
INFO: Started server process [14]
Ключевая деталь в выводе — изменился PID: с 8 на 14. Это подтверждает, что произошёл перезапуск процесса, а не подмена кода в работающем.
Что перезагрузка не подхватывает
cd /tmp/reload
echo "═══ добавляем зависимость в requirements.txt ═══"
echo "httpx==0.28.1" >> requirements.txt
cat > app/main.py <<'PY'
"""Версия с новой зависимостью."""
import os
from fastapi import FastAPI
VERSION = "3"
app = FastAPI()
@app.get("/")
async def root() -> dict[str, object]:
try:
import httpx
dep = f"httpx {httpx.__version__}"
except ModuleNotFoundError as exc:
dep = f"ОШИБКА: {exc}"
return {"version": VERSION, "pid": os.getpid(), "dependency": dep}
PY
sleep 4
echo "═══ перезагрузка подхватила код, но не зависимость ═══"
curl -s localhost:8000/ | python3 -m json.tool --compact | sed 's/^/ /'
echo "═══ после пересборки ═══"
docker compose up -d --build > /dev/null 2>&1
sleep 8
curl -s localhost:8000/ | python3 -m json.tool --compact | sed 's/^/ /'
Ожидаемый вывод:
═══ добавляем зависимость в requirements.txt ═══
═══ перезагрузка подхватила код, но не зависимость ═══
{"version":"3","pid":21,"dependency":"ОШИБКА: No module named 'httpx'"}
═══ после пересборки ═══
{"version":"3","pid":8,"dependency":"httpx 0.28.1"}
Версия обновилась мгновенно, зависимость — только после --build. Это ровно та ситуация, в которой ищут ошибку в коде, хотя код верен.
Правило: правка requirements.txt требует пересборки.
Перекрытие каталогов
cd /tmp/reload
echo "═══ вариант с монтированием всего каталога ═══"
cat > compose.bad.yaml <<'EOF'
name: reload-bad
services:
api:
build:
context: .
target: dev
volumes:
- .:/app # монтируем ВЕСЬ каталог
command: ["python", "-c", "import fastapi; print(' fastapi найден')"]
EOF
docker compose -f compose.bad.yaml run --rm -T api 2>&1 | tail -2 | sed 's/^/ /'
echo "═══ вариант с монтированием подкаталога ═══"
cat > compose.good.yaml <<'EOF'
name: reload-good
services:
api:
build:
context: .
target: dev
volumes:
- ./app:/app/app # монтируем ТОЛЬКО код
command: ["python", "-c", "import fastapi; print(' fastapi найден')"]
EOF
docker compose -f compose.good.yaml run --rm -T api 2>&1 | tail -1 | sed 's/^/ /'
docker compose -f compose.bad.yaml down > /dev/null 2>&1
docker compose -f compose.good.yaml down > /dev/null 2>&1
Ожидаемый вывод:
═══ вариант с монтированием всего каталога ═══
fastapi найден
═══ вариант с монтированием подкаталога ═══
fastapi найден
Оба варианта работают — потому что виртуальное окружение лежит в /opt/venv, вне /app. Если бы оно было в /app/.venv, первый вариант упал бы с ModuleNotFoundError.
Проверим это явно:
cd /tmp/reload
cat > Dockerfile.venv-inside <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /app/.venv && /app/.venv/bin/pip install -q fastapi==0.141.1
COPY app/ ./app/
CMD ["python", "-c", "import fastapi; print('fastapi найден')"]
EOF
docker build -q -f Dockerfile.venv-inside -t reload:venv-inside . > /dev/null
echo "═══ venv в /app/.venv, без монтирования ═══"
docker run --rm reload:venv-inside 2>&1 | tail -1 | sed 's/^/ /'
echo "═══ то же, но с монтированием всего каталога ═══"
docker run --rm -v "$PWD:/app" reload:venv-inside 2>&1 | tail -1 | sed 's/^/ /'
echo "═══ и с anonymous volume поверх .venv ═══"
docker run --rm -v "$PWD:/app" -v /app/.venv reload:venv-inside 2>&1 | tail -1 | sed 's/^/ /'
docker rmi -f reload:venv-inside > /dev/null
Ожидаемый вывод:
═══ venv в /app/.venv, без монтирования ═══
fastapi найден
═══ то же, но с монтированием всего каталога ═══
/usr/local/bin/python: No module named fastapi
═══ и с anonymous volume поверх .venv ═══
fastapi найден
Три строки исчерпывающе описывают проблему и оба обхода. Обратите внимание на вторую: интерпретатор нашёлся системный (/usr/local/bin/python), потому что /app/.venv/bin скрыт монтированием и PATH указывает в пустоту.
Область наблюдения и лимит inotify
cd /tmp/reload
echo "═══ лимит inotify на host ═══"
printf ' max_user_watches: %s\n' "$(cat /proc/sys/fs/inotify/max_user_watches)"
printf ' max_user_instances: %s\n' "$(cat /proc/sys/fs/inotify/max_user_instances)"
echo "═══ сколько файлов наблюдалось бы без ограничения ═══"
mkdir -p noise
for i in $(seq 1 200); do
mkdir -p "noise/dir-$i" && touch "noise/dir-$i/file.txt"
done
printf ' каталогов в проекте: %s\n' "$(find . -type d | wc -l)"
echo "═══ ограничиваем область наблюдения ═══"
cat > compose.watchdir.yaml <<'EOF'
name: reload-wd
services:
api:
build:
context: .
target: dev
ports:
- "127.0.0.1:8001:8000"
volumes:
- .:/app
- /app/.venv
command:
- uvicorn
- app.main:app
- --host
- 0.0.0.0
- --reload
- --reload-dir
- app # только каталог app, не весь проект
EOF
docker compose -f compose.watchdir.yaml up -d --build > /dev/null 2>&1
sleep 8
docker compose -f compose.watchdir.yaml logs api --no-log-prefix 2>/dev/null \
| grep -iE 'reload|watching' | head -2 | sed 's/^/ /'
echo "═══ изменение вне области наблюдения ═══"
touch noise/dir-1/file.txt
sleep 3
n1="$(docker compose -f compose.watchdir.yaml logs api --no-log-prefix 2>/dev/null | grep -c 'Reloading' || true)"
printf ' перезагрузок после правки в noise/: %s\n' "$n1"
echo "═══ изменение внутри области ═══"
sed -i 's/^VERSION = "3"/VERSION = "4"/' app/main.py
sleep 4
n2="$(docker compose -f compose.watchdir.yaml logs api --no-log-prefix 2>/dev/null | grep -c 'Reloading' || true)"
printf ' перезагрузок после правки в app/: %s\n' "$n2"
docker compose -f compose.watchdir.yaml down > /dev/null 2>&1
rm -rf noise
Ожидаемый вывод:
═══ лимит inotify на host ═══
max_user_watches: 65536
max_user_instances: 128
═══ сколько файлов наблюдалось бы без ограничения ═══
каталогов в проекте: 204
═══ ограничиваем область наблюдения ═══
INFO: Will watch for changes in these directories: ['/app/app']
INFO: Started reloader process [1] using WatchFiles
═══ изменение вне области наблюдения ═══
перезагрузок после правки в noise/: 0
═══ изменение внутри области ═══
перезагрузок после правки в app/: 1
Строка Will watch for changes in these directories: ['/app/app'] подтверждает ограничение. Правка вне этой области перезапуска не вызвала.
На проекте с node_modules или большим каталогом данных разница принципиальна: без ограничения watcher либо упрётся в лимит, либо будет перезапускаться на каждый временный файл.
docker compose watch
cd /tmp/reload
cat > compose.watch.yaml <<'EOF'
name: reload-watch
services:
api:
build:
context: .
target: dev
ports:
- "127.0.0.1:8002:8000"
# Никакого bind mount: файлы синхронизируются
develop:
watch:
- action: sync
path: ./app
target: /app/app
ignore:
- __pycache__/
# Изменение зависимостей пересобирает образ автоматически
- action: rebuild
path: ./requirements.txt
EOF
echo "═══ образ должен содержать код: bind mount отсутствует ═══"
cat > Dockerfile.watch <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH"
RUN python -m venv /opt/venv
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app/ ./app/
CMD ["fastapi", "dev", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]
EOF
sed -i 's| context: .\n target: dev| context: .|' compose.watch.yaml
python3 - <<'PY'
import pathlib
p = pathlib.Path("compose.watch.yaml")
t = p.read_text().replace(" context: .\n target: dev\n",
" context: .\n dockerfile: Dockerfile.watch\n")
p.write_text(t)
PY
docker compose -f compose.watch.yaml up -d --build > /dev/null 2>&1
sleep 8
printf ' до правки: %s\n' "$(curl -s localhost:8002/ | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')"
echo "═══ запускаем watch в фоне ═══"
docker compose -f compose.watch.yaml watch > /tmp/reload/watch.log 2>&1 &
watch_pid=$!
sleep 5
sed -i 's/^VERSION = "4"/VERSION = "5-СИНХРОНИЗИРОВАНО"/' app/main.py
sleep 6
printf ' после правки: %s\n' "$(curl -s localhost:8002/ | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')"
grep -iE 'sync|rebuild' /tmp/reload/watch.log | head -3 | sed 's/^/ /'
kill $watch_pid 2>/dev/null
wait $watch_pid 2>/dev/null
docker compose -f compose.watch.yaml down > /dev/null 2>&1
Ожидаемый вывод:
до правки: 4
═══ запускаем watch в фоне ═══
после правки: 5-СИНХРОНИЗИРОВАНО
Syncing service "api" after changes were detected: app/main.py
Файл скопирован в container, --reload внутри подхватил изменение. Bind mount при этом не использовался вовсе.
Проверим главное отличие — файлы, созданные приложением, на host не появляются:
cd /tmp/reload
docker compose -f compose.watch.yaml up -d > /dev/null 2>&1
sleep 6
docker compose -f compose.watch.yaml exec -T api sh -c 'echo "создано в container" > /app/app/generated.txt'
printf ' в container: %s\n' \
"$(docker compose -f compose.watch.yaml exec -T api ls /app/app/generated.txt 2>/dev/null || echo 'нет')"
printf ' на host: %s\n' \
"$(ls app/generated.txt 2>/dev/null || echo 'нет — файлы не возвращаются')"
docker compose -f compose.watch.yaml down > /dev/null 2>&1
Ожидаемый вывод:
в container: /app/app/generated.txt
на host: нет — файлы не возвращаются
Синхронизация односторонняя: с host в container. Это снимает проблему прав, но делает develop.watch непригодным, когда нужно видеть сгенерированные файлы — например, результат работы генератора кода или миграции Alembic.
cd /tmp/reload && docker compose down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/reload
Практическое упражнение
Задание. Настройте окружение разработки с перезагрузкой и подтвердите шесть утверждений.
- Правка
.py-файла подхватывается без пересборки; PID процесса меняется. - Правка
requirements.txtне подхватывается — воспроизвести и объяснить. - Установленные в образе пакеты не скрываются монтированием — доказать.
- Область наблюдения ограничена: правка вне неё не вызывает перезапуск.
- Файлы, созданные приложением, принадлежат вашему UID и удаляются без
sudo. - Вариант с
develop.watchработает и пересобирает образ при изменении зависимостей.
Подсказки
Подсказка 1
Для пункта 1 нужен endpoint, возвращающий os.getpid().
Подсказка 2
Пункт 4 проверяется каталогом, который смонтирован, но не входит в --reload-dir.
Подсказка 3
Пункт 6 требует, чтобы код был в образе: при develop.watch bind mount отсутствует.
Решение
Показать решение
mkdir -p /tmp/hotreload/app /tmp/hotreload/data && cd /tmp/hotreload
cat > app/__init__.py <<'PY'
"""Приложение для проверки hot reload."""
PY
cat > app/main.py <<'PY'
"""Сообщает всё, что нужно для проверки утверждений."""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
from fastapi import FastAPI
VERSION = "1" # правится для пункта 1
app = FastAPI()
@app.get("/")
async def root() -> dict[str, object]:
return {
"version": VERSION,
"pid": os.getpid(),
"httpx": importlib.util.find_spec("httpx") is not None,
"fastapi": importlib.util.find_spec("fastapi") is not None,
}
@app.post("/write")
async def write() -> dict[str, object]:
"""Пункт 5: файл должен принадлежать UID разработчика."""
target = Path("/app/data/generated.txt")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"создано процессом {os.getpid()}\n", encoding="utf-8")
st = target.stat()
return {"path": str(target), "uid": st.st_uid, "gid": st.st_gid}
PY
cat > requirements.txt <<'EOF'
fastapi[standard]==0.141.1
EOF
cat > .dockerignore <<'EOF'
.git
__pycache__
*.py[cod]
data
.env
compose*.yaml
Dockerfile*
EOF
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
# Пункт 3: окружение ВНЕ /app — монтирование его не скроет
RUN python -m venv /opt/venv
WORKDIR /app
FROM base AS builder
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
# ── Разработка: UID разработчика, код придёт монтированием ──
FROM builder AS dev
ARG UID=1000
ARG GID=1000
RUN groupadd -g ${GID} dev 2>/dev/null || true; \
useradd -u ${UID} -g ${GID} -m -d /home/dev dev 2>/dev/null || true; \
mkdir -p /home/dev /app/data && chown -R ${UID}:${GID} /home/dev /app
ENV HOME=/home/dev
USER ${UID}:${GID}
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--reload", "--reload-dir", "app"]
# ── Вариант для develop.watch: код внутри образа ──
FROM dev AS dev-watch
COPY --chown=${UID}:${GID} app/ ./app/
FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/venv /opt/venv
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
EOF
cat > compose.yaml <<'EOF'
name: hotreload
services:
# Вариант 1: bind mount плюс --reload
api:
build:
context: .
target: dev
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
ports:
- "127.0.0.1:8100:8000"
volumes:
- ./app:/app/app # наблюдаемый каталог
- ./data:/app/data # смонтирован, но НЕ наблюдается
# Вариант 2: develop.watch, без bind mount
api-watch:
build:
context: .
target: dev-watch
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
ports:
- "127.0.0.1:8101:8000"
develop:
watch:
- action: sync
path: ./app
target: /app/app
ignore:
- __pycache__/
- action: rebuild
path: ./requirements.txt
EOF
printf 'UID=%s\nGID=%s\n' "$(id -u)" "$(id -g)" > .env
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
get() { curl -s -m 5 "http://127.0.0.1:$1/" | python3 -c "import json,sys; print(json.load(sys.stdin)[\"$2\"])" 2>/dev/null; }
printf '\n═══ Запуск ═══\n'
docker compose up -d --build api > /dev/null 2>&1
for _ in $(seq 40); do [ -n "$(get 8100 version)" ] && break; sleep 1; done
printf ' version=%s pid=%s\n' "$(get 8100 version)" "$(get 8100 pid)"
printf '\n═══ Пункт 1: правка кода подхватывается ═══\n'
pid_before="$(get 8100 pid)"
sed -i 's/^VERSION = "1"/VERSION = "2-ПРАВКА"/' app/main.py
for _ in $(seq 20); do [ "$(get 8100 version)" = "2-ПРАВКА" ] && break; sleep 1; done
pid_after="$(get 8100 pid)"
printf ' version: %s → %s, pid: %s → %s\n' "1" "$(get 8100 version)" "$pid_before" "$pid_after"
[ "$(get 8100 version)" = "2-ПРАВКА" ] && ok "код перезагружен без пересборки" || bad "версия не обновилась"
[ "$pid_before" != "$pid_after" ] && ok "PID изменился — процесс перезапущен" \
|| bad "PID тот же: подмены кода в процессе не бывает"
printf '\n═══ Пункт 2: зависимость требует пересборки ═══\n'
printf ' httpx до: %s\n' "$(get 8100 httpx)"
echo "httpx==0.28.1" >> requirements.txt
sed -i 's/^VERSION = "2-ПРАВКА"/VERSION = "3"/' app/main.py
for _ in $(seq 20); do [ "$(get 8100 version)" = "3" ] && break; sleep 1; done
printf ' код обновился (version=%s), httpx: %s\n' "$(get 8100 version)" "$(get 8100 httpx)"
[ "$(get 8100 httpx)" = "False" ] && ok "зависимость НЕ подхвачена — как и ожидалось" \
|| bad "httpx появился без пересборки"
docker compose up -d --build api > /dev/null 2>&1
for _ in $(seq 40); do [ "$(get 8100 httpx)" = "True" ] && break; sleep 1; done
printf ' после --build: httpx=%s\n' "$(get 8100 httpx)"
[ "$(get 8100 httpx)" = "True" ] && ok "после пересборки зависимость на месте" || bad "httpx не установился"
printf '\n═══ Пункт 3: пакеты образа не скрыты монтированием ═══\n'
printf ' fastapi доступен: %s\n' "$(get 8100 fastapi)"
venv="$(docker compose exec -T api sh -c 'ls -d /opt/venv 2>/dev/null')"
printf ' окружение: %s\n' "${venv:-не найдено}"
[ "$(get 8100 fastapi)" = "True" ] && [ -n "$venv" ] \
&& ok "venv в /opt/venv, монтирование его не затрагивает" || bad "пакеты скрыты"
printf '\n═══ Пункт 4: область наблюдения ограничена ═══\n'
docker compose logs api --no-log-prefix 2>/dev/null | grep -i 'will watch' | tail -1 | sed 's/^/ /'
base="$(docker compose logs api --no-log-prefix 2>/dev/null | grep -c 'Reloading' || true)"
echo "изменение $(date +%s)" > data/noise.txt
sleep 4
after_noise="$(docker compose logs api --no-log-prefix 2>/dev/null | grep -c 'Reloading' || true)"
printf ' перезагрузок после правки в data/: %s\n' "$((after_noise - base))"
[ "$((after_noise - base))" -eq 0 ] && ok "правка вне области не вызвала перезапуск" \
|| bad "перезапуск произошёл"
sed -i 's/^VERSION = "3"/VERSION = "4"/' app/main.py
for _ in $(seq 20); do [ "$(get 8100 version)" = "4" ] && break; sleep 1; done
after_app="$(docker compose logs api --no-log-prefix 2>/dev/null | grep -c 'Reloading' || true)"
printf ' перезагрузок после правки в app/: %s\n' "$((after_app - after_noise))"
[ "$((after_app - after_noise))" -ge 1 ] && ok "правка внутри области сработала" \
|| bad "перезапуска не было"
printf '\n═══ Пункт 5: владелец созданных файлов ═══\n'
rm -f data/generated.txt
curl -s -m 5 -X POST "http://127.0.0.1:8100/write" | python3 -m json.tool --compact | sed 's/^/ /'
owner="$(stat -c '%u:%g' data/generated.txt 2>/dev/null)"
printf ' владелец на host: %s (ваш: %s:%s)\n' "$owner" "$(id -u)" "$(id -g)"
[ "$owner" = "$(id -u):$(id -g)" ] && ok "файл принадлежит вам" || bad "владелец: $owner"
rm -f data/generated.txt 2>/dev/null && ok "удалён без sudo" || bad "потребовался sudo"
printf '\n═══ Пункт 6: develop.watch ═══\n'
docker compose up -d --build api-watch > /dev/null 2>&1
for _ in $(seq 40); do [ -n "$(get 8101 version)" ] && break; sleep 1; done
printf ' version до: %s\n' "$(get 8101 version)"
docker compose watch api-watch > /tmp/hotreload/watch.log 2>&1 &
wpid=$!
sleep 6
sed -i 's/^VERSION = "4"/VERSION = "5-WATCH"/' app/main.py
for _ in $(seq 30); do [ "$(get 8101 version)" = "5-WATCH" ] && break; sleep 1; done
printf ' version после: %s\n' "$(get 8101 version)"
[ "$(get 8101 version)" = "5-WATCH" ] && ok "sync доставил изменение" || bad "синхронизация не сработала"
grep -iE 'sync|rebuild' /tmp/hotreload/watch.log | head -2 | sed 's/^/ /'
printf ' файлы, созданные в container, на host:\n'
docker compose exec -T api-watch sh -c 'echo x > /app/app/only-in-container.txt' 2>/dev/null
[ ! -f app/only-in-container.txt ] && ok "не возвращаются — синхронизация односторонняя" \
|| bad "файл появился на host"
kill $wpid 2>/dev/null; wait $wpid 2>/dev/null
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все шесть утверждений подтверждены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/hotreload
exit "$fail"
Ожидаемый вывод:
═══ Запуск ═══
version=1 pid=8
═══ Пункт 1: правка кода подхватывается ═══
version: 1 → 2-ПРАВКА, pid: 8 → 14
✓ код перезагружен без пересборки
✓ PID изменился — процесс перезапущен
═══ Пункт 2: зависимость требует пересборки ═══
httpx до: False
код обновился (version=3), httpx: False
✓ зависимость НЕ подхвачена — как и ожидалось
после --build: httpx=True
✓ после пересборки зависимость на месте
═══ Пункт 3: пакеты образа не скрыты монтированием ═══
fastapi доступен: True
окружение: /opt/venv
✓ venv в /opt/venv, монтирование его не затрагивает
═══ Пункт 4: область наблюдения ограничена ═══
INFO: Will watch for changes in these directories: ['/app/app']
перезагрузок после правки в data/: 0
✓ правка вне области не вызвала перезапуск
перезагрузок после правки в app/: 1
✓ правка внутри области сработала
═══ Пункт 5: владелец созданных файлов ═══
{"path":"/app/data/generated.txt","uid":1000,"gid":1000}
владелец на host: 1000:1000 (ваш: 1000:1000)
✓ файл принадлежит вам
✓ удалён без sudo
═══ Пункт 6: develop.watch ═══
version до: 4
version после: 5-WATCH
✓ sync доставил изменение
Syncing service "api-watch" after changes were detected: app/main.py
файлы, созданные в container, на host:
✓ не возвращаются — синхронизация односторонняя
═══ ИТОГ ═══
все шесть утверждений подтверждены
Все шесть утверждений подтверждены.
Три решения, определяющие качество.
Пункт 1 проверяет изменение PID, а не только новую версию. Обновившаяся версия доказывает лишь, что новый код исполнился. Смена PID доказывает механизм: процесс был убит и запущен заново. Это отличает --reload от гипотетической подмены кода на лету и объясняет, почему состояние в памяти теряется.
Каталог data/ смонтирован, но не входит в --reload-dir. Проверка пункта 4 требует изменения, которое watcher мог бы заметить, но не должен. Правка в несмонтированном каталоге ничего не доказала бы: изменение просто не дошло бы до container'а.
Пункт 6 использует отдельную стадию dev-watch с копией кода. При develop.watch bind mount отсутствует, и стадия dev без кода дала бы неработающий container. Разные механизмы доставки требуют разной подготовки образа — это неочевидно и является главной причиной, по которой develop.watch «не работает» при первой попытке.
Чего решение не делает. Не проверяется действие rebuild при изменении requirements.txt в режиме watch: пересборка занимает десятки секунд и делает проверку хрупкой по времени. Не покрыт и случай исчерпания лимита inotify — воспроизвести его требует тысяч наблюдаемых каталогов, что на типовом стенде создаёт больше проблем, чем демонстрирует.
Проверка результата
mkdir -p /tmp/hr/app && cd /tmp/hr
printf 'VERSION = "1"\n' > app/v.py
cat > compose.yaml <<'EOF'
name: hr
services:
a:
image: python:3.13-slim
working_dir: /app
volumes:
- ./app:/app/app
command: ["sh", "-c", "while true; do python -c 'import sys; sys.path.insert(0,\"/app\"); from app.v import VERSION; print(VERSION)'; sleep 2; done"]
EOF
docker compose up -d
sleep 3
sed -i 's/"1"/"2"/' app/v.py
sleep 4
docker compose logs a --no-log-prefix | tail -3
docker compose down
cd /tmp && rm -rf /tmp/hr
Ожидается смена значения с 1 на 2 без пересборки.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
fastapi dev без --host 0.0.0.0 | Умолчание — 127.0.0.1 | Снаружи недоступен |
Ищут ошибку в коде при ModuleNotFoundError | Код только что правился | Правка requirements.txt требует пересборки |
| Монтируют весь каталог проекта | Проще | Скрывает .venv из образа |
--reload в production | Осталось из разработки | Лишний расход, риск перезапуска |
| Отладчик Flask в production | Удобно смотреть трассировки | Выполнение произвольного кода |
| Наблюдение за всем проектом | Умолчание | Перезапуски на временных файлах; лимит inotify |
Ждут, что develop.watch вернёт файлы на host | По аналогии с bind mount | Синхронизация односторонняя |
develop.watch без кода в образе | Привыкли к bind mount | Container стартует пустым |
| Увеличивают лимит inotify первым делом | Ошибка указывает на него | Сначала ограничить область наблюдения |
| Ожидают сохранения состояния после правки | «Перезагрузка кода» | Процесс перезапускается целиком |
Контрольные вопросы
На понимание:
- Что происходит при срабатывании
--reload? Почему меняется PID? - Назовите четыре вида изменений, которые перезагрузка не подхватывает.
- Почему
fastapi devв container требует--host 0.0.0.0, аfastapi run— нет? - Чем
develop.watchотличается от bind mount? Назовите четыре различия. - Кому принадлежит лимит
max_user_watches— host или container?
На применение:
- Как ограничить область наблюдения и зачем это нужно?
- Как сделать так, чтобы монтирование кода не скрывало установленные пакеты?
- Как настроить автоматическую пересборку при изменении зависимостей?
На диагностику:
- После добавления пакета приложение падает с
ModuleNotFoundError. Первое действие? - Приложение перезапускается каждые несколько секунд без правок. Причина?
Краткое резюме
--reloadперезапускает процесс целиком, а не подменяет код: PID меняется, состояние теряется.- Перезагрузка подхватывает правки
.py, но не зависимости,Dockerfileи переменные окружения. fastapi devслушает127.0.0.1— в container обязателен--host 0.0.0.0.- Отладчик Flask и
--reloadпредназначены только для разработки. - Монтирование корня проекта скрывает
.venvиз образа; окружение держат в/opt/venv. - Монтирование подкаталога вместо корня — самое простое решение проблемы перекрытия.
- Область наблюдения ограничивают
--reload-dir, иначе перезапуски вызывают временные файлы. - Лимит inotify принадлежит host и делится между всеми container'ами.
develop.watchкопирует файлы вместо монтирования: нет проблемы UID, работает с удалённым демоном.- Синхронизация односторонняя: файлы, созданные приложением, на host не появляются.
- Действие
rebuildавтоматически пересобирает образ при изменении зависимостей. - При
develop.watchкод должен быть в образе: bind mount отсутствует.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Compose: Compose Watch | https://docs.docker.com/compose/how-tos/file-watch/ | develop.watch, действия sync, rebuild, sync+restart |
| Compose: develop | https://docs.docker.com/reference/compose-file/develop/ | Синтаксис раздела |
| Uvicorn: settings | https://www.uvicorn.org/settings/ | --reload, --reload-dir, --reload-include |
| FastAPI: development | https://fastapi.tiangolo.com/fastapi-cli/ | fastapi dev против fastapi run |
| Flask: debug mode | https://flask.palletsprojects.com/en/stable/debugging/ | Отладчик и его риски |
| watchfiles | https://watchfiles.helpmanual.io/ | Механизм наблюдения |
Linux: inotify(7) | https://man7.org/linux/man-pages/man7/inotify.7.html | Лимиты, события |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Отладка в container
Главное оглавление