7.2. Volumes
Цели
После этого материала вы сможете:
- создавать, инспектировать и удалять volumes; находить их данные на host;
- объяснить разницу между named и anonymous volume и почему вторые накапливаются;
- предсказать, когда volume наполнится содержимым образа, а когда останется пустым;
- объяснить, почему
VOLUMEв Dockerfile чаще вредит, чем помогает; - понимать, что
docker volume pruneпо умолчанию удаляет не все неиспользуемые volumes; - использовать драйвер
localс опциями для NFS и для привязки к каталогу host.
Предварительные знания
- 7.1. Файловая система container — три механизма и критерии выбора.
Ключевые термины
| Термин | Объяснение |
|---|---|
named volume | Volume с именем, заданным человеком |
anonymous volume | Volume со случайным именем, созданный автоматически |
_data | Каталог внутри volume, где лежат сами файлы |
auto-population | Наполнение пустого volume содержимым образа при первом монтировании |
dangling | Volume, не подключённый ни к одному container'у |
driver | Модуль, реализующий хранение; по умолчанию local |
Теория
Named и anonymous
Разница только в том, кто задал имя, — но последствия существенные.
| Named | Anonymous | |
|---|---|---|
| Создание | docker volume create name или -v name:/path | -v /path без имени, VOLUME в Dockerfile |
| Имя | Ваше | 64-символьный hex |
| Найти повторно | Легко | Практически невозможно |
Удаляется при docker rm -v | Нет | Да |
Удаляется docker volume prune | Только с --all | Да |
| Пригоден для данных, которые нужны | Да | Нет |
Anonymous volumes появляются чаще, чем их создают осознанно: почти всегда из-за инструкции VOLUME в Dockerfile образа, который вы взяли готовым. Отсюда их свойство накапливаться — каждый запуск создаёт новый.
Где лежат данные
/var/lib/docker/volumes/<имя>/_data/
Подкаталог _data не деталь оформления: рядом с ним Docker хранит служебные метаданные, поэтому путь к файлам всегда заканчивается на _data.
Точное расположение даёт docker volume inspect, и полагаться следует на него, а не на шаблон пути:
docker volume inspect myvol --format '{{.Mountpoint}}'
Важное отличие от writable layer: этот путь не зависит от хранилища образов. При containerd image store слои устроены иначе, а volumes лежат там же (урок 7.1).
Каталог принадлежит root, и чтение с host требует sudo. Это ожидаемо: доступ к данным предполагается через container.
Auto-population: единственный случай
Правило, которое объясняет львиную долю недоумений с volumes:
Пустой volume, примонтированный на путь, где в образе есть файлы, наполняется этими файлами.
Условия все обязательны:
| Условие | Если нарушено |
|---|---|
| Это volume, а не bind mount | Bind mount просто скрывает содержимое |
| Volume пуст | Существующее содержимое остаётся, файлы образа скрыты |
| Путь в образе непуст | Копировать нечего |
| Монтирование при создании container'а | — |
Последствие, которое ловит почти каждого: volume наполняется один раз и потом живёт своей жизнью. Обновили образ, изменили файлы по этому пути — container продолжит видеть старую версию из volume. Внешне: «изменения не применяются», хотя образ пересобран правильно.
Диагностика проста — сравнить содержимое volume с содержимым образа по тому же пути.
Инструкция VOLUME в Dockerfile
VOLUME /data
Инструкция объявляет, что путь предназначен для внешних данных. На практике она приносит больше проблем, чем пользы.
| Эффект | Оценка |
|---|---|
| Запуск без явного монтирования создаёт anonymous volume | Volumes накапливаются незаметно |
| Изменения этого пути в последующих инструкциях Dockerfile теряются | Тихая и трудная ошибка |
| Отменить объявление в дочернем образе нельзя | Наследуется навсегда |
| Документирует намерение | Единственный плюс — и его даёт README |
Второй пункт заслуживает пояснения. После VOLUME /data любая инструкция RUN, пишущая в /data, выполнится во временном монтировании, и результат не попадёт в образ. Ошибка молчаливая: сборка проходит успешно.
Рекомендация: не использовать VOLUME в своих образах. Точку монтирования задаёт тот, кто запускает container. Официальные образы (postgres, mysql) объявление используют — это осознанное решение для их сценариев, и о накоплении anonymous volumes надо помнить именно с ними.
Опции монтирования
docker run --mount type=volume,source=vol,target=/data,readonly ...
docker run -v vol:/data:ro ...
| Опция | --mount | -v |
|---|---|---|
| Только чтение | readonly или ro | :ro |
| Подпуть внутри volume | volume-subpath=sub | — |
| Не наполнять из образа | volume-nocopy | :nocopy |
| Опции драйвера | volume-opt=... | — |
volume-nocopy отключает auto-population — полезно, когда содержимое образа по этому пути не нужно и лишь замедляет первый запуск.
Драйвер local с опциями
Драйвер по умолчанию умеет больше, чем «каталог на диске». Он принимает те же параметры, что и системный mount.
Привязка к конкретному каталогу host — volume, ведущий себя как bind mount, но управляемый Docker:
docker volume create --driver local \
--opt type=none --opt device=/srv/appdata --opt o=bind appdata
Зачем это, если есть bind mount: имя volume остаётся стабильным, а фактический путь задаётся один раз при создании. Compose-файл при этом не содержит абсолютных путей и переносится между машинами.
NFS:
docker volume create --driver local \
--opt type=nfs --opt device=:/exported/path \
--opt o=addr=10.0.0.10,rw,nfsvers=4 nfsdata
Данные лежат на сетевом хранилище и доступны нескольким узлам — основа для работы в кластере.
tmpfs через драйвер local:
docker volume create --driver local --opt type=tmpfs --opt device=tmpfs mem
Отличается от --mount type=tmpfs тем, что имеет имя и может разделяться между container'ами на одном узле.
Общий volume для нескольких container'ов
Один volume можно примонтировать в несколько container'ов одновременно — они увидят одни и те же файлы.
Docker при этом не даёт никакой синхронизации. Две программы, пишущие в один файл, получат обычные гонки уровня файловой системы. Безопасны сценарии, где роли разделены:
| Сценарий | Безопасность |
|---|---|
| Один пишет, несколько читают | Безопасно при атомарной записи (write + rename) |
| Несколько пишут в разные файлы | Безопасно |
| Несколько пишут в один файл | Требует блокировок на уровне приложения |
| Файлы базы данных из двух container'ов | Повреждение данных |
Последняя строка — не теоретический риск: два процесса PostgreSQL на одном каталоге данных разрушат его.
Удаление и prune
| Команда | Что удаляет |
|---|---|
docker volume rm <имя> | Указанный volume; откажет, если он используется |
docker rm -v <container> | Container и его anonymous volumes |
docker volume prune | Только неиспользуемые anonymous volumes |
docker volume prune --all | Все неиспользуемые volumes, включая named |
docker compose down -v | Volumes, объявленные в этом Compose-файле |
Вторая строка снизу — изменение поведения, о котором часто не знают: начиная с Docker 23.0 обычный docker volume prune не трогает named volumes. Раньше трогал, и это приводило к потере данных. Флаг --all возвращает старое поведение — применять его следует сознательно.
Внутренний механизм
Как выполняется auto-population
При создании container'а с volume Docker:
- Проверяет, пуст ли каталог
_datavolume. - Если пуст и в образе по целевому пути есть файлы — копирует их, сохраняя владельца и права.
- Монтирует каталог
_dataв целевую точку.
Копирование выполняется один раз — при первом монтировании непустого пути в пустой volume. Никакой синхронизации в дальнейшем нет.
Владелец и права копируются из образа, поэтому volume для образа с USER 10001 получит нужные права автоматически — важное отличие от bind mount (урок 7.5).
Что происходит при docker volume rm
Docker проверяет, не используется ли volume работающим или остановленным container'ом. При использовании — отказ с перечислением container'ов. Иначе — удаление каталога _data целиком, без корзины и подтверждения.
Восстановления нет. Единственная защита — резервная копия (урок 7.6).
Команды и примеры
Жизненный цикл volume
docker volume create app-data
docker volume ls --filter name=app-data
docker volume inspect app-data
Ожидаемый вывод:
app-data
DRIVER VOLUME NAME
local app-data
[
{
"CreatedAt": "2026-07-30T12:20:11Z",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/app-data/_data",
"Name": "app-data",
"Options": null,
"Scope": "local"
}
]
Mountpoint — путь к данным на host. Проверим, что запись через container туда и попадает:
docker run --rm --mount type=volume,source=app-data,target=/data \
alpine:3.21 sh -c 'echo "из container" > /data/file.txt'
mp="$(docker volume inspect app-data --format '{{.Mountpoint}}')"
sudo cat "$mp/file.txt"
sudo ls -la "$mp"
Ожидаемый вывод:
из container
total 12
drwxr-xr-x 2 root root 4096 Jul 30 12:20 .
drwx-----x 3 root root 4096 Jul 30 12:20 ..
-rw-r--r-- 1 root root 13 Jul 30 12:20 file.txt
Файл на месте, владелец — root, потому что процесс в container'е работал от root.
Anonymous volumes накапливаются
echo "═══ образ с инструкцией VOLUME ═══"
docker build -q -t volimg - <<'EOF' > /dev/null
FROM alpine:3.21
VOLUME /data
CMD ["sh", "-c", "echo работает; sleep 2"]
EOF
before="$(docker volume ls -q | wc -l)"
for i in 1 2 3; do
docker run --rm volimg > /dev/null
done
after="$(docker volume ls -q | wc -l)"
printf ' volumes до: %s\n' "$before"
printf ' volumes после: %s\n' "$after"
printf ' создано за 3 запуска с --rm: %s\n' "$((after - before))"
Ожидаемый вывод:
═══ образ с инструкцией VOLUME ═══
volumes до: 1
volumes после: 1
создано за 3 запуска с --rm: 0
С флагом --rm anonymous volumes удаляются вместе с container'ом. А без него:
before="$(docker volume ls -q | wc -l)"
for i in 1 2 3; do
docker run --name "anon-$i" volimg > /dev/null
done
after="$(docker volume ls -q | wc -l)"
printf ' создано за 3 запуска без --rm: %s\n' "$((after - before))"
echo "═══ как они выглядят ═══"
docker volume ls -q | grep -E '^[0-9a-f]{64}$' | head -3
echo "═══ уборка ═══"
docker rm -v anon-1 anon-2 anon-3 > /dev/null
printf ' осталось volumes: %s\n' "$(docker volume ls -q | wc -l)"
docker rmi -f volimg > /dev/null
Ожидаемый вывод:
создано за 3 запуска без --rm: 3
═══ как они выглядят ═══
3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5
a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
c9d8e7f6a5b4c3d2e1f09876543210fedcba9876543210fedcba9876543210fe
═══ уборка ═══
осталось volumes: 1
Три запуска — три volume со случайными именами, и в них лежат данные, которые уже никак не соотнести с container'ом. Ключевая деталь: удалил их не docker rm, а docker rm -v.
Именно так на долгоживущих машинах накапливаются десятки гигабайт: docker rm без -v оставляет anonymous volumes навсегда.
Auto-population и её главное следствие
docker build -q -t seeded - <<'EOF' > /dev/null
FROM alpine:3.21
RUN mkdir -p /content && \
echo "версия 1" > /content/version.txt && \
echo "конфиг из образа" > /content/config.ini
CMD ["sleep", "300"]
EOF
docker volume create seed-vol > /dev/null
echo "═══ первое монтирование в пустой volume ═══"
docker run --rm --mount type=volume,source=seed-vol,target=/content \
seeded sh -c 'ls /content; echo -n "содержимое: "; cat /content/version.txt'
Ожидаемый вывод:
═══ первое монтирование в пустой volume ═══
config.ini
version.txt
содержимое: версия 1
Volume был пуст — Docker скопировал в него файлы из образа. Теперь обновим образ:
docker build -q -t seeded - <<'EOF' > /dev/null
FROM alpine:3.21
RUN mkdir -p /content && \
echo "версия 2 — ОБНОВЛЕНО" > /content/version.txt && \
echo "конфиг из образа" > /content/config.ini && \
echo "новый файл" > /content/added.txt
CMD ["sleep", "300"]
EOF
echo "═══ тот же volume, обновлённый образ ═══"
docker run --rm --mount type=volume,source=seed-vol,target=/content \
seeded sh -c 'ls /content; echo -n "содержимое: "; cat /content/version.txt'
echo "═══ что на самом деле в образе ═══"
docker run --rm seeded sh -c 'ls /content; echo -n "содержимое: "; cat /content/version.txt'
Ожидаемый вывод:
═══ тот же volume, обновлённый образ ═══
config.ini
version.txt
содержимое: версия 1
═══ что на самом деле в образе ═══
added.txt
config.ini
version.txt
содержимое: версия 2 — ОБНОВЛЕНО
Вот та самая ситуация «изменения не применяются». Образ обновлён правильно — в нём версия 2 и новый файл. Но volume уже непуст, поэтому наполнение не повторилось, и container видит версию 1.
Диагностика — сравнение образа и volume по одному пути:
diff <(docker run --rm seeded sh -c 'cd /content && md5sum * | sort') \
<(docker run --rm --mount type=volume,source=seed-vol,target=/content \
seeded sh -c 'cd /content && md5sum * | sort') \
&& echo "совпадает" || echo " ↑ volume и образ расходятся"
Ожидаемый вывод:
1,2c1
< 5a4e... added.txt
< 8b7c... config.ini
---
> 8b7c... config.ini
↑ volume и образ расходятся
Исправление — удалить volume и дать ему наполниться заново, предварительно сохранив то, что нужно:
docker volume rm seed-vol > /dev/null
docker volume create seed-vol > /dev/null
docker run --rm --mount type=volume,source=seed-vol,target=/content \
seeded cat /content/version.txt
Ожидаемый вывод:
версия 2 — ОБНОВЛЕНО
Отключить наполнение целиком можно опцией volume-nocopy:
docker volume create nocopy-vol > /dev/null
docker run --rm --mount type=volume,source=nocopy-vol,target=/content,volume-nocopy \
seeded sh -c 'ls -A /content | wc -l | xargs echo "файлов в volume:"'
docker volume rm nocopy-vol > /dev/null
Ожидаемый вывод:
файлов в volume: 0
Почему VOLUME ломает последующие RUN
docker build -q -t volbug - <<'EOF' > /dev/null
FROM alpine:3.21
VOLUME /data
RUN echo "записано при сборке" > /data/build.txt
CMD ["sh", "-c", "ls -A /data | wc -l | xargs echo 'файлов в /data:'; cat /data/build.txt 2>/dev/null || echo 'build.txt отсутствует'"]
EOF
echo "═══ сборка прошла успешно, но: ═══"
docker run --rm volbug
Ожидаемый вывод:
═══ сборка прошла успешно, но: ═══
файлов в /data: 0
build.txt отсутствует
Инструкция RUN отработала без ошибки, файл был создан — но во временном монтировании, которое отбрасывается по завершении шага. В образ он не попал.
Перестановка инструкций решает проблему:
docker build -q -t volok - <<'EOF' > /dev/null
FROM alpine:3.21
RUN mkdir -p /data && echo "записано при сборке" > /data/build.txt
VOLUME /data
CMD ["sh", "-c", "cat /data/build.txt"]
EOF
docker run --rm volok
docker rmi -f volbug volok seeded > /dev/null
docker volume rm seed-vol > /dev/null 2>&1
Ожидаемый вывод:
записано при сборке
Теперь файл в образе, и auto-population перенесёт его в volume. Но проще не объявлять VOLUME вовсе: то же самое достигается монтированием при запуске, без побочных эффектов.
Общий volume между container'ами
docker volume create shared > /dev/null
docker run -d --name writer --mount type=volume,source=shared,target=/data \
alpine:3.21 sh -c 'i=0; while true; do i=$((i+1));
echo "$i" > /data/counter.tmp && mv /data/counter.tmp /data/counter.txt;
sleep 1; done' > /dev/null
docker run -d --name reader --mount type=volume,source=shared,target=/data,readonly \
alpine:3.21 sh -c 'while true; do sleep 1; done' > /dev/null
sleep 4
echo "═══ reader видит записи writer ═══"
docker exec reader cat /data/counter.txt
echo "═══ reader не может писать ═══"
docker exec reader sh -c 'echo test > /data/x.txt' 2>&1 | tail -1
docker rm -f writer reader > /dev/null
docker volume rm shared > /dev/null
Ожидаемый вывод:
═══ reader видит записи writer ═══
4
═══ reader не может писать ═══
sh: can't create /data/x.txt: Read-only file system
Обратите внимание на способ записи в writer: сначала во временный файл, затем mv. Переименование в пределах одной файловой системы атомарно, поэтому читатель никогда не увидит файл наполовину записанным. Без этого приёма чтение иногда возвращало бы пустую строку.
Флаг readonly на стороне читателя — не украшение: он превращает ошибку логики в явный отказ вместо тихой порчи данных.
Volume, привязанный к каталогу host
sudo mkdir -p /srv/demo-appdata
sudo sh -c 'echo "данные на host" > /srv/demo-appdata/host.txt'
docker volume create --driver local \
--opt type=none --opt device=/srv/demo-appdata --opt o=bind bound-vol > /dev/null
docker volume inspect bound-vol --format '{{json .Options}}'
docker run --rm --mount type=volume,source=bound-vol,target=/data alpine:3.21 cat /data/host.txt
Ожидаемый вывод:
{"device":"/srv/demo-appdata","o":"bind","type":"none"}
данные на host
Внешне поведение как у bind mount, но в Compose-файле фигурирует только имя bound-vol — абсолютный путь задан один раз при создании volume. Это делает конфигурацию переносимой между машинами с разной раскладкой каталогов.
docker volume rm bound-vol > /dev/null
sudo rm -rf /srv/demo-appdata
prune удаляет не всё
docker volume create keep-me > /dev/null
docker run --name tmp-c -v /anon-path alpine:3.21 true > /dev/null
docker rm tmp-c > /dev/null # без -v: anonymous volume остаётся
echo "═══ до prune ═══"
printf ' named: %s\n' "$(docker volume ls -q --filter name=keep-me | wc -l)"
printf ' anonymous: %s\n' "$(docker volume ls -q | grep -cE '^[0-9a-f]{64}$')"
docker volume prune -f > /dev/null
echo "═══ после docker volume prune ═══"
printf ' named: %s ← не тронут\n' "$(docker volume ls -q --filter name=keep-me | wc -l)"
printf ' anonymous: %s ← удалены\n' "$(docker volume ls -q | grep -cE '^[0-9a-f]{64}$')"
docker volume rm keep-me > /dev/null
Ожидаемый вывод:
═══ до prune ═══
named: 1
anonymous: 1
═══ после docker volume prune ═══
named: 1 ← не тронут
anonymous: 0 ← удалены
Named volume уцелел. Для его удаления понадобился бы docker volume prune --all — и именно поэтому эту команду не запускают не глядя.
docker volume rm app-data > /dev/null 2>&1
Практическое упражнение
Задание. Продемонстрируйте и устраните ловушку auto-population.
- Соберите образ, в котором по пути
/app/configлежит файл с версией1. - Запустите container с named volume на этом пути, покажите, что volume наполнился.
- Обновите образ до версии
2, запустите с тем же volume, покажите, что container видит версию1. - Напишите скрипт, который обнаруживает расхождение volume и образа и сообщает о нём.
- Реализуйте безопасное обновление: содержимое volume приводится к образу с сохранением файлов, созданных приложением.
- Докажите, что после обновления версия
2видна, а созданный приложением файл уцелел.
Подсказки
Подсказка 1
Для пункта 4 нужно сравнить два списка контрольных сумм: из образа без монтирования и из container'а с volume.
Подсказка 2
Файлы приложения нужно отличить от файлов образа. Проще всего — по списку файлов, известных образу.
Подсказка 3
Обновление удобно делать вспомогательным container'ом, которому примонтированы и volume, и путь из образа.
Решение
Показать решение
mkdir -p /tmp/autopop && cd /tmp/autopop
# ── 1. Образ версии 1 ──
cat > Dockerfile <<'EOF'
FROM alpine:3.21
ARG VERSION=1
RUN mkdir -p /app/config && \
echo "version=${VERSION}" > /app/config/app.conf && \
echo "shared setting" > /app/config/common.conf
CMD ["sleep", "300"]
EOF
docker build -q --build-arg VERSION=1 -t cfg:v1 . > /dev/null
docker volume create cfg-vol > /dev/null
echo "═══ 2. Первое монтирование: volume наполнился ═══"
docker run --rm --mount type=volume,source=cfg-vol,target=/app/config \
cfg:v1 cat /app/config/app.conf | sed 's/^/ /'
# Приложение создаёт собственный файл — его терять нельзя
docker run --rm --mount type=volume,source=cfg-vol,target=/app/config \
cfg:v1 sh -c 'echo "api_key=локальный-секрет" > /app/config/local.conf'
echo " приложение создало local.conf"
echo
echo "═══ 3. Образ обновлён до версии 2 ═══"
docker build -q --build-arg VERSION=2 -t cfg:v2 . > /dev/null
printf ' в образе: %s\n' "$(docker run --rm cfg:v2 cat /app/config/app.conf)"
printf ' через volume: %s ← старая!\n' \
"$(docker run --rm --mount type=volume,source=cfg-vol,target=/app/config cfg:v2 cat /app/config/app.conf)"
# ── 4. Обнаружение расхождения ──
cat > detect.sh <<'SH'
#!/usr/bin/env bash
# Сравнивает содержимое volume с содержимым образа по одному пути.
# Ненулевой код — есть расхождение.
set -uo pipefail
IMAGE="${1:?образ}"; VOL="${2:?volume}"; DIR="${3:?путь}"
sums_image="$(docker run --rm "$IMAGE" sh -c "cd '$DIR' && md5sum * 2>/dev/null | sort")"
sums_vol="$(docker run --rm --mount type=volume,source="$VOL",target="$DIR" \
"$IMAGE" sh -c "cd '$DIR' && md5sum * 2>/dev/null | sort")"
if [ "$sums_image" = "$sums_vol" ]; then
echo " volume совпадает с образом"
exit 0
fi
echo " РАСХОЖДЕНИЕ volume и образа:"
diff <(echo "$sums_image") <(echo "$sums_vol") | while read -r line; do
case "$line" in
"< "*) echo " только в образе: ${line#< }" ;;
"> "*) echo " только в volume: ${line#> }" ;;
esac
done
exit 1
SH
chmod +x detect.sh
echo
echo "═══ 4. Обнаружение ═══"
./detect.sh cfg:v2 cfg-vol /app/config
# ── 5. Безопасное обновление ──
cat > sync.sh <<'SH'
#!/usr/bin/env bash
# Приводит содержимое volume к содержимому образа, не трогая файлы,
# которых в образе нет (их создало приложение).
set -euo pipefail
IMAGE="${1:?образ}"; VOL="${2:?volume}"; DIR="${3:?путь}"
# Файлы образа монтируем во вспомогательный путь, volume — в целевой.
# Так обе версии видны одному процессу одновременно.
docker run --rm \
--mount type=volume,source="$VOL",target=/target \
"$IMAGE" sh -c "
cd '$DIR' 2>/dev/null || exit 0
for f in *; do
[ -f \"\$f\" ] || continue
if [ -f \"/target/\$f\" ] && cmp -s \"\$f\" \"/target/\$f\"; then
continue
fi
cp -a \"\$f\" \"/target/\$f\"
echo \" обновлён: \$f\"
done
"
SH
chmod +x sync.sh
echo
echo "═══ 5. Обновление volume из образа ═══"
# Volume монтируется в /target, а НЕ в /app/config: иначе он скрыл бы файлы
# образа, которые как раз и нужно скопировать.
./sync.sh cfg:v2 cfg-vol /app/config
echo
echo "═══ 6. Проверка ═══"
docker run --rm --mount type=volume,source=cfg-vol,target=/app/config cfg:v2 sh -c '
echo -n " версия: "; cat /app/config/app.conf
echo -n " файл приложения: "; cat /app/config/local.conf
echo -n " всего файлов: "; ls -1 /app/config | wc -l
'
./detect.sh cfg:v2 cfg-vol /app/config 2>&1 | head -4
docker volume rm cfg-vol > /dev/null
docker rmi -f cfg:v1 cfg:v2 > /dev/null
cd /tmp && rm -rf /tmp/autopop
Ожидаемый вывод:
═══ 2. Первое монтирование: volume наполнился ═══
version=1
приложение создало local.conf
═══ 3. Образ обновлён до версии 2 ═══
в образе: version=2
через volume: version=1 ← старая!
═══ 4. Обнаружение ═══
РАСХОЖДЕНИЕ volume и образа:
только в образе: 4a1f... app.conf
только в volume: 9c3e... app.conf
только в volume: 7b2d... local.conf
═══ 5. Обновление volume из образа ═══
обновлён: app.conf
═══ 6. Проверка ═══
версия: version=2
файл приложения: api_key=локальный-секрет
всего файлов: 3
РАСХОЖДЕНИЕ volume и образа:
только в volume: 7b2d... local.conf
Требования 6 выполнены: версия обновилась, файл приложения уцелел.
Три решения, определяющие качество.
sync.sh монтирует volume в /target, а не в исходный путь. Смонтировать volume на /app/config означало бы скрыть файлы образа — тот самый эффект, с которым мы боремся. Файлы образа должны остаться видимыми, поэтому целевым делается вспомогательный путь. Это неочевидно и является сутью решения.
Обновляются только отличающиеся файлы, а не все. Проверка cmp -s перед копированием даёт две вещи: понятный отчёт о том, что реально изменилось, и сохранение времени модификации у нетронутых файлов. Слепое cp -a * сработало бы, но не позволило бы отличить «обновлено 1 из 3» от «переписано всё».
local.conf остаётся в выводе detect.sh как расхождение — и это правильно. Скрипт сообщает: в volume есть файл, которого нет в образе. Подавить это сообщение было бы ошибкой — именно так обнаруживаются файлы, оставшиеся от предыдущих версий приложения. Расхождение здесь — информация, а не проблема.
Чего решение не делает. Оно не удаляет из volume файлы, исчезнувшие из образа: отличить их от файлов приложения без дополнительного списка невозможно. Промышленное решение хранит рядом с volume манифест — список файлов, пришедших из образа, — и сверяется с ним. Для конфигурации, которую правит и приложение, и образ, надёжнее вообще не смешивать: файлы образа монтировать read-only по одному пути, данные приложения держать по другому.
Проверка результата
docker volume create check-vol > /dev/null
docker run --rm -v check-vol:/d alpine:3.21 sh -c 'echo ok > /d/f'
docker run --rm -v check-vol:/d alpine:3.21 cat /d/f
docker volume inspect check-vol --format '{{.Mountpoint}}'
docker volume rm check-vol
Ожидается ok, путь, оканчивающийся на /_data, и успешное удаление.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| «Изменения образа не применяются» | Volume наполнился один раз и не обновляется | Сравнить volume с образом; пересоздать volume |
docker rm без -v | Не знают про anonymous volumes | Volumes накапливаются; использовать -v |
VOLUME в своём Dockerfile | Кажется хорошим тоном | Anonymous volumes и потеря изменений в RUN |
RUN после VOLUME для того же пути | Порядок не продуман | Изменения не попадают в образ; переставить |
docker volume prune в расчёте на полную уборку | Поведение изменилось в 23.0 | Named volumes не трогаются без --all |
docker volume prune --all не глядя | Хотели освободить место | Удалит named volumes с данными |
| Два container'а пишут в один файл | Volume общий — значит, можно | Нужны блокировки; для БД — недопустимо |
| Данные БД в volume, примонтированном двум container'ам | Кажется отказоустойчивым | Разрушение каталога данных |
Заучен путь /var/lib/docker/volumes/... | Обычно верен | Спрашивать docker volume inspect |
Правка файлов volume через sudo на host | Быстрее | Владелец и SELinux-контекст могут сбиться |
Контрольные вопросы
На понимание:
- При каких условиях volume наполнится содержимым образа?
- Почему после обновления образа container видит старую конфигурацию?
- Чем anonymous volume отличается от named с точки зрения удаления?
- Почему
VOLUMEв Dockerfile может «съесть» результатRUN? - Что удаляет
docker volume pruneбез флагов?
На применение:
- Как найти файлы volume на host?
- Как смонтировать volume только на чтение?
- Как сделать volume, указывающий на конкретный каталог host?
На диагностику:
- Место на диске кончается, containers удалены. Что проверить?
- Приложение читает конфигурацию не ту, что в образе. Порядок действий?
Краткое резюме
- Named volume создаётся человеком, anonymous — автоматически и со случайным именем.
- Данные лежат в
/var/lib/docker/volumes/<имя>/_data; путь даётdocker volume inspect. - Расположение volumes не зависит от хранилища образов, в отличие от writable layer.
- Пустой volume наполняется содержимым образа при первом монтировании — один раз.
- Непустой volume не обновляется никогда: это причина «изменения не применяются».
volume-nocopyотключает наполнение.VOLUMEв Dockerfile создаёт anonymous volumes и рушит последующиеRUNпо тому же пути.docker rm -vудаляет anonymous volumes container'а; без-vони остаются навсегда.docker volume pruneс версии 23.0 не трогает named volumes без--all.- Драйвер
localпринимает опцииmount: bind, NFS, tmpfs. - Общий volume не даёт синхронизации; для одного файла нужны блокировки.
- Два процесса БД на одном каталоге данных разрушают его.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: volumes | https://docs.docker.com/engine/storage/volumes/ | Named и anonymous, auto-population, опции монтирования |
Docker: docker volume create | https://docs.docker.com/reference/cli/docker/volume/create/ | Опции драйвера local: NFS, bind, tmpfs |
Docker: docker volume prune | https://docs.docker.com/reference/cli/docker/volume/prune/ | Поведение по умолчанию и флаг --all |
Dockerfile: VOLUME | https://docs.docker.com/reference/dockerfile/#volume | Побочные эффекты для последующих инструкций |
| Docker: storage overview | https://docs.docker.com/engine/storage/ | Выбор между механизмами |
| Compose: volumes | https://docs.docker.com/reference/compose-file/volumes/ | Объявление volumes в Compose |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Bind mounts
Главное оглавление