open atlas
↑ К треку
Деплой и инфра DEP · 09 · 02

Тома и персистентность

Writable-слой контейнера умирает вместе с контейнером, поэтому персистентным данным нужен mount: Docker-управляемый named volume для баз, bind mount для dev-исходников, tmpfs для секретов в памяти — у каждого свой жизненный цикл и ловушки.

DEP Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

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 volumeBind mounttmpfs
Управляется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 в рантайме. Что увидит контейнер?

Вспомните перед уходом
  1. 01
    Почему данные, записанные внутри контейнера, эфемерны по умолчанию, и какие три типа mount'ов ты используешь, чтобы персистить их (или намеренно не персистить)?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.