Контейнер одноразовый, данные — нет: куда их положить, чтобы пережили обновление
Слои образа — только для чтения. Всё, что контейнер пишет, попадает в его собственный слой записи. Пока контейнер существует, даже остановленный, данные можно достать (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 | В оперативной памяти, исчезает при остановке | Временные файлы, которые не должны попасть на диск |
Том — каталог, который 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 или в облачных дисках.
Главный пример — 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 вообще без -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 (базы, очереди, реестры) всегда задавайте именованный том.
Если перед двоеточием — абсолютный путь, 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).
Кроме краткой записи -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 понятнее: опечатка в пути даёт ошибку, а не пустой каталог, в котором приложение потом не найдёт настройки.
Ядро одно на хост и контейнеры, и права проверяются по числовым 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: Отказано в доступе
правка
notes:1) не может писать в каталог, принадлежащий UID 1000.-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, запись запрещена.
--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, где соберём параметры безопасного запуска вместе.
Для базы данных правильная копия — средствами СУБД: копия файлов работающей базы может оказаться несогласованной. 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 не спрашивает, нужна ли вам база. Резервная копия — до уборки, а не после.:ro; относительный путь без ./ создаст том-u "$(id -u):$(id -g)" или chown каталога данных в Dockerfile--read-only с --tmpfs защищает файлы приложения от подменыpg_dump, копия тома — tar временным контейнером