Вы запустили Postgres в контейнере, добавили данные, потом удалили контейнер — и всё пропало. Это не баг, а нормальное поведение Docker. Разбираемся, почему так устроено и как правильно хранить данные, которые должны переживать перезапуски.
Ниже — что происходит с записанными строками при удалении контейнера: сначала без тома, потом с томом.
Без -v том всё равно появится, только безымянный — найти его потом не по чему. Дали тому имя — новый контейнер с тем же -v видит те же строки. Поэтому compose down тома не трогает: для удаления нужен явный --volumes.
Почему данные в контейнере эфемерны
Контейнер — это изолированный процесс с собственной файловой системой. Эта файловая система создаётся из образа при старте контейнера, а при его удалении — исчезает вместе с ним.
Короткая формула: образ — неизменяемый шаблон, контейнер — временная рабочая копия.
Всё, что вы записали внутри работающего контейнера (загруженные файлы, логи, временные файлы), живёт на так называемом слое записи — тонком слое поверх образа. Docker удаляет этот слой вместе с контейнером командой docker rm.
docker run -d --name demo -e POSTGRES_PASSWORD=secret postgres:16
# ... создали таблицы, добавили строки ...
docker stop demo && docker rm demo # работающий контейнер сначала останавливают
# Данные исчезли
С базами всё чуть хитрее, и знать это полезно. Образ postgres:16 внутри себя объявляет каталог /var/lib/postgresql/data томом (VOLUME). Поэтому, даже если вы не написали ни одного -v, Docker при старте заводит безымянный том и база пишет туда, а не в слой записи. После docker rm demo этот том остаётся лежать на диске — просто у него нет имени, и новый контейнер его не найдёт. Для вас данные пропали; для диска — нет, они занимают место, пока их не уберут (docker rm -v при удалении контейнера или docker volume prune потом).
Отсюда же берутся безымянные тома с длинными шестнадцатеричными именами, которые однажды обнаруживаются в docker volume ls. Это следы контейнеров, запущенных без -v.
Для разовых экспериментов такое поведение даже удобно: запустил контейнер для теста, поиграл, удалил — чисто. Но базы данных, пользовательские файлы, состояние приложения должны жить дольше, чем один контейнер, и у их хранилища должно быть имя.
Три способа хранить данные вне контейнера
Docker предлагает три механизма: named volumes, bind mounts и tmpfs. У каждого своя область применения.
Точка монтирования внутри контейнера во всех трёх случаях одна и та же, а байты лежат в разных местах: смотрите на правый столбец.
Named volumes — рекомендованный способ для данных
Том (named volume) — это каталог, которым управляет Docker. Он живёт на хосте в специальном месте (/var/lib/docker/volumes/) и не зависит от жизненного цикла контейнера.
# Создать том явно
docker volume create pgdata
# Подключить том к контейнеру
# -v том:путь_внутри_контейнера
docker run -d \
--name postgres \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:16
Теперь можно удалить контейнер, создать новый с тем же томом — данные никуда не денутся:
docker stop postgres && docker rm postgres
docker run -d \
--name postgres \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:16
# Все данные на месте
Управлять томами можно через docker volume:
docker volume ls # список томов
docker volume inspect pgdata # где лежит, когда создан
docker volume rm pgdata # удалить (только если не подключён)
docker volume prune # удалить безымянные «ничейные» тома
docker volume prune --all # удалить ещё и именованные, которые никому не нужны
На prune стоит посмотреть внимательно. Начиная с Docker 23.0 команда без ключей трогает только безымянные тома — те самые следы запусков без -v. Именованные она оставляет в покое, и убирает их лишь --all. Разница нешуточная: pgdata с вашей базой уйдёт именно от --all.
Named volumes — правильный выбор для баз данных, файловых хранилищ, любых данных, которые нужны в продакшене.
Bind mounts — для разработки
Bind mount монтирует конкретный каталог или файл с хоста прямо в контейнер. Никакой «магии» Docker: вы сами говорите, какой путь на вашей машине куда попадёт внутри.
# -v путь_на_хосте:путь_в_контейнере
docker run -d \
--name app \
-v ~/projects/myapp:/app \
eclipse-temurin:21-jre \
java -jar /app/app.jar
Путь на хосте обязан быть абсолютным. Тильда в ~/projects/myapp не в счёт — её оболочка сама заменит на ваш домашний каталог ещё до того, как команда дойдёт до Docker. А вот относительный путь оборачивается ловушкой, и захлопывается она тихо: строка -v myapp:/app не смонтирует каталог myapp, а заведёт именованный том с таким именем. Контейнер увидит пустую директорию, приложение пожалуется, что файлов нет, и догадаться, в чём дело, почти невозможно. Если нужен путь от текущей директории — пишите -v "$(pwd)/myapp:/app".
Главное применение — разработка: правите код в IDE, изменения сразу видны внутри контейнера без пересборки образа. Для Spring Boot это особенно удобно в паре с devtools.
# docker-compose.yml для разработки
services:
app:
image: eclipse-temurin:21-jre
volumes:
- ./build/libs:/app # собранный jar с хоста
command: java -jar /app/app.jar
Bind mounts не стоит использовать для хранения данных Postgres или другого состояния в продакшене: вы становитесь зависимым от структуры файловой системы конкретного хоста, а это ломает переносимость.
tmpfs — данные только в памяти
tmpfs монтирует каталог внутри контейнера в оперативную память хоста. Данные не уходят на диск и исчезают при остановке контейнера.
docker run --tmpfs /tmp:size=64m myapp
Размер здесь не для красоты. Всё, что легло в tmpfs, — это живая память хоста, и считается она в лимит памяти контейнера наравне с кучей приложения. Контейнер с --memory=512m, который налил в /tmp сотню мегабайт, приблизился к OOMKilled ровно на эту сотню. Без size= tmpfs по умолчанию готов занять половину памяти хоста — ограничение стоит ставить осознанно.
Применяется для временных данных, которые не должны попасть на диск: чувствительные файлы (токены, ключи), кэши сессий, промежуточные вычисления. Недоступен для Windows-контейнеров: tmpfs — файловая система ядра Linux, а у них другое ядро.
Главное отличие: что происходит с содержимым каталога
Тома и bind mount описывают как «два способа положить данные снаружи», и на этом теряется различие, из которого растёт половина недоумений «почему в контейнере вдруг пусто».
Речь о случае, когда каталог в образе не пустой — например, /var/lib/postgresql/data у подготовленного образа или /usr/share/nginx/html со страницей по умолчанию.
Именованный том при первом подключении копирует в себя содержимое каталога из образа. Том пустой, каталог в образе не пустой — Docker переносит файлы в том и монтирует его. Дальше том живёт своей жизнью: при следующих запусках копирования уже не происходит, даже если в образе файлы изменились. Отсюда неожиданность в обратную сторону: обновили образ, а в томе лежат старые данные инициализации — и это правильно, иначе обновление образа затирало бы вашу базу.
Bind mount просто закрывает каталог собой. Ничего не копируется: что лежит на хосте, то и видно внутри, а содержимое образа становится недоступно — как будто его нет. Примонтировали пустой каталог хоста в /usr/share/nginx/html — получили пустой сайт и никакой ошибки.
Отсюда практические выводы.
Для данных, которые создаёт приложение (база, загруженные файлы), берут том: инициализация из образа при первом старте — именно то, что нужно. Для кода и конфигурации при разработке берут bind mount: вы хотите видеть свои файлы, а не то, что было в образе.
И отдельная ловушка, которая бьёт всех: монтирование в каталог внутри дерева проекта. Классика — -v $PWD:/app вместе с /app/target или /app/build, куда пишет сборка: файлы с хоста закрывают собой то, что собрал контейнер, и приложение не находит собранный jar. Лечится это тем, что внутренние каталоги сборки перекрывают отдельным анонимным томом или просто не монтируют проект целиком.
Права доступа: почему «permission denied» при старте
Второе, обо что спотыкаются с bind mount, — владелец файлов. Процесс в контейнере работает под своим пользователем: у официального образа PostgreSQL это postgres с uid 999, у многих образов приложений — специально созданный непривилегированный пользователь. А bind mount отдаёт внутрь файлы ровно с теми владельцами и правами, что на хосте, потому что это тот же каталог — Docker их не переписывает.
Совпадения номеров пользователей не бывает: на хосте вы обычно uid 1000, внутри процесс 999, и результат — could not create directory: Permission denied при старте или молчаливая невозможность записать файл.
Как это решают, по убыванию удобства:
- Взять именованный том вместо bind mount. Тома Docker создаёт сам и сразу выставляет владельцем того пользователя, под которым стартует контейнер. Для баз данных это и есть правильный ответ — а bind mount под данные базы почти всегда ошибка.
- Выдать нужные права на каталоге хоста.
chown -R 999:999 ./pgdataили менее аккуратноеchmod 777. Работает, но привязывает каталог к конкретному номеру пользователя из образа. - Запустить контейнер под своим пользователем.
docker run --user "$(id -u):$(id -g)" ...— тогда процесс внутри имеет ваш номер, и файлы на хосте создаются от вас. Годится для сборки и для утилит; для образов с готовой структурой каталогов (снова базы) обычно ломает старт, потому что каталоги внутри принадлежат другому пользователю.
На macOS и Windows к этому добавляется ещё одно обстоятельство. Docker там работает внутри виртуальной машины с Linux, и bind mount проходит через проброс файловой системы из хоста в эту машину. Права при этом подменяются на удобные (поэтому «permission denied» на маке встречается реже), а вот скорость страдает заметно: сборка проекта Java на смонтированном каталоге может идти в разы дольше, чем на диске внутри виртуальной машины. Отсюда рабочая привычка для macOS: код монтировать, а каталоги зависимостей и сборки (~/.gradle, target, build, node_modules) держать в томах, не на смонтированном диске хоста.
Анонимные тома: откуда на диске десятки гигабайт
Есть третий вид, который никто не создаёт специально, но который появляется сам: анонимный том. Его Docker делает, когда контейнеру нужен том, а имя не указано:
docker run -v /var/lib/postgresql/data postgres:16 # путь без имени слева
Такой том получает вместо имени длинную шестнадцатеричную строку. Он полноценный, данные в нём сохраняются — но найти его потом можно только по этой строке, и никакой связи с контейнером в имени нет. То же происходит само, когда в образе объявлена инструкция VOLUME: удалили контейнер, а том остался.
Отсюда и появляется удивление «куда ушло тридцать гигабайт»: полсотни анонимных томов от давно удалённых контейнеров с локальными базами. Посмотреть и убрать их так:
docker volume ls -f dangling=true # тома, к которым не подключён ни один контейнер
docker volume prune # удалить их (спросит подтверждение)
Две привычки, которые избавляют от этой уборки. Всегда давать тому имя (-v pgdata:/var/lib/postgresql/data) — тогда видно, чей он. И для одноразовых запусков добавлять --rm: при удалении контейнера его анонимные тома удаляются вместе с ним, а вот именованные — нет, и это правильно.
Синтаксис --mount и режим только для чтения
Всё выше записано через -v, потому что так короче и так пишут в большинстве руководств. У него есть неприятное свойство: опечатка в имени тома не является ошибкой. Написали -v pgdta:/var/lib/postgresql/data — Docker молча создаст новый пустой том pgdta и запустит контейнер с пустой базой. Данные не потеряны, но их нет, и выглядит это как «данные пропали».
Поэтому у Docker есть второй, подробный синтаксис — --mount, который документация и рекомендует:
docker run -d --name postgres \
--mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret postgres:16
# bind mount: только для чтения и с проверкой существования каталога
docker run --rm \
--mount type=bind,source="$PWD"/config,target=/app/config,readonly \
my-app:1.0
Отличия от -v три: параметры именованные и читаются без запоминания порядка, type= указывается явно (volume, bind, tmpfs), и для type=bind Docker отказывается запускать контейнер, если каталога на хосте нет, вместо того чтобы создать пустой. Последнее и есть главная причина им пользоваться.
Режим только для чтения доступен в обоих синтаксисах и стоит того, чтобы про него помнить: -v ./config:/app/config:ro или readonly в --mount. Так монтируют конфигурацию, сертификаты, статику — всё, что приложение должно читать и не должно менять. Заодно это проверка предположений: если приложение внезапно пытается писать в примонтированный каталог, вы узнаете об этом на первом запуске, а не через месяц из-за испорченного файла.
Сравнение: когда что использовать
| Ситуация | Механизм |
|---|---|
| База данных в продакшене (Postgres, Redis) | Named volume |
| Файлы, загружаемые пользователями | Named volume |
| Код при локальной разработке | Bind mount |
| Конфигурационные файлы при разработке | Bind mount |
| Временные файлы, чувствительные данные | tmpfs |
Postgres в контейнере: полный пример
Запустим Postgres с именованным томом, чтобы данные сохранялись между перезапусками. Это типичная настройка для локальной разработки Spring Boot приложения.
# docker-compose.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data # именованный том
volumes:
pgdata: # Docker создаст том автоматически
docker compose up -d
# Создали таблицы, добавили данные...
docker compose down # остановили и удалили контейнеры
docker compose up -d # подняли снова — данные на месте
Обратите внимание: docker compose down не удаляет тома. Для удаления тома нужен явный флаг:
docker compose down --volumes # удалить контейнеры И тома
Это защищает от случайной потери данных.
Посмотреть, забрать и перенести том
Том — это ценные данные, и с ними надо уметь делать три вещи: смотреть, копировать, переносить. Прямого доступа к файлам тома при этом обычно нет, и это первое, что сбивает с толку.
Где лежат файлы. На Linux — в /var/lib/docker/volumes/<имя>/_data, и туда можно заглянуть от суперпользователя. На macOS и Windows такого каталога на хосте нет: Docker работает внутри виртуальной машины, и путь, который печатает docker volume inspect, существует только внутри неё. Поэтому универсальный способ посмотреть содержимое — не файловый менеджер, а контейнер:
docker run --rm -it -v pgdata:/data alpine sh -c 'ls -la /data'
Тот же приём работает для любой разовой работы с томом: подключаем его к одноразовому контейнеру с нужными утилитами и делаем что нужно.
Резервная копия тома. Архив собирают тем же способом — контейнером, который видит и том, и каталог хоста:
docker run --rm \
-v pgdata:/data:ro \
-v "$PWD":/backup \
alpine tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .
Восстановление — обратная операция в пустой том:
docker volume create pgdata-restored
docker run --rm \
-v pgdata-restored:/data \
-v "$PWD":/backup \
alpine sh -c 'tar xzf /backup/pgdata-2026-09-25.tar.gz -C /data'
Так же том переносят на другую машину: собрали архив, скопировали файл, развернули в новый том.
И важная оговорка про базы данных. Копия файлов тома, снятая у работающей базы, может оказаться несогласованной: часть страниц записана, часть нет. Для PostgreSQL правильный путь — штатная выгрузка (pg_dump или pg_basebackup) из работающего контейнера, а копирование файлов тома делают на остановленном контейнере. Разбор того, чем отличаются эти способы, — в статье про резервные копии.
Куда смотреть, если данные пропали
Если после перезапуска контейнера данные исчезли, причин обычно три. Первая — тома не было вовсе: данные писались в слой контейнера, и docker rm унёс его вместе с ними. Вторая — том был, но при остановке стека применили флаг --volumes (docker compose down -v), а он удаляет и тома. Третья и самая коварная — том примонтирован не туда, куда приложение реально пишет: у Postgres это /var/lib/postgresql/data, у Redis — /data, у других — свои пути, смотрите в документации образа. Ошиблись в пути — данные легли в слой контейнера, а том остался пустым.
Оба каталога существуют, контейнер стартует без единой ошибки, и разойтись путям хватает одной буквы.
docker inspect postgres | grep -A 10 Mounts # проверить, что примонтировано
Глубже: куда уходит место: слои, тома, кэш сборки и pruneрасширенное
Через месяц работы с Docker место на диске заканчивается, и это происходит с каждым. Причина в том, что Docker почти ничего не удаляет сам, а хранит в четырёх местах.
docker system df показывает все четыре: образы, контейнеры, тома и кэш сборки, с колонкой RECLAIMABLE, сколько из этого никем не используется. Флаг -v раскладывает по отдельным образам и томам. Физически всё лежит в /var/lib/docker: слои образов и контейнеров в overlay2, тома в volumes, журналы контейнеров в containers. На macOS и Windows это внутри диска виртуальной машины Docker Desktop, который растёт до заданного предела и не уменьшается сам, даже когда внутри всё удалили.
Откуда набегает. Каждая сборка с изменённым кодом создаёт новый образ, а старый с тем же тегом становится безымянным (<none> в docker images) и продолжает занимать место. Кэш сборки хранит промежуточные слои всех сборок, включая зависимости, скачанные на этапе builder, и растёт быстрее образов. Остановленные контейнеры держат свои слои и журналы. Тома живут после удаления контейнеров, и база, которую подняли «посмотреть» полгода назад, всё ещё лежит в volumes.
Чистят по нарастающей осторожности:
docker container prune # остановленные контейнеры
docker image prune # безымянные образы
docker builder prune --filter until=168h # кэш сборки старше недели
docker volume prune # тома, не привязанные ни к одному контейнеру
docker system prune -a # всё неиспользуемое, включая образы с тегами
volume prune и system prune --volumes это единственные команды здесь, которые удаляют данные, а не кэш: том с базой, чей контейнер удалили вчера, уйдёт вместе с ними. Перед ними смотрят docker volume ls и думают. Остальное восстанавливается повторной сборкой или загрузкой, и system prune -a раз в неделю на машине разработчика это нормальная гигиена; на сервере то же делает задача по расписанию с фильтром по возрасту, чтобы не стереть образ, который нужен для отката.
Что не чистит prune: журналы контейнеров (их ограничивает ротация из статьи про запуск) и файлы, которые приложение пишет в слой контейнера вместо тома. Второе находят через docker diff и лечат томом или tmpfs.
Коротко
- Данные внутри контейнера эфемерны: удалил контейнер — потерял данные.
- Named volume — правильный выбор для баз данных и любого состояния, которое должно жить дольше контейнера.
- Bind mount — монтирует каталог с хоста; подходит для разработки, не для продакшена. tmpfs — только в памяти, исчезает при остановке; для временных и чувствительных данных.
docker volume ls / inspect / rm / prune— основные команды управления томами;pruneбез ключей убирает только безымянные тома, именованные — по--all.docker compose downне удаляет тома — нужен явный--volumes.- Место уходит в четыре места, и
docker system dfих показывает: безымянные образы, кэш сборки, остановленные контейнеры и забытые тома;pruneпо нарастающей, и толькоvolume pruneудаляет данные. - Именованный том при первом подключении копирует в себя содержимое каталога из образа, bind mount закрывает его собой: отсюда «в контейнере пусто» и затёртые каталоги сборки внутри примонтированного проекта.
- Права с bind mount приходят с хоста, а процесс внутри работает под своим номером: для данных берут том, иначе
chownпод нужный номер или запуск с--user; на macOS смонтированный каталог ещё и медленный. - Анонимные тома (путь без имени или
VOLUMEв образе) и составляют те самые десятки гигабайт: смотретьdocker volume ls -f dangling=true, чиститьvolume prune, а тома всегда называть. --mountвместо-v: опечатка в имени тома у-vмолча создаёт новый пустой том, аtype=bindбез существующего каталога не запустится;:roмонтирует конфигурацию только для чтения.- Смотрят, копируют и переносят том одноразовым контейнером с
tar; у работающей базы копия файлов несогласованна — нужна штатная выгрузка.
Что почитать дальше
- Запуск контейнеров — флаги
docker run, режимы работы, управление контейнерами. - Docker Compose — как описывать многоконтейнерные приложения и задавать тома в
docker-compose.yml. - Сетевое взаимодействие — как контейнеры общаются друг с другом и с хостом.