← назад к разделу

Вы запустили Postgres в контейнере, добавили данные, потом удалили контейнер — и всё пропало. Это не баг, а нормальное поведение Docker. Разбираемся, почему так устроено и как правильно хранить данные, которые должны переживать перезапуски.

Ниже — что происходит с записанными строками при удалении контейнера: сначала без тома, потом с томом.

без -v: образ postgres объявляет VOLUME — том заводится самконтейнер demoбезымянный том/var/lib/postgresql/data12 строкобраз postgres:16 — только чтение docker rm demoтом остался, но имени нет — 12 строк не найти с томом: -v pgdata:/var/lib/postgresql/data — путь уходит наружуконтейнер postgresслой записи (r/w)данных базы в нём нетобраз postgres:16 — только чтениетом pgdata на хосте/var/lib/docker/volumes/pgdata/_data12 строк лежат здесь удаляем контейнер — том переживает удалениеdocker rm postgresслой записи исчезтом pgdata цел12 строк на местеновый контейнертот же -v — видит ихcompose down том не трогает — удаляет только down --volumes

Без -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. У каждого своя область применения.

именованный том -v pgdata:/data /var/lib/docker/volumes/pgdata bind mount -v /home/me/cfg:/data каталог cfg на хосте tmpfs --tmpfs /data память хоста, не диск

Точка монтирования внутри контейнера во всех трёх случаях одна и та же, а байты лежат в разных местах: смотрите на правый столбец.

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, у других — свои пути, смотрите в документации образа. Ошиблись в пути — данные легли в слой контейнера, а том остался пустым.

приложение пишет в /var/lib/postgresql/data слой контейнера исчезнет с docker rm том примонтирован в /var/lib/postgres/data том pgdata остаётся пустым

Оба каталога существуют, контейнер стартует без единой ошибки, и разойтись путям хватает одной буквы.

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; у работающей базы копия файлов несогласованна — нужна штатная выгрузка.

Что почитать дальше