SSD Nodes Learn 🎉 VPS от $5.50/мес
Руководства Matt ConnorАвтор: Matt Connor · Обновлено 2026-08-13

Базы данных в Docker или на хосте: что выбрать?

Запуск PostgreSQL, MySQL или Redis в контейнерах подходит для production. Узнайте, как правильно настроить тома, обновления и резервное копирование, чтобы избежать потери данных.

Стоит ли запускать базу данных в Docker или на хосте?

Запускайте базу данных в Docker. Для одного стека приложений на одном VPS контейнеризированные PostgreSQL, MySQL, MongoDB или Redis — это стандартный выбор для production, а споры вокруг этого обычно ведутся не по существу. Контейнер — это процесс Linux с пространствами имен (namespaces) и cgroups, а не виртуальная машина, поэтому между базой данных и диском нет гипервизора. При использовании bind mount или локального именованного тома операции чтения и записи выполняются непосредственно в файловой системе хоста, точно так же, как при установке пакета из репозитория.

Реальная цена вопроса — эксплуатация. Четыре фактора определяют, будет ли эта конфигурация работать стабильно или приведет к катастрофе: где хранятся данные, кто является владельцем этого каталога, как проходит обновление мажорной версии и приходилось ли вам когда-либо восстанавливать резервную копию. Если вы правильно настроите эти аспекты, контейнер станет лишь деталью реализации. Если допустите ошибки, контейнер станет тем, на что вы будете списывать все проблемы.

Это решение одинаково для любой серверной базы данных. В примерах ниже используются PostgreSQL, MySQL, MongoDB и Redis, а специфические для конкретного продукта различия отмечены там, где они имеют значение.

Что на самом деле меняет контейнер

Не путь к хранилищу, если вы используете монтирование. Ядро, page cache и файловая система остаются прежними.

Существует одна реальная ловушка производительности: случай, когда вы ничего не монтируете. Без volume директория с данными попадает в записываемый слой контейнера — это overlay filesystem, расположенная поверх образа. Запись туда происходит медленнее, а весь слой удаляется при удалении контейнера. Именно поэтому возникает ситуация «утром моя база данных оказалась пустой».

Что меняется на самом деле:

  • Жизненный цикл. docker compose down уничтожает контейнер. Все, что не было сохранено в volume, исчезает вместе с ним.
  • Версия. Тег образа определяет версию. Внутри контейнера с базой данных нет apt upgrade, которая сохранилась бы после следующего docker compose pull.
  • Учет памяти. Лимит cgroup — это жесткое ограничение, принудительно установленное ядром, о котором база данных не «знает».
  • Пользователь. Процесс выполняется под числовым идентификатором пользователя (UID) внутри контейнера, который может не иметь прав доступа ни к чему на вашем хосте.

Место хранения данных определяет всё

Существует два хороших варианта и одна распространенная ошибка.

  • Именованный том (named volume): pgdata:/var/lib/postgresql/data. Docker создает директорию по пути /var/lib/docker/volumes/<project>_pgdata/_data, а entrypoint образа устанавливает права владения при первом запуске. Это стандартное решение.
  • Примонтированный каталог (bind mount): /srv/appname/pg:/var/lib/postgresql/data. Вы сами выбираете путь, поэтому решение проблем с правами доступа ложится на вас.
  • Отсутствие монтирования. См. выше. Данные остаются внутри контейнера.

Полный разбор преимуществ и недостатков — это отдельная тема, и она подробно описана в сравнении bind mounts и named volumes. Краткий совет для базы данных: используйте именованный том, если у вас нет веских причин указывать конкретный путь на хосте. Если вы все же используете bind mount, разместите его в стабильном месте, например /srv/appname/pg, а не внутри директории проекта, где его может затронуть git clean.

Важное ограничение: не размещайте директорию с данными базы данных на NFS (network file system) или любом другом сетевом ресурсе, поведение которого в части блокировок и fsync вы не тестировали. Базы данных исходят из того, что успешный fsync означает запись байтов на энергонезависимый носитель. Если это допущение неверно, возникнет повреждение данных, которое проявится спустя недели.

Закрепите имя тома, чтобы он не потерялся

Compose присваивает тому имя <project>_<volume>, при этом имя проекта по умолчанию совпадает с именем директории. Таким образом, идентификатор тома зависит от имени директории, которое пользователи часто меняют, не задумываясь о последствиях.

Переместите /srv/app в /srv/app-old или переименуйте ключ pgdata в файле compose, и при следующем запуске docker compose up -d будет создан новый пустой том. Postgres инициализирует в нём чистый кластер. Контейнер будет работать исправно, приложение запустится, но все таблицы исчезнут. Хорошая новость в том, что старый том останется на диске под прежним именем.

docker volume ls
docker volume inspect app_pgdata

Закрепите имена, чтобы этого не произошло. Задайте имя проекта и имя тома явно:

name: myapp

services:
  db:
    image: postgres:17
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
    name: myapp_pgdata

Если данные уже находятся в «осиротевшем» томе, скопируйте их, предварительно остановив базу данных:

docker compose stop db
docker run --rm -v app_pgdata:/from -v myapp_pgdata:/to alpine sh -c 'cp -a /from/. /to/'
docker compose start db

Если копировать данные во время работы базы данных, вы получите повреждённую копию файлов, которые находились в процессе записи. Сначала остановите сервис.

Кому принадлежит каталог данных

Официальные образы Postgres, MySQL и MongoDB запускают сервер от имени непривилегированного пользователя, обычно с идентификатором 999. Когда контейнер запускается от имени root, точка входа (entrypoint) меняет владельца каталога данных на этого пользователя, а затем сбрасывает привилегии. Именно поэтому пустой bind mount обычно работает с первой попытки.

Всё ломается, как только вы задаете user: в файле compose, так как у точки входа больше нет прав для внесения изменений. Postgres сообщает об этом напрямую:

initdb: error: could not change permissions of directory "/var/lib/postgresql/data": Operation not permitted

Каталог данных с неверными правами доступа вызывает другое сообщение, и его стоит запомнить, так как решение — это chmod, а не chown:

FATAL:  data directory "/var/lib/postgresql/data" has invalid permissions
DETAIL:  Permissions should be u=rwx (0700) or u=rwx,g=rx (0750).

MongoDB при использовании bind mount, принадлежащего root, завершается с ошибкой при работе с файлом блокировки:

Unable to create/open the lock file: /data/db/mongod.lock (Permission denied). Ensure the user executing mongod is the owner of the lock file and has the appropriate permissions.

Решение заключается в выполнении chown для каталога на хосте с использованием числового идентификатора, а не имени пользователя:

sudo chown -R 999:999 /srv/appname/pg
sudo chmod 700 /srv/appname/pg
ls -ldn /srv/appname/pg

ls -ldn выводит числа вместо имен, и там должно быть 999 999. Учетная запись с именем postgres на вашем хосте и учетная запись с именем postgres внутри образа никак не связаны: ядро сравнивает только числа, а имена разрешаются отдельно для каждой стороны. В том, как PUID и PGID отображают пользователей хоста в контейнер, подробно описан этот процесс. При использовании rootless Docker или переназначении пространств имен пользователей (user namespace remapping) числа снова меняются, поэтому лучше считывать идентификаторы из работающего контейнера, а не полагаться на 999.

Именованные тома (named volumes) позволяют избежать всех описанных проблем при первом запуске, так как Docker создает пустой каталог, а точка входа назначает его владельца.

Обновления: обновление пакета при смене тега образа

На хосте apt upgrade позволяет перейти на минорную версию. Дистрибутив не обновит мажорную версию базы данных без вашего ведома, а при необходимости перехода оба набора бинарных файлов могут быть установлены одновременно, что именно и требуется для pg_upgrade.

В контейнере тег является версией, поэтому обновление сводится к редактированию одной строки. Это делает минорные обновления тривиальными, а мажорные — полноценной процедурой.

Измените postgres:16 на postgres:17, выполните docker compose up -d, и контейнер немедленно завершит работу:

PostgreSQL Database directory appears to contain a database; Skipping initialization
FATAL:  database files are incompatible with server
DETAIL:  The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.

Ничего не повреждено. Новые бинарные файлы отказываются читать старую структуру каталога на диске, которая меняется между мажорными версиями. Верните тег на postgres:16, и сервис снова запустится. Этот откат — единственное реальное преимущество контейнеров при обновлении.

Поддерживаемый путь — это дамп и восстановление. PostgreSQL требует, чтобы дамп был сделан более новым клиентом, поэтому запускайте его из нового образа против всё ещё работающего старого сервера в сети compose:

docker run --rm --network myapp_default -e PGPASSWORD="$POSTGRES_PASSWORD" \
  postgres:17 pg_dumpall -h db -U postgres > /srv/backups/all.sql
ls -lh /srv/backups/all.sql
tail -n 2 /srv/backups/all.sql

Файл должен быть размером минимум в десятки килобайт и заканчиваться строкой PostgreSQL database cluster dump complete. Файл размером в несколько сотен байт означает, что дамп не удался и вы собираетесь удалить том впустую. Только после этой проверки:

docker compose down
docker volume rm myapp_pgdata
# edit the compose file: image: postgres:17
docker compose up -d db
docker compose exec -T db psql -U postgres -f /dev/stdin < /srv/backups/all.sql

Другие движки имеют свои особенности:

  • MySQL 8 обновляет собственный словарь данных при запуске, поэтому смена минорного тега обычно требует только перезапуска. Перед переходом между сериями релизов читайте примечания к выпуску и в любом случае сначала делайте дамп.
  • MariaDB требует выполнения mariadb-upgrade после запуска сервера на новой версии.
  • MongoDB необходимо обновлять последовательно по одной мажорной версии за раз, и после каждого шага нужно устанавливать версию совместимости функций (feature compatibility version) перед переходом к следующей. Пропуск версии приведет к тому, что mongod откажется запускаться и запишет в лог строку UPGRADE PROBLEM с указанием featureCompatibilityVersion. Начиная с MongoDB 7.0, команда требует явного флага подтверждения: db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true }).
  • Redis успешно загружает старые файлы снимков, но не новые, поэтому обновление — это перезапуск, а при откате данные могут не загрузиться.

Общее правило: контейнер упрощает откат, но не упрощает само обновление.

Почему мой контейнер с базой данных завершается с кодом 137?

Это происходит из-за того, что механизм OOM (Out of Memory) killer ядра принудительно завершил процесс. Код 137 складывается из 128 и сигнала 9.

docker compose ps
docker inspect myapp-db-1 | grep -i oomkilled
journalctl -k | tail -n 20

docker compose ps показывает Exited (137), в выводе команды inspect указано "OOMKilled": true, а в журнале ядра присутствует соответствующая запись:

Memory cgroup out of memory: Killed process 4711 (postgres) total-vm:2170416kB

Механизм этого процесса часто становится неожиданностью. PostgreSQL и MySQL определяют размер своих буферов на основе общего объема памяти, который сообщает хост. Лимиты cgroup не меняют это значение для них. На хосте с 16 ГБ памяти и лимитом в 2 ГБ база данных планирует работу так, будто в её распоряжении все 16 ГБ, и cgroup завершает её задолго до того, как на самом хосте возникнет нехватка ресурсов. Поэтому одного лишь лимита памяти недостаточно. Вы должны явно указать базе данных доступный объем:

  • PostgreSQL: настройте shared_buffers и уделите внимание work_mem. work_mem выделяется на каждую операцию сортировки для каждого соединения, поэтому завышенное значение, умноженное на пятьдесят соединений, — типичная причина, по которой контейнер падает под нагрузкой, а не при запуске.
  • MySQL и MariaDB: настройте innodb_buffer_pool_size, значение по умолчанию для которого составляет 128M. Отключите innodb_dedicated_server в контейнере, так как его основная задача — автоматически определять размер на основе памяти машины.
  • MongoDB: явно укажите размер кэша WiredTiger, вместо того чтобы позволять ему оценивать объем на основе памяти хоста.
  • Redis: maxmemory по умолчанию не ограничено, поэтому Redis будет расти, пока cgroup не остановит его. Установите maxmemory с запасом ниже лимита контейнера и выберите подходящую maxmemory-policy.

Postgres также сообщает об этом событии со своей стороны, и в логах вы увидите следующую пару строк:

LOG:  server process (PID 123) was terminated by signal 9: Killed
LOG:  terminating any other active sessions due to crash of another server process

Завершение одного бэкенд-процесса вынуждает перезапускаться все остальные, так как разделяемая память может стать несогласованной. Для вашего приложения это означает шквал соединений, а не просто тихое событие. В разделе установка лимитов памяти в Docker Compose описан синтаксис и разница между mem_limit и формой deploy.resources.

Проблема не исчезает на хосте, она лишь перемещается. Без cgroup база данных конкурирует за ресурсы со всеми остальными процессами, и OOM killer хоста выбирает жертву на основе рейтинга, которой может оказаться даже sshd. Лимит, который предсказуемо завершает базу данных, проще в эксплуатации, чем OOM-событие на хосте, которое может заблокировать вам доступ к системе.

Резервное копирование: дамп внутри, бэкап снаружи

Не делайте резервную копию работающей базы данных путем простого копирования её директории с данными. Файловая копия, созданная в момент записи данных сервером, будет повреждена, и вы узнаете об этом только в момент восстановления.

Существует два надежных метода: создание дампа с помощью штатных инструментов базы данных во время её работы с последующим бэкапом этого дампа, либо остановка контейнера и «холодное» копирование тома.

docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backups/appdb.dump
docker compose exec -T db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --all-databases > /srv/backups/mysql.sql
docker compose exec -T db mongodump --archive --gzip --db appdb > /srv/backups/appdb.archive.gz
docker compose exec -T redis redis-cli BGSAVE

Флаг -T критически важен. Без него docker compose exec может подключить терминал к команде, а уровень терминала добавит символы возврата каретки в поток вывода. Текстовый дамп при восстановлении будет выдавать странные ошибки, а бинарный дамп окажется просто поврежденным. Это приводит к сбою без уведомлений во время бэкапа и к серьезным проблемам спустя месяц.

Параметр --single-transaction обеспечивает mysqldump согласованный снимок таблиц InnoDB без блокировки всего сервера.

Эти команды создают по одному файлу. Это не система резервного копирования: здесь нет политики хранения, копирования на другой узел и проверки целостности. Передайте директорию с дампами инструменту, который выполняет все три задачи, для чего и предназначено руководство restic backups from a VPS. Делайте бэкап /srv/backups, а не /var/lib/docker/volumes.

Затем выполните восстановление, так как бэкап, который вы никогда не восстанавливали — это не бэкап:

docker compose exec -T db createdb -U postgres restore_test
docker compose exec -T db pg_restore -U postgres -d restore_test < /srv/backups/appdb.dump
docker compose exec -T db psql -U postgres -d restore_test -c '\dt'

Команда \dt должна вывести список таблиц вашего приложения. Пустой результат или Did not find any relations. означают, что дамп содержит не то, что вы ожидали. Удалите restore_test после завершения работы.

Команда, которая удаляет всё

docker compose down -v.

Обычная команда down удаляет контейнеры и сеть. Флаг -v также удаляет все именованные тома, объявленные в этом файле compose, а также все анонимные тома, присоединенные к этим контейнерам. Запрос на подтверждение отсутствует, отмена операции невозможна. Это самый распространенный способ уничтожения базы данных при self-hosted размещении; обычно это происходит во время устранения неполадок, не связанных с данными, потому что так было предложено в ответе на форуме.

Четыре правила позволяют уменьшить радиус поражения:

  • Объявляйте том базы данных как external: true. Compose не удаляет тома, которыми он не управляет, поэтому -v не сможет их затронуть. Создайте такой том один раз с помощью docker volume create myapp_pgdata.
  • Используйте docker compose stop и docker compose start для плановых перезапусков. В статье разница между down и stop в Compose подробно описано, что именно удаляет каждая из этих команд.
  • Храните дампы по пути на хосте, который находится вне всех томов, управляемых compose.
  • Никогда не копируйте -v из ответов по устранению неполадок в стек, содержащий важные для вас данные.

Не публикуйте порт базы данных

Эта строка открывает доступ к вашей базе данных из публичного интернета:

    ports:
      - "5432:5432"

Она привязывает сервис ко всем сетевым интерфейсам. Docker публикует порт, перезаписывая адрес назначения пакета до того, как он попадет в правила входящего трафика вашего межсетевого экрана. Поскольку правила ufw находятся в цепочке input, команда ufw deny 5432 не оказывает никакого эффекта. В почему опубликованные порты Docker обходят ufw описан процесс прохождения пакетов по цепочкам.

Приложение в том же проекте compose обращается к базе данных по имени сервиса внутри сети compose, поэтому публикация порта не требуется. Удалите этот блок. Если вам нужен доступ к базе данных с хоста, привяжите её только к интерфейсу loopback:

    ports:
      - "127.0.0.1:5432:5432"

Проверьте, какие порты действительно прослушиваются:

sudo ss -ltnp | grep 5432

127.0.0.1:5432 — это желаемый результат. 0.0.0.0:5432 означает, что любой пользователь может попытаться подобрать ваш пароль.

Что и где запускать

Одно приложение на одном VPS. Контейнер. Именованный том с фиксированным именем, без публикации портов, с ограничением по памяти, соответствующим настройкам базы данных, и ежедневным дампом в директорию хоста, откуда его забирает restic. Начните с чистой установки Docker на VPS и храните стек в одном файле compose, который вы коммитите в репозиторий. Преимущество очевидно: версия базы данных становится строкой в git, которую можно проверить.

Хост с несколькими сервисами. Контейнеры, по одной базе данных на каждое приложение, а не один общий сервер для всех. Общий сервер привязывает все приложения к одному графику обновлений, и один «тяжелый» запрос приводит к простою всех сервисов сразу. Установите для каждого контейнера лимит памяти, чтобы некорректный запрос затрагивал только то приложение, которое его отправило. Несколько небольших экземпляров Postgres требуют чуть больше дискового пространства, но значительно упрощают управление.

База данных — это основной продукт. Запускайте её на хосте из официального репозитория пакетов вендора или используйте управляемый облачный сервис. pg_upgrade требует одновременной установки двух мажорных версий бинарных файлов, что обеспечивают пакеты, но не обеспечивают образы с одной версией. Репликация и восстановление на момент времени (PITR) с архивацией WAL (write ahead log) выполняются проще, когда база данных «владеет» машиной и её дисками. Выбирайте предсказуемый путь для системы, которая будет присылать вам уведомления в 03:00.

Приложение небольшое. Рассмотрите вариант работы вообще без серверной базы данных. Для веб-приложения с одним пишущим процессом на одном VPS часто лучше подходит SQLite в production на VPS, где резервная копия представляет собой один файл, а путь обновления — это просто смена версии библиотеки.

FAQ

Безопасно ли запускать промышленную базу данных в Docker?

Да, для стека приложений на одном сервере. Контейнер — это Linux-процесс, ограниченный пространствами имён и cgroups. При использовании смонтированного тома база данных записывает данные в ту же файловую систему хоста, что и при установке из пакета. Риски здесь скорее эксплуатационные, а не связанные со скоростью: том с нефиксированным именем, bind mount с неверным владельцем, не протестированное восстановление и docker compose down -v. Устраните эти четыре фактора, и контейнер будет работать стабильно. Переходите на установку непосредственно на хост, когда база данных является основной нагрузкой и вам требуются pg_upgrade, репликация или восстановление на момент времени (point in time recovery).

Что лучше использовать для данных базы данных: bind mount или именованный том?

Используйте именованный том, если у вас нет специфических причин знать путь на хосте. Docker создаёт директорию, а entrypoint образа устанавливает права владения при первом запуске, поэтому проблема с доступом не возникает. Закрепите том с помощью явного name: или пометьте его как external: true, иначе переименование директории проекта приведёт к созданию нового пустого тома и пустой базы данных. Bind mount подходит, если вы установите владельцем директории на хосте числовой идентификатор пользователя (UID), под которым работает образ (999 для официальных образов Postgres, MySQL и MongoDB). Проверьте это с помощью ls -ldn, так как ls -l показывает имя пользователя на вашем хосте для этого числа, а внутри контейнера это имя не имеет смысла.

Что удаляет docker compose down -v?

Эта команда удаляет контейнеры и сеть, как и обычный down, а -v дополнительно удаляет все именованные тома, объявленные в файле compose, вместе со всеми анонимными томами, привязанными к этим контейнерам. Это включает и базу данных. Запрос на подтверждение не выводится, восстановление невозможно. Тома, помеченные как external: true, не удаляются — это основная причина помечать том базы данных как внешний. Для обычного перезапуска используйте docker compose stop и docker compose start.

Как обновить PostgreSQL до новой мажорной версии в Docker?

Используйте дамп и восстановление. Смена postgres:16 на postgres:17 и перезапуск приведут к FATAL: database files are incompatible with server с сообщением DETAIL, в котором указаны обе версии, так как новые бинарные файлы не смогут прочитать структуру старого каталога. Ничего не повредится: верните старый тег, и всё запустится. Сделайте pg_dumpall, используя клиент новой версии против работающего старого контейнера, убедитесь, что файл заканчивается на PostgreSQL database cluster dump complete, затем запустите новый тег на пустом томе и загрузите дамп. Минорные обновления внутри одной мажорной версии требуют только pull и перезапуска.

Почему мой контейнер с базой данных завершается с кодом 137?

137 — это 128 плюс сигнал 9, значит, процесс был принудительно завершён. Выполните docker inspect <container> | grep -i oomkilled; значение true означает, что контейнер достиг лимита памяти cgroup. Обычно это происходит потому, что PostgreSQL и MySQL считывают общий объём памяти хоста и не видят лимитов контейнера, поэтому планируют работу исходя из 16 ГБ, находясь в рамках 2 ГБ. Установите shared_buffers и work_mem или innodb_buffer_pool_size в соответствии с лимитом, который вы задали контейнеру. Проверьте journalctl -k на наличие соответствующей строки Memory cgroup out of memory, чтобы подтвердить, какой именно процесс выбрало ядро.