Bind mount і named volume

Docker for Spring
Рівень 13 , Лекція 1
Відкрита

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["Хост (ваш ноутбук)"]
        SRC_BIND["джерело (bind): ./data/exports"]
        SRC_VOL["джерело (volume): catalog_exports"]
    end

    subgraph CTR["Контейнер"]
        TGT["призначення: /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 майже ідеальний, коли вам потрібен результат, який має бути видимий на хості як файл, без додаткових команд і гри в «де 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 сам керує місцем зберігання даних. Ви не обираєте конкретний шлях на хості (у цьому й сенс), ви просто даєте volume імʼя, а далі кажете: «підʼєднай цей volume всередину контейнера ось сюди». Виходить щось на кшталт персональної шафки в Docker: вам не обовʼязково знати точну адресу, зате ви знаєте імʼя і можете використовувати його знову.

Коли named volume — правильний вибір

Named volume добрий, коли вам важливо, щоб дані переживали повторне створення контейнера, але вам не потрібен прямий доступ до них як до звичайної папки проєкту. Це часто стосується стану сервісу, який має зберігатися між запусками: наприклад, внутрішніх файлів стану, кешу, локальних даних, що не є артефактом для людини.

У навчальному проєкті ми не хочемо перетворювати сервіс на файлову базу даних, але важливо зрозуміти сам принцип: named volume — про стійке зберігання без привʼязки до вашої ./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: «створи мені керований об’єкт для даних». Тепер можна підʼєднати його як 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-кейсів.

Найкоротше робоче правило

Якщо вам потрібен результат, видимий на хості як файл — ви хочете відкрити папку проєкту й побачити експорт — насамперед думайте про bind mount. Якщо вам потрібен стан, який переживає повторне створення контейнера, але не зобовʼязаний бути частиною структури проєкту й не повинен вимагати «правильного» шляху на диску, — насамперед думайте про named volume.

Щоб закріпити, ось таблиця порівняння: вона замінює довгий список і допомагає бачити картину цілком.

Критерій Bind mount Named volume
Що є source конкретний шлях на хості обʼєкт Docker з імʼям
Видимість файлів на хості пряма (це та сама папка) непряма (потрібна діагностика або тимчасовий контейнер)
Портативність запуску на іншу машину гірша (шляхи відрізняються) краща (потрібні лише імʼя volume і Docker)
Типовий сценарій у розробці експорт, локальні артефакти, конфіги, шаблони стійкий стан без привʼязки до папки проєкту
Що буде після видалення контейнера файли залишаються на хості 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 із порожньої папки на хості. Усередині контейнера ви робите ls /app/exports і бачите… порожнечу. «Docker зʼїв мої файли!» — ні, Docker просто чесно показав вам вміст цього mount, який перекрив початковий каталог.

Це особливо важливо для конфігів: якщо ви монтуєте зовнішній каталог у /app/config, ви можете випадково сховати ті конфіги, які були всередині image. Тому вибирайте target path усвідомлено і не монтуйте навмання.

Діагностична звичка

Коли «файли зникли», корисно ставити собі два запитання. По-перше, «куди саме я змонтував?» (target). По-друге, «що саме я змонтував?» (source). Зазвичай відповідь швидко пояснює, чому в каталозі не той вміст, який ви очікували побачити.

6. Read-only mount: дивитися, не чіпати

Docker mount — це не лише «куди писати». Дуже часто mount потрібен як спосіб дати контейнеру вхідні дані: конфіг, шаблон, сертифікат, якийсь export-template.txt. І тут вмикається здорова обережність: якщо файл керується хост-машиною, контейнеру часто не обовʼязково мати право запису.

Read-only bind mount як «захисний ковпачок»

Read-only mount — це простий захист від випадкових змін. Не від хакерів — ми не на курсі з безпеки — а від ваших експериментів і помилок. Ви можете змонтувати каталог або файл як 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ів

Навіть якщо ви все розумієте теоретично, практика любить підкидати сюрпризи: «я змонтував — але файла немає», «я видалив контейнер — а дані залишилися», «чому каталог недоступний для запису?». Тому корисно мати кілька буденних команд, які дають факти, а не відчуття.

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 та обирає те, що менше друкувати. У результаті вибір робиться за синтаксисом, а не за завданням. Правильний критерій не в кількості символів, а в тому, чи потрібен вам результат, видимий на хості, або стійкий стан без привʼязки до конкретної папки.

Помилка № 2: намагатися «пояснити Docker» бізнес-коду.
Іноді зʼявляється бажання зберігати в коді прапорець useBindMount=true і робити розгалуження. Це перетворює застосунок на набір спеціальних режимів під оточення і ламає головний принцип курсу: один image, різні runtime-конфігурації. У коді має бути лише шлях export directory, а тип mount — виключно деталь запуску контейнера.

Помилка № 3: не розуміти, що mount перекриває каталог, і лякатися «зниклих» файлів.
Коли ви монтуєте зовнішню папку в непорожній каталог контейнера, вміст image стає невидимим. Новачок сприймає це як «Docker видалив файли», а насправді вони просто перекриті. Це особливо боляче, якщо ви змонтували конфіг поверх вбудованих файлів і раптом застосунок перестав бачити налаштування за замовчуванням.

Помилка № 4: очікувати, що --rm видалить named volume.
--rm видаляє контейнер, але не видаляє named volume — і це нормальна, корисна семантика. Якщо ви не тримаєте це в голові, ви отримуєте «примарні» дані, які живуть довше за контейнер і впливають на поведінку застосунку. У підсумку здається, що сервіс «магічно памʼятає минуле», хоча це просто volume, який ви забули видалити.

Помилка № 5: давати контейнеру доступ на запис туди, де він не потрібен.
Bind mount за замовчуванням дає контейнеру можливість писати в папку на хості. Іноді це потрібно (експорт), але іноді — ні (шаблон, конфіг). Без read-only mount ви легко отримаєте ситуацію «контейнер перезаписав файл на моїй машині». Це не катастрофа світового масштабу, але дратує і підриває довіру до оточення. Краще заздалегідь розділяти сценарії «читати» і «писати» та використовувати readonly, коли запис не потрібен.

1
Задача
Docker for Spring, 13 рівень, 1 лекція
Недоступна
Експорт у host-каталог через bind mount
Експорт у host-каталог через bind mount
1
Задача
Docker for Spring, 13 рівень, 1 лекція
Недоступна
Збереження стану через іменований том
Збереження стану через іменований том
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ