Модуль 04 — Тома и данные

Контейнер одноразовый, данные — нет: куда их положить, чтобы пережили обновление

Прогресс курса Модуль 4 из 9

Что вы освоите в этом модуле

01

Слой записи живёт, пока живёт контейнер

Слои образа — только для чтения. Всё, что контейнер пишет, попадает в его собственный слой записи. Пока контейнер существует, даже остановленный, данные можно достать (docker cp копирует файлы между контейнером и хостом). Удалили контейнер — слой записи удалён вместе с ним:

$ docker run --name box alpine:3.22 sh -c "echo важные данные > /data.txt"
$ docker cp box:/data.txt . && cat data.txt
важные данные
$ docker rm box
box
$ docker run --rm alpine:3.22 cat /data.txt
cat: can't open '/data.txt': No such file or directory

Обновление приложения — это удаление контейнера и создание нового из нового образа (модуль 02). Значит, всё, что должно пережить обновление: файлы базы данных, загрузки пользователей, сертификаты, — хранится вне слоя записи. Для этого у Docker три способа:

СпособГде данныеКогда использовать
Том (volume)В /var/lib/docker/volumes/, управляет DockerДанные приложения: базы, загрузки. Основной способ
Bind mountЛюбой каталог хоста, который вы указалиКонфигурация с хоста, исходный код при разработке, обмен файлами с хостом
tmpfsВ оперативной памяти, исчезает при остановкеВременные файлы, которые не должны попасть на диск
02

Именованный том

Том — каталог, который Docker создаёт и подключает в контейнер по указанному пути. Запись -v имя:/путь: если тома с таким именем нет, Docker создаст его сам; docker volume create делает это явно. Два контейнера по очереди пишут в один том — данные копятся:

$ docker volume create notes-data
notes-data
$ docker run --rm -v notes-data:/data alpine:3.22 sh -c "echo запись 1 >> /data/log.txt"
$ docker run --rm -v notes-data:/data alpine:3.22 sh -c "echo запись 2 >> /data/log.txt; cat /data/log.txt"
запись 1
запись 2
$ docker volume ls
DRIVER    VOLUME NAME
local     notes-data
$ docker volume inspect notes-data
[
    {
        "CreatedAt": "2026-10-23T20:10:19+03:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/notes-data/_data",
        "Name": "notes-data",
        "Options": null,
        "Scope": "local"
    }
]

Mountpoint — где том лежит на хосте. Читать и править файлы там напрямую можно (от root), но не нужно: работайте с томом через контейнеры. Драйвер local — том на диске этого хоста; сторонние драйверы умеют хранить тома на NFS или в облачных дисках.

03

База данных в томе

Главный пример — PostgreSQL. Официальный образ хранит базу в /var/lib/postgresql/data, туда и подключаем том. Создадим таблицу, удалим контейнер и запустим новый на том же томе:

$ docker run -d --name pg -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:17
9431ac56cde2d08be1776b0e5bb8816f69e034cd47e1efe10bd3ebc2cb515982
$ docker exec pg psql -U postgres -c "CREATE TABLE notes (id serial, text text)" -c "INSERT INTO notes (text) VALUES ('пережить пересоздание')"
CREATE TABLE
INSERT 0 1
$ docker rm -f pg
pg
$ docker run -d --name pg -e POSTGRES_PASSWORD=другой -v pgdata:/var/lib/postgresql/data postgres:17
041e820c2c0407a12dbe50a5019673fa898ea18ce61fd35e4af4bda25222a6a6
$ docker exec pg psql -U postgres -c "SELECT * FROM notes"
 id |         text          
----+-----------------------
  1 | пережить пересоздание
(1 row)

$ docker logs pg 2>&1 | grep -m1 -i "skipping"
PostgreSQL Database directory appears to contain a database; Skipping initialization

Данные на месте. Обратите внимание: второй контейнер запущен с другим паролем, но пароль не изменился. Переменные POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB используются только при первом запуске на пустом томе, когда скрипт образа создаёт базу. Если том уже содержит базу, скрипт пропускает инициализацию — строка Skipping initialization в журнале. Сменить пароль после этого можно только средствами самой СУБД: ALTER USER.

У одного тома с базой — один работающий контейнер СУБД. Два PostgreSQL на одном каталоге данных испортят базу. Новую версию запускают, только когда старый контейнер остановлен.
04

Анонимные тома

Что будет, если запустить PostgreSQL вообще без -v? В Dockerfile образа есть инструкция VOLUME /var/lib/postgresql/data, и Docker на каждый такой запуск создаёт анонимный том со случайным именем:

$ docker rm -f pg
pg
$ docker run -d --name pg2 -e POSTGRES_PASSWORD=secret postgres:17
4bebe26fc362dacad5f2ad5d535673df0462904be7946910cb7cc71ff624a2c8
$ docker volume ls
DRIVER    VOLUME NAME
local     d22de03184d5cb24f7c30f4dc98d52df67a2098ba1ddafdfc79ae7d72977b4e4
local     notes-data
local     pgdata
$ docker image inspect -f "{{json .Config.Volumes}}" postgres:17
{"/var/lib/postgresql/data":{}}
$ docker rm -f -v pg2
pg2
$ docker volume ls
DRIVER    VOLUME NAME
local     notes-data
local     pgdata

Данные не попали в слой записи, но и найти их потом трудно: имя-хеш ничего не говорит, а при следующем docker run создастся новый пустой том. Так на серверах копятся гигабайты «ничьих» томов. docker rm -v удаляет анонимные тома контейнера вместе с ним; именованные тома не удаляются никогда, пока вы не попросите явно.

Правило: для образов с VOLUME (базы, очереди, реестры) всегда задавайте именованный том.

05

Bind mount: каталог хоста

Если перед двоеточием — абсолютный путь, Docker подключает этот каталог хоста. Изменения видны сразу в обе стороны. Суффикс :ro запрещает контейнеру запись:

$ cat site/index.html
<h1>Версия 1</h1>
$ docker run -d --name web -p 8080:80 -v "$PWD/site":/usr/share/nginx/html:ro nginx:1.28-alpine
29487db5c56cb20885876302740cf19088edb5358d6fd61029d025a5de84d3b5
$ curl -s http://localhost:8080
<h1>Версия 1</h1>
$ echo "<h1>Версия 2</h1>" > site/index.html
$ curl -s http://localhost:8080
<h1>Версия 2</h1>
$ docker exec web sh -c "echo взлом > /usr/share/nginx/html/index.html"
sh: can't create /usr/share/nginx/html/index.html: Read-only file system
$ docker rm -f web
web

Сайт обновился без перезапуска контейнера, а попытка записи изнутри отбита. Так подключают конфигурацию (nginx.conf, сертификаты) и исходный код при разработке. Для конфигурации и сертификатов :ro — почти всегда правильный выбор: взломанное приложение не сможет их подменить.

Ловушка — относительный путь. Строка без / в начале считается именем тома, и Docker молча создаёт пустой том site вместо подключения каталога:

$ docker run --rm -v site:/data alpine:3.22 ls /data
$ docker volume ls
DRIVER    VOLUME NAME
local     notes-data
local     pgdata
local     site
$ docker volume rm site
site

Поэтому в командах пишут "$PWD/site" или ./site — с ./ Docker тоже поймёт, что это путь:

$ docker run --rm -v ./site:/data alpine:3.22 ls /data
index.html

В Compose относительные пути считаются от файла проекта (модуль 06).

06

-v и --mount

Кроме краткой записи -v есть подробная --mount: пары ключ-значение, тип указан явно. Разница не только в синтаксисе. -v с несуществующим путём хоста молча создаёт каталог, причём от root; --mount выдаёт ошибку:

$ docker run --rm -v "$PWD/nosuch":/data alpine:3.22 true; ls -ld nosuch
drwxr-xr-x 2 root root 40 окт 23 20:10 nosuch
$ docker run --rm --mount type=bind,src="$PWD/nosuch2",dst=/data alpine:3.22 true
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /tmp/dkpage.Uddszx/ex/nosuch2.
See 'docker run --help'.
-v--mount
-v pgdata:/var/lib/postgresql/data--mount type=volume,src=pgdata,dst=/var/lib/postgresql/data
-v "$PWD/site":/usr/share/nginx/html:ro--mount type=bind,src="$PWD/site",dst=/usr/share/nginx/html,readonly
--tmpfs /tmp--mount type=tmpfs,dst=/tmp

В скриптах и документации --mount понятнее: опечатка в пути даёт ошибку, а не пустой каталог, в котором приложение потом не найдёт настройки.

07

Права: чей файл

Ядро одно на хост и контейнеры, и права проверяются по числовым UID и GID, а не по именам. Пользователь app с UID 999 в контейнере для хоста — просто 999. Отсюда три ситуации, все в одном опыте:

$ mkdir out; id -u; ls -ld out
1000
drwxrwxr-x 2 padmin padmin 40 окт 23 20:10 out
$ docker run --rm -v "$PWD/out":/out alpine:3.22 sh -c "echo от root > /out/root.txt"
$ docker run --rm -u 999 -v "$PWD/out":/out alpine:3.22 sh -c "echo от 999 > /out/app.txt"
sh: can't create /out/app.txt: Permission denied
$ docker run --rm -u "$(id -u):$(id -g)" -v "$PWD/out":/out alpine:3.22 sh -c "echo от меня > /out/me.txt"
$ ls -l out
итого 8
-rw-r--r-- 1 padmin padmin 14 окт 23 20:10 me.txt
-rw-r--r-- 1 root   root   10 окт 23 20:10 root.txt
$ echo правка | tee -a out/root.txt
tee: out/root.txt: Отказано в доступе
правка
  1. Контейнер от root пишет в каталог хоста — файл принадлежит root, и обычный пользователь хоста не может его править.
  2. Контейнер от UID 999 (как наш образ notes:1) не может писать в каталог, принадлежащий UID 1000.
  3. -u "$(id -u):$(id -g)" запускает процесс с UID и GID текущего пользователя хоста — файлы получаются «свои».

С именованными томами проще: при первом подключении пустого тома Docker копирует в него содержимое каталога образа вместе с владельцем. Поэтому образы готовят каталоги данных в Dockerfile: RUN mkdir /data && chown app /data до USER app. Проверим образом, где /data принадлежит UID 999 и процесс работает от него же:

$ printf "FROM alpine:3.22\nRUN mkdir /data && chown 999:999 /data\nUSER 999\n" | docker build -q -t owned - >/dev/null
$ docker run --rm -v appdata:/data owned sh -c "ls -ldn /data; touch /data/ok && echo запись удалась"
drwxr-xr-x    2 999      999           4096 Oct 23 17:10 /data
запись удалась
$ docker run --rm -v "$PWD/out":/data owned sh -c "ls -ldn /data; touch /data/ok"
drwxrwxr-x    2 1000     1000            80 Oct 23 17:10 /data
touch: /data/ok: Permission denied

Новый том получил владельца 999 из образа — запись удалась. У bind mount такого копирования нет: каталог хоста закрывает собой то, что было в образе по этому пути, — владелец остался 1000, запись запрещена.

08

tmpfs и файловая система только для чтения

--read-only делает всю файловую систему контейнера, кроме томов, доступной только для чтения. Приложение, которое пишет только в отведённые места, так защищено от подмены собственных файлов. Временным файлам дают --tmpfs — каталог в памяти:

$ docker run --rm --read-only --tmpfs /tmp alpine:3.22 sh -c "touch /tmp/ok && echo /tmp: можно; touch /etc/x"
/tmp: можно
touch: /etc/x: Read-only file system

Вернёмся к этому в модуле 07, где соберём параметры безопасного запуска вместе.

09

Резервное копирование

Для базы данных правильная копия — средствами СУБД: копия файлов работающей базы может оказаться несогласованной. pg_dump запускаем внутри контейнера, вывод сохраняем на хосте:

$ docker run -d --name pg -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:17
b869cc6d2fde842f93ee5c91d9d58c7a9714b193cfcee8b99329ddcc0b5cc2b2
$ docker exec pg pg_dump -U postgres --clean postgres > dump.sql; grep -c "" dump.sql; grep -A3 "^COPY" dump.sql
91
COPY public.notes (id, text) FROM stdin;
1	пережить пересоздание
\.

$ docker run --rm -v notes-data:/from:ro -v "$PWD":/backup alpine:3.22 tar czf /backup/notes-data.tgz -C /from .
$ tar tzvf notes-data.tgz
drwxr-xr-x root/root         0 2026-10-23 20:10 ./
-rw-r--r-- root/root        30 2026-10-23 20:10 ./log.txt

Том с обычными файлами копируют временным контейнером: он подключает том только для чтения и каталог хоста для архива, запускает tar и удаляется. Восстановление — тот же приём в обратную сторону: tar xzf в пустой том.

Удалить том, подключённый к контейнеру, Docker не даст, даже если контейнер остановлен. Сначала контейнер, потом том:

$ docker volume rm pgdata
Error response from daemon: remove pgdata: volume is in use - [b869cc6d2fde842f93ee5c91d9d58c7a9714b193cfcee8b99329ddcc0b5cc2b2]
$ docker rm -f pg
pg
$ docker volume rm pgdata notes-data
pgdata
notes-data
$ docker volume prune -f
Total reclaimed space: 0B

Команда docker volume prune в Docker 23 и новее по умолчанию не трогает именованные тома, а с -a удаляет все неподключённые:

$ docker volume create keepme >/dev/null; docker run --rm postgres:17 true; docker volume ls -q | wc -l
2
$ docker volume prune -f
Total reclaimed space: 0B
$ docker volume ls
DRIVER    VOLUME NAME
local     appdata
local     keepme
$ docker volume prune -af
Deleted Volumes:
appdata
keepme

Total reclaimed space: 0B
$ docker volume ls
DRIVER    VOLUME NAME
docker volume prune -a удаляет и том базы, если её контейнер удалили, — например, перед пересозданием. Ни одна команда prune не спрашивает, нужна ли вам база. Резервная копия — до уборки, а не после.

Ключевые выводы модуля

Запомните главное

  • Слой записи удаляется вместе с контейнером; данные — в томах
  • Базы — в именованных томах; переменные инициализации образа действуют только на пустом томе
  • Bind mount — абсолютный путь; конфигурацию и сертификаты подключать :ro; относительный путь без ./ создаст том
  • Права считаются по UID: -u "$(id -u):$(id -g)" или chown каталога данных в Dockerfile
  • --read-only с --tmpfs защищает файлы приложения от подмены
  • Копия базы — pg_dump, копия тома — tar временным контейнером
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 03: Dockerfile Все модули Модуль 05: Сети Docker