Тома и персистентность
Writable-слой контейнера умирает вместе с контейнером, поэтому персистентным данным нужен mount: Docker-управляемый named volume для баз, bind mount для dev-исходников, tmpfs для секретов в памяти — у каждого свой жизненный цикл и ловушки.
docker compose down && docker compose up на staging-машине стёр базу. Две недели засеянных тестовых данных — нет; ни rm, ни DROP, ни человеческой ошибки в привычном смысле. Postgres всё это время писал в собственный writable-слой контейнера, потому что никто не объявил volume. Удаление контейнера удалило слой, а слой и был данными. Фикс — одна строка в compose-файле. Урок — что storage контейнера эфемерен по умолчанию, и персистентность ты включаешь намеренно.
Writable-слой — это черновик, а не хранилище
Прежде чем добавить базу данных в любой Docker-сетап, спроси себя: что станет с данными после рестарта контейнера? Ответ на этот вопрос определит, найдёшь ли ты в понедельник пустую базу или живые данные.
Запущенный контейнер — это его read-only слои образа плюс один тонкий writable-слой (writable layer, записываемый слой) сверху, сшитые union-файловой системой (объединяющей ФС). Каждый файл, который твой процесс создаёт или меняет в рантайме — каталог данных Postgres, загруженная аватарка, лог-файл — попадает в этот writable-слой. Он ощущается как обычный диск, и в этом-то и ловушка.
Этот слой привязан к жизненному циклу контейнера. Удали контейнер (docker rm, compose down, передеплой, который пересоздаёт его) — и writable-слой удаляется вместе с ним. Данные не «где-то на хосте, откуда их можно выкопать» — они жили внутри собственного diff’а контейнера и исчезли. Хуже того, пока контейнер жив, всё, что ты туда пишешь, раздувает размер контейнера и идёт через union-абстракцию storage-драйвера, которая медленнее записи прямо на хост. Writable-слой — для эфемерного черновика: временных файлов, рантайм-состояния процесса, — но никогда для того, что было бы жаль потерять.
Чтобы персистить данные, ты монтируешь storage, который живёт вне writable-слоя. Docker даёт три типа mount’ов, и выбор правильного — это и есть весь навык.
Named volumes: дефолт для данных, которые хранишь
Named volume — это storage, который Docker создаёт и сам им управляет; он живёт на хосте под /var/lib/docker/volumes/<name>/_data. Ты никогда не трогаешь этот путь напрямую — ты обращаешься к volume по имени, а Docker делает остальное. Это рекомендуемый механизм для всего stateful: файлов баз данных, данных очередей, загруженного пользователями контента.
# Создать по требованию и смонтировать; -v <name>:<путь-в-контейнере>
docker run -d --name db \
-v pgdata:/var/lib/postgresql/data \
postgres:16
# Явная, самодокументирующаяся форма
docker run -d --name db \
--mount type=volume,src=pgdata,dst=/var/lib/postgresql/data \
postgres:16# docker-compose.yml — одна строка, которая спасает staging-машину
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Named volumes выигрывают для stateful-данных по трём пунктам. Они отвязаны от раскладки хоста — ты ссылаешься на pgdata, а не на /home/deploy/что-то, поэтому один и тот же compose-файл бежит и на ноутбуке, и на сервере. Они портируемы и бэкапятся — Docker управляет ими, инспектирует и бэкапит как объекты первого класса, и они переживают удаление контейнера by design. И они пишут прямо в файловую систему хоста, а не через union-слой, поэтому быстрее для write-heavy нагрузок, которые генерируют базы данных.
Bind mounts: путь хоста, проброшенный напрямую
Bind mount мапит конкретный каталог или файл с машины-хоста внутрь контейнера. В отличие от volume, ты владеешь путём на хосте и его раскладкой; Docker просто прокидывает его, и хост с контейнером видят одни и те же файлы вживую.
# Смонтировать исходники текущего проекта в контейнер для live reload
docker run -it \
--mount type=bind,src="$(pwd)",dst=/app \
node:22 bash
# Короткая форма (-v с абсолютным путём хоста — это bind mount, а не volume)
docker run -it -v "$(pwd)":/app node:22 bashУбойный сценарий — разработка: bind-монтируешь дерево исходников в контейнер, и dev-сервер делает hot-reload на каждом сохранении, без пересборки. Но bind mounts привязывают тебя к раскладке каталогов хоста (путь должен существовать и выглядеть одинаково везде, где бежит контейнер) и несут острый край — bind mount поверх пути, который образ уже наполнил, скрывает исходное содержимое этого пути на всё время mount’а. Прокинь пустую папку хоста на /app после того, как node_modules поставился туда при сборке, — и контейнер теперь видит пустой /app: установка затенена, а не слита. Это одна из самых частых загадок «в образе работало, а при запуске нет».
▸Почему это работает
Почему bind mount затеняет, а не сливает? Mount заменяет то, что контейнер видит по этому пути, видом хоста — ровно как монтирование USB-флешки поверх папки на ноутбуке: исходное содержимое папки всё ещё на диске под ней, но невидимо, пока mount активен. Named volumes ведут себя иначе в одном полезном случае — когда ты монтируешь пустой named volume на путь, который наполнил образ, Docker сначала копирует существующие файлы образа в свежий volume, поэтому данные засеиваются, а не прячутся. Bind mount никогда не делает этого copy-up.
tmpfs: в памяти, никогда не персистится
tmpfs mount живёт только в RAM хоста. На диск ничего не пишется, и в момент, когда контейнер останавливается, данные испаряются. В этом и смысл: это для данных, которые ты специально не хочешь персистить — расшифрованных секретов, сессионного черновика, промежуточных кэшей, которые лучше не оставлять на диске.
# Смонтировать in-memory файловую систему на /run/secrets (только Linux)
docker run -it --tmpfs /run/secrets myapp
# Явная форма с опциями
docker run -it \
--mount type=tmpfs,dst=/run/secrets,tmpfs-size=64m \
myappТянись к tmpfs, когда секрет должен существовать как файл, чтобы один процесс его прочитал, но никогда не должен касаться долговечного storage, — или для горячего черновика, где важна скорость RAM, а не долговечность. Никогда не используй его для того, что нужно после рестарта: by design восстанавливать нечего.
| Свойство | Named volume | Bind mount | tmpfs |
|---|---|---|---|
| Управляется | Docker (под /var/lib/docker/volumes) | Тобой (любой путь хоста) | Ядром (RAM хоста) |
| Персистится? | Да — переживает удаление контейнера | Да — живёт на хосте | Нет — исчезает при stop |
| Сценарий | База / stateful-данные | Live-reload dev-исходников | Секреты / черновик в памяти |
| Портируемость | Высокая — имя, не путь | Низкая — привязка к раскладке хоста | Н/Д — ничего не хранится |
Жизненный цикл volume: ловушка «куда делся диск»
У volumes жизненный цикл, намеренно независимый от контейнеров, и эта независимость режет в обе стороны. Volume создаётся по требованию при первой ссылке на него и переживает удаление контейнера — это и есть нужная долговечность. Но это же значит, что volume никогда не чистится автоматически. Удали контейнер — и named volume остаётся, занимая диск, пока ты явно его не удалишь.
docker volume ls # список томов
docker volume inspect pgdata # посмотреть Mountpoint на хосте
docker volume rm pgdata # удалить один том (должен быть не используемым)
docker volume prune # удалить ВСЕ тома, не используемые контейнеромСеньорская ловушка живёт в docker volume prune. Он удаляет каждый volume, не прикреплённый сейчас к контейнеру, — а volume, чей контейнер ты удалил на прошлой неделе, по этому определению «не используется». Запусти небрежный prune, чтобы освободить диск, — и можешь стереть базу, которую полностью собирался оставить. Обратная сторона, протухшие volumes молча съедают диск, — более частый повседневный сюрприз: месяцы мёртвых анонимных томов от удалённых контейнеров копятся под /var/lib/docker/volumes, пока на хосте не кончается место.
Контейнер Postgres должен сохранять данные через передеплои, которые пересоздают контейнер. Где должен жить каталог данных?
Сеньорские случаи: host-local — это не сеть
Три правила отделяют того, кто умеет смонтировать volume, от того, кто умеет гонять stateful-нагрузки. Первое: данные базы ДОЛЖНЫ жить на named volume, а не на слое контейнера — хук и есть это правило, выученное на горьком опыте. Второе: никогда не bind-монтируй поверх пути, который наполнил образ, если только ты специально не хочешь его затенить; именно так node_modules и другое build-time содержимое молча исчезает в рантайме. Третье, и оно кусает на масштабе: volumes — host-local. Named volume живёт на диске одной машины. В кластере из нескольких узлов (Swarm, Kubernetes, несколько VM за балансировщиком) volume на узле A невидим контейнеру, запланированному на узел B. Для многоузловой персистентности нужен сетевой или объектный storage — управляемая база, S3-совместимое объектное хранилище или CSI/network-volume драйвер, — а не простой локальный volume. Считать локальный volume общим хранилищем — классическая ошибка, когда одноузловой compose-сетап дорастает до кластера. Вместе эти три правила означают: решения о хранилище, принятые при написании compose-файла, определяют устойчивость всей системы — пропустить второе правило значит потерять данные молча, пропустить третье — потерять данные всего узла на масштабе кластера.
Контейнер Postgres без объявленного volume удалён и пересоздан при передеплое. Что станет с данными?
Твой образ ставит node_modules на /app при сборке. Затем ты bind-монтируешь пустую папку хоста на /app в рантайме. Что увидит контейнер?
- 01Почему данные, записанные внутри контейнера, эфемерны по умолчанию, и какие три типа mount'ов ты используешь, чтобы персистить их (или намеренно не персистить)?
- 02Почему `docker volume prune` опасен и почему локальных volumes недостаточно для многоузлового деплоя?
Запущенный контейнер — это его read-only слои образа плюс один тонкий writable-слой, и этот writable-слой привязан к жизненному циклу контейнера: удали контейнер — и всё записанное там удаляется вместе с ним, поэтому Postgres без volume теряет данные на следующем передеплое. Персистентность включается намеренно, через один из трёх mount’ов, подключённых вне writable-слоя. Named volume — Docker-управляемый storage под /var/lib/docker/volumes, к которому обращаются по имени; он отвязан от путей хоста, портируем, бэкапится, пишет прямо в файловую систему хоста и переживает удаление контейнера, что делает его дефолтом для баз и любых stateful-данных. Bind mount мапит конкретный путь хоста прямо внутрь — идеален для live-reload в dev, но привязывается к раскладке хоста и молча затеняет всё, что образ уже наполнил по этому пути, поэтому никогда не bind-монтируй поверх каталога вроде build-time node_modules, если не хочешь его спрятать. tmpfs mount живёт только в RAM и исчезает при stop — для секретов и черновика, которые ты никогда не хочешь на диске. У volumes независимый жизненный цикл: создаются по требованию, переживают удаление контейнера и удаляются только явно — поэтому docker volume prune может стереть базу, которую ты собирался оставить, и поэтому мёртвые тома тихо съедают диск. Наконец, volumes host-local: в многоузловом кластере тебе нужен сетевой или объектный storage, а не простой локальный volume. Теперь, когда открываешь чужой compose-файл, первым делом смотри — объявлен ли named volume у каждого stateful-контейнера: одна пропущенная строка — и следующий передеплой превращается в потерю данных.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.