1. Важливість файлів у Docker
Якщо ви досі писали Java-застосунки «на своєму ноутбуці», файли здавалися чимось надійним і простим: записали CSV у папку — і він там лежить. У контейнерному світі це відчуття швидко підводить, бо в контейнера є своя файлова реальність. Вона дуже чесна: контейнер зберігає зміни не тому, що він «добрий», а тому, що ви ще не знищили саме цей контейнер.
Уявіть типовий сценарій нашого наскрізного проєкту Container-Ready Catalog Service: сервіс експортує каталог у CSV. Ви звертаєтеся до кінцевої точки експорту, бачите в логах «Export completed», навіть можете зайти всередину контейнера й побачити файл. Усе виглядає ідеально… рівно до моменту, коли ви виконуєте docker rm і раптом розумієте, що «ідеальний» файл був не результатом, а тимчасовою нотаткою в записнику контейнера.
Ця лекція потрібна, щоб у вас склалася правильна mental model: куди насправді пише застосунок, що таке writable layer, чому він прив’язаний до конкретного контейнера і чому контейнер — це не «маленька віртуалка з диском», навіть якщо дуже хочеться так думати. А в наступній лекції ми навчимося робити файл «справжнім» — виносити стан назовні через mount.
2. Read-only шари та writable layer
Коли ми говорили про Docker layers і кеш збирання, ми розглядали шари образу як частину build-time історії. Але шари важливі й у runtime, бо файлова система контейнера майже завжди будується за моделлю «багато read-only шарів + один шар запису». Саме цей єдиний шар запису й називається writable layer.
Спрощено це виглядає так: є Docker image (набір read-only шарів), і коли ви запускаєте контейнер, Docker накриває образ зверху ще одним шаром, у який потрапляють усі зміни: створені файли, змінені файли, видалені файли. Це як прозора плівка поверх надрукованого тексту: оригінал (image) незмінний, а ваші правки живуть на плівці (writable layer).
Невелика схема (не про низькорівневі деталі storage driver, а про інтуїцію):
flowchart TB
%% Образ складається з read-only шарів, а запис іде в окремий шар контейнера
subgraph IMG["Образ Docker (шари лише для читання)"]
L1["Базовий шар: ОС + середовище виконання JVM"]
L2["Шар застосунку: наш jar і ресурси"]
L3["Стандартні налаштування: те, що всередині образу"]
end
%% Цей шар створюється під час запуску контейнера і живе рівно стільки ж, скільки контейнер
W["Writable layer (прив’язаний до конкретного контейнера)"]
IMG --> W
Ключова думка: writable layer належить контейнеру, а не образу. З цього автоматично випливають два важливі наслідки.
Перше: якщо ви запустили два контейнери з одного й того самого image, у кожного буде свій writable layer. І якщо один контейнер створив файл /app/exports/catalog.csv, другий контейнер цього файлу не побачить, бо він живе в «особистому зошиті» першого контейнера.
Друге: коли контейнер зникає, зникає й його writable layer. І тут починається найчастіша пастка новачка: «я ж зробив docker restart, файл залишився — значить, усе надійно». Ні, це означає лише те, що ви ще не видалили контейнер. Видалення контейнера — це той момент, коли Docker каже: «Ну все, зошит викидаємо».
3. Запис файлу всередині контейнера
Коли Java-код пише файл через Files.writeString(...), він узагалі не знає і не зобов’язаний знати, що працює в контейнері. Для нього це просто файлова система. Але ми, як інженери, маємо розуміти: якщо цей шлях не винесено назовні, запис потрапить у writable layer контейнера. Поки контейнер живий — файл існує. Далі починається лотерея життєвого циклу.
Мініприклад коду, який дуже легко написати в сервісі експорту (і приблизно так він виглядатиме в навчальному проєкті, хай і з обв’язкою):
import java.nio.file.Files;
import java.nio.file.Path;
// Куди пишемо: всередині контейнера це потрапить у writable layer, якщо каталог не примонтовано ззовні
Path exportFile = Path.of("/tmp/exports", "catalog.csv");
// Створюємо директорію, якщо її ще немає (інакше запис упаде з помилкою)
Files.createDirectories(exportFile.getParent());
// Зручніше читати CSV через Text block, ніж через \n в одному рядку
String csv = """
id,sku,title
1,SKU-1,Book
""";
// Пишемо вміст у файл
Files.writeString(exportFile, csv);
Цей код працює майже завжди, зокрема і всередині контейнера. Саме тому він небезпечний: створюється хибне відчуття, ніби файлову частину застосунку вже вирішено. Насправді ви просто записали файл туди, де він переживе stop/start контейнера, але не переживе його знищення. А в контейнерному світі знищення контейнера — нормальна операція. Ви робитимете її самі під час clean-up, її робитиме CI, її робитиме оркестрація, а Docker Desktop може спровокувати її опосередковано, якщо ви пересобрали й створили контейнер заново.
Тому варто заздалегідь поставити собі запитання: цей файл — тимчасовий внутрішній артефакт процесу (наприклад, проміжний фрагмент) чи зовнішній результат, який має бути доступний людині, тестам або іншим інструментам? Експорт каталогу в CSV майже завжди належить до другої категорії. Отже, шлях до нього не можна залишати «просто рядком у коді».
4. Життєвий цикл і доля файлів
У новачка є дуже зрозуміла, але підступна логіка: «контейнер перезапускався — файл не зник — значить, файл зберігається надійно». І ось тут варто акуратно розділити кілька схожих, але принципово різних подій: зупинку, запуск, перезапуск і видалення контейнера. Docker не намагається вас заплутати — він чесно поводиться як процес і його writable layer.
Давайте зведемо долю файлів у таблицю. Це не «вся правда життя», а практичний орієнтир для щоденної розробки:
| Що ви зробили | Приклад команди | Контейнер той самий? | Writable layer той самий? | Файл усередині контейнера залишиться? |
|---|---|---|---|---|
| Зупинили контейнер | |
так | так | так |
| Запустили той самий контейнер | |
так | так | так |
| Перезапустили контейнер | |
так | так | так |
| Видалили контейнер | |
ні | ні | ні |
| Видалили й створили новий (із того самого image) | |
ні | ні | ні |
І ось де у початківців виникає «магічна» плутанина: вони тестують лише перші три рядки. Це справді виглядає як персистентність. Але це не та персистентність, яка вам потрібна для результату експорту.
Щоб відчути це на практиці, достатньо один раз зробити мінідемо. Припустімо, контейнер називається catalog:
# Дивимося вміст каталогу всередині контейнера
docker exec -it catalog sh -lc "ls -la /tmp/exports"
# total 8
# -rw-r--r-- 1 root root 42 Mar 21 10:15 catalog.csv
Тепер зупинимо й запустимо знову:
# Зупинили: контейнер залишається тим самим, writable layer залишається тим самим
docker stop catalog
# Запустили знову: це той самий контейнер
docker start catalog
# Перевіряємо: файл і далі на місці, бо writable layer не знищувався
docker exec -it catalog sh -lc "ls -la /tmp/exports"
# catalog.csv на місці
І ось тут мозок каже: «Чудово, отже Docker зберігає файли». А тепер робимо дію, яка в контейнерному мисленні взагалі не вважається «екстраординарною»:
# Видаляємо контейнер: разом із ним видалиться і його writable layer
docker rm -f catalog
# Створюємо новий контейнер з того самого image і перевіряємо директорію знову
docker run --name catalog ... ваш-образ ...
docker exec -it catalog sh -lc "ls -la /tmp/exports"
# ls: /tmp/exports: No such file or directory
Файл зник. І це нормальна поведінка. Бо попередній контейнер знищено, а разом із ним — writable layer.
Дуже важливо: тут ми не говоримо «контейнери погані, бо не зберігають файли». Ми говоримо інше: контейнери не повинні мовчки перетворюватися на сховище стану. Сховище стану — це окреме інженерне рішення. І в Docker воно реалізується через mounts (bind mounts, named volumes), про які буде наступна лекція.
5. Writable layer: тимчасово, не назавжди
Поки що може здаватися, ніби writable layer — суцільне зло. Але це не так. Writable layer — це абсолютно нормальна частина моделі: без нього контейнер узагалі не міг би нічого записувати (ні тимчасові файли, ні кеш, ні навіть деякі runtime-артефакти). Проблема починається тоді, коли ви робите writable layer невидимим сховищем результату, який важливий користувачеві або процесу розробки.
Уявіть, що ви експортуєте каталог для перевірки в Postman або через .http запит. Вам потрібно відкрити CSV, подивитися на нього очима, надіслати колезі, прикріпити до задачі, порівняти з еталоном і хоча б просто побачити, що він фізично існує на вашій машині. Якщо файл лежить лише всередині контейнера, ви перетворюєте «подивитися експорт» на квест: спочатку docker exec, потім cat, потім копіювати назовні (і, звісно, ще й забути, куди саме). Це не «зручна розробка», це «чому все так складно».
Є й більш практична сторона: writable layer зростає в міру запису файлів. Якщо сервіс пише багато, контейнер починає займати більше місця, а ви потім дивуєтеся, чому на диску зникає вільний простір. Це особливо весело, коли ви згенерували десятки експортів «на спробу», а вони лежать у кількох контейнерах, про які ви вже забули.
Тому правило доволі просте й дуже корисне: у writable layer можна писати те, що можна втратити. Усе, що не можна втратити (або принаймні незручно втрачати), має бути винесене в окреме рішення для зберігання — навіть якщо це просто каталог на host-машині, примонтований усередину контейнера.
6. Експорт каталогу як runtime-контракт
Наш наскрізний проєкт Container-Ready Catalog Service спеціально обраний так, щоб у нього був природний файловий сценарій: експорт каталогу в CSV і запис ExportJob. Але в навчальному проєкті важливо не лише «записати файл», а й зробити цей сценарій контрактом, тобто чимось передбачуваним і керованим.
Для цього ми вводимо зовнішній параметр APP_EXPORT_DIR (він уже закладений у вимоги проєкту). І логіка тут повністю така сама, як і з конфігурацією: один і той самий image має працювати по-різному в різних середовищах. Сьогодні середовище — це ще й файлова система. Отже, шлях до export directory має задаватися ззовні, а не жити як захардкожений рядок.
На рівні чистої Java це може виглядати так:
import java.nio.file.Path;
class ExportLocation {
private final Path directory;
ExportLocation(String configuredDir) {
// configuredDir надходить із конфігурації (наприклад, env var APP_EXPORT_DIR)
this.directory = Path.of(configuredDir);
}
Path resolveFile(String fileName) {
// Централізуємо логіку формування шляху до файлу експорту
return directory.resolve(fileName);
}
}
А місце, де ви берете configuredDir, уже може бути Spring Boot-конфігурацією (env var → property → бин). Поки не заглиблюючись у Spring-анотації, нам важливий сенс: сервіс експорту не повинен «знати», що таке Docker і що таке mount. Він повинен знати лише шлях, куди він має право писати.
Навіть якщо сьогодні ви запускаєте контейнер «один раз і вручну», завтра ви захочете перенести проєкт на іншу машину або просто створити контейнер заново для чистоти експерименту. І тоді з’ясується, що «експорт лежав усередині контейнера». Краще, щоб це з’ясувалося зараз, на лекції, ніж через три дні, коли ви одночасно налагоджуватимете конфіг, логи та healthcheck.
7. Демо та підготовка до mount
Файл «усередині» і файл «ззовні»
Зараз ми зробимо дуже коротку демонстрацію, не перетворюючи лекцію на список Docker-прапорців. Мета — побачити на власні очі, що «файл усередині контейнера» живе за правилами writable layer. Нехай у нас уже зібрано image нашого сервісу, і він уміє робити експорт у певну директорію.
Запустимо контейнер і виконаємо експорт (уявімо, що у нас є endpoint POST /api/catalog/exports, який створює CSV). Після цього зайдемо всередину й подивимося на директорію:
docker exec -it catalog sh -lc "ls -la /app/exports"
# -rw-r--r-- 1 app app 128 Mar 21 10:20 catalog-2026-03-21T10-20-00.csv
Тепер повторимо ключовий психологічний трюк, який ламає неправильну модель: замість «перезапуску» зробимо «видалення»:
docker rm -f catalog
Запускаємо контейнер знову (із того самого образу), і ще раз перевіряємо:
docker run --name catalog -p 8080:8080 your-image:tag
docker exec -it catalog sh -lc "ls -la /app/exports"
# total 0
# (або директорії взагалі немає, залежить від того, чи створюєте ви її під час запуску)
Ось тут і відбувається дорослішання контейнерного мислення. Сервіс «той самий», образ «той самий», але стан зник, бо контейнер новий. І це не помилка Docker. Це ми спробували мовчки зробити контейнер сховищем.
Корисно звернути увагу ще на один момент: іноді розробник сам собі ускладнює життя, бо в голові тримає не «контейнери», а «процеси». У звичайній JVM на сервері ви запускали один процес, і він довго жив. У Docker розробник набагато частіше пересоздає контейнери: змінює конфіг, змінює змінні середовища, перевіряє різні варіанти, очищує середовище. Якщо в цей момент «випадково» втрачається важливий файл — значить, файловий стан у вас не спроєктовано.
Відокремлюємо «куди писати» від «що писати»
Зараз хочеться одразу перейти до питання «як зробити, щоб файл не зникав». І це правильне бажання, але методично важливо зробити крок назад і зрозуміти: стійкість файлу починається не з Docker-прапорця, а з того, що ваш код узагалі вміє писати туди, куди йому сказали.
Якщо сервіс експорту всередині себе вирішує «я завжди пишу в /tmp/exports», ви можете скільки завгодно намагатися монтувати директорії — буде боляче. Набагато правильніше, коли сервіс отримує export directory ззовні й працює з ним як із залежністю. Тоді один і той самий код однаково працює і локально, і в контейнері, і з mount, і без нього.
Найпростіший начерк сервісу (саме начерк, без повного домену й без зайвої архітектурної важкості):
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
class CatalogExportService {
private final Path exportDir;
CatalogExportService(Path exportDir) {
this.exportDir = exportDir;
}
Path export(String fileName, String csv) throws IOException {
Files.createDirectories(exportDir);
Path file = exportDir.resolve(fileName);
return Files.writeString(file, csv); // поверне шлях до створеного файлу
}
}
Тут немає нічого «докерного». І це добре. Ми спочатку робимо файлову поведінку керованою, а вже потім (у наступній лекції) підключаємо зовнішній світ через mount і обираємо стратегію зберігання. Тоді Docker стає інструментом, а не лотереєю.
8. Типові помилки під час роботи з writable layer
Майже в усіх новачків перша помилка звучить однаково: «Я створив файл у контейнері, він там лежить — отже, усе зроблено правильно». Це пастка спостереження: доки ви тестуєте лише stop/start/restart, writable layer поводиться як «нормальний диск», і мозок заспокоюється. Проблема проявляється пізніше — під час docker rm або створення контейнера заново.
Помилка №1: плутати файлову систему image і файлову систему контейнера.
Іноді здається, що якщо файл зʼявився «у контейнері», то він наче став частиною образу або «залишиться під час наступного запуску». Але образ — read-only, а файл зʼявився у writable layer конкретного контейнера. Якщо контейнер зник, файл зникне разом із ним, і це буде не баг, а закономірність.
Помилка №2: сприймати контейнер як маленьку віртуальну машину з диском.
У VM «диск» — частина сутності. У контейнера writable layer — тимчасовий шар поверх образу. У нормальній інженерній практиці контейнери легко знищуються і створюються заново. Якщо ви проєктуєте важливі дані так, ніби контейнер зобов’язаний жити вічно, ви самі собі ставите підніжку.
Помилка №3: писати важливі артефакти «куди завгодно»: /tmp, випадковий /app/out, домашню папку користувача в контейнері.
Проблема навіть не в конкретній директорії, а в тому, що її обрано як «випадково зручну». Важливі файли повинні мати явну роль (наприклад, export directory), а шлях до них має бути частиною runtime-контракту, тобто задаватися конфігурацією.
Помилка №4: не ставити запитання «хто споживач цього файлу».
Якщо споживач — лише ваш процес «тут і зараз», writable layer може бути достатнім. Але якщо файл потрібен людині на host-машині, тестам або як результат бізнес-операції (експорт), тримати його «всередині контейнера» означає ускладнити доступ і збільшити ризик втрати під час створення контейнера заново.
Помилка №5: виправляти втрату файлу копіюванням і ручним docker exec замість проєктування.
Іноді після першої втрати експорту виникає спокуса: «Гаразд, я просто копіюватиму файл назовні вручну». Так з’являються хаос і ручний процес, який ніхто не повторюватиме. Набагато краще один раз чесно визнати: якщо файл має жити зовні, йому потрібне зовнішнє зберігання через mount. Але це вже тема наступної лекції — до неї ми підійшли правильно й без містики.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ