Bind mounts: синтаксис, права, macOS-специфика
Bind mount — рабочая лошадка локальной разработки. На нём держится почти весь dev-workflow в контейнерах: редактируешь файл на хосте, контейнер видит изменение мгновенно. Но именно с bind mount связаны самые частые проблемы Junior’ов: «почему у файла owner = root», «почему npm install внутри контейнера пишет в host-папку с правами root», «почему bind на маке такой медленный». Разберём механику.
Два синтаксиса: -v и —mount
Исторически Docker предлагал короткий синтаксис через -v:
docker run -v $(pwd):/app python:3.13-slim
docker run -v /host/path:/container/path:ro alpine
Формат — host:container:options, через двоеточие. Удобно, но неоднозначно: если первая часть не начинается с /, Docker трактует её как имя named volume, а не как host path. Опечатался — получил пустой volume вместо bind.
Современный явный синтаксис — --mount:
docker run \
--mount type=bind,source=$(pwd),target=/app \
python:3.13-slim
Параметры через запятую, обязательно type=bind. Длиннее, но никаких сюрпризов. В compose-файлах для bind mount обычно используют именно длинную форму:
services:
app:
image: python:3.13-slim
volumes:
- type: bind
source: ./src
target: /app/src
Правило для скриптов: в compose — длинная форма (type: bind). В docker run для quick-experiments — короткая (-v), но всегда с абсолютным path через $(pwd), чтобы не получить случайный named volume.
Опции bind mount
| Опция | -v | —mount | Что делает |
|---|---|---|---|
| read-only | :ro | readonly | Контейнер не пишет в host |
| consistency | :cached | consistency=cached | Hint для macOS (исторический, deprecated) |
| bind-propagation | :shared | bind-propagation=shared | Submount propagation (Linux only) |
| selinux | :Z | - | SELinux-relabel (RHEL/Fedora) |
Самый частый случай — read-only:
docker run --rm \
-v $(pwd)/dags:/opt/airflow/dags:ro \
apache/airflow:2.10.0 airflow dags list
Airflow видит DAG-папку, но не может туда писать. Это разумный default для production: ETL-код приложение не должно само себя переписывать.
Пользователи и группы — UID/GID и /etc/passwd
Владелец и права файлов
Здесь начинается самое больное. На Linux при bind mount Docker не меняет UID/GID файлов — что было на хосте, то и видно в контейнере.
Пример:
# На хосте я — UID 1000.
ls -ln /tmp/data
# -rw-r--r-- 1 1000 1000 ... file.txt
docker run --rm -v /tmp/data:/data alpine ls -ln /data
# -rw-r--r-- 1 1000 1000 ... file.txt
Внутри alpine процесс запущен от root (UID 0). Root всё равно может читать и писать. Но если образ запускает процесс от своего пользователя (что правильно для security), могут быть проблемы:
docker run --rm \
-u 999:999 \
-v /tmp/data:/data \
alpine sh -c 'echo hi > /data/new.txt'
# Файл создан, owner = 999:999. Я (UID 1000) на хосте могу его прочитать, но не могу удалить, потому что я не владелец.
Решения:
- Запустить контейнер с UID хоста:
-u $(id -u):$(id -g). Подходит для dev, не подходит для prod-образов с подготовленным user’ом. - Chown через entrypoint.sh: скрипт при старте делает
chown -R user:user /dataи потом переходит наexec gosu user "$@". Стандартный паттерн в Postgres и Airflow. - Совпадающий UID в образе и хосте. Build-arg
UID=1000, в Dockerfileuseradd -u $UID. На Linux совпадает в 90% случаев.
На macOS и Windows bind mount проходит через VM, и Docker Desktop / OrbStack обычно сглаживает UID-конфликты на лету (внутренняя реализация различается). Поэтому Junior, который разрабатывает на маке, не видит проблемы. На Linux-сервере она проявляется сразу.
macOS: почему bind медленный
Docker на macOS — это Linux VM под капотом. Контейнеры работают внутри этой VM. Host path /Users/me/project физически живёт на macOS, контейнерный путь — внутри VM. Чтобы файл с хоста стал виден в VM, нужен механизм проброса:
- Docker Desktop: исторически osxfs (медленный), потом gRPC FUSE (быстрее), сейчас VirtioFS (самый быстрый из этой ветки). Включается в Settings, не везде по умолчанию.
- OrbStack: свой механизм поверх VirtFS, заметно быстрее Docker Desktop.
- Rancher Desktop / Lima: sshfs или 9p, медленнее обоих.
- Mutagen: двунаправленная файловая синхронизация в файловую систему VM (не bind mount). Скорость нативная, но усложняет setup.
Эффект: npm install или pip install в bind-папку на маке может занять 2-5 минут вместо 30 секунд на Linux. Решения:
-
OrbStack. На M-серии маков это первый выбор. VirtFS-mount в OrbStack — самое близкое к нативной скорости, что есть на macOS.
-
Держать
node_modules/.venvв named volume. В compose делаем bind для кода и named volume для tяжёлых директорий:volumes: - ./src:/app/src - ./pyproject.toml:/app/pyproject.toml - venv:/app/.venv -
Mutagen / docker-sync. Двунаправленная синхронизация в named volume — даёт скорость VM-FS.
-
:cached-хинт. Исторически Docker Desktop поддерживал:cached(контейнер может видеть устаревшую host-версию короткое время) и:delegated(хост может видеть устаревшую container-версию). После перехода на VirtioFS флаги стали no-op, но всё ещё валидны в синтаксисе.
OrbStack на M-серии — это default для DE-разработки на маке. Запуск Postgres-стенда через bind mount в OrbStack vs Docker Desktop отличается в 3-5 раз по скорости I/O. Если ты сидишь на Docker Desktop и pipeline тормозит — переключись.
Когда bind mount — плохая идея
| Сценарий | Почему bind плох |
|---|---|
Postgres data в bind-папке на маке | Очень медленно, плюс UID-конфликты, плюс fsync через VM bridge |
| Production-deploy с bind на host path | Не portable: новая машина — ручная подготовка папки и прав |
| Bind mount в multi-host swarm/k8s | Папка на одной ноде, контейнер на другой — fail |
Bind mount с node_modules/.venv на маке | I/O bottleneck (тысячи мелких файлов) |
| Bind read-write конфигов от прода | Контейнер случайно перезапишет, на хосте остаются артефакты |
Сценарии, где bind — правильный выбор:
| Сценарий | Почему bind подходит |
|---|---|
| DAG-файлы Airflow на dev-машине | Live edit, scheduler подхватывает за секунды |
| Конфиг pgAdmin / Adminer | Один YAML, изменения сразу применяются |
| Test-fixtures для testcontainers | Тест прокидывает sample-CSV в контейнер |
| dbt-проект для локального run | dbt run + редактирование SQL в IDE |
Попробуй сам
# 1. Bind read-only — контейнер не пишет.
mkdir -p /tmp/ro-demo && echo "config" > /tmp/ro-demo/app.conf
docker run --rm \
--mount type=bind,source=/tmp/ro-demo,target=/etc/app,readonly \
alpine sh -c 'echo trying >> /etc/app/app.conf'
# -> sh: can't create /etc/app/app.conf: Read-only file system
# 2. UID mismatch (на Linux чувствуется явно).
mkdir -p /tmp/uid-demo
docker run --rm \
-u 1500:1500 \
-v /tmp/uid-demo:/data \
alpine sh -c 'echo "from 1500" > /data/note.txt; ls -ln /data'
# На хосте:
ls -ln /tmp/uid-demo
# Видишь owner = 1500. На macOS Docker сглаживает, на Linux буквально.
# 3. Performance-тест на маке (если ты на маке).
docker run --rm -v $(pwd):/work alpine \
sh -c 'time find /work -type f | wc -l'
# Сравни время с такой же командой на native-папке.
Никогда не делай -v /:/host — кажется удобным «контейнер видит весь хост», но это полное снятие изоляции и прямой путь к рекурсивной катастрофе, если внутри запустится rm -rf.
В следующем уроке — named volumes: lifecycle, backup-стратегия, volume drivers.