Бази даних у Docker чи на хості: що краще?
PostgreSQL, MySQL, MongoDB і Redis у production працюють у Docker. Дізнайтеся, як volume, оновлення, резервні копії та memory limits впливають на надійність.
Запускати базу даних у Docker чи на хості?
Запускайте базу даних у Docker. Для одного стека застосунків на одному VPS контейнеризовані PostgreSQL, MySQL, MongoDB або Redis є звичайним вибором для production, а аргумент, навколо якого зазвичай точаться суперечки, здебільшого не стосується суті. Контейнер — це процес Linux із просторами імен і cgroups, а не віртуальна машина, тому між базою даних і диском немає гіпервізора. За використання bind mount або локального іменованого тому операції читання та запису виконуються у файловій системі хоста — у тій самій, яку використовувало б встановлення пакета.
Справжні витрати пов’язані з експлуатацією. Чотири чинники визначають, чи буде така схема нормальною, чи перетвориться на повільну катастрофу: де зберігаються дані, кому належить цей каталог, як виконуватиметься оновлення основної версії та чи відновлювали ви коли-небудь резервну копію. Якщо все це налаштовано правильно, контейнер — лише деталь. Якщо ні, саме контейнер ви звинувачуватимете в проблемах.
Це саме рішення стосується всіх серверних баз даних. У прикладах нижче використано PostgreSQL, MySQL, MongoDB і Redis; відмінності, специфічні для кожного продукту, зазначено там, де вони мають значення.
Що насправді змінює контейнер
Не шлях до сховища, якщо ви монтуєте його. Ядро те саме, кеш сторінок той самий, файлова система та сама.
Є одна реальна пастка, пов’язана з продуктивністю: коли ви нічого не монтуєте. Без тому каталог даних потрапляє в доступний для запису шар контейнера, який є overlay-файловою системою поверх образу. Запис у цей шар повільніший, а весь шар видаляється разом із контейнером. Саме через це з’являється ситуація «сьогодні вранці база даних була порожня».
Що насправді змінюється:
- Життєвий цикл.
docker compose downзнищує контейнер. Усе, що не зберігалося в томі, видаляється разом із ним. - Версія. Тег образу визначає версію. У контейнері бази даних немає
apt upgrade, який переживе наступнийdocker compose pull. - Облік пам’яті. Ліміт cgroup є жорстким обмеженням, яке забезпечує ядро, а база даних не знає про його наявність.
- Користувач. Процес працює в контейнері від імені користувача з числовим ідентифікатором, якому на хості можуть не належати жодні файли.
Розташування даних визначає все
Є два надійні варіанти та одна поширена помилка.
- Іменований том:
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 і іменовані томи. Для бази даних коротка відповідь така: використовуйте іменований том, якщо немає конкретної причини знати шлях на хості. Якщо використовуєте bind mount, розмістіть його у стабільному каталозі, наприклад /srv/appname/pg, а не в каталозі проєкту, до якого може дістатися git clean.
Є важливе обмеження: не розміщуйте каталог даних бази даних на NFS (мережевій файловій системі) або на іншому мережевому монтуванні, поведінку блокування та 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 зазвичай працює з першої спроби.
Це перестає працювати, щойно у compose-файлі встановлено user:, оскільки після цього entrypoint уже не має привілеїв, щоб щось виправити. 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, завершується помилкою під час роботи з lock-файлом:
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.Виправлення полягає в тому, щоб змінити власника каталогу на хості на числовий ідентифікатор, а не на ім’я:
sudo chown -R 999:999 /srv/appname/pg
sudo chmod 700 /srv/appname/pg
ls -ldn /srv/appname/pgls -ldn виводить числові ідентифікатори замість імен і має показати 999 999. Обліковий запис з назвою postgres на хості та обліковий запис з назвою postgres усередині образу не пов’язані між собою: ядро порівнює числові ідентифікатори, а імена окремо визначаються з кожного боку. У матеріалі як PUID і PGID зіставляють користувачів хоста з контейнером це зіставлення розглянуто докладніше. У rootless Docker або в разі remapping user namespace числові ідентифікатори знову змінюються, тому зчитуйте їх із запущеного контейнера, а не припускайте, що це 999.
Іменовані томи усувають усю цю проблему під час першого запуску, оскільки Docker створює порожній каталог, а entrypoint стає його власником.
Оновлення: оновлення пакета та зміна image tag
На хості apt upgrade переводить вас на наступну minor version. Дистрибутив не оновить major version бази даних без вашого рішення. Коли ви вирішите виконати таке оновлення, обидва набори бінарних файлів можна встановити одночасно. Саме цього потребує pg_upgrade.
У контейнері tag визначає версію, тому оновлення зводиться до редагування одного рядка. Через це minor upgrades прості, а major upgrades потребують окремої процедури.
Замініть 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.Дані не пошкоджено. Нові бінарні файли відмовляються читати стару структуру каталогу на диску, яка змінюється між major versions. Поверніть tag до postgres:16, і контейнер знову запуститься. Це єдина справжня перевага контейнерів під час оновлення: їх легко відкотити.
Підтримуваний спосіб — створити dump і відновити його. PostgreSQL рекомендує створювати dump новішим клієнтом. Тому виконайте команду з нового image, підключившись до старого сервера, який ще працює в compose network:
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. Файл розміром у кілька сотень байтів означає, що dump не створено. Інакше ви видалите volume без потреби. Лише після цієї перевірки виконайте:
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 оновлює власний data dictionary під час запуску, тому зміна minor tag зазвичай потребує лише перезапуску. Перед переходом між release series прочитайте release notes і в будь-якому разі спочатку створіть dump.
- MariaDB очікує виконання
mariadb-upgradeпісля запуску сервера на новій версії. - MongoDB потрібно оновлювати лише на одну major version за раз. Після кожного кроку встановіть feature compatibility version і лише потім переходьте далі. Якщо пропустити версію,
mongodвідмовиться запускатися та запише в журнал рядокUPGRADE PROBLEMіз назвоюfeatureCompatibilityVersion. Починаючи з MongoDB 7.0, команда потребує явного прапорця підтвердження:db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true }). - Redis без проблем завантажує старі snapshot files, але не новіші. Тому оновлення потребує перезапуску, а під час відкату завантаження даних може завершитися помилкою.
Загальне правило: контейнер спрощує downgrade, але не спрощує upgrade.
Чому мій контейнер бази даних завершується з кодом 137?
Тому що kernel OOM killer завершив його через нестачу пам’яті. 137 — це 128 плюс signal 9.
docker compose ps
docker inspect myapp-db-1 | grep -i oomkilled
journalctl -k | tail -n 20docker compose ps показує Exited (137), у рядку inspect зазначено "OOMKilled": true, а журнал kernel містить відповідний запис:
Memory cgroup out of memory: Killed process 4711 (postgres) total-vm:2170416kBОсь як це працює, і це часто дивує користувачів. PostgreSQL і MySQL визначають розмір буферів на основі загального обсягу пам’яті, який повідомляє host. Ліміт cgroup не змінює це значення для них. На host із 16 GB пам’яті та лімітом 2 GB база даних планує роботу так, ніби має 16 GB, а cgroup завершує її задовго до того, як сам host починає відчувати дефіцит пам’яті. Тому одного ліміту пам’яті недостатньо. Потрібно також повідомити базі даних, який обсяг пам’яті їй доступний:
- PostgreSQL: задайте
shared_buffersі зверніть увагу наwork_mem.work_memвиділяється для кожної операції сортування в кожному з’єднанні, тому завелике значення, помножене на п’ятдесят з’єднань, зазвичай спричиняє завершення контейнера під навантаженням, а не під час запуску. - MySQL і MariaDB: задайте
innodb_buffer_pool_size, значення якого за замовчуванням становить 128M. Не вмикайтеinnodb_dedicated_serverу контейнері, оскільки його основне призначення — визначати розмір на основі обсягу пам’яті виявленої машини. - MongoDB: задайте розмір кешу WiredTiger явно, замість того щоб дозволяти йому визначати його на основі пам’яті host.
- 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Завершення одного backend змушує всі інші backend перезапуститися, оскільки shared memory може бути неузгодженою. Для вашого застосунку це означає шквал з’єднань, а не непомітну подію. У розділі налаштування лімітів пам’яті в Docker Compose описано синтаксис і різницю між mem_limit та формою deploy.resources.
На host це нікуди не зникає. Проблема лише переміщується. Без cgroup база даних конкурує за ресурси з усіма іншими процесами на машині, а host OOM killer вибирає процес-жертву за score. Ним може виявитися sshd. Ліміт, який передбачувано завершує роботу бази даних, простіше обслуговувати, ніж OOM на host, через який ви втрачаєте доступ до системи.
Резервні копії: дамп усередині, копія назовні
Не створюйте резервну копію запущеної бази даних, копіюючи її каталог даних. Копія на рівні файлів, зроблена під час запису сервером, може бути неповною. Це виявиться лише під час відновлення.
Є два надійні методи: створити дамп власним інструментом бази даних під час її роботи й створити резервну копію дампу або зупинити контейнер і скопіювати volume у вимкненому стані.
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 з 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 пояснено, що саме видаляє кожна команда. - Зберігайте дампи в host path, який не належить до жодного тому, яким керує compose.
- Ніколи не вставляйте
-vз відповіді про усунення несправностей у stack, що містить важливі дані.
Не публікуйте порт бази даних
Цей рядок робить базу даних доступною з публічного інтернету:
ports:
- "5432:5432"Він прив’язує порт до всіх інтерфейсів. Docker публікує порт, змінюючи адресу призначення пакета ще до того, як пакет потрапляє до правил input вашого firewall. Правила ufw розташовані в ланцюжку input, тому ufw deny 5432 взагалі нічого не змінює. У статті чому опубліковані Docker порти обходять ufw показано проходження пакета ланцюжками.
Застосунок у тому самому compose-проєкті підключається до бази даних за іменем сервісу в compose network, тому опублікований порт йому не потрібен. Видаліть цей блок. Якщо клієнт має працювати на хості, прив’яжіть порт лише до loopback:
ports:
- "127.0.0.1:5432:5432"Перевірте, що саме прослуховує порт:
sudo ss -ltnp | grep 5432127.0.0.1:5432 — це потрібний результат. 0.0.0.0:5432 означає, що будь-хто може спробувати підібрати ваш пароль.
Що і де запускати
Один застосунок на одному VPS. Використовуйте контейнер. Створіть іменований volume із зафіксованою назвою, не публікуйте порт, установіть обмеження пам’яті та узгодьте з ним параметри бази даних. Налаштуйте нічний дамп у шлях на хості, який збирає restic. Почніть із чистої інсталяції Docker на VPS і зберігайте стек в одному compose-файлі, який ви комітите. Перевага очевидна: версія бази даних стає рядком у git, який можна перевірити під час code review.
Хост, на якому працює кілька сервісів. Використовуйте контейнери та окрему базу даних для кожного застосунку, а не один спільний сервер для всіх. Спільний сервер прив’язує всі застосунки до одного графіка оновлень, а один неконтрольований запит спричиняє простій усіх. Установіть для кожного контейнера власне обмеження пам’яті, щоб помилковий запит впливав лише на застосунок, який його створив. Кілька невеликих екземплярів Postgres потребують трохи більше дискового простору, але значно менше координації.
База даних є самим продуктом. Запускайте її на хості з репозиторію пакетів постачальника або використовуйте керований сервіс. pg_upgrade одночасно потребує двох основних версій бінарних файлів. Пакети це забезпечують, а образ з однією версією — ні. Реплікацію та відновлення до моменту часу з архівацією WAL (write ahead log) легше налаштувати, коли база даних керує власною машиною та її дисками. Для системи, яка надсилатиме вам сповіщення о 03:00, обирайте найпростішу та передбачувану схему.
Застосунок невеликий. Розгляньте варіант узагалі не використовувати серверну базу даних. Для вебзастосунку з одним записувачем на одному VPS часто краще використовувати SQLite у production на VPS, де резервна копія — це один файл, а шлях оновлення визначається версією бібліотеки.
FAQ
Чи безпечно запускати production-базу даних у Docker?
Так, для стеку застосунків на одному сервері. Контейнер — це процес Linux з ізольованими namespace і cgroup навколо нього. Якщо змонтувати volume, база даних записує дані в ту саму файлову систему хоста, яку вона використовувала б після встановлення з пакета. Ризики стосуються експлуатації, а не швидкодії: volume без зафіксованого імені, bind mount із неправильним user id, неперевірлене відновлення та docker compose down -v. Якщо усунути ці чотири ризики, контейнер можна використовувати. Встановлюйте базу безпосередньо на хості, якщо вона є основним навантаженням і вам потрібні pg_upgrade, реплікація або point-in-time recovery.
Використовувати bind mount чи named volume для даних бази даних?
Використовуйте named volume, якщо немає конкретної причини знати шлях на хості. Docker створює каталог, а entrypoint образу під час першого запуску налаштовує його власника. Тому проблема з правами доступу не виникає. Зафіксуйте volume за допомогою явного name: або позначте його як external: true. Інакше перейменування каталогу проєкту непомітно створить новий порожній volume і порожню базу даних. Bind mount можна використовувати, якщо змінити власника каталогу на хості на числовий user id, під яким працює образ. Для офіційних образів Postgres, MySQL і MongoDB це 999. Перевірте його за допомогою ls -ldn, оскільки ls -l показує ім’я цього числа на хості, а це ім’я нічого не означає всередині контейнера.
Що видаляє docker compose down -v?
Воно видаляє контейнери та network так само, як звичайний down. Крім того, -v видаляє кожен named volume, оголошений у цьому compose-файлі, а також кожен anonymous volume, підключений до цих контейнерів. Сюди входить і база даних. Підтвердження не запитується, а відновлення неможливе. Volumes, позначені як external: true, не видаляються. Це головна причина позначити volume бази даних як external. Для звичайного перезапуску використовуйте docker compose stop і docker compose start.
Як оновити PostgreSQL до нової major version у Docker?
Виконайте dump і restore. Заміна postgres:16 на postgres:17 та перезапуск дають FATAL: database files are incompatible with server із рядком DETAIL, у якому вказано обидві версії. Нові binaries не можуть прочитати старий формат catalog. Дані не пошкоджено: поверніть старий tag, і контейнер запуститься. Створіть pg_dumpall за допомогою client нової версії, підключеного до запущеного контейнера зі старою версією. Переконайтеся, що файл закінчується на PostgreSQL database cluster dump complete. Потім запустіть новий tag із порожнім volume і завантажте dump. Для minor upgrades у межах однієї major version достатньо виконати pull і перезапуск.
Чому мій контейнер бази даних завершує роботу з кодом 137?
137 — це 128 плюс signal 9, тому процес було примусово завершено. Виконайте docker inspect <container> | grep -i oomkilled. Значення true означає, що контейнер досяг ліміту пам’яті cgroup. Зазвичай PostgreSQL і MySQL визначають загальний обсяг пам’яті на хості та не бачать ліміт контейнера. Тому вони планують роботу для 16 GB, перебуваючи в межах 2 GB. Налаштуйте shared_buffers і work_mem або innodb_buffer_pool_size відповідно до ліміту контейнера. Перевірте journalctl -k на наявність відповідного рядка Memory cgroup out of memory, щоб визначити, який процес вибрало ядро.