3.2. Слои и copy-on-write
Цели
После этого материала вы сможете:
- объяснить, как из неизменяемых слоёв собирается единая файловая система;
- назвать компоненты OverlayFS: lowerdir, upperdir, workdir, merged — и роль каждого;
- объяснить механизм copy-up и оценить его стоимость;
- объяснить, как реализуется удаление файла из нижнего слоя;
- объяснить, почему
RUN rmв отдельной инструкции не уменьшает образ; - найти writable layer работающего container на диске;
- собрать overlay-монтирование вручную и убедиться в его поведении.
Предварительные знания
- 3.1. Архитектура image;
- 2.3. Linux namespaces — mount namespace;
- понимание точек монтирования Linux.
Ключевые термины
| Термин | Объяснение |
|---|---|
OverlayFS | Файловая система Linux, объединяющая несколько каталогов в один вид |
lowerdir | Нижние каталоги — слои образа, доступны только для чтения |
upperdir | Верхний каталог — сюда попадают все изменения |
workdir | Служебный каталог для атомарных операций OverlayFS |
merged | Результирующий вид: объединение lowerdir и upperdir |
copy-up | Копирование файла из нижнего слоя в верхний перед изменением |
whiteout | Специальный файл, помечающий удалённый объект нижнего слоя |
writable layer | Слой записи container — его upperdir |
Теория
Задача, которую решает объединение слоёв
Образ состоит из нескольких неизменяемых слоёв. Процессу внутри container нужна одна файловая система, а не пять каталогов. И ему нужна возможность писать — хотя все слои образа доступны только для чтения.
OverlayFS решает обе задачи. Она берёт несколько каталогов и показывает их как один, а все изменения направляет в отдельный каталог записи.
merged (то, что видит процесс)
/usr /etc /app/main.py /app/cache.db
▲
┌─────────────────────┴─────────────────────┐
│ │
┌────┴────────────────────┐ ┌─────────┴──────────┐
│ upperdir │ │ lowerdir │
│ writable layer │ │ слои образа │
│ (чтение и запись) │ │ (только чтение) │
├─────────────────────────┤ ├────────────────────┤
│ /app/cache.db │ │ слой 3: /app │
│ (создан в container) │ │ слой 2: зависимости│
└─────────────────────────┘ │ слой 1: базовая ОС│
└────────────────────┘
Правила просмотра простые:
- Файл ищется сверху вниз: сначала в upperdir, затем в слоях lowerdir по порядку.
- Первое найденное побеждает. Файл в верхнем слое перекрывает одноимённый файл в нижнем.
- Каталоги сливаются: если
/appесть в двух слоях, в merged видно объединение их содержимого.
Третье правило отличает каталоги от файлов и объясняет, почему добавление файла в /usr/local/bin не «скрывает» остальное содержимое этого каталога.
Copy-on-write
Слои образа доступны только для чтения. Что происходит при попытке изменить файл из нижнего слоя?
Срабатывает copy-up:
- Процесс открывает файл на запись.
- Ядро обнаруживает, что файл находится в lowerdir.
- Файл целиком копируется в upperdir.
- Изменения применяются к копии.
- Все последующие обращения идут к копии в upperdir.
Ключевое слово — целиком. Изменение одного байта в файле размером 2 GB копирует все 2 GB.
Из этого следуют практические выводы:
Первая запись дороже последующих. Приложение, которое при старте изменяет большой файл из образа, будет медленно стартовать в первый раз и быстро — потом (в пределах жизни одного container).
Базы данных нельзя держать в writable layer. СУБД постоянно изменяет файлы данных. Каждое изменение файла, унаследованного из образа, вызывает copy-up. Это одна из причин, по которым данные выносят в volume (раздел 07).
Container с интенсивной записью растёт. Writable layer занимает место на диске, и docker ps -s это показывает.
Whiteout — удаление невозможного
Слои образа неизменяемы. Как тогда удалить файл, который в них лежит?
Никак. Его нельзя удалить — можно только скрыть. OverlayFS создаёт в upperdir специальный файл-маркер: символьное устройство с номерами 0/0 и тем же именем. Он называется whiteout.
lowerdir: /var/log/big.log (500 MB)
│
│ rm /var/log/big.log
▼
upperdir: /var/log/big.log ← whiteout, character device 0:0, размер 0
│
▼
merged: файла нет
Файл исчез из виду. Но 500 MB в нижнем слое остались на месте.
Для удаления целого каталога используется расширенный атрибут trusted.overlay.opaque=y на каталоге в upperdir — он означает «содержимое нижних слоёв для этого каталога не показывать».
Почему RUN rm не уменьшает образ
Это самое практически важное следствие whiteout. Рассмотрим Dockerfile:
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/*
Что происходит:
- Инструкция 2 создаёт слой размером около 300 MB, включая кэш apt.
- Инструкция 3 создаёт новый слой, содержащий только whiteout-маркеры.
- Слой из инструкции 2 никуда не делся: он по-прежнему часть образа и по-прежнему скачивается при
docker pull.
Итог: образ стал больше (добавился ещё один слой), а не меньше. Файлы не видны, но переданы по сети и лежат на диске.
Правильный вариант — удалять в той же инструкции, где создавали:
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
Здесь слой формируется по результату всей команды: временные файлы не попадают в него вовсе.
Тот же принцип относится к любым временным данным: скачанным архивам, кэшу пакетных менеджеров, промежуточным артефактам сборки. Радикальное решение — multi-stage build (раздел 05), где нужное копируется в чистый образ.
Секрет, удалённый в следующем слое, остаётся в образе
Тот же механизм даёт проблему безопасности:
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:company/private.git /src
RUN rm /root/.ssh/id_rsa
Ключ удалён из merged-вида, но лежит в слое от инструкции COPY. Любой, кто получит образ, извлечёт его — например, через docker save и распаковку архива. Демонстрация — в разделе 12, правильное решение — secret mounts в разделе 05.
Внутренний механизм
Как Docker собирает overlay
Для запуска container Docker формирует монтирование вида:
mount -t overlay overlay \
-o lowerdir=/слой3:/слой2:/слой1,upperdir=/writable,workdir=/work \
/merged
Обратите внимание на порядок в lowerdir: первым указывается верхний слой. Список идёт сверху вниз, а не снизу вверх — частый источник ошибок при ручных экспериментах.
workdir — служебный каталог, который OverlayFS использует для атомарных операций (в том числе copy-up). Он должен находиться на той же файловой системе, что и upperdir, и быть пустым при монтировании.
Стоимость copy-up
Copy-up выполняется при первой записи и включает:
- создание каталогов-предков в upperdir;
- копирование содержимого файла;
- копирование метаданных: права, владелец, расширенные атрибуты;
- создание жёсткой ссылки или переименование через workdir для атомарности.
Для мелких файлов это незаметно. Для крупных — заметно сразу. Практический ориентир: копирование файла со скоростью последовательной записи вашего диска.
Ограничения OverlayFS
| Ограничение | Проявление |
|---|---|
| Число слоёв в lowerdir | Ядро ограничивает длину строки монтирования; практический предел — 128 слоёв |
| Жёсткие ссылки между слоями | Ссылка на файл нижнего слоя после copy-up разрывается: копия становится отдельным файлом |
rename() каталога с нижнего слоя | Не поддерживается напрямую без redirect_dir; может вернуть EXDEV |
| Не все файловые системы годятся как upperdir | Нужна поддержка расширенных атрибутов; xfs требует ftype=1 |
| Изменение inode при copy-up | Программы, кэширующие номер inode, могут повести себя неожиданно |
Из-за первого ограничения существует рекомендация не плодить слои без необходимости. На практике до предела доходят редко, но образ из 60 слоёв — признак того, что Dockerfile стоит переписать.
Команды и примеры
Слои образа
docker pull -q python:3.13-slim
docker image inspect python:3.13-slim --format '{{json .GraphDriver}}' | python3 -m json.tool
При классическом хранилище (overlay2) вы увидите конкретные пути:
{
"Data": {
"LowerDir": "/var/lib/docker/overlay2/abc.../diff:/var/lib/docker/overlay2/def.../diff",
"MergedDir": "/var/lib/docker/overlay2/ghi.../merged",
"UpperDir": "/var/lib/docker/overlay2/ghi.../diff",
"WorkDir": "/var/lib/docker/overlay2/ghi.../work"
},
"Name": "overlay2"
}
При containerd image store (overlayfs) поле GraphDriver для образа обычно пустое — слоями управляет снапшоттер containerd, и пути устроены иначе. Это ожидаемо; смотрите слои через RootFS.Layers, как в уроке 3.1.
Writable layer container
docker run -d --name cow-demo python:3.13-slim sleep 3600
docker inspect cow-demo --format \
'{{if .GraphDriver}}{{json .GraphDriver.Data}}{{else}}GraphDriver отсутствует (containerd image store){{end}}'
GraphDriver отсутствует (containerd image store)
Проверка на {{if}} здесь обязательна. На умолчании Docker 29 поля GraphDriver у container'а нет вовсе, и обращение к .GraphDriver.Data даёт не пустое значение, а отказ шаблонизатора:
template parsing error: template: :1:19: executing "" at <.GraphDriver.Data>:
map has no entry for key "GraphDriver"
При классическом overlay2 тот же формат даёт пути:
{
"LowerDir": "/var/lib/docker/overlay2/l/XYZ:/var/lib/docker/overlay2/l/ABC",
"MergedDir": "/var/lib/docker/overlay2/9f8e.../merged",
"UpperDir": "/var/lib/docker/overlay2/9f8e.../diff",
"WorkDir": "/var/lib/docker/overlay2/9f8e.../work"
}
UpperDir — и есть writable layer этого container.
При containerd image store путей в inspect нет, но сам слой никуда не делся — его размер виден снаружи:
docker exec cow-demo sh -c 'dd if=/dev/zero of=/tmp/f bs=1M count=5 2>/dev/null'
docker ps -s --filter name=cow-demo --format 'table {{.Names}}\t{{.Size}}'
NAMES SIZE
cow-demo 5.25MB (virtual 138MB)
Первое число — writable layer, virtual — вместе со слоями образа. Пять мегабайт появились ровно потому, что мы их записали.
Универсальный способ, работающий при любом хранилище, — найти overlay-монтирование в mount namespace container:
CPID="$(docker inspect cow-demo --format '{{.State.Pid}}')"
sudo nsenter -t "$CPID" -m findmnt -n -o SOURCE,FSTYPE /
overlay overlay
Корень container — это overlay-монтирование. Параметры монтирования:
sudo grep -m1 ' / overlay ' /proc/"$CPID"/mountinfo | tr ',' '\n' | grep -E 'lowerdir|upperdir|workdir'
Наблюдение за copy-up
Создадим файл в container и найдём его в upperdir на host.
UPPER="$(docker inspect cow-demo --format '{{.GraphDriver.Data.UpperDir}}')"
echo "upperdir: ${UPPER:-(недоступен при containerd image store)}"
docker exec cow-demo sh -c 'echo "новые данные" > /newfile.txt'
if [ -n "$UPPER" ]; then
sudo ls -l "$UPPER/newfile.txt"
sudo cat "$UPPER/newfile.txt"
fi
-rw-r--r-- 1 root root 24 Jul 29 15:40 /var/lib/docker/overlay2/9f8e.../diff/newfile.txt
новые данные
Файл, созданный внутри container, физически лежит в его writable layer на host.
Теперь изменим файл, унаследованный из образа:
docker exec cow-demo sh -c 'echo "# изменено" >> /etc/hostname'
if [ -n "$UPPER" ]; then
sudo ls -l "$UPPER/etc/hostname"
fi
-rw-r--r-- 1 root root 25 Jul 29 15:41 /var/lib/docker/overlay2/9f8e.../diff/etc/hostname
Файл появился в upperdir, хотя мы его не создавали — это copy-up: перед изменением он был скопирован из нижнего слоя.
Измерение стоимости copy-up
docker run --rm python:3.13-slim sh -c '
# создаём файл в writable layer
dd if=/dev/zero of=/big.bin bs=1M count=200 2>/dev/null
sync
echo "--- изменение файла, уже находящегося в upperdir ---"
time sh -c "printf X | dd of=/big.bin bs=1 seek=0 conv=notrunc 2>/dev/null"
'
Быстро — copy-up не нужен, файл уже в верхнем слое.
Теперь то же самое для файла из образа. Соберём образ с крупным файлом:
mkdir -p /tmp/cowtest && cd /tmp/cowtest
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
RUN dd if=/dev/zero of=/data.bin bs=1M count=200 2>/dev/null
EOF
docker build -q -t cowtest:1 .
docker run --rm cowtest:1 sh -c '
echo "--- первое изменение файла из образа (нужен copy-up) ---"
time sh -c "printf X | dd of=/data.bin bs=1 seek=0 conv=notrunc 2>/dev/null"
echo "--- второе изменение того же файла (copy-up уже сделан) ---"
time sh -c "printf Y | dd of=/data.bin bs=1 seek=1 conv=notrunc 2>/dev/null"
'
--- первое изменение файла из образа (нужен copy-up) ---
real 0m 0.42s
--- второе изменение того же файла (copy-up уже сделан) ---
real 0m 0.00s
Разница на два порядка. Записан один байт, скопировано 200 MB.
Точные цифры зависят от диска, но соотношение сохраняется всегда.
Whiteout своими глазами
docker run -d --name wo-demo python:3.13-slim sleep 3600
docker exec wo-demo ls -l /etc/hostname
docker exec wo-demo rm /etc/hostname
docker exec wo-demo ls /etc/hostname 2>&1 || echo "в container файла больше нет"
UPPER="$(docker inspect wo-demo --format '{{.GraphDriver.Data.UpperDir}}')"
if [ -n "$UPPER" ]; then
echo "--- что лежит в upperdir ---"
sudo ls -l "$UPPER/etc/hostname"
fi
-rw-r--r-- 1 root root 13 Jul 29 15:44 /etc/hostname
ls: /etc/hostname: No such file or directory
в container файла больше нет
--- что лежит в upperdir ---
c--------- 1 root root 0, 0 Jul 29 15:45 /var/lib/docker/overlay2/abc.../diff/etc/hostname
Разберём последнюю строку:
| Часть | Значение |
|---|---|
c | Тип файла — character device |
0, 0 | Старший и младший номера устройства — оба нули |
| размер 0 | Данных нет |
Это и есть whiteout. Файл «удалён» добавлением специального маркера, а не удалением данных.
docker rm -f wo-demo
Демонстрация: удаление не уменьшает образ
cd /tmp/cowtest
cat > Dockerfile.bad <<'EOF'
FROM debian:trixie-slim
RUN dd if=/dev/zero of=/tmp/big.bin bs=1M count=150 2>/dev/null
RUN rm /tmp/big.bin
EOF
cat > Dockerfile.good <<'EOF'
FROM debian:trixie-slim
RUN dd if=/dev/zero of=/tmp/big.bin bs=1M count=150 2>/dev/null && rm /tmp/big.bin
EOF
docker build -q -f Dockerfile.bad -t sizetest:bad . > /dev/null
docker build -q -f Dockerfile.good -t sizetest:good . > /dev/null
docker images sizetest --format 'table {{.Tag}}\t{{.Size}}'
TAG SIZE
good 97.2MB
bad 254MB
Оба образа содержат ровно ноль полезных файлов сверх базового. Разница — 157 MB.
Посмотрим, где они:
echo "=== bad ==="
docker history sizetest:bad --format 'table {{.Size}}\t{{.CreatedBy}}' | head -4
echo "=== good ==="
docker history sizetest:good --format 'table {{.Size}}\t{{.CreatedBy}}' | head -4
=== bad ===
SIZE CREATED BY
0B RUN /bin/sh -c rm /tmp/big.bin # buildkit
157MB RUN /bin/sh -c dd if=/dev/zero of=/tmp/big.bin bs=1M count=150 2>/…
97.2MB /bin/sh -c #(nop) ADD file:... in /
=== good ===
SIZE CREATED BY
0B RUN /bin/sh -c dd if=/dev/zero of=/tmp/big.bin bs=1M count=150 2>/…
97.2MB /bin/sh -c #(nop) ADD file:... in /
В варианте bad слой на 157 MB присутствует и будет скачан при каждом docker pull. Слой удаления имеет размер 0 — он содержит только whiteout.
В варианте good файл создан и удалён внутри одной инструкции, поэтому слой пустой.
Ручная сборка overlay
Чтобы убедиться, что механизм принадлежит ядру, а не Docker:
mkdir -p /tmp/ovl/{lower1,lower2,upper,work,merged}
echo "из нижнего слоя 1" > /tmp/ovl/lower1/a.txt
echo "из нижнего слоя 1" > /tmp/ovl/lower1/common.txt
echo "из нижнего слоя 2" > /tmp/ovl/lower2/b.txt
echo "из нижнего слоя 2 (перекрывает)" > /tmp/ovl/lower2/common.txt
sudo mount -t overlay overlay \
-o lowerdir=/tmp/ovl/lower2:/tmp/ovl/lower1,upperdir=/tmp/ovl/upper,workdir=/tmp/ovl/work \
/tmp/ovl/merged
echo "--- содержимое merged ---"
ls /tmp/ovl/merged
echo "--- какой common.txt победил ---"
cat /tmp/ovl/merged/common.txt
--- содержимое merged ---
a.txt b.txt common.txt
--- какой common.txt победил ---
из нижнего слоя 2 (перекрывает)
lower2 указан в lowerdir первым, поэтому он верхний и перекрывает lower1.
Теперь copy-up:
echo "изменено" >> /tmp/ovl/merged/a.txt
echo "--- в upperdir появилась копия ---"
ls -l /tmp/ovl/upper/
echo "--- оригинал в lower1 не изменился ---"
cat /tmp/ovl/lower1/a.txt
--- в upperdir появилась копия ---
-rw-r--r-- 1 root root 28 Jul 29 15:52 a.txt
--- оригинал в lower1 не изменился ---
из нижнего слоя 1
И whiteout:
sudo rm /tmp/ovl/merged/b.txt
ls -l /tmp/ovl/upper/b.txt
ls /tmp/ovl/lower2/
c--------- 1 root root 0, 0 Jul 29 15:53 /tmp/ovl/upper/b.txt
b.txt common.txt
Whiteout в upperdir, оригинал в lower2 на месте. Ровно то же, что делает Docker.
Уборка:
sudo umount /tmp/ovl/merged
sudo rm -rf /tmp/ovl
Размер writable layer
docker ps -s --filter name=cow-demo --format 'table {{.Names}}\t{{.Size}}'
NAMES SIZE
cow-demo 25B (virtual 178MB)
Первое число — размер writable layer, virtual — вместе со слоями образа. Флаг -s замедляет команду, поэтому по умолчанию выключен.
Уборка
docker rm -f cow-demo
docker rmi cowtest:1 sizetest:bad sizetest:good 2>/dev/null || true
rm -rf /tmp/cowtest
Практическое упражнение
Задание. Докажите измерениями три утверждения этого урока.
- Copy-up стоит дорого. Постройте образ с файлом 300 MB. Измерьте время изменения одного байта в нём при первой и второй записи. Объясните разницу.
- Удаление не уменьшает образ. Соберите два образа, отличающихся только тем, что в одном временный файл удаляется в отдельной инструкции, а в другом — в той же. Сравните размеры и покажите, в каком слое остались данные.
- Whiteout существует. Найдите whiteout-файл для удалённого объекта и покажите его тип и размер.
Оформите результат таблицей с числами.
Подсказки
Подсказка 1
Для измерения времени внутри container подойдёт встроенный time оболочки. Не забудьте sync, иначе измерите скорость записи в кэш страниц, а не на диск.
Подсказка 2
docker history --format 'table {{.Size}}\t{{.CreatedBy}}' показывает, какой слой сколько занимает.
Подсказка 3
UpperDir доступен через docker inspect <container> --format '{{.GraphDriver.Data.UpperDir}}' — при классическом хранилище. Если поле пустое, вы работаете на containerd image store: используйте /proc/<pid>/mountinfo для поиска upperdir.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/usr/bin/env bash
# cow-proof.sh — измеримое подтверждение свойств copy-on-write.
set -euo pipefail
WORK="$(mktemp -d)"
trap 'docker rm -f cow-probe >/dev/null 2>&1 || true;
docker rmi -f cow:big size:sep size:same >/dev/null 2>&1 || true;
rm -rf "$WORK"' EXIT
cd "$WORK"
echo "=== 1. Стоимость copy-up ==="
cat > Dockerfile.big <<'EOF'
FROM debian:trixie-slim
RUN dd if=/dev/zero of=/data.bin bs=1M count=300 status=none && sync
EOF
docker build -q -f Dockerfile.big -t cow:big . > /dev/null
docker run --rm cow:big sh -c '
first=$( { time -p sh -c "printf X | dd of=/data.bin bs=1 seek=0 conv=notrunc status=none; sync"; } 2>&1 | awk "/^real/{print \$2}" )
second=$( { time -p sh -c "printf Y | dd of=/data.bin bs=1 seek=1 conv=notrunc status=none; sync"; } 2>&1 | awk "/^real/{print \$2}" )
printf " первая запись (copy-up 300 MB): %s c\n" "$first"
printf " вторая запись (файл уже вверху): %s c\n" "$second"
'
echo
echo "=== 2. Удаление не уменьшает образ ==="
cat > Dockerfile.sep <<'EOF'
FROM debian:trixie-slim
RUN dd if=/dev/zero of=/tmp/junk bs=1M count=200 status=none
RUN rm /tmp/junk
EOF
cat > Dockerfile.same <<'EOF'
FROM debian:trixie-slim
RUN dd if=/dev/zero of=/tmp/junk bs=1M count=200 status=none && rm /tmp/junk
EOF
docker build -q -f Dockerfile.sep -t size:sep . > /dev/null
docker build -q -f Dockerfile.same -t size:same . > /dev/null
base="$(docker images debian:trixie-slim --format '{{.Size}}')"
sep="$(docker images size:sep --format '{{.Size}}')"
same="$(docker images size:same --format '{{.Size}}')"
printf ' база debian:trixie-slim: %s\n' "$base"
printf ' удаление в отдельной инструкции: %s\n' "$sep"
printf ' удаление в той же инструкции: %s\n' "$same"
echo
echo " Где остались данные (size:sep):"
docker history size:sep --format ' {{.Size}}\t{{.CreatedBy}}' | head -3
echo
echo "=== 3. Whiteout ==="
docker run -d --name cow-probe debian:trixie-slim sleep 300 > /dev/null
docker exec cow-probe rm /etc/hostname
UPPER="$(docker inspect cow-probe --format '{{.GraphDriver.Data.UpperDir}}')"
if [ -z "$UPPER" ]; then
CPID="$(docker inspect cow-probe --format '{{.State.Pid}}')"
UPPER="$(sudo grep -m1 ' / overlay ' /proc/"$CPID"/mountinfo \
| tr ',' '\n' | awk -F= '/^upperdir/{print $2}')"
fi
if [ -n "$UPPER" ] && sudo test -e "$UPPER/etc/hostname"; then
echo " whiteout-файл:"
sudo ls -l "$UPPER/etc/hostname" | sed 's/^/ /'
echo " тип: $(sudo stat -c '%F' "$UPPER/etc/hostname"), размер: $(sudo stat -c '%s' "$UPPER/etc/hostname") байт"
else
echo " upperdir недоступен напрямую (containerd image store)"
fi
Ожидаемый вывод:
=== 1. Стоимость copy-up ===
первая запись (copy-up 300 MB): 0.61 c
вторая запись (файл уже вверху): 0.00 c
=== 2. Удаление не уменьшает образ ===
база debian:trixie-slim: 97.2MB
удаление в отдельной инструкции: 307MB
удаление в той же инструкции: 97.2MB
Где остались данные (size:sep):
0B RUN /bin/sh -c rm /tmp/junk # buildkit
210MB RUN /bin/sh -c dd if=/dev/zero of=/tmp/junk bs=1M count=200 s…
97.2MB /bin/sh -c #(nop) ADD file:... in /
=== 3. Whiteout ===
whiteout-файл:
c--------- 1 root root 0, 0 Jul 29 16:02 /var/lib/docker/overlay2/abc.../diff/etc/hostname
тип: character special file, размер: 0 байт
Итоговая таблица.
| Утверждение | Измерение | Вывод |
|---|---|---|
| Copy-up дорог | 0.61 c против 0.00 c при записи одного байта | Копируется весь файл, а не изменённая часть |
| Удаление не уменьшает образ | 307 MB против 97.2 MB | Слой с данными остаётся частью образа и скачивается при pull |
| Whiteout — character device 0:0 | размер 0 байт, тип c | Удаление реализовано маркером, а не удалением данных |
Практический вывод. Данные, попавшие в слой, из образа уже не убрать — их можно только не помещать туда. Отсюда два правила: удалять временное в той же инструкции и использовать multi-stage build для всего, что нужно только на этапе сборки.
Проверка результата
docker run -d --name check debian:trixie-slim sleep 60
docker exec check sh -c 'echo test > /marker.txt'
docker ps -s --filter name=check --format '{{.Names}}: {{.Size}}'
docker rm -f check
Размер writable layer должен быть ненулевым и заметно меньше virtual. Объясните, почему.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
RUN rm в отдельной инструкции для уменьшения образа | Кажется, что удаление освобождает место | Удалять в той же инструкции, где создавали, либо multi-stage |
| Хранение данных СУБД в writable layer | Работает, пока container жив | Volume: данные переживают container и не страдают от copy-up |
| Секрет, «удалённый» следующей инструкцией | Из merged он исчез | Слой с секретом остаётся; использовать secret mounts |
| Ожидание, что изменение байта в большом файле дёшево | Copy-on-write не очевиден | Copy-up копирует файл целиком |
Неверный порядок в lowerdir при ручном монтировании | Кажется, что первым идёт нижний слой | Первым указывается верхний слой |
workdir на другой файловой системе | Монтирование не удастся | workdir и upperdir должны быть на одной ФС |
Оценка размера container по docker ps без -s | Размер не показывается по умолчанию | Использовать docker ps -s |
| Десятки слоёв «для удобства кэширования» | Каждый слой имеет накладные расходы, есть предел | Объединять логически связанные шаги |
Контрольные вопросы
На понимание:
- Что произойдёт при попытке записи в файл, находящийся в нижнем слое?
- Почему изменение одного байта в файле размером 1 GB занимает заметное время только в первый раз?
- Как реализовано удаление файла из неизменяемого слоя?
- Почему
RUN rm -rf /var/lib/apt/lists/*отдельной инструкцией делает образ больше? - Какой слой окажется наверху при
lowerdir=/l3:/l2:/l1?
На применение:
- Как найти writable layer работающего container на диске?
- Как узнать, сколько места занимает writable layer, не считая слоёв образа?
- Как определить, какой слой образа ответственен за его размер?
На диагностику:
- Приложение стартует 40 секунд при первом запуске container и 2 секунды при последующих обращениях к тем же данным. Какое объяснение стоит проверить первым?
- В образе нашли приватный ключ, хотя в
DockerfileестьRUN rmдля него. Как это возможно и что делать?
Краткое резюме
- OverlayFS объединяет слои образа (lowerdir) и слой записи (upperdir) в единый вид (merged).
- Файл ищется сверху вниз; каталоги сливаются, файлы перекрываются.
- В
lowerdirпервым указывается верхний слой. - Copy-up копирует файл из нижнего слоя в верхний целиком перед первой записью.
- Первая запись в унаследованный файл дорога, последующие — нет.
- Удаление файла нижнего слоя реализуется whiteout — character device 0:0 нулевого размера.
- Данные удалённого файла остаются в слое и скачиваются при
docker pull. RUN rmотдельной инструкцией увеличивает образ, а не уменьшает.- Секрет, удалённый в следующем слое, извлекается из образа.
- Интенсивная запись должна идти в volume, а не в writable layer.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Use the OverlayFS storage driver | https://docs.docker.com/engine/storage/drivers/overlayfs-driver/ | Структура lowerdir/upperdir/workdir/merged, механизм copy-up, whiteout, ограничения |
| Docker storage drivers | https://docs.docker.com/engine/storage/drivers/ | Модель слоёв, writable container layer, стоимость записи |
| OverlayFS (kernel documentation) | https://docs.kernel.org/filesystems/overlayfs.html | Правила объединения, whiteout как character device 0:0, trusted.overlay.opaque, ограничения rename |
| OCI Image Layer Specification | https://github.com/opencontainers/image-spec/blob/main/layer.md | Представление удалений в слоях образа (.wh. файлы в tar) |
| Building best practices | https://docs.docker.com/build/building/best-practices/ | Рекомендация удалять временные файлы в той же инструкции |
| Build secrets | https://docs.docker.com/build/building/secrets/ | Почему секреты нельзя удалять последующей инструкцией |
| docker ps reference | https://docs.docker.com/reference/cli/docker/container/ls/ | Флаг -s и значение virtual |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Tags и digests
Главное оглавление