Главная/Development workflow/Урок

10.2. Hot reload

Цели

После этого материала вы сможете:

  • настроить автоматическую перезагрузку кода и объяснить, что при этом происходит;
  • назвать, какие изменения перезагрузка не подхватывает;
  • диагностировать «пропажу» установленных пакетов при монтировании кода;
  • ограничить область наблюдения и объяснить, зачем это нужно на больших проектах;
  • использовать docker compose watch и выбрать между sync, sync+restart и rebuild;
  • понимать ограничения inotify и что делать при их исчерпании.

Предварительные знания

Ключевые термины

ТерминОбъяснение
hot reloadПерезапуск приложения при изменении файлов
inotifyМеханизм ядра для уведомлений об изменениях файлов
watchfilesБиблиотека наблюдения, используемая Uvicorn
develop.watchМеханизм Compose: синхронизация без bind mount
pollingОпрос файлов вместо уведомлений; медленно, но работает везде

Теория

Что такое перезагрузка на самом деле

--reload не подменяет код в работающем процессе. Он перезапускает процесс целиком.

text
watcher (родитель)
   │  наблюдает за файлами
   ▼
процесс приложения ──изменение──► убить ──► запустить заново

Отсюда следствия, которые определяют, что перезагрузка умеет, а что нет:

ИзменениеПодхватывается
Правка .py-файлаДа
Новый файл в наблюдаемом каталогеДа
Изменение шаблона или статикиЗависит от --reload-include
Новая зависимость в requirements.txtНет — нужна пересборка
Изменение DockerfileНет — нужна пересборка
Изменение переменных окруженияНет — нужен перезапуск container'а
Изменение compose.yamlНет — нужен up

Строка про зависимости — источник самой частой потери времени: разработчик добавляет пакет в requirements.txt, видит ModuleNotFoundError и ищет ошибку в коде.

Второе следствие: перезапуск теряет состояние в памяти. Пулы соединений, кэши, счётчики создаются заново. Для разработки это обычно приемлемо, но объясняет, почему после правки первый запрос медленнее.

Инструменты и их умолчания

ИнструментКомандаМеханизм
FastAPIfastapi dev app/main.pyUvicorn плюс watchfiles
Uvicornuvicorn app.main:app --reloadwatchfiles
Flaskflask --app app run --debugWerkzeug reloader
Gunicorngunicorn --reloadОпрос stat
Djangomanage.py runserverВстроенный autoreloader

Важное различие: fastapi dev привязывается к 127.0.0.1, а fastapi run — к 0.0.0.0. В container это означает недоступность снаружи (урок 8.3):

dockerfile
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 скрывает всё, что было в образе по этому пути. Классический симптом:

text
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'ей ядра.

bash
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

Ядро ограничивает число наблюдаемых объектов на пользователя:

bash
cat /proc/sys/fs/inotify/max_user_watches

Типичное значение — 8192 или 65536. Исчерпание даёт ошибку:

text
OSError: [Errno 28] inotify watch limit reached
РешениеКомментарий
Ограничить --reload-dirПравильное: наблюдать только нужное
Увеличить лимит на hostfs.inotify.max_user_watches в sysctl
Перейти на опросМедленно и грузит CPU; крайняя мера

Лимит принадлежит host, а не container'у: namespace для inotify не существует. Несколько container'ов с наблюдением делят один лимит.

docker compose watch — альтернатива bind mount

yaml
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
bash
docker compose watch
ДействиеЧто делает
syncКопирует изменённые файлы в container
rebuildПересобирает образ и пересоздаёт container
sync+restartКопирует и перезапускает container
sync+execКопирует и выполняет команду внутри

Ключевое отличие от bind mount: файлы копируются, а не монтируются.

Bind mountdevelop.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'е.

Это не работает на файловых системах без поддержки уведомлений — например, при монтировании по сети. Тогда остаётся опрос.


Команды и примеры

Перезагрузка работает

bash
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/^/  /'

Ожидаемый вывод:

text
═══ до правки ═══
  {"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. Это подтверждает, что произошёл перезапуск процесса, а не подмена кода в работающем.

Что перезагрузка не подхватывает

bash
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/^/  /'

Ожидаемый вывод:

text
═══ добавляем зависимость в requirements.txt ═══
═══ перезагрузка подхватила код, но не зависимость ═══
  {"version":"3","pid":21,"dependency":"ОШИБКА: No module named 'httpx'"}
═══ после пересборки ═══
  {"version":"3","pid":8,"dependency":"httpx 0.28.1"}

Версия обновилась мгновенно, зависимость — только после --build. Это ровно та ситуация, в которой ищут ошибку в коде, хотя код верен.

Правило: правка requirements.txt требует пересборки.

Перекрытие каталогов

bash
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

Ожидаемый вывод:

text
═══ вариант с монтированием всего каталога ═══
    fastapi найден
═══ вариант с монтированием подкаталога ═══
    fastapi найден

Оба варианта работают — потому что виртуальное окружение лежит в /opt/venv, вне /app. Если бы оно было в /app/.venv, первый вариант упал бы с ModuleNotFoundError.

Проверим это явно:

bash
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

Ожидаемый вывод:

text
═══ 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

bash
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

Ожидаемый вывод:

text
═══ лимит 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

bash
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

Ожидаемый вывод:

text
  до правки: 4
═══ запускаем watch в фоне ═══
  после правки: 5-СИНХРОНИЗИРОВАНО
  Syncing service "api" after changes were detected: app/main.py

Файл скопирован в container, --reload внутри подхватил изменение. Bind mount при этом не использовался вовсе.

Проверим главное отличие — файлы, созданные приложением, на host не появляются:

bash
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

Ожидаемый вывод:

text
  в container: /app/app/generated.txt
  на host:     нет — файлы не возвращаются

Синхронизация односторонняя: с host в container. Это снимает проблему прав, но делает develop.watch непригодным, когда нужно видеть сгенерированные файлы — например, результат работы генератора кода или миграции Alembic.

bash
cd /tmp/reload && docker compose down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/reload

Практическое упражнение

Задание. Настройте окружение разработки с перезагрузкой и подтвердите шесть утверждений.

  1. Правка .py-файла подхватывается без пересборки; PID процесса меняется.
  2. Правка requirements.txt не подхватывается — воспроизвести и объяснить.
  3. Установленные в образе пакеты не скрываются монтированием — доказать.
  4. Область наблюдения ограничена: правка вне неё не вызывает перезапуск.
  5. Файлы, созданные приложением, принадлежат вашему UID и удаляются без sudo.
  6. Вариант с develop.watch работает и пересобирает образ при изменении зависимостей.

Подсказки

Подсказка 1

Для пункта 1 нужен endpoint, возвращающий os.getpid().

Подсказка 2

Пункт 4 проверяется каталогом, который смонтирован, но не входит в --reload-dir.

Подсказка 3

Пункт 6 требует, чтобы код был в образе: при develop.watch bind mount отсутствует.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Запуск ═══
    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 — воспроизвести его требует тысяч наблюдаемых каталогов, что на типовом стенде создаёт больше проблем, чем демонстрирует.

Проверка результата

bash
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 mountContainer стартует пустым
Увеличивают лимит inotify первым деломОшибка указывает на негоСначала ограничить область наблюдения
Ожидают сохранения состояния после правки«Перезагрузка кода»Процесс перезапускается целиком

Контрольные вопросы

На понимание:

  1. Что происходит при срабатывании --reload? Почему меняется PID?
  2. Назовите четыре вида изменений, которые перезагрузка не подхватывает.
  3. Почему fastapi dev в container требует --host 0.0.0.0, а fastapi run — нет?
  4. Чем develop.watch отличается от bind mount? Назовите четыре различия.
  5. Кому принадлежит лимит max_user_watches — host или container?

На применение:

  1. Как ограничить область наблюдения и зачем это нужно?
  2. Как сделать так, чтобы монтирование кода не скрывало установленные пакеты?
  3. Как настроить автоматическую пересборку при изменении зависимостей?

На диагностику:

  1. После добавления пакета приложение падает с ModuleNotFoundError. Первое действие?
  2. Приложение перезапускается каждые несколько секунд без правок. Причина?

Краткое резюме

  1. --reload перезапускает процесс целиком, а не подменяет код: PID меняется, состояние теряется.
  2. Перезагрузка подхватывает правки .py, но не зависимости, Dockerfile и переменные окружения.
  3. fastapi dev слушает 127.0.0.1 — в container обязателен --host 0.0.0.0.
  4. Отладчик Flask и --reload предназначены только для разработки.
  5. Монтирование корня проекта скрывает .venv из образа; окружение держат в /opt/venv.
  6. Монтирование подкаталога вместо корня — самое простое решение проблемы перекрытия.
  7. Область наблюдения ограничивают --reload-dir, иначе перезапуски вызывают временные файлы.
  8. Лимит inotify принадлежит host и делится между всеми container'ами.
  9. develop.watch копирует файлы вместо монтирования: нет проблемы UID, работает с удалённым демоном.
  10. Синхронизация односторонняя: файлы, созданные приложением, на host не появляются.
  11. Действие rebuild автоматически пересобирает образ при изменении зависимостей.
  12. При develop.watch код должен быть в образе: bind mount отсутствует.

Официальные источники

ИсточникСсылкаЧто подтверждает
Compose: Compose Watchhttps://docs.docker.com/compose/how-tos/file-watch/develop.watch, действия sync, rebuild, sync+restart
Compose: develophttps://docs.docker.com/reference/compose-file/develop/Синтаксис раздела
Uvicorn: settingshttps://www.uvicorn.org/settings/--reload, --reload-dir, --reload-include
FastAPI: developmenthttps://fastapi.tiangolo.com/fastapi-cli/fastapi dev против fastapi run
Flask: debug modehttps://flask.palletsprojects.com/en/stable/debugging/Отладчик и его риски
watchfileshttps://watchfiles.helpmanual.io/Механизм наблюдения
Linux: inotify(7)https://man7.org/linux/man-pages/man7/inotify.7.htmlЛимиты, события

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Отладка в container
Главное оглавление

Markdown на GitHub ↗