Главная/Storage/Урок

7.3. Bind mounts

Цели

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

  • смонтировать каталог host в container и объяснить, чем это отличается от volume;
  • предсказать, что произойдёт с файлами образа под точкой монтирования;
  • диагностировать «пропажу» установленных пакетов при монтировании исходного кода;
  • объяснить, почему bind mount отдельного файла ломается после правки редактором;
  • применять :ro, :z и :Z осознанно;
  • оценить риски монтирования сокета Docker.

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

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

ТерминОбъяснение
bind mountКаталог или файл host, подключённый в container напрямую
shadowingСокрытие содержимого образа точкой монтирования
inodeИдентификатор файла в файловой системе; bind mount файла привязан к нему
:z / :ZМетки SELinux: общий и частный доступ
propagationКак монтирования распространяются между host и container

Теория

Чем bind mount отличается от volume

VolumeBind mount
Что монтируетсяКаталог, созданный DockerСуществующий путь host
Указывается какИмяАбсолютный путь
Наполняется из образаДа, если пустНикогда
Скрывает содержимое образаТолько если непустВсегда
Переносимость конфигурацииВысокаяНизкая
Docker управляет жизненным цикломДаНет
Владелец и праваИз образа при наполненииИз host, как есть
Производительность на LinuxКак у hostКак у host

Две строки определяют почти все практические различия: bind mount всегда скрывает содержимое образа и никогда не наследует права из образа.

Shadowing: файлы образа исчезают

Монтирование накладывается поверх точки монтирования. Всё, что было в образе по этому пути, становится недоступным — не удаляется, а перестаёт быть видимым.

text
Образ:      /app/main.py, /app/.venv/, /app/lib/
Монтируем:  ./src → /app
Container видит: только содержимое ./src

Для volume это происходит лишь когда volume непуст. Для bind mount — всегда, включая монтирование пустого каталога: тогда container увидит пустоту на месте файлов образа.

Симптом в Python-проекте узнаваемый:

text
ModuleNotFoundError: No module named 'fastapi'

Пакет установлен в образе, сборка прошла, но виртуальное окружение оказалось внутри смонтированного каталога и было скрыто.

Решение — держать окружение вне монтируемого пути:

dockerfile
RUN python -m venv /opt/venv          # НЕ /app/.venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app

Тогда -v "$PWD:/app" скрывает только код — который и должен приходить с host, — а окружение остаётся на месте.

Альтернатива, когда окружение обязано быть внутри /app: закрыть его anonymous volume поверх bind mount.

bash
docker run -v "$PWD:/app" -v /app/.venv ...

Второе монтирование глубже первого, поэтому побеждает: /app приходит с host, а /app/.venv — из образа. Приём работает, но добавляет anonymous volume при каждом запуске (урок 7.2); первый вариант чище.

Абсолютные пути и переносимость

Bind mount требует абсолютного пути. docker run не разворачивает относительные — их разворачивает shell:

bash
docker run -v "$PWD/data:/data" ...        # shell подставит абсолютный путь
docker run -v ./data:/data ...             # ошибка или неожиданный результат

В Compose иначе: относительные пути разрешаются от каталога Compose-файла, и это делает конфигурацию переносимой:

yaml
services:
  app:
    volumes:
      - ./src:/app/src          # относительно расположения compose.yaml

Отсюда практическое правило: bind mounts описывают в Compose, а не в командах docker run. В Compose путь остаётся относительным и переносится вместе с репозиторием.

Bind mount отдельного файла

Монтировать можно не только каталог:

bash
docker run -v "$PWD/config.ini:/app/config.ini:ro" ...

Здесь скрывается ровно один файл. Но у такого монтирования есть неочевидное свойство: привязка идёт к inode, а не к имени.

Большинство редакторов сохраняют файл не записью на месте, а созданием нового и переименованием. Новый файл получает новый inode — а bind mount остался привязан к старому. Container продолжает видеть прежнее содержимое.

Способ правкиInodeContainer видит изменения
echo ... >> fileПрежнийДа
sed -i без резервной копииНовыйНет
Сохранение из vim, VS CodeОбычно новыйНет
cp new fileПрежнийДа

Отсюда правило: монтируйте каталог, а не файл. Каталог не привязан к inode отдельного файла, и правки видны всегда.

Если файл монтировать необходимо, изменения применяются перезапуском container'а.

Read-only

bash
docker run --mount type=bind,source=/etc/app,target=/etc/app,readonly ...
docker run -v /etc/app:/etc/app:ro ...

Флаг стоит ставить по умолчанию для всего, что container не должен изменять: конфигурация, сертификаты, справочные данные. Он превращает ошибку в приложении в явный отказ вместо порчи файлов host.

Ограничение: read-only относится к монтированию, а не к host. Процесс с достаточными правами на host изменит файлы независимо от флага.

SELinux: :z и :Z

На системах с SELinux (Fedora, RHEL, CentOS, Rocky) bind mount без метки даёт Permission denied даже при верных правах Unix — доступ блокирует политика SELinux.

СуффиксЧто делаетКогда
:zОбщая метка: каталог доступен нескольким container'амОбщие данные
:ZЧастная метка: каталог доступен только этому container'уДанные одного container'а
bash
docker run -v /srv/data:/data:Z ...

Предупреждение. Суффикс изменяет метки SELinux рекурсивно и необратимо для исходного каталога. Применение :Z к системному каталогу вроде /home или /usr может нарушить работу host. Метки ставят только на каталоги, созданные для этой цели.

На Ubuntu и Debian с AppArmor суффиксы не нужны и игнорируются.

Propagation

Опция управляет тем, увидит ли container монтирования, выполненные на host внутри примонтированного каталога, и наоборот.

РежимПоведение
rprivate (по умолчанию)Монтирования не распространяются ни в одну сторону
rsharedРаспространяются в обе стороны
rslaveHost → container

Значение по умолчанию подходит почти всегда. rslave нужен, когда host монтирует что-то внутрь уже примонтированного каталога и container должен это увидеть — например, при работе с внешними дисками.

Сокет Docker

bash
docker run -v /var/run/docker.sock:/var/run/docker.sock ...

Приём применяется для CI-агентов и инструментов, которым нужно управлять container'ами.

Это эквивалент выдачи root на host. Процесс с доступом к сокету может запустить container с --privileged и монтированием /, то есть получить полный контроль над машиной. Никакой изоляции здесь нет.

Допустимо в средах, где вы и так доверяете коду с правами администратора. Недопустимо для кода, полученного извне. Альтернативы разбираются в разделе 12.


Внутренний механизм

Что делает ядро

Bind mount — стандартная возможность Linux:

bash
mount --bind /source /target

Ядро добавляет запись в таблицу монтирований mount namespace container'а. Файловая система одна и та же — доступ идёт напрямую, без промежуточного слоя.

Отсюда два следствия. Первое: на Linux bind mount не имеет накладных расходов — это тот же путь к тем же данным. Второе: содержимое, скрытое точкой монтирования, никуда не делось. Оно перестало быть достижимым по этому пути в этом namespace (урок 2.3).

Как убедиться, что файлы образа целы

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


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

Bind mount скрывает файлы образа

bash
docker build -q -t shadowdemo - <<'EOF' > /dev/null
FROM alpine:3.21
RUN mkdir -p /app && \
    echo "из образа" > /app/from-image.txt && \
    echo "тоже из образа" > /app/second.txt
CMD ["sleep", "300"]
EOF

mkdir -p /tmp/shadow-src && echo "с host" > /tmp/shadow-src/from-host.txt

echo "═══ без монтирования ═══"
docker run --rm shadowdemo ls -1 /app

echo "═══ с bind mount ═══"
docker run --rm -v /tmp/shadow-src:/app shadowdemo ls -1 /app

echo "═══ с монтированием ПУСТОГО каталога ═══"
mkdir -p /tmp/shadow-empty
docker run --rm -v /tmp/shadow-empty:/app shadowdemo sh -c 'ls -A /app | wc -l | xargs echo "файлов:"'

echo "═══ для сравнения: пустой volume ═══"
docker volume create shadow-vol > /dev/null
docker run --rm -v shadow-vol:/app shadowdemo ls -1 /app
docker volume rm shadow-vol > /dev/null

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

text
═══ без монтирования ═══
from-image.txt
second.txt
═══ с bind mount ═══
from-host.txt
═══ с монтированием ПУСТОГО каталога ═══
файлов: 0
═══ для сравнения: пустой volume ═══
from-image.txt
second.txt

Четыре блока показывают разницу исчерпывающе. Bind mount скрывает файлы образа всегда — даже пустой каталог даёт пустоту. Пустой volume, наоборот, наполняется файлами образа (урок 7.2).

Файлы образа при этом целы: первый блок выполнялся с тем же образом.

Классическая ошибка Python-проекта

bash
mkdir -p /tmp/venv-trap/src && cd /tmp/venv-trap

cat > src/app.py <<'PY'
import sys

try:
    import click
    print(f"click {click.__version__} найден")
except ModuleNotFoundError as exc:
    print(f"ОШИБКА: {exc}")
    sys.exit(1)
PY

cat > requirements.txt <<'EOF'
click==8.3.0
EOF

echo "═══ вариант A: venv внутри /app ═══"
cat > Dockerfile.bad <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /app/.venv && /app/.venv/bin/pip install -q -r requirements.txt
ENV PATH="/app/.venv/bin:$PATH"
COPY src/ ./src/
CMD ["python", "src/app.py"]
EOF

docker build -q -f Dockerfile.bad -t venv-bad . > /dev/null
printf '  без монтирования: '
docker run --rm venv-bad
printf '  с bind mount:     '
docker run --rm -v "$PWD/src:/app/src" venv-bad
printf '  монтируем весь /app: '
docker run --rm -v "$PWD:/app" venv-bad || true

echo
echo "═══ вариант B: venv в /opt/venv ═══"
cat > Dockerfile.good <<'EOF'
FROM python:3.13-slim
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY requirements.txt .
RUN pip install -q -r requirements.txt
COPY src/ ./src/
CMD ["python", "src/app.py"]
EOF

docker build -q -f Dockerfile.good -t venv-good . > /dev/null
printf '  без монтирования: '
docker run --rm venv-good
printf '  монтируем весь /app: '
docker run --rm -v "$PWD:/app" venv-good

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

text
═══ вариант A: venv внутри /app ═══
  без монтирования: click 8.3.0 найден
  с bind mount:     click 8.3.0 найден
  монтируем весь /app: ОШИБКА: No module named 'click'

═══ вариант B: venv в /opt/venv ═══
  без монтирования: click 8.3.0 найден
  с bind mount:     click 8.3.0 найден
  монтируем весь /app: click 8.3.0 найден

Третья строка первого блока — самая частая ошибка в разработке с Docker. Пакет установлен, образ собран правильно, но /app/.venv скрыт монтированием, и PATH указывает в пустоту.

Обратите внимание на вторую строку: монтирование только src/ проблемы не вызывает — окружение лежит выше по дереву. Ошибка проявляется лишь при монтировании всего /app, что и делают обычно.

Вариант B устойчив к обоим способам монтирования: /opt/venv вне /app и скрыть его нельзя.

Приём с anonymous volume

Если окружение обязано находиться в /app:

bash
cd /tmp/venv-trap
printf '  bind mount + anonymous volume поверх: '
docker run --rm -v "$PWD:/app" -v /app/.venv venv-bad

echo "  созданных anonymous volumes:"
docker volume ls -q | grep -cE '^[0-9a-f]{64}$' | sed 's/^/    /'

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

text
  bind mount + anonymous volume поверх: click 8.3.0 найден
  созданных anonymous volumes:
    1

Приём работает: более глубокое монтирование побеждает. Цена — anonymous volume на каждый запуск без --rm, который придётся убирать (урок 7.2).

bash
docker volume prune -f > /dev/null
docker rmi -f venv-bad venv-good shadowdemo > /dev/null 2>&1

Bind mount файла и правка редактором

bash
mkdir -p /tmp/filemount && cd /tmp/filemount
echo "version=1" > config.ini

docker run -d --name filemnt \
    -v "$PWD/config.ini:/app/config.ini:ro" \
    alpine:3.21 sh -c 'while true; do sleep 1; done' > /dev/null

printf 'исходно:               %s\n' "$(docker exec filemnt cat /app/config.ini)"

echo "═══ дописывание в конец (inode сохраняется) ═══"
inode_before="$(stat -c '%i' config.ini)"
echo "extra=yes" >> config.ini
printf '  inode: %s → %s\n' "$inode_before" "$(stat -c '%i' config.ini)"
printf '  container видит: %s\n' "$(docker exec filemnt sh -c 'tr "\n" " " < /app/config.ini')"

echo "═══ правка через sed -i (создаётся новый файл) ═══"
inode_before="$(stat -c '%i' config.ini)"
sed -i 's/version=1/version=2/' config.ini
printf '  inode: %s → %s\n' "$inode_before" "$(stat -c '%i' config.ini)"
printf '  на host:         %s\n' "$(head -1 config.ini)"
printf '  container видит: %s  ← не обновилось\n' "$(docker exec filemnt head -1 /app/config.ini)"

docker rm -f filemnt > /dev/null

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

text
исходно:               version=1
═══ дописывание в конец (inode сохраняется) ═══
  inode: 1443521 → 1443521
  container видит: version=1 extra=yes 
═══ правка через sed -i (создаётся новый файл) ═══
  inode: 1443521 → 1443893
  на host:         version=2
  container видит: version=1  ← не обновилось

Расхождение видно прямо: на host version=2, в container'е version=1. Причина в изменившемся inode — bind mount остался привязан к старому файлу, который ещё существует, пока его держит монтирование.

Теперь то же самое с монтированием каталога:

bash
cd /tmp/filemount
docker run -d --name dirmnt -v "$PWD:/app:ro" \
    alpine:3.21 sh -c 'while true; do sleep 1; done' > /dev/null

printf 'до правки:  %s\n' "$(docker exec dirmnt head -1 /app/config.ini)"
sed -i 's/version=2/version=3/' config.ini
printf 'после sed:  %s  ← обновилось\n' "$(docker exec dirmnt head -1 /app/config.ini)"

docker rm -f dirmnt > /dev/null
cd /tmp && rm -rf /tmp/filemount /tmp/venv-trap /tmp/shadow-src /tmp/shadow-empty

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

text
до правки:  version=2
после sed:  version=3  ← обновилось

Монтирование каталога не привязано к inode отдельного файла, поэтому видит любую правку. Это и есть причина рекомендации монтировать каталоги.

Read-only и куда он не распространяется

bash
mkdir -p /tmp/ro-demo && echo "исходные данные" > /tmp/ro-demo/data.txt

echo "═══ запись из container ═══"
docker run --rm --mount type=bind,source=/tmp/ro-demo,target=/data,readonly \
    alpine:3.21 sh -c 'echo изменено > /data/data.txt' 2>&1 | tail -1

echo "═══ файл на host не изменился ═══"
cat /tmp/ro-demo/data.txt

echo "═══ вложенное монтирование на запись ═══"
mkdir -p /tmp/ro-demo/writable
docker run --rm \
    --mount type=bind,source=/tmp/ro-demo,target=/data,readonly \
    --mount type=bind,source=/tmp/ro-demo/writable,target=/data/writable \
    alpine:3.21 sh -c 'echo можно > /data/writable/ok.txt && echo "во вложенный каталог запись прошла"'
cat /tmp/ro-demo/writable/ok.txt

rm -rf /tmp/ro-demo

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

text
═══ запись из container ═══
sh: can't create /data/data.txt: Read-only file system
═══ файл на host не изменился ═══
исходные данные
═══ вложенное монтирование на запись ═══
во вложенный каталог запись прошла
можно

Третий блок показывает важное: readonly относится к конкретному монтированию, а не ко всему поддереву. Более глубокое монтирование задаёт свои права. Это регулярно используют — read-only корень плюс отдельные каталоги на запись (урок 7.4).

Диагностика: что скрыто монтированием

bash
docker build -q -t diag-shadow - <<'EOF' > /dev/null
FROM python:3.13-slim
RUN mkdir -p /app && echo "print('из образа')" > /app/entry.py && \
    mkdir -p /app/lib && echo "x = 1" > /app/lib/helper.py
CMD ["sleep", "300"]
EOF

mkdir -p /tmp/diag-src && echo "print('с host')" > /tmp/diag-src/entry.py

echo "═══ что в образе по пути /app ═══"
docker run --rm diag-shadow find /app -type f | sort

echo "═══ что видно с монтированием ═══"
docker run --rm -v /tmp/diag-src:/app diag-shadow find /app -type f | sort

echo "═══ разница = скрытое ═══"
diff <(docker run --rm diag-shadow find /app -type f | sort) \
     <(docker run --rm -v /tmp/diag-src:/app diag-shadow find /app -type f | sort) \
    | grep '^<' | sed 's/^< /  скрыто: /'

docker rmi -f diag-shadow > /dev/null; rm -rf /tmp/diag-src

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

text
═══ что в образе по пути /app ═══
/app/entry.py
/app/lib/helper.py
═══ что видно с монтированием ═══
/app/entry.py
═══ разница = скрытое ═══
  скрыто: /app/lib/helper.py

Такое сравнение — самый быстрый ответ на вопрос «куда делся файл». Два запуска одного образа, с монтированием и без, и diff списков.

Заметьте, что /app/entry.py присутствует в обоих списках, но это разные файлы: в первом случае из образа, во втором с host. Сравнение по именам этого не покажет — при подозрении на подмену сравнивают содержимое.


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

Задание. Организуйте разработку Python-приложения с bind mount, устойчивую к четырём проблемам.

  1. Правка кода на host видна в container без пересборки.
  2. Установленные в образе пакеты не скрываются монтированием — доказать.
  3. Конфигурация монтируется read-only, и правка её редактором видна container'у.
  4. Каталог кэша приложения не засоряет репозиторий на host.
  5. Одна команда запускает всё; конфигурация переносима (без абсолютных путей).

Каждый пункт подтвердите проверкой.

Подсказки

Подсказка 1

Пункт 2 решается расположением виртуального окружения, а не флагами монтирования.

Подсказка 2

Пункт 3 требует монтировать каталог с конфигурацией, а не сам файл.

Подсказка 3

Пункт 4: то, что не должно попадать на host, монтируют volume или tmpfs поверх bind mount.

Подсказка 4

Пункт 5 — про Compose: там пути относительны каталогу файла.

Решение

Показать решение
bash
mkdir -p /tmp/devmount/src/app /tmp/devmount/config && cd /tmp/devmount

cat > src/app/__init__.py <<'PY'
"""Приложение для демонстрации bind mount при разработке."""
PY

cat > src/app/main.py <<'PY'
"""Проверяет всё, что требуется от окружения разработки."""
from __future__ import annotations

import os
import sys
from pathlib import Path

CACHE_DIR = Path(os.environ.get("APP_CACHE", "/app/.cache"))
CONFIG = Path(os.environ.get("APP_CONFIG", "/app/config/app.ini"))

VERSION = "1"  # правится на host для проверки пункта 1


def main() -> int:
    # Пункт 2: пакет из образа должен быть доступен
    try:
        import click
        pkg = f"click {click.__version__}"
    except ModuleNotFoundError as exc:
        print(f"  пакет:   ОШИБКА — {exc}")
        return 1

    # Пункт 3: конфигурация читается с host
    config_line = CONFIG.read_text().strip().splitlines()[0] if CONFIG.exists() else "ОТСУТСТВУЕТ"

    # Пункт 4: кэш пишется, но не в репозиторий host
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    (CACHE_DIR / "marker.txt").write_text("кэш приложения\n")

    print(f"  версия:  {VERSION}")
    print(f"  пакет:   {pkg}")
    print(f"  конфиг:  {config_line}")
    print(f"  кэш:     {CACHE_DIR} (записан)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > config/app.ini <<'EOF'
setting=исходное
EOF

cat > requirements.txt <<'EOF'
click==8.3.0
EOF

cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.py[cod]
.cache
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PATH="/opt/venv/bin:$PATH"

# Пункт 2: окружение ВНЕ /app — bind mount его не скроет
RUN python -m venv /opt/venv

WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

COPY src/ ./src/
ENV PYTHONPATH=/app/src
CMD ["python", "-m", "app.main"]
EOF

cat > compose.yaml <<'EOF'
services:
  dev:
    build: .
    environment:
      APP_CONFIG: "/app/config/app.ini"
      APP_CACHE: "/app/.cache"
    volumes:
      # Пункт 1: код с host, пути относительны этому файлу (пункт 5)
      - ./src:/app/src
      # Пункт 3: каталог, а не файл — правка редактором будет видна
      - ./config:/app/config:ro
      # Пункт 4: кэш в volume поверх bind mount, на host не попадает
      - dev-cache:/app/.cache
    command: ["sh", "-c", "while true; do sleep 1; done"]

volumes:
  dev-cache:
EOF

# ── Проверка ──
docker compose up -d --build > /dev/null 2>&1
sleep 3

echo "═══ Пункты 2, 3, 4: базовый запуск ═══"
docker compose exec -T dev python -m app.main

echo
echo "═══ Пункт 1: правка кода без пересборки ═══"
sed -i 's/^VERSION = "1"/VERSION = "2-ПРАВКА-НА-HOST"/' src/app/main.py
docker compose exec -T dev python -m app.main | grep версия

echo
echo "═══ Пункт 2: доказательство, что окружение не скрыто ═══"
printf '  venv в образе:      %s\n' "$(docker compose exec -T dev sh -c 'ls -d /opt/venv')"
printf '  click доступен:     %s\n' "$(docker compose exec -T dev python -c 'import click; print("да")')"
printf '  /app/src с host:    %s\n' \
    "$(docker compose exec -T dev sh -c 'stat -c %Y /app/src/app/main.py')"

echo
echo "═══ Пункт 3: правка конфигурации редактором (новый inode) ═══"
printf '  inode до:  %s\n' "$(stat -c '%i' config/app.ini)"
sed -i 's/setting=исходное/setting=ИЗМЕНЕНО-РЕДАКТОРОМ/' config/app.ini
printf '  inode после: %s\n' "$(stat -c '%i' config/app.ini)"
docker compose exec -T dev python -m app.main | grep конфиг

echo
echo "═══ Пункт 3: конфигурация недоступна на запись ═══"
docker compose exec -T dev sh -c 'echo x > /app/config/app.ini' 2>&1 | tail -1 | sed 's/^/  /'

echo
echo "═══ Пункт 4: кэш не попал в репозиторий host ═══"
printf '  в container: %s\n' "$(docker compose exec -T dev sh -c 'ls /app/.cache')"
printf '  на host:     %s\n' "$(ls .cache 2>/dev/null || echo 'каталога нет — верно')"

echo
echo "═══ Пункт 5: переносимость ═══"
grep -c '/tmp/devmount\|/home/' compose.yaml | xargs printf '  абсолютных путей в compose.yaml: %s\n'

docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/devmount

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

text
═══ Пункты 2, 3, 4: базовый запуск ═══
  версия:  1
  пакет:   click 8.3.0
  конфиг:  setting=исходное
  кэш:     /app/.cache (записан)

═══ Пункт 1: правка кода без пересборки ═══
  версия:  2-ПРАВКА-НА-HOST

═══ Пункт 2: доказательство, что окружение не скрыто ═══
  venv в образе:      /opt/venv
  click доступен:     да
  /app/src с host:    1785412843

═══ Пункт 3: правка конфигурации редактором (новый inode) ═══
  inode до:  1443521
  inode после: 1443902
  конфиг:  setting=ИЗМЕНЕНО-РЕДАКТОРОМ

═══ Пункт 3: конфигурация недоступна на запись ═══
  sh: can't create /app/config/app.ini: Read-only file system

═══ Пункт 4: кэш не попал в репозиторий host ═══
  в container: marker.txt
  на host:     каталога нет — верно

═══ Пункт 5: переносимость ═══
  абсолютных путей в compose.yaml: 0

Все пять пунктов выполнены.

Три решения, определяющие качество.

/opt/venv вместо /app/.venv — весь пункт 2 держится на этом. Никакие флаги монтирования не помогли бы: bind mount на /app скрывает всё, что под ним. Проблема решается расположением, а не настройкой. Это общий принцип: то, что приходит из образа, должно лежать вне монтируемых путей.

Монтируется ./config, а не ./config/app.ini. Вывод показывает, что inode изменился после sed -i — при монтировании файла container увидел бы старую версию. Каталог к inode отдельного файла не привязан, и правка любым редактором видна сразу.

Кэш закрыт named volume поверх bind mount. Приложение пишет в /app/.cache, но /app/.cache — отдельное монтирование, глубже /app/src. На host ничего не появляется, .gitignore для этого не нужен, а кэш переживает перезапуск (в отличие от tmpfs). Named volume вместо anonymous выбран сознательно: его видно в docker volume ls и удаляет он compose down -v.

Чего решение не делает. Файлы, созданные приложением в /app/src, будут принадлежать пользователю container'а — если это root, на host они окажутся root-овыми и не удалятся обычным пользователем. Здесь этого не видно, потому что приложение в src/ не пишет. Полное решение проблемы — в уроке 7.5.

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

bash
mkdir -p /tmp/bm-check && echo host > /tmp/bm-check/f.txt
docker run --rm -v /tmp/bm-check:/d:ro alpine:3.21 sh -c 'cat /d/f.txt; echo x > /d/f.txt' 2>&1 | tail -2
rm -rf /tmp/bm-check

Ожидается вывод host и отказ записи Read-only file system.

Типичные ошибки

ОшибкаПричинаИсправление
ModuleNotFoundError при монтировании кодаОкружение внутри монтируемого путиVenv в /opt/venv вне /app
Монтирование одного файла конфигурацииКажется точнееПравка редактором меняет inode; монтировать каталог
Относительный путь в docker run -vРаботает в ComposeИспользовать "$PWD/..." или Compose
Абсолютные пути в ComposeСкопировали из docker runОтносительные пути переносимы
:Z на системном каталогеСкопировали из инструкцииМетки меняются рекурсивно; можно сломать host
Ожидают наполнения из образаПутают с volumeBind mount никогда не наполняется
-v с опечаткой в путиПозиционный синтаксисDocker создаст пустой каталог; --mount даст ошибку
Монтирование сокета Docker «для удобства»Нужен доступ к APIЭквивалент root на host
Bind mount для данных базыКажется прозрачнееПрава и переносимость; volume лучше
Забывают :ro для конфигурацииНе подумалиПриложение может испортить файлы host

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

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

  1. Почему bind mount скрывает файлы образа, а пустой volume — нет?
  2. Почему sed -i по смонтированному файлу не виден в container'е?
  3. Чем :z отличается от :Z и почему их применяют осторожно?
  4. Распространяется ли readonly на вложенные монтирования?
  5. Почему монтирование сокета Docker эквивалентно выдаче root?

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

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

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

  1. После docker compose up приложение не находит зависимость. Порядок действий?
  2. Правка файла на host не видна в container'е. Две версии?

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

  1. Bind mount подключает существующий путь host напрямую, без участия Docker.
  2. Он всегда скрывает содержимое образа по этому пути, включая пустой каталог.
  3. Файлы образа при этом целы — они недоступны, а не удалены.
  4. Виртуальное окружение держат вне монтируемого пути: /opt/venv, не /app/.venv.
  5. Более глубокое монтирование побеждает: -v /app/.venv вернёт окружение из образа.
  6. Bind mount файла привязан к inode; правка редактором создаёт новый и рвёт связь.
  7. Монтируйте каталоги, а не отдельные файлы.
  8. docker run требует абсолютных путей, Compose разрешает относительные от своего каталога.
  9. :ro относится к конкретному монтированию; вложенные задают права сами.
  10. :z и :Z нужны на системах с SELinux и меняют метки рекурсивно.
  11. На Linux bind mount не имеет накладных расходов — это тот же путь к тем же данным.
  12. Монтирование /var/run/docker.sock равносильно выдаче прав root на host.

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

ИсточникСсылкаЧто подтверждает
Docker: bind mountshttps://docs.docker.com/engine/storage/bind-mounts/Поведение, shadowing, -v против --mount
Docker: storage overviewhttps://docs.docker.com/engine/storage/Сравнение механизмов
Docker: --mount referencehttps://docs.docker.com/reference/cli/docker/container/run/#mountОпции readonly, bind-propagation
Compose: volumeshttps://docs.docker.com/reference/compose-file/services/#volumesОтносительные пути, короткий и длинный синтаксис
Docker: SELinux labelshttps://docs.docker.com/engine/storage/bind-mounts/#configure-the-selinux-label:z и :Z
Linux: mount --bindhttps://man7.org/linux/man-pages/man8/mount.8.htmlМеханизм ядра, propagation
Docker: securityhttps://docs.docker.com/engine/security/Риски доступа к сокету демона

Навигация

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

Markdown на GitHub ↗