1. Mount: снаружи проще, чем внутри
Когда writable layer живёт ровно столько, сколько живёт контейнер, следующая практическая задача звучит просто: куда деть файл, который нельзя потерять после docker rm? Если честно, слово mount звучит так, будто Docker зовёт нас в горы с рюкзаком и карабинами. На практике всё гораздо прозаичнее: mount — это способ «подключить» внешнее хранилище к пути внутри контейнера. Важно поймать мысль: контейнер может писать в /app/exports, а мы решаем, что это за /app/exports на самом деле — временная область writable layer или «окно наружу» в host/volume.
Две координаты: source path и target path
Самая частая путаница у новичка — воспринимать mount как «параметр Docker», а не как сопоставление двух мест. Поэтому держим в голове два адреса: source живёт снаружи контейнера, а target — внутри контейнера и всегда является абсолютным путём в контейнерной файловой системе. Когда вы говорите «смонтируй вот это туда», вы буквально описываете стрелку source -> target.
Схематично это можно представить так:
flowchart LR
subgraph HOST["Host (ваш ноутбук)"]
SRC_BIND["source (bind): ./data/exports"]
SRC_VOL["source (volume): catalog_exports"]
end
subgraph CTR["Container"]
TGT["target: /app/exports"]
end
SRC_BIND -- bind mount --> TGT
SRC_VOL -- named volume --> TGT
Обратите внимание на «философию»: target для приложения всегда один и тот же (/app/exports), а вот source может быть либо конкретным каталогом на вашем диске (bind), либо внутренним хранилищем Docker (named volume).
С точки зрения Java-кода mount — это просто «папка»
Это важный моральный ориентир дня. Ваш Spring Boot сервис, если он хорошо спроектирован, не должен знать, каким способом /app/exports оказался доступен. Он должен знать только одно: «у меня есть export directory, он задан конфигурацией, и я могу в него писать». А Docker уже решает, куда эти записи физически попадут.
Мини-пример на чистой Java (не Spring, просто чтобы не отвлекаться):
import java.nio.file.Files;
import java.nio.file.Path;
class ExportWriter {
void writeCsv(Path exportDir, String fileName, String csv) throws Exception {
// На всякий случай создаём директорию (в контейнере она может быть пустой)
Files.createDirectories(exportDir);
// Пишем файл в "целевую" директорию, а что под ней (bind/volume) — не наше дело
Files.writeString(exportDir.resolve(fileName), csv);
}
}
Здесь exportDir — это и есть наш target path глазами приложения. Дальше всё зависит от того, что Docker «подложил» под этот путь.
2. Bind mount: «провод» с host в контейнер
Bind mount — самый интуитивный вариант: вы берёте реальный путь на вашей машине и делаете так, чтобы внутри контейнера он выглядел как обычный каталог. Это похоже на ситуацию, когда вы показываете контейнеру «вот эта папка на моём ноутбуке — твоя папка внутри контейнера». Очень удобно, но и очень привязывает запуск к конкретной машине и её путям.
Когда bind mount — отличный выбор
Bind mount почти идеален, когда вам нужен результат, который должен быть виден с host-машины как файл, без дополнительных команд и «где Docker спрятал мои данные». В курсе это прямо наш кейс: экспорт каталога в CSV. Вы хотите после запроса экспорта открыть папку data/exports/ в проекте и увидеть файл, а не идти в археологическую экспедицию по внутренним директориям Docker.
Bind mount также хорош для «host-managed» входных файлов: например, когда вы хотите подложить в контейнер конфиг или шаблон и сделать это read-only, чтобы контейнер не мог случайно его перезаписать.
Явный синтаксис: --mount type=bind
Docker исторически поддерживает и короткую форму -v, и более явную --mount. Для новичка (и для команды) явная форма обычно понятнее, потому что в ней словами написано, что происходит: type=bind, source=..., target=....
Пример запуска нашего учебного сервиса с bind mount для экспортов. Предположим, образ уже собран, а каталог data/exports существует:
docker run --rm --name catalog-service \
-p 8080:8080 \
-e APP_EXPORT_DIR=/app/exports \
--mount type=bind,source="$(pwd)/data/exports",target=/app/exports \
docker-java-catalog-service:latest
Если вы запускаете команду не из bash/zsh, подставьте тот же абсолютный путь к data/exports в синтаксисе своей оболочки.
Здесь случилось три важных вещи, и их стоит проговаривать вслух, чтобы мозг привыкал.
Во-первых, мы сказали приложению: «экспортируй в /app/exports» через APP_EXPORT_DIR. Во-вторых, мы сказали Docker: «а /app/exports — это не writable layer, а папка проекта ./data/exports». В-третьих, изнутри контейнера всё выглядит как обычная директория, но файлы сразу появляются у вас на диске.
Короткая форма -v: почему её используют и где ошибаются
Короткая форма тоже будет встречаться почти везде: в статьях, на StackOverflow, в подсказках коллег, которые «так делают с 2017 года и всё работает». В ней есть плюс: она короткая. Но и минус: в ней легко перепутать местами source/target или не заметить, что вы вообще сделали bind mount.
Эквивалентный запуск через -v выглядит так:
docker run --rm --name catalog-service \
-p 8080:8080 \
-e APP_EXPORT_DIR=/app/exports \
-v "$(pwd)/data/exports:/app/exports" \
docker-java-catalog-service:latest
На этом этапе курса можно запомнить практическое правило: если вы пишете команды «для себя» — можно и -v, если вы пишете команды «для команды и будущего себя через три месяца» — чаще выигрывает --mount.
3. Named volume: данные живут дольше контейнера
Named volume — это вариант, в котором Docker сам управляет местом хранения данных. Вы не выбираете конкретный host-путь (в этом и смысл), вы просто даёте volume имя, а дальше говорите: «подключи этот volume внутрь контейнера вот сюда». Получается что-то вроде «персонального шкафчика» у Docker: вы не обязаны знать точный адрес, но вы знаете имя и можете использовать его снова.
Когда named volume — правильный выбор
Named volume хорош, когда вам важно, чтобы данные переживали пересоздание контейнера, но вам не нужен прямой доступ к ним как к обычной папке проекта. Это часто относится к «состоянию сервиса», которое должно сохраняться между запусками: например, внутренние файлы состояния, кэш, локальные данные, которые не являются «артефактом для человека».
В учебном проекте мы не хотим превращать сервис в файловую базу данных, но важно понять сам принцип: named volume — про persistence без привязки к вашему ./some-folder.
Жизненный цикл named volume: он не исчезает сам по себе
С точки зрения новичка, самый неожиданный момент такой: вы запускаете контейнер с --rm, он удаляется, а данные… остаются. И это не баг: volume живёт отдельно от контейнера.
Начнём с создания volume:
# Создаём volume (Docker сам выберет, где физически его хранить)
docker volume create catalog_exports
# Смотрим список volume на машине
docker volume ls
# Смотрим детали конкретного volume (в т.ч. где он лежит)
docker volume inspect catalog_exports
Тут мы буквально сказали Docker: «создай мне managed-объект для данных». Теперь можно подключить его как target внутри контейнера:
docker run --rm --name catalog-service \
-p 8080:8080 \
-e APP_EXPORT_DIR=/app/exports \
--mount type=volume,source=catalog_exports,target=/app/exports \
docker-java-catalog-service:latest
Сервис пишет в /app/exports, а Docker складывает эти файлы в volume catalog_exports. Контейнер вы удалили — volume никуда не делся. В следующий запуск вы подключаете тот же volume и видите старые данные.
Это, кстати, хороший тест на понимание предыдущей лекции: bind mount хранит данные на вашем диске, named volume хранит данные «у Docker», а writable layer хранит данные «у контейнера».
Просмотр содержимого named volume
Named volume специально создан так, чтобы вы не опирались на «где он физически лежит». Но иногда нужно посмотреть, что там находится (например, для диагностики). Частый трюк — запустить временный контейнер и примонтировать volume внутрь него, чтобы сделать ls:
# Временный контейнер "проводник": подключаем volume в /data и смотрим содержимое
docker run --rm \
--mount type=volume,source=catalog_exports,target=/data \
alpine:3.21 \
sh -c "ls -la /data"
Это выглядит слегка как шаманство, но на самом деле это просто: «дай мне контейнер-проводник, который заглянет в volume». Для начинающего разработчика полезно увидеть, что volume — это не магия, а просто хранилище, доступное через mount.
4. Правило выбора: bind mount vs volume
Когда вы впервые слышите «есть два варианта», мозг автоматически хочет спросить: «а какой правильный?». В Docker, как и в жизни, правильный ответ: «зависит». Но мы не будем оставлять вас с философией — дадим простое правило, которое реально работает для большинства Java/Spring кейсов.
Самое короткое рабочее правило
Если вам нужен результат, видимый на host-машине как файл (вы хотите открыть папку проекта и увидеть экспорт) — в первую очередь думайте о bind mount. Если вам нужно состояние, которое переживает пересоздание контейнера, но не обязано быть частью структуры проекта и не должно требовать “правильного пути на диске” — в первую очередь думайте о named volume.
Чтобы закрепить, вот таблица сравнения (она заменяет нам длинный список и помогает держать картину целиком):
| Критерий | Bind mount | Named volume |
|---|---|---|
| Что является source | конкретный путь на host | объект Docker с именем |
| Видимость файлов на host | прямая (это та же папка) | непрямая (нужна диагностика/временный контейнер) |
| Портируемость запуска на другую машину | хуже (пути отличаются) | лучше (нужны только имя volume и Docker) |
| Типичный dev-юзкейс | экспорт, локальные артефакты, конфиги, шаблоны | устойчивое состояние без привязки к папке проекта |
| Что будет, если удалить контейнер | файлы остаются на host | volume остаётся (пока вы его явно не удалите) |
Важный «поворот мышления»: код не должен выбирать bind/volume
Есть опасная идея, которая иногда появляется у начинающих: «раз есть два типа mount, давайте в коде сделаем if (bind) ... else ...». Это почти всегда ложный путь. Код должен работать с директорией, а не с типом mount. Тип mount — это деталь окружения. Её выбирают командой docker run (или позже — декларативной конфигурацией окружения), но не Java-классами.
Поэтому вместо «bind vs volume» в коде у вас должна быть простая абстракция: “export directory = строка из конфигурации”. Всё.
5. Mount перекрывает каталог в контейнере
Одна из самых неприятных и при этом абсолютно логичных особенностей mount’ов: когда вы монтируете что-то в target, вы перекрываете содержимое этого каталога, которое было внутри образа. Файлы не удаляются из image, но становятся невидимыми для запущенного контейнера, пока mount активен.
Простой мысленный эксперимент
Представьте, что в вашем образе (на этапе сборки) лежал файл /app/exports/README.txt. Вы запускаете контейнер и монтируете туда bind mount из пустой папки host. Внутри контейнера вы делаете ls /app/exports и видите… пустоту. «Docker съел мои файлы!» — нет, Docker просто честно показал вам содержимое mount’а, которое перекрыло исходную директорию.
Это особенно важно для конфигов: если вы монтируете внешний каталог в /app/config, вы можете случайно спрятать те конфиги, которые были внутри image. Поэтому выбирайте target-path осознанно и не монтируйте «наугад».
Диагностическая привычка
Когда «файлы пропали», полезно задавать себе два вопроса. Во-первых, “куда именно я смонтировал?” (target). Во-вторых, “что именно я смонтировал?” (source). Обычно ответ быстро объясняет, почему в каталоге не то содержимое, которое вы ожидали увидеть.
6. Read-only mount: смотреть, не трогать
Docker mount — это не только «куда писать». Очень часто mount нужен как способ дать контейнеру входные данные: конфиг, шаблон, сертификат, какой-нибудь export-template.txt. И вот тут включается здоровая паранойя: если файл управляется host-машиной, контейнеру часто не обязательно иметь право записи.
Read-only bind mount как «защитный колпачок»
Read-only mount — это банально включённая защита от случайностей. Не от хакеров (мы не в security-курсе), а от ваших же экспериментов и ошибок. Вы можете смонтировать каталог или файл как read-only и быть уверенным, что сервис его не перезапишет, даже если в код случайно попал Files.writeString(...) не туда.
Пример: монтируем каталог ./config внутрь контейнера и делаем его read-only:
docker run --rm --name catalog-service \
-p 8080:8080 \
--mount type=bind,source="$(pwd)/config",target=/app/config,readonly \
docker-java-catalog-service:latest
Мини-пример Java-кода: читаем файл, не пытаясь в него писать
import java.nio.file.Files;
import java.nio.file.Path;
class TemplateReader {
String readTemplate(String path) throws Exception {
// Файл читается как внешний ресурс (например, read-only bind mount)
return Files.readString(Path.of(path));
}
}
Идея простая: если файл пришёл как read-only mount, вы можете его читать сколько угодно, но запись туда будет ошибкой — и это хорошо, потому что ошибка будет быстрой и очевидной.
7. Сервису нужен путь, а не тип mount
Здесь важно не спутать две разные ответственности. Приложение знает только export directory — например, /app/exports. Bind mount это, named volume или вообще writable layer, решает окружение запуска.
Поэтому в коде не должно появляться ветвление вида if (bind) ... else .... Сервис получает одну директорию из конфигурации и пишет в неё через Path. Тогда один и тот же image можно запускать с bind mount, с volume и без mount’а для временных файлов — а бизнес-код от этого не расползается.
Эта же мысль полезна и для сквозного проекта: export directory — часть runtime-конфига, а mount — деталь контейнерного запуска. Пока это различение держится, файловый сценарий остаётся управляемым.
8. Мини-диагностика mount’ов
Даже если всё понимаете теоретически, практика любит подкинуть сюрприз: «я смонтировал — но файла нет», «я удалил контейнер — а данные остались», «почему каталог не writable?». Поэтому полезно иметь пару «дежурных» команд, которые дают правду, а не ощущения.
docker inspect: показываем mount’ы контейнера как факт
Самый прямой способ увидеть, что примонтировано:
docker inspect catalog-service --format '{{json .Mounts}}' | jq .
Если jq не установлен — можно и без него, просто будет длиннее. В выводе вы увидите Type (bind или volume), Source и Destination (это и есть наш target).
docker exec + ls: проверяем «изнутри»
Иногда удобнее быстро заглянуть в контейнер и посмотреть, что внутри каталога:
docker exec -it catalog-service sh
ls -la /app/exports
Это особенно полезно, когда вы подозреваете, что mount перекрыл каталог, или когда права на запись не те, что вы ожидали. Да, это «ручной» путь, но для обучения он отличный: вы буквально видите файловую систему глазами приложения.
9. Типичные ошибки при выборе bind mount и named volume
Ошибка №1: выбирать bind mount и named volume по принципу «какая команда короче».
Новичок часто смотрит на -v и --mount и выбирает «что меньше печатать». В результате выбор делается по синтаксису, а не по задаче. Правильный критерий не в количестве символов, а в том, нужен ли вам host-visible результат (тогда bind mount) или устойчивое состояние без привязки к конкретной папке (тогда named volume).
Ошибка №2: пытаться «объяснить Docker» бизнес-коду.
Иногда появляется желание хранить в коде флаг useBindMount=true и делать ветвления. Это превращает приложение в набор специальных режимов под окружение и ломает главный принцип курса: один image, разные runtime-конфигурации. В коде должен быть только путь export directory, а тип mount — исключительно деталь запуска контейнера.
Ошибка №3: не понимать, что mount перекрывает каталог, и пугаться «пропавших» файлов.
Когда вы монтируете внешнюю папку в непустой каталог контейнера, содержимое image становится невидимым. Новичок воспринимает это как «Docker удалил файлы», а на самом деле они просто перекрыты. Это особенно болезненно, если вы смонтировали конфиг «поверх» встроенных файлов и вдруг приложение перестало видеть default-настройки.
Ошибка №4: ожидать, что --rm удалит named volume.
--rm удаляет контейнер, но не удаляет named volume — и это нормальная, полезная семантика. Если вы не держите это в голове, вы получаете «призрачные» данные, которые живут дольше контейнера и влияют на поведение приложения. В итоге кажется, что сервис «магически помнит прошлое», хотя это просто volume, который вы забыли удалить.
Ошибка №5: давать контейнеру write-доступ туда, где он не нужен.
Bind mount по умолчанию даёт контейнеру возможность писать в host-папку. Иногда это нужно (экспорт), но иногда — нет (шаблон, конфиг). Без read-only mount вы легко получаете ситуацию «контейнер перезаписал файл на моей машине». Это не катастрофа мирового масштаба, но раздражает и убивает доверие к окружению. Лучше заранее отделять сценарии «читать» и «писать» и использовать readonly, когда запись не требуется.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ