Главная/Storage/Урок

7.2. Volumes

Цели

После этого материала вы сможете:

  • создавать, инспектировать и удалять volumes; находить их данные на host;
  • объяснить разницу между named и anonymous volume и почему вторые накапливаются;
  • предсказать, когда volume наполнится содержимым образа, а когда останется пустым;
  • объяснить, почему VOLUME в Dockerfile чаще вредит, чем помогает;
  • понимать, что docker volume prune по умолчанию удаляет не все неиспользуемые volumes;
  • использовать драйвер local с опциями для NFS и для привязки к каталогу host.

Предварительные знания

Ключевые термины

ТерминОбъяснение
named volumeVolume с именем, заданным человеком
anonymous volumeVolume со случайным именем, созданный автоматически
_dataКаталог внутри volume, где лежат сами файлы
auto-populationНаполнение пустого volume содержимым образа при первом монтировании
danglingVolume, не подключённый ни к одному container'у
driverМодуль, реализующий хранение; по умолчанию local

Теория

Named и anonymous

Разница только в том, кто задал имя, — но последствия существенные.

NamedAnonymous
Созданиеdocker volume create name или -v name:/path-v /path без имени, VOLUME в Dockerfile
ИмяВаше64-символьный hex
Найти повторноЛегкоПрактически невозможно
Удаляется при docker rm -vНетДа
Удаляется docker volume pruneТолько с --allДа
Пригоден для данных, которые нужныДаНет

Anonymous volumes появляются чаще, чем их создают осознанно: почти всегда из-за инструкции VOLUME в Dockerfile образа, который вы взяли готовым. Отсюда их свойство накапливаться — каждый запуск создаёт новый.

Где лежат данные

text
/var/lib/docker/volumes/<имя>/_data/

Подкаталог _data не деталь оформления: рядом с ним Docker хранит служебные метаданные, поэтому путь к файлам всегда заканчивается на _data.

Точное расположение даёт docker volume inspect, и полагаться следует на него, а не на шаблон пути:

bash
docker volume inspect myvol --format '{{.Mountpoint}}'

Важное отличие от writable layer: этот путь не зависит от хранилища образов. При containerd image store слои устроены иначе, а volumes лежат там же (урок 7.1).

Каталог принадлежит root, и чтение с host требует sudo. Это ожидаемо: доступ к данным предполагается через container.

Auto-population: единственный случай

Правило, которое объясняет львиную долю недоумений с volumes:

Пустой volume, примонтированный на путь, где в образе есть файлы, наполняется этими файлами.

Условия все обязательны:

УсловиеЕсли нарушено
Это volume, а не bind mountBind mount просто скрывает содержимое
Volume пустСуществующее содержимое остаётся, файлы образа скрыты
Путь в образе непустКопировать нечего
Монтирование при создании container'а

Последствие, которое ловит почти каждого: volume наполняется один раз и потом живёт своей жизнью. Обновили образ, изменили файлы по этому пути — container продолжит видеть старую версию из volume. Внешне: «изменения не применяются», хотя образ пересобран правильно.

Диагностика проста — сравнить содержимое volume с содержимым образа по тому же пути.

Инструкция VOLUME в Dockerfile

dockerfile
VOLUME /data

Инструкция объявляет, что путь предназначен для внешних данных. На практике она приносит больше проблем, чем пользы.

ЭффектОценка
Запуск без явного монтирования создаёт anonymous volumeVolumes накапливаются незаметно
Изменения этого пути в последующих инструкциях Dockerfile теряютсяТихая и трудная ошибка
Отменить объявление в дочернем образе нельзяНаследуется навсегда
Документирует намерениеЕдинственный плюс — и его даёт README

Второй пункт заслуживает пояснения. После VOLUME /data любая инструкция RUN, пишущая в /data, выполнится во временном монтировании, и результат не попадёт в образ. Ошибка молчаливая: сборка проходит успешно.

Рекомендация: не использовать VOLUME в своих образах. Точку монтирования задаёт тот, кто запускает container. Официальные образы (postgres, mysql) объявление используют — это осознанное решение для их сценариев, и о накоплении anonymous volumes надо помнить именно с ними.

Опции монтирования

bash
docker run --mount type=volume,source=vol,target=/data,readonly ...
docker run -v vol:/data:ro ...
Опция--mount-v
Только чтениеreadonly или ro:ro
Подпуть внутри volumevolume-subpath=sub
Не наполнять из образаvolume-nocopy:nocopy
Опции драйвераvolume-opt=...

volume-nocopy отключает auto-population — полезно, когда содержимое образа по этому пути не нужно и лишь замедляет первый запуск.

Драйвер local с опциями

Драйвер по умолчанию умеет больше, чем «каталог на диске». Он принимает те же параметры, что и системный mount.

Привязка к конкретному каталогу host — volume, ведущий себя как bind mount, но управляемый Docker:

bash
docker volume create --driver local \
    --opt type=none --opt device=/srv/appdata --opt o=bind appdata

Зачем это, если есть bind mount: имя volume остаётся стабильным, а фактический путь задаётся один раз при создании. Compose-файл при этом не содержит абсолютных путей и переносится между машинами.

NFS:

bash
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:

bash
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 -vVolumes, объявленные в этом Compose-файле

Вторая строка снизу — изменение поведения, о котором часто не знают: начиная с Docker 23.0 обычный docker volume prune не трогает named volumes. Раньше трогал, и это приводило к потере данных. Флаг --all возвращает старое поведение — применять его следует сознательно.


Внутренний механизм

Как выполняется auto-population

При создании container'а с volume Docker:

  1. Проверяет, пуст ли каталог _data volume.
  2. Если пуст и в образе по целевому пути есть файлы — копирует их, сохраняя владельца и права.
  3. Монтирует каталог _data в целевую точку.

Копирование выполняется один раз — при первом монтировании непустого пути в пустой volume. Никакой синхронизации в дальнейшем нет.

Владелец и права копируются из образа, поэтому volume для образа с USER 10001 получит нужные права автоматически — важное отличие от bind mount (урок 7.5).

Что происходит при docker volume rm

Docker проверяет, не используется ли volume работающим или остановленным container'ом. При использовании — отказ с перечислением container'ов. Иначе — удаление каталога _data целиком, без корзины и подтверждения.

Восстановления нет. Единственная защита — резервная копия (урок 7.6).


Команды и примеры

Жизненный цикл volume

bash
docker volume create app-data
docker volume ls --filter name=app-data
docker volume inspect app-data

Ожидаемый вывод:

text
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 туда и попадает:

bash
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"

Ожидаемый вывод:

text
из 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 накапливаются

bash
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))"

Ожидаемый вывод:

text
═══ образ с инструкцией VOLUME ═══
  volumes до:    1
  volumes после: 1
  создано за 3 запуска с --rm: 0

С флагом --rm anonymous volumes удаляются вместе с container'ом. А без него:

bash
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

Ожидаемый вывод:

text
  создано за 3 запуска без --rm: 3
═══ как они выглядят ═══
3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5
a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
c9d8e7f6a5b4c3d2e1f09876543210fedcba9876543210fedcba9876543210fe
═══ уборка ═══
  осталось volumes: 1

Три запуска — три volume со случайными именами, и в них лежат данные, которые уже никак не соотнести с container'ом. Ключевая деталь: удалил их не docker rm, а docker rm -v.

Именно так на долгоживущих машинах накапливаются десятки гигабайт: docker rm без -v оставляет anonymous volumes навсегда.

Auto-population и её главное следствие

bash
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'

Ожидаемый вывод:

text
═══ первое монтирование в пустой volume ═══
config.ini
version.txt
содержимое: версия 1

Volume был пуст — Docker скопировал в него файлы из образа. Теперь обновим образ:

bash
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'

Ожидаемый вывод:

text
═══ тот же volume, обновлённый образ ═══
config.ini
version.txt
содержимое: версия 1
═══ что на самом деле в образе ═══
added.txt
config.ini
version.txt
содержимое: версия 2 — ОБНОВЛЕНО

Вот та самая ситуация «изменения не применяются». Образ обновлён правильно — в нём версия 2 и новый файл. Но volume уже непуст, поэтому наполнение не повторилось, и container видит версию 1.

Диагностика — сравнение образа и volume по одному пути:

bash
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 и образ расходятся"

Ожидаемый вывод:

text
1,2c1
< 5a4e...  added.txt
< 8b7c...  config.ini
---
> 8b7c...  config.ini
  ↑ volume и образ расходятся

Исправление — удалить volume и дать ему наполниться заново, предварительно сохранив то, что нужно:

bash
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

Ожидаемый вывод:

text
версия 2 — ОБНОВЛЕНО

Отключить наполнение целиком можно опцией volume-nocopy:

bash
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

Ожидаемый вывод:

text
файлов в volume: 0

Почему VOLUME ломает последующие RUN

bash
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

Ожидаемый вывод:

text
═══ сборка прошла успешно, но: ═══
файлов в /data: 0
build.txt отсутствует

Инструкция RUN отработала без ошибки, файл был создан — но во временном монтировании, которое отбрасывается по завершении шага. В образ он не попал.

Перестановка инструкций решает проблему:

bash
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

Ожидаемый вывод:

text
записано при сборке

Теперь файл в образе, и auto-population перенесёт его в volume. Но проще не объявлять VOLUME вовсе: то же самое достигается монтированием при запуске, без побочных эффектов.

Общий volume между container'ами

bash
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

Ожидаемый вывод:

text
═══ reader видит записи writer ═══
4
═══ reader не может писать ═══
sh: can't create /data/x.txt: Read-only file system

Обратите внимание на способ записи в writer: сначала во временный файл, затем mv. Переименование в пределах одной файловой системы атомарно, поэтому читатель никогда не увидит файл наполовину записанным. Без этого приёма чтение иногда возвращало бы пустую строку.

Флаг readonly на стороне читателя — не украшение: он превращает ошибку логики в явный отказ вместо тихой порчи данных.

Volume, привязанный к каталогу host

bash
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

Ожидаемый вывод:

text
{"device":"/srv/demo-appdata","o":"bind","type":"none"}
данные на host

Внешне поведение как у bind mount, но в Compose-файле фигурирует только имя bound-vol — абсолютный путь задан один раз при создании volume. Это делает конфигурацию переносимой между машинами с разной раскладкой каталогов.

bash
docker volume rm bound-vol > /dev/null
sudo rm -rf /srv/demo-appdata

prune удаляет не всё

bash
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

Ожидаемый вывод:

text
═══ до prune ═══
  named:     1
  anonymous: 1
═══ после docker volume prune ═══
  named:     1  ← не тронут
  anonymous: 0  ← удалены

Named volume уцелел. Для его удаления понадобился бы docker volume prune --all — и именно поэтому эту команду не запускают не глядя.

bash
docker volume rm app-data > /dev/null 2>&1

Практическое упражнение

Задание. Продемонстрируйте и устраните ловушку auto-population.

  1. Соберите образ, в котором по пути /app/config лежит файл с версией 1.
  2. Запустите container с named volume на этом пути, покажите, что volume наполнился.
  3. Обновите образ до версии 2, запустите с тем же volume, покажите, что container видит версию 1.
  4. Напишите скрипт, который обнаруживает расхождение volume и образа и сообщает о нём.
  5. Реализуйте безопасное обновление: содержимое volume приводится к образу с сохранением файлов, созданных приложением.
  6. Докажите, что после обновления версия 2 видна, а созданный приложением файл уцелел.

Подсказки

Подсказка 1

Для пункта 4 нужно сравнить два списка контрольных сумм: из образа без монтирования и из container'а с volume.

Подсказка 2

Файлы приложения нужно отличить от файлов образа. Проще всего — по списку файлов, известных образу.

Подсказка 3

Обновление удобно делать вспомогательным container'ом, которому примонтированы и volume, и путь из образа.

Решение

Показать решение
bash
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

Ожидаемый вывод:

text
═══ 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 по одному пути, данные приложения держать по другому.

Проверка результата

bash
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 volumesVolumes накапливаются; использовать -v
VOLUME в своём DockerfileКажется хорошим тономAnonymous volumes и потеря изменений в RUN
RUN после VOLUME для того же путиПорядок не продуманИзменения не попадают в образ; переставить
docker volume prune в расчёте на полную уборкуПоведение изменилось в 23.0Named volumes не трогаются без --all
docker volume prune --all не глядяХотели освободить местоУдалит named volumes с данными
Два container'а пишут в один файлVolume общий — значит, можноНужны блокировки; для БД — недопустимо
Данные БД в volume, примонтированном двум container'амКажется отказоустойчивымРазрушение каталога данных
Заучен путь /var/lib/docker/volumes/...Обычно веренСпрашивать docker volume inspect
Правка файлов volume через sudo на hostБыстрееВладелец и SELinux-контекст могут сбиться

Контрольные вопросы

На понимание:

  1. При каких условиях volume наполнится содержимым образа?
  2. Почему после обновления образа container видит старую конфигурацию?
  3. Чем anonymous volume отличается от named с точки зрения удаления?
  4. Почему VOLUME в Dockerfile может «съесть» результат RUN?
  5. Что удаляет docker volume prune без флагов?

На применение:

  1. Как найти файлы volume на host?
  2. Как смонтировать volume только на чтение?
  3. Как сделать volume, указывающий на конкретный каталог host?

На диагностику:

  1. Место на диске кончается, containers удалены. Что проверить?
  2. Приложение читает конфигурацию не ту, что в образе. Порядок действий?

Краткое резюме

  1. Named volume создаётся человеком, anonymous — автоматически и со случайным именем.
  2. Данные лежат в /var/lib/docker/volumes/<имя>/_data; путь даёт docker volume inspect.
  3. Расположение volumes не зависит от хранилища образов, в отличие от writable layer.
  4. Пустой volume наполняется содержимым образа при первом монтировании — один раз.
  5. Непустой volume не обновляется никогда: это причина «изменения не применяются».
  6. volume-nocopy отключает наполнение.
  7. VOLUME в Dockerfile создаёт anonymous volumes и рушит последующие RUN по тому же пути.
  8. docker rm -v удаляет anonymous volumes container'а; без -v они остаются навсегда.
  9. docker volume prune с версии 23.0 не трогает named volumes без --all.
  10. Драйвер local принимает опции mount: bind, NFS, tmpfs.
  11. Общий volume не даёт синхронизации; для одного файла нужны блокировки.
  12. Два процесса БД на одном каталоге данных разрушают его.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: volumeshttps://docs.docker.com/engine/storage/volumes/Named и anonymous, auto-population, опции монтирования
Docker: docker volume createhttps://docs.docker.com/reference/cli/docker/volume/create/Опции драйвера local: NFS, bind, tmpfs
Docker: docker volume prunehttps://docs.docker.com/reference/cli/docker/volume/prune/Поведение по умолчанию и флаг --all
Dockerfile: VOLUMEhttps://docs.docker.com/reference/dockerfile/#volumeПобочные эффекты для последующих инструкций
Docker: storage overviewhttps://docs.docker.com/engine/storage/Выбор между механизмами
Compose: volumeshttps://docs.docker.com/reference/compose-file/volumes/Объявление volumes в Compose

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Bind mounts
Главное оглавление

Markdown на GitHub ↗