Главная/Docker internals/Урок

17.5. OverlayFS

Цели

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

  • назвать четыре каталога overlay-монтирования и роль каждого;
  • собрать overlay вручную командой mount и убедиться, что он работает как слои образа;
  • объяснить copy-up и измерить его стоимость;
  • объяснить, как удаление файла из нижнего слоя записывается в верхний;
  • найти на диске слои образа и writable layer container'а;
  • назвать, что меняется при containerd image store.

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

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

ТерминОбъяснение
lowerdirНижние слои, только чтение
upperdirВерхний слой, куда идёт запись
workdirСлужебный каталог для атомарных операций
mergedИтоговое представление
copy-upКопирование файла из нижнего слоя в верхний при записи
whiteoutЗапись об удалении файла нижнего слоя

Теория

Четыре каталога

text
merged/          то, что видит процесс
   ▲
   │  объединение
   │
upperdir/        запись идёт сюда
lowerdir/        слои образа, только чтение
workdir/         служебный, рядом с upperdir
КаталогНазначениеТребование
lowerdirОдин или несколько слоёв, разделённых :Только чтение
upperdirИзмененияЗапись
workdirПромежуточные состояния при атомарных операцияхТа же файловая система, что upperdir
mergedТочка монтированияПустой каталог

Требование к workdir — частая причина отказа монтирования: он должен находиться на той же файловой системе, что upperdir, и быть пустым.

Порядок в lowerdirсправа налево по старшинству: первый указанный слой перекрывает последующие.

bash
mount -t overlay overlay \
    -o lowerdir=layer2:layer1,upperdir=upper,workdir=work \
    merged

Здесь layer2 важнее layer1: при совпадении имён файл берётся из layer2.

Это соответствует порядку слоёв образа: последняя инструкция Dockerfile даёт верхний слой.

Copy-up

Запись в файл, находящийся в lowerdir, невозможна: слой доступен только для чтения. Ядро выполняет copy-up:

text
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).

Уменьшает только удаление в той же инструкции, где файлы созданы.

Где это на диске

bash
docker inspect ОБРАЗ_ИЛИ_CONTAINER --format '{{json .GraphDriver}}'
ПолеЧто содержит
LowerDirСлои образа через :
UpperDirWritable layer container'а
WorkDirСлужебный каталог
MergedDirТочка монтирования

Для образа UpperDir отсутствует: у образа нет writable layer.

Каталоги лежат в /var/lib/docker/overlay2/<хеш>/:

text
overlay2/<хеш>/
├── diff/      содержимое слоя
├── link       короткое имя для сокращения строки монтирования
├── lower      ссылки на нижние слои
└── work/      служебный (только у слоя container'а)

Файл link существует из-за ограничения на длину параметров монтирования: полные пути к десяти слоям в неё не помещаются, поэтому используются короткие имена из overlay2/l/.

Что меняется при containerd image store

С переходом на containerd в качестве хранилища образов (урок 3.1):

СвойствоКлассический overlay2containerd image store
Где слои/var/lib/docker/overlay2//var/lib/docker/containerd/
Поле GraphDriverЗаполненоМожет быть пустым или иным
МеханизмТот же OverlayFSТот же OverlayFS
Управлениеdockerdcontainerd snapshotter

Механизм не меняется — меняется, кто им управляет и где лежат данные. Команды docker inspect могут не показывать привычные поля, и путь к слоям приходится искать через ctr.

Проверить, какое хранилище используется:

bash
docker info --format '{{.DriverStatus}}'

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

Почему нужен workdir

Некоторые операции требуют атомарности: например, copy-up должен либо завершиться полностью, либо не начинаться.

Ядро создаёт файл в workdir, наполняет его и переименовывает в upperdir. Переименование в пределах одной файловой системы атомарно.

Отсюда требование: workdir и upperdir — на одной файловой системе. Иначе переименование превратилось бы в копирование, и атомарность потерялась бы.

Почему lowerdir может быть длинным

Каждая инструкция Dockerfile, изменяющая файловую систему, даёт слой. Образ из пятнадцати инструкций даёт до пятнадцати слоёв, и все они перечисляются в lowerdir.

Ограничение на длину строки параметров монтирования — около одной страницы памяти. Отсюда приём с короткими именами в overlay2/l/: вместо пути в сотню символов используется имя из двадцати шести.

Это же объясняет практический предел на число слоёв: он существует, хотя и высок.


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

Overlay вручную

bash
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

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

text
═══ готовим слои ═══
  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

bash
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

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

text
═══ два слоя с одинаковым файлом ═══
  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: измерение стоимости

bash
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

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

text
═══ образ с крупным файлом ═══
═══ дописать один байт в файл из нижнего слоя ═══
  дописан 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).

Где слои на диске

bash
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

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

text
═══ 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

bash
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

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

text
═══ какое хранилище используется ═══
  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.

Требования:

  1. Собрать overlay вручную и показать перекрытие файлов — или отметить отсутствие прав.
  2. Воспроизвести то же средствами Docker: файл из верхнего слоя перекрывает нижний.
  3. Показать, что удалённый файл остаётся в слоях образа.
  4. Измерить стоимость copy-up: запись байта в крупный файл против создания нового.
  5. Показать размер writable layer после copy-up.
  6. Определить, какое хранилище образов используется, и объяснить, что это меняет.

Подсказки

Подсказка 1

Порядок в lowerdir — справа налево: первый указанный слой старше.

Подсказка 2

Для пункта 3 распакуйте docker save и поищите файл в слоях — он там, хотя в container'е его нет.

Подсказка 3

docker ps -s показывает размер writable layer; docker diff — какие файлы туда попали.

Решение

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

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

text
═══ Требование 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 от размера линейна по устройству, но не проверялась.

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

bash
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 тот же

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

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

  1. Четыре каталога overlay — назовите и объясните роль каждого.
  2. Почему workdir должен быть на той же файловой системе, что upperdir?
  3. Что такое copy-up и что его вызывает?
  4. Почему RUN rm -rf отдельной инструкцией не уменьшает образ?
  5. Что меняется при переходе на containerd image store, а что остаётся?

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

  1. Как измерить стоимость copy-up?
  2. Как найти writable layer container'а?
  3. Как определить, какое хранилище образов используется?

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

  1. Приложение записало байт, writable layer вырос на сотни мегабайт. Причина?
  2. Монтирование overlay не удаётся с ошибкой. Что проверить в первую очередь?

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

  1. Overlay объединяет lowerdir (только чтение), upperdir (запись), workdir и merged.
  2. workdir должен быть на той же файловой системе, что upperdir, — из-за атомарного переименования.
  3. Порядок в lowerdir — справа налево: первый указанный слой старше.
  4. Copy-up копирует файл целиком при первой записи в него.
  5. chmod и chown тоже вызывают copy-up: метаданные хранятся с файлом.
  6. Создание нового файла copy-up не вызывает — он сразу в upperdir.
  7. Удаление файла нижнего слоя записывается whiteout-меткой в верхнем.
  8. Для каталога используется признак opaque.
  9. RUN rm отдельной инструкцией не уменьшает образ: файлы остаются в слое.
  10. Файл link в каталоге слоя существует из-за ограничения длины параметров монтирования.
  11. У образа нет UpperDir — writable layer появляется только у container'а.
  12. Containerd image store меняет место хранения и управление, но не механизм.

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

ИсточникСсылкаЧто подтверждает
Linux: OverlayFShttps://docs.kernel.org/filesystems/overlayfs.htmlКаталоги, copy-up, whiteout
Docker: overlay2 driverhttps://docs.docker.com/engine/storage/drivers/overlayfs-driver/Структура на диске
Docker: storage drivershttps://docs.docker.com/engine/storage/drivers/Выбор драйвера
Docker: containerd image storehttps://docs.docker.com/engine/storage/containerd/Переход и различия
containerd: snapshottershttps://github.com/containerd/containerd/blob/main/docs/snapshotters/README.mdУправление слоями
Linux: mount(8)https://man7.org/linux/man-pages/man8/mount.8.htmlПараметры монтирования

Навигация

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

Markdown на GitHub ↗