Контейнеры одноразовы. Это их суперсила и одновременно ловушка. Ты поднял Postgres, налил туда данные, пересоздал контейнер с новым флагом - и база пустая. Или хуже: данные есть, но процесс внутри пишет с ошибкой "permission denied", хотя на хосте каталог явно доступен на запись. Или ты сделал docker volume rm и понял, что только что грохнул прод. Всё это - не баги Docker, а следствие того, что про docker данные многие думают поверхностно: примонтировал каталог и забыл.
Этот урок - про то, как хранение устроено под капотом. Разберём драйверы томов, разницу named volume vs bind vs tmpfs по производительности, почему возникает рассинхрон UID/GID и docker volume права, как делается надёжный бэкап docker volume через tar, как мигрировать данные между хостами и где данные держать нельзя категорически. Это продвинутая часть, поэтому идём вглубь механики, а не по верхам.

Три способа дать контейнеру состояние
У Docker есть три типа монтирования, и путать их - классическая ошибка.
Named volume (именованный том) - управляемая Docker сущность. Данные живут в /var/lib/docker/volumes/<имя>/_data на хосте, но ты обращаешься к ним по имени, а не по пути. Docker сам создаёт каталог, ведёт метаданные, отслеживает связи с контейнерами. Это дефолтный выбор для любого состояния, которое должно пережить контейнер: базы, загруженные пользователями файлы, индексы.
Bind mount (привязка) - ты руками прокидываешь конкретный путь хоста внутрь контейнера. Docker тут ничего не "владеет", просто пробрасывает. Идеально для разработки (исходники проекта внутрь контейнера), для конфигов, для сокетов вроде /var/run/docker.sock. Минус: ты завязан на структуру файловой системы хоста, и переносимость страдает.
tmpfs - данные в оперативной памяти, на диск не попадают вообще. Живут пока жив контейнер. Это для секретов, временных файлов, кэшей, которые не должны касаться диска. Работает только на Linux-хостах.
Под капотом разница принципиальная. Named volume и bind - это, по сути, точки монтирования поверх верхнего слоя контейнера. Когда процесс пишет в примонтированный путь, запись идёт напрямую в файловую систему хоста, минуя storage driver (overlay2). А вот запись в обычный путь внутри контейнера проходит через copy-on-write слой overlay2 - и это медленно для интенсивного I/O.
Код: Выделить всё
docker run -d --name db \
--mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
--mount type=bind,source=/etc/myapp,target=/etc/myapp,readonly \
--mount type=tmpfs,target=/tmp,tmpfs-size=64m \
postgres:16
Производительность: что реально быстрее
Главный практический вопрос: что выбрать для нагруженной БД. Ответ - named volume, и вот почему.
- Named volume на Linux пишет прямо в нативную ФС хоста (обычно ext4/xfs). Накладных расходов почти ноль - это та же скорость, что и запись на диск напрямую.
- Bind mount на Linux тоже быстр, по сути эквивалент named volume по скорости. Но на Docker Desktop (Mac/Windows) bind mount идёт через виртуализованную ФС (gRPC-FUSE или virtiofs), и это драматически медленнее - на тысячах мелких файлов разница может быть в разы. Поэтому на Mac/Windows для зависимостей (node_modules, vendor) держи named volume, а не bind с хоста.
- tmpfs - быстрее всех, это RAM. Но платишь оперативкой и теряешь данные при остановке.
Драйверы томов: local и не только
У каждого тома есть docker volume драйвер. По умолчанию - local. Но local умеет куда больше, чем "каталог на диске": через driver_opts он пробрасывает опции в обычный системный mount(8). Это значит, что NFS не требует отдельного плагина - его монтирует штатный local-драйвер.
Код: Выделить всё
docker volume create --driver local \
--opt type=nfs \
--opt o=addr=10.0.0.5,rw,nfsvers=4.1,soft,timeo=300 \
--opt device=:/exports/shared \
shared_nfs
Код: Выделить всё
volumes:
shared:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.5,rw,nfsvers=4.1,soft,timeo=300
device: ":/exports/shared"
Код: Выделить всё
docker plugin install vieux/sshfs
docker volume create -d vieux/sshfs \
-o sshcmd=user@10.0.0.7:/data \
-o IdentityFile=/root/.ssh/id_rsa \
remote_ssh
Права, владельцы и боль UID/GID
Самая частая загадка новичка: docker volume права. Создал том, контейнер пишет - "permission denied". Причина в том, что внутри контейнера и на хосте процессы идентифицируются числовыми UID/GID, а не именами. Имя пользователя - это просто запись в /etc/passwd, которая внутри образа своя. Ядро же видит только числа.
Пример. Образ Postgres запускает процесс под пользователем postgres, у которого внутри UID 999. Когда Postgres пишет в named volume, файлы на хосте получают владельца с числовым UID 999 - и на хосте это будет какой-то случайный системный пользователь или вообще никто. А теперь ты монтируешь bind с хоста, где каталог принадлежит твоему юзеру (UID 1000), а процесс в контейнере бежит под UID 999 - запись запрещена. Имена ни при чём, дело в несовпадении чисел.
Что делать:
- Пустой named volume Docker инициализирует владельцем, который владеет точкой монтирования в образе. То есть для официального Postgres первый запуск сам выставит UID 999 на _data. Это работает само, пока том пустой.
- Для bind mount магии нет - права наследуются от хоста. Приводи их сам: chown -R 999:999 /path или запускай контейнер под нужным UID через --user.
- Флаг --user 1000:1000 (или поле user: в compose) заставляет процесс бежать под заданными числами. Удобно сматчить с владельцем каталога на хосте.
Код: Выделить всё
#!/bin/sh
set -e
# каталог тома мог приехать с чужим владельцем - выравниваем
chown -R app:app /data
# дальше работаем без root
exec gosu app "$@"
Бэкап docker volume: tar через временный контейнер
У named volume нет "пути, который можно просто заархивировать" в переносимом виде - лезть руками в /var/lib/docker/volumes плохая идея (а при userns-remap ещё и права не те). Канонический способ сделать бэкап docker volume - примонтировать том в одноразовый контейнер и выгрузить tar:
Код: Выделить всё
docker run --rm \
-v pgdata:/data:ro \
-v "$(pwd)":/backup \
alpine \
tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .
Важный нюанс про БД: для Postgres/MySQL файловый tar "на горячую" может поймать несогласованное состояние, если идёт запись. Либо останови контейнер перед бэкапом, либо для баз используй штатный логический дамп (pg_dump, mysqldump) - он консистентен по дизайну. tar тома хорош для файлов приложения, очередей, загрузок; для активной БД предпочитай нативные дампы.
Восстановление - зеркально: создаём пустой том и распаковываем в него.
Код: Выделить всё
docker volume create pgdata_restored
docker run --rm \
-v pgdata_restored:/data \
-v "$(pwd)":/backup \
alpine \
sh -c "tar xzf /backup/pgdata-2026-06-15.tar.gz -C /data"
Миграция данных между хостами
Перенос - это тот же бэкап плюс передача архива. Собрал tar.gz на старом хосте, перекинул scp/rsync, развернул в новый том на целевом:
Код: Выделить всё
# на старом хосте
docker run --rm -v pgdata:/data:ro -v "$(pwd)":/b alpine \
tar czf /b/pgdata.tar.gz -C /data .
scp pgdata.tar.gz user@newhost:/tmp/
# на новом хосте
docker volume create pgdata
docker run --rm -v pgdata:/data -v /tmp:/b alpine \
tar xzf /b/pgdata.tar.gz -C /data
Где данные держать нельзя
Главный антипаттерн - класть данные в слой образа. Всё, что ты пишешь в Dockerfile (COPY, RUN, генерация файлов), вмерзает в неизменяемые слои. Образ - это шаблон, он должен быть stateless. Если положить туда базу или пользовательские загрузки:
- Данные теряются при любом пересоздании контейнера - слой образа read-only, изменения уходят в эфемерный writable-слой и умирают с контейнером.
- Образ раздувается, кэш слоёв ломается, пуш/пул в Yandex или VK Container Registry становится медленным и дорогим.
- Секреты, случайно зашитые в слой, остаются в истории образа навсегда, даже если "удалить" их следующей инструкцией - предыдущий слой никуда не делся.
Осиротевшие тома и уборка
Named volume переживает свой контейнер - это фича, но и источник мусора. Удалил контейнер без --rm и без удаления тома - том осиротел, занимает место, висит безымянным хэшем (anonymous volume, их плодят образы с инструкцией VOLUME без явного имени).
Код: Выделить всё
docker volume ls -f dangling=true # показать осиротевшие
docker volume prune # удалить неиспользуемые
docker volume prune -a # включая именованные, на которые нет ссылок
Мини-лаба (повтори руками)
- Создай том data1, запусти alpine, запиши в него файл: docker run --rm -v data1:/d alpine sh -c "echo hello > /d/test.txt". Проверь, что файл пережил контейнер, перечитав его новым контейнером.
- Сделай бэкап data1 в tar.gz через одноразовый alpine (по образцу выше). Грохни том, создай заново и восстанови из архива. Убедись, что test.txt на месте.
- Запусти контейнер с --user 1000:1000 и попробуй записать в том, инициализированный под другим UID. Поймай permission denied и почини через chown в контейнере под root.
- Создай анонимный том через образ с VOLUME, удали контейнер, найди осиротевший том через ls -f dangling=true.
- Почему запись в named volume быстрее, чем в обычный путь внутри контейнера, и при чём тут copy-on-write overlay2?
- Откуда берётся permission denied при bind mount и почему имена пользователей тут не важны - объясни через UID/GID.
- Зачем для бэкапа тома нужен временный контейнер с tar, а не прямое копирование из /var/lib/docker/volumes?
- Что произойдёт с владельцами файлов при миграции тома между хостами с разным userns-remap и как это починить?
Образ - неизменяемый код, том - живое состояние, и смешивать их нельзя. Named volume - дефолт для любой персистентности и самый быстрый вариант на Linux; bind - для разработки и конфигов; tmpfs - для секретов в памяти. local-драйвер тянет NFS без плагинов, sshfs и облако - через плагины. Права упираются в числовые UID/GID, а не имена - лечится chown в entrypoint, флагом --user и userns-remap. Бэкап и миграция - всегда через временный контейнер и tar, для БД лучше нативный дамп. И помни про docker volume prune: на проде это команда с предохранителем.