17.5. OverlayFS
Цели
После этого материала вы сможете:
- назвать четыре каталога overlay-монтирования и роль каждого;
- собрать overlay вручную командой
mountи убедиться, что он работает как слои образа; - объяснить copy-up и измерить его стоимость;
- объяснить, как удаление файла из нижнего слоя записывается в верхний;
- найти на диске слои образа и writable layer container'а;
- назвать, что меняется при containerd image store.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
lowerdir | Нижние слои, только чтение |
upperdir | Верхний слой, куда идёт запись |
workdir | Служебный каталог для атомарных операций |
merged | Итоговое представление |
copy-up | Копирование файла из нижнего слоя в верхний при записи |
whiteout | Запись об удалении файла нижнего слоя |
Теория
Четыре каталога
merged/ то, что видит процесс
▲
│ объединение
│
upperdir/ запись идёт сюда
lowerdir/ слои образа, только чтение
workdir/ служебный, рядом с upperdir
| Каталог | Назначение | Требование |
|---|---|---|
lowerdir | Один или несколько слоёв, разделённых : | Только чтение |
upperdir | Изменения | Запись |
workdir | Промежуточные состояния при атомарных операциях | Та же файловая система, что upperdir |
merged | Точка монтирования | Пустой каталог |
Требование к workdir — частая причина отказа монтирования: он должен находиться на той же файловой системе, что upperdir, и быть пустым.
Порядок в lowerdir — справа налево по старшинству: первый указанный слой перекрывает последующие.
mount -t overlay overlay \
-o lowerdir=layer2:layer1,upperdir=upper,workdir=work \
merged
Здесь layer2 важнее layer1: при совпадении имён файл берётся из layer2.
Это соответствует порядку слоёв образа: последняя инструкция Dockerfile даёт верхний слой.
Copy-up
Запись в файл, находящийся в lowerdir, невозможна: слой доступен только для чтения. Ядро выполняет copy-up:
1. Файл найден в lowerdir
2. Файл целиком копируется в upperdir
3. Запись выполняется в копию
4. Дальнейшие обращения идут к копии
Стоимость: копируется весь файл, а не изменённая часть.
| Действие | Стоимость |
|---|---|
| Дописать байт в файл на 1 ГБ | Копирование 1 ГБ |
| Изменить права файла | Копирование файла |
| Обновить время доступа | Обычно нет copy-up |
| Создать новый файл | Нет copy-up: файл сразу в upperdir |
Вторая строка неочевидна: chmod на файле из нижнего слоя вызывает копирование, потому что метаданные хранятся вместе с файлом.
Отсюда практическое следствие (урок 7.1): база данных, работающая с файлами образа, при первой записи копирует их целиком. Поэтому данные держат в volume.
Whiteout: удаление
Удалить файл из lowerdir нельзя — он доступен только для чтения. Вместо этого в upperdir создаётся whiteout: символическая запись, скрывающая файл.
| Реализация | Как выглядит |
|---|---|
| Классическая | Символьное устройство с номерами 0:0 |
Через xattr (metacopy, userxattr) | Расширенный атрибут trusted.overlay.whiteout |
Для каталога, содержимое которого полностью скрывается, используется признак opaque: расширенный атрибут trusted.overlay.opaque=y.
Отсюда следствие для размера образа: RUN rm -rf /var/cache в отдельной инструкции не уменьшает образ. Файлы остаются в нижнем слое, а в верхнем добавляются whiteout-записи (урок 5.5).
Уменьшает только удаление в той же инструкции, где файлы созданы.
Где это на диске
docker inspect ОБРАЗ_ИЛИ_CONTAINER --format '{{json .GraphDriver}}'
| Поле | Что содержит |
|---|---|
LowerDir | Слои образа через : |
UpperDir | Writable layer container'а |
WorkDir | Служебный каталог |
MergedDir | Точка монтирования |
Для образа UpperDir отсутствует: у образа нет writable layer.
Каталоги лежат в /var/lib/docker/overlay2/<хеш>/:
overlay2/<хеш>/
├── diff/ содержимое слоя
├── link короткое имя для сокращения строки монтирования
├── lower ссылки на нижние слои
└── work/ служебный (только у слоя container'а)
Файл link существует из-за ограничения на длину параметров монтирования: полные пути к десяти слоям в неё не помещаются, поэтому используются короткие имена из overlay2/l/.
Что меняется при containerd image store
С переходом на containerd в качестве хранилища образов (урок 3.1):
| Свойство | Классический overlay2 | containerd image store |
|---|---|---|
| Где слои | /var/lib/docker/overlay2/ | /var/lib/docker/containerd/ |
Поле GraphDriver | Заполнено | Может быть пустым или иным |
| Механизм | Тот же OverlayFS | Тот же OverlayFS |
| Управление | dockerd | containerd snapshotter |
Механизм не меняется — меняется, кто им управляет и где лежат данные. Команды docker inspect могут не показывать привычные поля, и путь к слоям приходится искать через ctr.
Проверить, какое хранилище используется:
docker info --format '{{.DriverStatus}}'
Внутренний механизм
Почему нужен workdir
Некоторые операции требуют атомарности: например, copy-up должен либо завершиться полностью, либо не начинаться.
Ядро создаёт файл в workdir, наполняет его и переименовывает в upperdir. Переименование в пределах одной файловой системы атомарно.
Отсюда требование: workdir и upperdir — на одной файловой системе. Иначе переименование превратилось бы в копирование, и атомарность потерялась бы.
Почему lowerdir может быть длинным
Каждая инструкция Dockerfile, изменяющая файловую систему, даёт слой. Образ из пятнадцати инструкций даёт до пятнадцати слоёв, и все они перечисляются в lowerdir.
Ограничение на длину строки параметров монтирования — около одной страницы памяти. Отсюда приём с короткими именами в overlay2/l/: вместо пути в сотню символов используется имя из двадцати шести.
Это же объясняет практический предел на число слоёв: он существует, хотя и высок.
Команды и примеры
Overlay вручную
mkdir -p /tmp/ovl && cd /tmp/ovl
mkdir -p lower1 lower2 upper work merged
echo "═══ готовим слои ═══"
echo "из нижнего слоя 1" > lower1/only-in-lower1.txt
echo "версия из слоя 1" > lower1/shadowed.txt
echo "будет удалён" > lower1/to-delete.txt
mkdir -p lower1/somedir && echo "внутри" > lower1/somedir/file.txt
echo "из нижнего слоя 2" > lower2/only-in-lower2.txt
echo "версия из слоя 2" > lower2/shadowed.txt
printf ' lower1: %s\n' "$(ls lower1 | tr '\n' ' ')"
printf ' lower2: %s\n' "$(ls lower2 | tr '\n' ' ')"
echo "═══ монтируем overlay ═══"
if sudo -n true 2>/dev/null; then
sudo mount -t overlay overlay \
-o "lowerdir=$PWD/lower2:$PWD/lower1,upperdir=$PWD/upper,workdir=$PWD/work" \
"$PWD/merged" 2>&1 && mounted=1 || mounted=0
else
mounted=0
fi
if [ "$mounted" = "1" ]; then
printf ' смонтировано\n'
printf ' merged: %s\n' "$(ls merged | tr '\n' ' ')"
printf ' содержимое shadowed.txt: %s\n' "$(cat merged/shadowed.txt)"
else
cat <<'TXT'
монтирование НЕ ВЫПОЛНЯЛОСЬ: нужен sudo.
Команда для справки:
sudo mount -t overlay overlay \
-o lowerdir=$PWD/lower2:$PWD/lower1,upperdir=$PWD/upper,workdir=$PWD/work \
$PWD/merged
Что было бы видно в merged:
only-in-lower1.txt only-in-lower2.txt shadowed.txt
somedir/ to-delete.txt
Содержимое shadowed.txt: «версия из слоя 2»
— потому что lower2 указан ПЕРВЫМ и потому старше.
TXT
fi
echo "═══ порядок в lowerdir ═══"
cat <<'TXT'
lowerdir=layer2:layer1
Порядок СПРАВА НАЛЕВО по старшинству:
layer2 перекрывает layer1
Это соответствует порядку слоёв образа: последняя инструкция
Dockerfile даёт верхний слой, и он перекрывает предыдущие.
Ошибка в порядке даёт файлы из неверного слоя — и обнаруживается
не сразу, а когда содержимое окажется старым.
TXT
Ожидаемый вывод:
═══ готовим слои ═══
lower1: only-in-lower1.txt shadowed.txt somedir to-delete.txt
lower2: only-in-lower2.txt shadowed.txt
═══ монтируем overlay ═══
монтирование НЕ ВЫПОЛНЯЛОСЬ: нужен sudo.
Команда для справки:
sudo mount -t overlay overlay \
-o lowerdir=$PWD/lower2:$PWD/lower1,upperdir=$PWD/upper,workdir=$PWD/work \
$PWD/merged
Что было бы видно в merged:
only-in-lower1.txt only-in-lower2.txt shadowed.txt
somedir/ to-delete.txt
Содержимое shadowed.txt: «версия из слоя 2»
— потому что lower2 указан ПЕРВЫМ и потому старше.
═══ порядок в lowerdir ═══
lowerdir=layer2:layer1
...
То же средствами Docker
cd /tmp/ovl
echo "═══ два слоя с одинаковым файлом ═══"
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN echo "версия из слоя 1" > /shadowed.txt \
&& echo "только в слое 1" > /only-in-1.txt \
&& echo "будет удалён" > /to-delete.txt
RUN echo "версия из слоя 2" > /shadowed.txt \
&& echo "только в слое 2" > /only-in-2.txt
RUN rm -f /to-delete.txt
EOF
docker build -q -t ovl:demo . > /dev/null
docker run --rm ovl:demo sh -c '
printf " shadowed.txt: %s\n" "$(cat /shadowed.txt)"
printf " only-in-1.txt: %s\n" "$(cat /only-in-1.txt)"
printf " only-in-2.txt: %s\n" "$(cat /only-in-2.txt)"
printf " to-delete.txt: %s\n" "$(cat /to-delete.txt 2>&1 | tail -1)"
' 2>/dev/null
echo "═══ удалённый файл остался в слое ═══"
docker save ovl:demo -o demo.tar 2>/dev/null
mkdir -p unpacked && tar -xf demo.tar -C unpacked 2>/dev/null
found=0
while read -r layer; do
if tar -tf "$layer" 2>/dev/null | grep -q 'to-delete.txt'; then
entry="$(tar -tf "$layer" 2>/dev/null | grep 'to-delete.txt' | head -1)"
printf ' найден в слое: %s\n' "$entry"
found=$((found + 1))
fi
done < <(find unpacked -name '*.tar' 2>/dev/null)
printf ' вхождений to-delete.txt в слоях: %s\n' "$found"
echo "═══ вывод ═══"
cat <<'TXT'
Файл удалён инструкцией RUN rm, в container'е его нет —
но в слоях образа он остался. В верхнем слое добавилась
whiteout-запись, скрывающая его.
Отсюда: RUN rm -rf в ОТДЕЛЬНОЙ инструкции не уменьшает образ.
Уменьшает только удаление в ТОЙ ЖЕ инструкции, где файлы созданы:
RUN apt-get update \
&& apt-get install -y пакет \
&& rm -rf /var/lib/apt/lists/*
Это же объясняет, почему секрет, удалённый следующей
инструкцией, извлекается из слоёв ([урок 12.6]).
TXT
rm -rf unpacked demo.tar
Ожидаемый вывод:
═══ два слоя с одинаковым файлом ═══
shadowed.txt: версия из слоя 2
only-in-1.txt: только в слое 1
only-in-2.txt: только в слое 2
to-delete.txt: cat: can't open '/to-delete.txt': No such file or directory
═══ удалённый файл остался в слое ═══
найден в слое: to-delete.txt
вхождений to-delete.txt в слоях: 1
═══ вывод ═══
Файл удалён инструкцией RUN rm, в container'е его нет —
но в слоях образа он остался. В верхнем слое добавилась
whiteout-запись, скрывающая его.
...
Файл недоступен в container'е и присутствует в слое. Это буквально то же явление, что извлечение секрета из образа (урок 12.6).
Copy-up: измерение стоимости
cd /tmp/ovl
echo "═══ образ с крупным файлом ═══"
cat > Dockerfile.big <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN dd if=/dev/zero of=/big.bin bs=1M count=128 2>/dev/null
EOF
docker build -q -f Dockerfile.big -t ovl:big . > /dev/null
echo "═══ дописать один байт в файл из нижнего слоя ═══"
docker run --rm ovl:big sh -c '
start=$(date +%s%N)
echo "x" >> /big.bin
end=$(date +%s%N)
printf " дописан 1 байт: %s мс\n" "$(( (end - start) / 1000000 ))"
' 2>/dev/null
echo "═══ создать новый файл того же размера ═══"
docker run --rm ovl:big sh -c '
start=$(date +%s%N)
dd if=/dev/zero of=/new.bin bs=1M count=1 2>/dev/null
end=$(date +%s%N)
printf " создан 1 МиБ: %s мс\n" "$(( (end - start) / 1000000 ))"
' 2>/dev/null
echo "═══ размер writable layer ═══"
docker run -d --name ovl-copyup ovl:big sh -c 'echo x >> /big.bin; sleep 60' > /dev/null
sleep 3
docker ps -s --filter 'name=ovl-copyup' --format ' {{.Names}}: {{.Size}}' 2>/dev/null
docker diff ovl-copyup 2>/dev/null | head -3 | sed 's/^/ /'
docker rm -f ovl-copyup > /dev/null 2>&1
echo "═══ что произошло ═══"
cat <<'TXT'
Дописан ОДИН байт — writable layer вырос на 128 МиБ.
Механизм copy-up: файл из lowerdir доступен только для чтения,
поэтому ядро копирует его ЦЕЛИКОМ в upperdir и пишет в копию.
Копируется весь файл, а не изменённая часть.
Что вызывает copy-up:
любая запись в файл нижнего слоя
chmod, chown — метаданные хранятся вместе с файлом
truncate
Что НЕ вызывает:
создание нового файла — он сразу в upperdir
чтение
Отсюда правило: данные, в которые пишут, держат в volume,
а не в файловой системе container'а ([урок 7.1]).
TXT
Ожидаемый вывод:
═══ образ с крупным файлом ═══
═══ дописать один байт в файл из нижнего слоя ═══
дописан 1 байт: 187 мс
═══ создать новый файл того же размера ═══
создан 1 МиБ: 4 мс
═══ размер writable layer ═══
ovl-copyup: 134.2MB (virtual 141.8MB)
C /big.bin
═══ что произошло ═══
Дописан ОДИН байт — writable layer вырос на 128 МиБ.
...
187 миллисекунд на один байт против 4 на мегабайт нового файла. Разница — стоимость копирования 128 МиБ.
Строка C /big.bin в docker diff означает «изменён» — файл переехал в upperdir (урок 13.5).
Где слои на диске
cd /tmp/ovl
echo "═══ GraphDriver образа ═══"
docker inspect ovl:demo --format '{{.GraphDriver.Name}}' 2>/dev/null | sed 's/^/ драйвер: /'
docker inspect ovl:demo --format '{{json .GraphDriver.Data}}' 2>/dev/null \
| python3 -c "
import json, sys
try:
data = json.load(sys.stdin) or {}
except Exception:
data = {}
if not data:
print(' данных нет — вероятно, containerd image store')
else:
for key, value in data.items():
if key == 'LowerDir':
layers = value.split(':')
print(f' {key}: {len(layers)} слоёв')
for l in layers[:3]:
print(f' {l}')
else:
print(f' {key}: {value}')
"
echo "═══ GraphDriver container'а ═══"
docker run -d --name ovl-layers ovl:demo sleep 60 > /dev/null
sleep 2
docker inspect ovl-layers --format '{{json .GraphDriver.Data}}' 2>/dev/null \
| python3 -c "
import json, sys
try:
data = json.load(sys.stdin) or {}
except Exception:
data = {}
if not data:
print(' данных нет — containerd image store')
else:
for key in ('UpperDir', 'WorkDir', 'MergedDir'):
if key in data:
print(f' {key}: {data[key]}')
if 'LowerDir' in data:
print(f\" LowerDir: {len(data['LowerDir'].split(':'))} слоёв\")
"
echo "═══ разница образа и container'а ═══"
cat <<'TXT'
У образа UpperDir ОТСУТСТВУЕТ: у него нет writable layer.
У container'а он есть — туда идёт вся запись.
Структура каталога слоя в /var/lib/docker/overlay2/<хеш>/:
diff/ содержимое слоя
link короткое имя для сокращения строки монтирования
lower ссылки на нижние слои
work/ служебный (только у слоя container'а)
Файл link существует из-за ограничения на длину параметров
монтирования: полные пути к десяти слоям в неё не помещаются.
TXT
echo "═══ фактическое монтирование ═══"
if sudo -n true 2>/dev/null; then
sudo findmnt -t overlay -o TARGET,OPTIONS 2>/dev/null | head -3 | sed 's/^/ /'
else
echo " просмотр монтирований НЕ ВЫПОЛНЯЛСЯ: нужен sudo"
echo " команда: sudo findmnt -t overlay"
fi
docker rm -f ovl-layers > /dev/null 2>&1
Ожидаемый вывод:
═══ GraphDriver образа ═══
драйвер: overlayfs
данных нет — вероятно, containerd image store
═══ GraphDriver container'а ═══
данных нет — containerd image store
═══ разница образа и container'а ═══
У образа UpperDir ОТСУТСТВУЕТ: у него нет writable layer.
У container'а он есть — туда идёт вся запись.
...
═══ фактическое монтирование ═══
просмотр монтирований НЕ ВЫПОЛНЯЛСЯ: нужен sudo
команда: sudo findmnt -t overlay
Пустой GraphDriver.Data — признак containerd image store: слои управляются snapshotter'ом containerd, а не драйвером dockerd.
Что меняется при containerd image store
cd /tmp/ovl
echo "═══ какое хранилище используется ═══"
docker info --format '{{.Driver}}' 2>/dev/null | sed 's/^/ Driver: /'
docker info --format '{{range .DriverStatus}} {{index . 0}}: {{index . 1}}
{{end}}' 2>/dev/null | head -4
echo "═══ сравнение ═══"
python3 - <<'PY'
ROWS = [
("Где лежат слои", "/var/lib/docker/overlay2/",
"/var/lib/docker/containerd/"),
("Кто управляет", "dockerd, драйвер overlay2",
"containerd, snapshotter"),
("GraphDriver.Data в inspect", "заполнено путями",
"пусто или иное"),
("Механизм объединения слоёв", "OverlayFS", "OverlayFS — ТОТ ЖЕ"),
("Мультиплатформенные образы", "ограниченно", "полноценно"),
("Как найти слой", "docker inspect", "ctr --namespace moby"),
]
print(f" {'свойство':<32} {'классический overlay2':<30} containerd image store")
print(" " + "─" * 96)
for name, old, new in ROWS:
print(f" {name:<32} {old:<30} {new}")
print()
print(" Ключевая строка — четвёртая: МЕХАНИЗМ НЕ МЕНЯЕТСЯ.")
print(" Меняется, кто им управляет и где хранятся данные.")
print()
print(" Практическое следствие: привычные поля docker inspect")
print(" могут быть пустыми, и путь к слоям ищут через ctr.")
PY
echo "═══ поиск слоёв через ctr ═══"
if command -v ctr > /dev/null 2>&1 && sudo -n true 2>/dev/null; then
sudo ctr --namespace moby snapshots list 2>/dev/null | head -4 | sed 's/^/ /'
else
cat <<'TXT'
ctr недоступен или нет прав — НЕ ВЫПОЛНЯЛОСЬ.
Команды для справки:
sudo ctr --namespace moby snapshots list
sudo ctr --namespace moby snapshots info <ключ>
sudo ctr --namespace moby images list
TXT
fi
docker rmi -f ovl:demo ovl:big > /dev/null 2>&1
cd /tmp && rm -rf /tmp/ovl
Ожидаемый вывод:
═══ какое хранилище используется ═══
Driver: overlayfs
driver-type: io.containerd.snapshotter.v1
═══ сравнение ═══
свойство классический overlay2 containerd image store
────────────────────────────────────────────────────────────────────────────────────────────────
Где лежат слои /var/lib/docker/overlay2/ /var/lib/docker/containerd/
Кто управляет dockerd, драйвер overlay2 containerd, snapshotter
GraphDriver.Data в inspect заполнено путями пусто или иное
Механизм объединения слоёв OverlayFS OverlayFS — ТОТ ЖЕ
Мультиплатформенные образы ограниченно полноценно
Как найти слой docker inspect ctr --namespace moby
Ключевая строка — четвёртая: МЕХАНИЗМ НЕ МЕНЯЕТСЯ.
Меняется, кто им управляет и где хранятся данные.
...
═══ поиск слоёв через ctr ═══
ctr недоступен или нет прав — НЕ ВЫПОЛНЯЛОСЬ.
...
Строка driver-type: io.containerd.snapshotter.v1 подтверждает: используется snapshotter containerd, а не классический драйвер.
Практическое упражнение
Задание. Разберите механизм слоёв и измерьте стоимость copy-up.
Требования:
- Собрать overlay вручную и показать перекрытие файлов — или отметить отсутствие прав.
- Воспроизвести то же средствами Docker: файл из верхнего слоя перекрывает нижний.
- Показать, что удалённый файл остаётся в слоях образа.
- Измерить стоимость copy-up: запись байта в крупный файл против создания нового.
- Показать размер writable layer после copy-up.
- Определить, какое хранилище образов используется, и объяснить, что это меняет.
Подсказки
Подсказка 1
Порядок в lowerdir — справа налево: первый указанный слой старше.
Подсказка 2
Для пункта 3 распакуйте docker save и поищите файл в слоях — он там, хотя в container'е его нет.
Подсказка 3
docker ps -s показывает размер writable layer; docker diff — какие файлы туда попали.
Решение
Показать решение
mkdir -p /tmp/ovllab && cd /tmp/ovllab
cat > overlay_manual.sh <<'SH'
#!/usr/bin/env bash
# Сборка overlay вручную и проверка перекрытия слоёв.
set -uo pipefail
BASE="${1:-$PWD/manual}"
rm -rf "$BASE"
mkdir -p "$BASE"/{lower1,lower2,upper,work,merged}
echo "нижний слой 1" > "$BASE/lower1/only1.txt"
echo "версия из слоя 1" > "$BASE/lower1/shadowed.txt"
echo "будет скрыт" > "$BASE/lower1/hidden.txt"
echo "нижний слой 2" > "$BASE/lower2/only2.txt"
echo "версия из слоя 2" > "$BASE/lower2/shadowed.txt"
if ! sudo -n true 2>/dev/null; then
cat <<'MSG'
НЕ ВЫПОЛНЯЛОСЬ: монтирование overlay требует root.
Команда для справки:
sudo mount -t overlay overlay \
-o lowerdir=$BASE/lower2:$BASE/lower1,\
upperdir=$BASE/upper,workdir=$BASE/work \
$BASE/merged
Что было бы видно:
merged/only1.txt из lower1
merged/only2.txt из lower2
merged/shadowed.txt «версия из слоя 2» — lower2 указан первым
merged/hidden.txt из lower1
Требования к монтированию:
workdir на ТОЙ ЖЕ файловой системе, что upperdir
workdir и merged должны быть пустыми
порядок lowerdir: справа налево по старшинству
MSG
exit 3
fi
sudo mount -t overlay overlay \
-o "lowerdir=$BASE/lower2:$BASE/lower1,upperdir=$BASE/upper,workdir=$BASE/work" \
"$BASE/merged" || { echo " монтирование не удалось"; exit 1; }
printf ' merged: %s\n' "$(ls "$BASE/merged" | tr '\n' ' ')"
printf ' shadowed.txt: %s\n' "$(cat "$BASE/merged/shadowed.txt")"
echo "тронуто" >> "$BASE/merged/shadowed.txt"
printf ' после записи файл появился в upper: %s\n' \
"$(ls "$BASE/upper" | tr '\n' ' ')"
sudo rm -f "$BASE/merged/hidden.txt"
printf ' после удаления в upper: %s\n' "$(ls -a "$BASE/upper" | tr '\n' ' ')"
printf ' тип записи hidden.txt в upper: %s\n' \
"$(stat -c '%F' "$BASE/upper/hidden.txt" 2>/dev/null || echo 'не найдено')"
sudo umount "$BASE/merged" 2>/dev/null
exit 0
SH
chmod +x overlay_manual.sh
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
# Слой 1
RUN echo "версия из слоя 1" > /shadowed.txt \
&& echo "только в слое 1" > /only1.txt \
&& echo "СЕКРЕТНОЕ-СОДЕРЖИМОЕ-XYZ" > /to-delete.txt
# Слой 2: перекрывает shadowed.txt
RUN echo "версия из слоя 2" > /shadowed.txt \
&& echo "только в слое 2" > /only2.txt
# Слой 3: удаление — но файл остаётся в слое 1
RUN rm -f /to-delete.txt
EOF
cat > Dockerfile.big <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN dd if=/dev/zero of=/big.bin bs=1M count=128 2>/dev/null
EOF
cat > measure_copyup.py <<'PY'
"""Измерение стоимости copy-up.
Запись в файл нижнего слоя копирует его ЦЕЛИКОМ в upperdir.
Создание нового файла copy-up не вызывает.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
def run_in(image: str, script: str) -> str:
result = subprocess.run(
["docker", "run", "--rm", image, "sh", "-c", script],
capture_output=True, text=True)
return result.stdout.strip()
def main(image: str) -> int:
# Запись одного байта в файл из нижнего слоя
append = run_in(image, """
start=$(date +%s%N)
echo "x" >> /big.bin
end=$(date +%s%N)
echo $(( (end - start) / 1000000 ))
""")
# Создание нового файла того же размера
create = run_in(image, """
start=$(date +%s%N)
dd if=/dev/zero of=/new.bin bs=1M count=128 2>/dev/null
end=$(date +%s%N)
echo $(( (end - start) / 1000000 ))
""")
# Создание маленького нового файла
small = run_in(image, """
start=$(date +%s%N)
echo "x" > /small.txt
end=$(date +%s%N)
echo $(( (end - start) / 1000000 ))
""")
# chmod на файле нижнего слоя — тоже copy-up
chmod = run_in(image, """
start=$(date +%s%N)
chmod 600 /big.bin
end=$(date +%s%N)
echo $(( (end - start) / 1000000 ))
""")
def num(value: str) -> int:
return int(value) if value.isdigit() else -1
result = {
"дописать_байт_в_большой": num(append),
"создать_новый_128МиБ": num(create),
"создать_маленький": num(small),
"chmod_на_большом": num(chmod),
}
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "ovllab:big"))
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: overlay вручную ═══\n'
./overlay_manual.sh "$PWD/manual"; manual_rc=$?
case "$manual_rc" in
0) ok "overlay собран вручную; перекрытие и whiteout показаны" ;;
3) ok "НЕ ВЫПОЛНЯЛОСЬ: нет прав root — команды приведены, шаг не засчитан" ;;
*) bad "неожиданный код: $manual_rc" ;;
esac
printf '\n═══ Требование 2: перекрытие слоёв средствами Docker ═══\n'
docker build -q -t ovllab:layers . > /dev/null 2>&1
docker run --rm ovllab:layers sh -c '
printf " shadowed.txt: %s\n" "$(cat /shadowed.txt)"
printf " only1.txt: %s\n" "$(cat /only1.txt)"
printf " only2.txt: %s\n" "$(cat /only2.txt)"
printf " to-delete.txt: %s\n" "$(test -e /to-delete.txt && echo есть || echo НЕТ)"
' 2>/dev/null
shadowed="$(docker run --rm ovllab:layers cat /shadowed.txt 2>/dev/null)"
deleted_gone="$(docker run --rm ovllab:layers sh -c \
'test -e /to-delete.txt && echo есть || echo нет' 2>/dev/null)"
[ "$shadowed" = "версия из слоя 2" ] && [ "$deleted_gone" = "нет" ] \
&& ok "верхний слой перекрывает нижний; удалённого файла в container'е нет" \
|| bad "shadowed=$shadowed удалён=$deleted_gone"
printf '\n═══ Требование 3: удалённый файл остался в слоях ═══\n'
docker save ovllab:layers -o layers.tar 2>/dev/null
rm -rf unpacked && mkdir -p unpacked
tar -xf layers.tar -C unpacked 2>/dev/null
found_file=0
found_content=0
while read -r layer; do
if tar -tf "$layer" 2>/dev/null | grep -q 'to-delete.txt'; then
found_file=$((found_file + 1))
content="$(tar -xOf "$layer" \
"$(tar -tf "$layer" | grep 'to-delete.txt' | head -1)" 2>/dev/null)"
case "$content" in
*СЕКРЕТНОЕ*) found_content=$((found_content + 1))
printf ' содержимое из слоя: %s\n' "$content" ;;
esac
fi
done < <(find unpacked -name '*.tar' 2>/dev/null)
printf ' вхождений файла в слоях: %s\n' "$found_file"
printf ' из них с читаемым содержимым: %s\n' "$found_content"
printf '\n Файл удалён инструкцией RUN rm, в container е его нет,\n'
printf ' но в слое он остался. В верхнем слое — whiteout-запись.\n'
printf ' Это то же явление, что извлечение секрета ([урок 12.6]).\n'
rm -rf unpacked layers.tar
[ "$found_file" -ge 1 ] \
&& ok "удалённый файл найден в слоях образа" \
|| bad "вхождений: $found_file"
printf '\n═══ Требования 4-5: стоимость copy-up ═══\n'
docker build -q -f Dockerfile.big -t ovllab:big . > /dev/null 2>&1
python3 measure_copyup.py ovllab:big > copyup.json 2>/dev/null
python3 - <<'PY'
import json
from pathlib import Path
d = json.loads(Path("copyup.json").read_text())
rows = [
("дописать 1 байт в файл 128 МиБ", d["дописать_байт_в_большой"],
"copy-up: копируется ВЕСЬ файл"),
("chmod на файле 128 МиБ", d["chmod_на_большом"],
"copy-up: метаданные хранятся с файлом"),
("создать новый файл 128 МиБ", d["создать_новый_128МиБ"],
"без copy-up: сразу в upperdir"),
("создать маленький новый файл", d["создать_маленький"],
"без copy-up"),
]
print(f" {'операция':<34} {'время':>9} причина")
print(" " + "─" * 80)
for name, ms, why in rows:
value = f"{ms} мс" if ms >= 0 else "—"
print(f" {name:<34} {value:>9} {why}")
append = d["дописать_байт_в_большой"]
small = d["создать_маленький"]
if append > 0 and small >= 0:
print()
print(f" Один байт в файл нижнего слоя стоит {append} мс,")
print(f" маленький новый файл — {small} мс.")
print(" Разница — стоимость копирования 128 МиБ.")
PY
printf '\n размер writable layer после copy-up:\n'
docker rm -f ovllab-cu > /dev/null 2>&1
docker run -d --name ovllab-cu ovllab:big sh -c 'echo x >> /big.bin; sleep 60' > /dev/null
sleep 3
docker ps -s --filter 'name=ovllab-cu' --format ' {{.Names}}: {{.Size}}' 2>/dev/null
printf ' docker diff:\n'
docker diff ovllab-cu 2>/dev/null | head -3 | sed 's/^/ /'
layer_size="$(docker ps -s --filter 'name=ovllab-cu' --format '{{.Size}}' 2>/dev/null \
| cut -d' ' -f1)"
docker rm -f ovllab-cu > /dev/null 2>&1
append_ms="$(python3 -c "
import json
from pathlib import Path
print(json.loads(Path('copyup.json').read_text())['дописать_байт_в_большой'])")"
small_ms="$(python3 -c "
import json
from pathlib import Path
print(json.loads(Path('copyup.json').read_text())['создать_маленький'])")"
printf '\n writable layer: %s (записан 1 байт)\n' "$layer_size"
[ "${append_ms:-0}" -gt "${small_ms:-0}" ] \
&& ok "copy-up измерен: запись байта дороже создания файла" \
|| bad "байт=$append_ms мс, маленький файл=$small_ms мс"
printf '\n═══ Требование 6: какое хранилище образов ═══\n'
driver="$(docker info --format '{{.Driver}}' 2>/dev/null)"
printf ' Driver: %s\n' "$driver"
docker info --format '{{range .DriverStatus}} {{index . 0}}: {{index . 1}}
{{end}}' 2>/dev/null | head -3
graph_data="$(docker inspect ovllab:layers --format '{{json .GraphDriver.Data}}' 2>/dev/null)"
printf ' GraphDriver.Data: %s\n' \
"$(echo "$graph_data" | cut -c1-50)"
is_containerd=0
echo "$graph_data" | grep -qE '^(null|\{\})$' && is_containerd=1
docker info 2>/dev/null | grep -qi 'snapshotter' && is_containerd=1
python3 - <<'PY'
ROWS = [
("Где слои", "/var/lib/docker/overlay2/", "/var/lib/docker/containerd/"),
("Кто управляет", "dockerd", "containerd snapshotter"),
("GraphDriver.Data", "пути к каталогам", "пусто или иное"),
("Механизм", "OverlayFS", "OverlayFS — ТОТ ЖЕ"),
("Поиск слоя", "docker inspect", "ctr --namespace moby snapshots"),
]
print()
print(f" {'свойство':<20} {'классический overlay2':<28} containerd image store")
print(" " + "─" * 84)
for name, old, new in ROWS:
print(f" {name:<20} {old:<28} {new}")
print()
print(" Механизм объединения слоёв НЕ МЕНЯЕТСЯ — это тот же OverlayFS.")
print(" Меняется, кто им управляет и где хранятся данные.")
print(" Практическое следствие: привычные поля docker inspect могут")
print(" быть пустыми, и путь к слоям ищут через ctr.")
PY
printf '\n определено: %s\n' \
"$([ "$is_containerd" = "1" ] && echo "containerd image store" || echo "классический overlay2")"
ok "хранилище определено; названо, что оно меняет и что остаётся тем же"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
[ "$manual_rc" = "3" ] && echo " примечание: ручное монтирование overlay требует root"
docker rmi -f ovllab:layers ovllab:big > /dev/null 2>&1
cd /tmp && rm -rf /tmp/ovllab
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: overlay вручную ═══
НЕ ВЫПОЛНЯЛОСЬ: монтирование overlay требует root.
Команда для справки:
sudo mount -t overlay overlay \
-o lowerdir=$BASE/lower2:$BASE/lower1,upperdir=$BASE/upper,workdir=$BASE/work \
$BASE/merged
...
✓ НЕ ВЫПОЛНЯЛОСЬ: нет прав root — команды приведены, шаг не засчитан
═══ Требование 2: перекрытие слоёв средствами Docker ═══
shadowed.txt: версия из слоя 2
only1.txt: только в слое 1
only2.txt: только в слое 2
to-delete.txt: НЕТ
✓ верхний слой перекрывает нижний; удалённого файла в container'е нет
═══ Требование 3: удалённый файл остался в слоях ═══
содержимое из слоя: СЕКРЕТНОЕ-СОДЕРЖИМОЕ-XYZ
вхождений файла в слоях: 1
из них с читаемым содержимым: 1
Файл удалён инструкцией RUN rm, в container е его нет,
но в слое он остался. В верхнем слое — whiteout-запись.
Это то же явление, что извлечение секрета ([урок 12.6]).
✓ удалённый файл найден в слоях образа
═══ Требования 4-5: стоимость copy-up ═══
операция время причина
────────────────────────────────────────────────────────────────────────────────
дописать 1 байт в файл 128 МиБ 184 мс copy-up: копируется ВЕСЬ файл
chmod на файле 128 МиБ 178 мс copy-up: метаданные хранятся с файлом
создать новый файл 128 МиБ 142 мс без copy-up: сразу в upperdir
создать маленький новый файл 3 мс без copy-up
Один байт в файл нижнего слоя стоит 184 мс,
маленький новый файл — 3 мс.
Разница — стоимость копирования 128 МиБ.
размер writable layer после copy-up:
ovllab-cu: 134.2MB (virtual 141.8MB)
docker diff:
C /big.bin
writable layer: 134.2MB (записан 1 байт)
✓ copy-up измерен: запись байта дороже создания файла
═══ Требование 6: какое хранилище образов ═══
Driver: overlayfs
driver-type: io.containerd.snapshotter.v1
GraphDriver.Data: {}
свойство классический overlay2 containerd image store
────────────────────────────────────────────────────────────────────────────────────
Где слои /var/lib/docker/overlay2/ /var/lib/docker/containerd/
Кто управляет dockerd containerd snapshotter
GraphDriver.Data пути к каталогам пусто или иное
Механизм OverlayFS OverlayFS — ТОТ ЖЕ
Поиск слоя docker inspect ctr --namespace moby snapshots
Механизм объединения слоёв НЕ МЕНЯЕТСЯ — это тот же OverlayFS.
...
определено: containerd image store
✓ хранилище определено; названо, что оно меняет и что остаётся тем же
═══ ИТОГ ═══
все требования выполнены
примечание: ручное монтирование overlay требует root
Все требования выполнены; ручное монтирование не выполнялось и не засчитано.
Строка writable layer: 134.2MB (записан 1 байт) — самый наглядный результат урока. Приложение записало один байт; на диске появилось 128 МиБ.
Три решения, определяющие качество.
Copy-up измеряется четырьмя операциями, а не одной. Одно измерение — «запись байта заняла 184 мс» — не отвечает на вопрос, дорого это или нет. Сравнение с созданием маленького файла (3 мс) и с созданием файла того же размера (142 мс) показывает: дело не в записи как таковой, а именно в копировании. Строка с chmod добавляет неочевидное: метаданные тоже вызывают copy-up.
Удалённый файл ищется по содержимому, а не по имени. Наличие записи to-delete.txt в архиве слоя можно объяснить whiteout-меткой. Прочитанная строка СЕКРЕТНОЕ-СОДЕРЖИМОЕ-XYZ не оставляет толкований: данные лежат в слое целиком и извлекаются.
Тип хранилища определяется двумя признаками. Пустой GraphDriver.Data может означать и ошибку разбора. Проверка docker info на слово snapshotter подтверждает независимо. Совпадение двух признаков делает вывод надёжным.
Чего решение не делает. Ручное монтирование overlay не выполнялось: оно требует root, и перекрытие слоёв показано только средствами Docker. Whiteout-запись не осмотрена напрямую — её тип (символьное устройство 0:0 или расширенный атрибут) зависит от настроек и виден только в upperdir с правами root. Путь к слоям при containerd image store не найден: ctr недоступен. Наконец, измерения выполнены на одном размере файла — зависимость стоимости copy-up от размера линейна по устройству, но не проверялась.
Проверка результата
docker inspect ОБРАЗ --format '{{json .GraphDriver}}'
docker info --format '{{.Driver}}'
docker ps -s
docker diff ИМЯ
sudo findmnt -t overlay
docker diff показывает C для файлов, прошедших copy-up.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
RUN rm -rf отдельной инструкцией | Кажется, что уменьшит образ | Файлы остаются в нижнем слое |
| Считать, что удалённого файла нет в образе | Его нет в container'е | Извлекается распаковкой слоёв |
| Запись в файлы образа вместо volume | Работает | Copy-up копирует файл целиком |
Ожидать дешёвого chmod | Меняются только метаданные | Вызывает copy-up |
workdir на другой файловой системе | Не задумываются | Монтирование не удастся |
Непустой workdir или merged | Оставили от прошлого раза | Монтирование не удастся |
Обратный порядок в lowerdir | Читают слева направо | Первый указанный — старший |
Искать слои в overlay2 при containerd store | Привычка | Путь другой; искать через ctr |
| Считать, что containerd store меняет механизм | Название иное | OverlayFS тот же |
Контрольные вопросы
На понимание:
- Четыре каталога overlay — назовите и объясните роль каждого.
- Почему
workdirдолжен быть на той же файловой системе, чтоupperdir? - Что такое copy-up и что его вызывает?
- Почему
RUN rm -rfотдельной инструкцией не уменьшает образ? - Что меняется при переходе на containerd image store, а что остаётся?
На применение:
- Как измерить стоимость copy-up?
- Как найти writable layer container'а?
- Как определить, какое хранилище образов используется?
На диагностику:
- Приложение записало байт, writable layer вырос на сотни мегабайт. Причина?
- Монтирование overlay не удаётся с ошибкой. Что проверить в первую очередь?
Краткое резюме
- Overlay объединяет
lowerdir(только чтение),upperdir(запись),workdirиmerged. workdirдолжен быть на той же файловой системе, чтоupperdir, — из-за атомарного переименования.- Порядок в
lowerdir— справа налево: первый указанный слой старше. - Copy-up копирует файл целиком при первой записи в него.
chmodиchownтоже вызывают copy-up: метаданные хранятся с файлом.- Создание нового файла copy-up не вызывает — он сразу в
upperdir. - Удаление файла нижнего слоя записывается whiteout-меткой в верхнем.
- Для каталога используется признак
opaque. RUN rmотдельной инструкцией не уменьшает образ: файлы остаются в слое.- Файл
linkв каталоге слоя существует из-за ограничения длины параметров монтирования. - У образа нет
UpperDir— writable layer появляется только у container'а. - Containerd image store меняет место хранения и управление, но не механизм.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Linux: OverlayFS | https://docs.kernel.org/filesystems/overlayfs.html | Каталоги, copy-up, whiteout |
| Docker: overlay2 driver | https://docs.docker.com/engine/storage/drivers/overlayfs-driver/ | Структура на диске |
| Docker: storage drivers | https://docs.docker.com/engine/storage/drivers/ | Выбор драйвера |
| Docker: containerd image store | https://docs.docker.com/engine/storage/containerd/ | Переход и различия |
| containerd: snapshotters | https://github.com/containerd/containerd/blob/main/docs/snapshotters/README.md | Управление слоями |
Linux: mount(8) | https://man7.org/linux/man-pages/man8/mount.8.html | Параметры монтирования |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление