Главная/Работа с Images/Урок

3.2. Слои и copy-on-write

Цели

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

  • объяснить, как из неизменяемых слоёв собирается единая файловая система;
  • назвать компоненты OverlayFS: lowerdir, upperdir, workdir, merged — и роль каждого;
  • объяснить механизм copy-up и оценить его стоимость;
  • объяснить, как реализуется удаление файла из нижнего слоя;
  • объяснить, почему RUN rm в отдельной инструкции не уменьшает образ;
  • найти writable layer работающего container на диске;
  • собрать overlay-монтирование вручную и убедиться в его поведении.

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

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

ТерминОбъяснение
OverlayFSФайловая система Linux, объединяющая несколько каталогов в один вид
lowerdirНижние каталоги — слои образа, доступны только для чтения
upperdirВерхний каталог — сюда попадают все изменения
workdirСлужебный каталог для атомарных операций OverlayFS
mergedРезультирующий вид: объединение lowerdir и upperdir
copy-upКопирование файла из нижнего слоя в верхний перед изменением
whiteoutСпециальный файл, помечающий удалённый объект нижнего слоя
writable layerСлой записи container — его upperdir

Теория

Задача, которую решает объединение слоёв

Образ состоит из нескольких неизменяемых слоёв. Процессу внутри container нужна одна файловая система, а не пять каталогов. И ему нужна возможность писать — хотя все слои образа доступны только для чтения.

OverlayFS решает обе задачи. Она берёт несколько каталогов и показывает их как один, а все изменения направляет в отдельный каталог записи.

text
                        merged (то, что видит процесс)
                        /usr  /etc  /app/main.py  /app/cache.db
                              ▲
        ┌─────────────────────┴─────────────────────┐
        │                                           │
   ┌────┴────────────────────┐            ┌─────────┴──────────┐
   │  upperdir               │            │  lowerdir          │
   │  writable layer         │            │  слои образа       │
   │  (чтение и запись)      │            │  (только чтение)   │
   ├─────────────────────────┤            ├────────────────────┤
   │  /app/cache.db          │            │  слой 3: /app      │
   │  (создан в container)   │            │  слой 2: зависимости│
   └─────────────────────────┘            │  слой 1: базовая ОС│
                                          └────────────────────┘

Правила просмотра простые:

  1. Файл ищется сверху вниз: сначала в upperdir, затем в слоях lowerdir по порядку.
  2. Первое найденное побеждает. Файл в верхнем слое перекрывает одноимённый файл в нижнем.
  3. Каталоги сливаются: если /app есть в двух слоях, в merged видно объединение их содержимого.

Третье правило отличает каталоги от файлов и объясняет, почему добавление файла в /usr/local/bin не «скрывает» остальное содержимое этого каталога.

Copy-on-write

Слои образа доступны только для чтения. Что происходит при попытке изменить файл из нижнего слоя?

Срабатывает copy-up:

  1. Процесс открывает файл на запись.
  2. Ядро обнаруживает, что файл находится в lowerdir.
  3. Файл целиком копируется в upperdir.
  4. Изменения применяются к копии.
  5. Все последующие обращения идут к копии в upperdir.

Ключевое слово — целиком. Изменение одного байта в файле размером 2 GB копирует все 2 GB.

Из этого следуют практические выводы:

Первая запись дороже последующих. Приложение, которое при старте изменяет большой файл из образа, будет медленно стартовать в первый раз и быстро — потом (в пределах жизни одного container).

Базы данных нельзя держать в writable layer. СУБД постоянно изменяет файлы данных. Каждое изменение файла, унаследованного из образа, вызывает copy-up. Это одна из причин, по которым данные выносят в volume (раздел 07).

Container с интенсивной записью растёт. Writable layer занимает место на диске, и docker ps -s это показывает.

Whiteout — удаление невозможного

Слои образа неизменяемы. Как тогда удалить файл, который в них лежит?

Никак. Его нельзя удалить — можно только скрыть. OverlayFS создаёт в upperdir специальный файл-маркер: символьное устройство с номерами 0/0 и тем же именем. Он называется whiteout.

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

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.

Итог: образ стал больше (добавился ещё один слой), а не меньше. Файлы не видны, но переданы по сети и лежат на диске.

Правильный вариант — удалять в той же инструкции, где создавали:

dockerfile
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), где нужное копируется в чистый образ.

Секрет, удалённый в следующем слое, остаётся в образе

Тот же механизм даёт проблему безопасности:

dockerfile
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 формирует монтирование вида:

bash
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 стоит переписать.


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

Слои образа

bash
docker pull -q python:3.13-slim
docker image inspect python:3.13-slim --format '{{json .GraphDriver}}' | python3 -m json.tool

При классическом хранилище (overlay2) вы увидите конкретные пути:

json
{
    "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

bash
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}}'
text
GraphDriver отсутствует (containerd image store)

Проверка на {{if}} здесь обязательна. На умолчании Docker 29 поля GraphDriver у container'а нет вовсе, и обращение к .GraphDriver.Data даёт не пустое значение, а отказ шаблонизатора:

text
template parsing error: template: :1:19: executing "" at <.GraphDriver.Data>:
map has no entry for key "GraphDriver"

При классическом overlay2 тот же формат даёт пути:

json
{
    "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 нет, но сам слой никуда не делся — его размер виден снаружи:

bash
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}}'
text
NAMES      SIZE
cow-demo   5.25MB (virtual 138MB)

Первое число — writable layer, virtual — вместе со слоями образа. Пять мегабайт появились ровно потому, что мы их записали.

Универсальный способ, работающий при любом хранилище, — найти overlay-монтирование в mount namespace container:

bash
CPID="$(docker inspect cow-demo --format '{{.State.Pid}}')"
sudo nsenter -t "$CPID" -m findmnt -n -o SOURCE,FSTYPE /
text
overlay  overlay

Корень container — это overlay-монтирование. Параметры монтирования:

bash
sudo grep -m1 ' / overlay ' /proc/"$CPID"/mountinfo | tr ',' '\n' | grep -E 'lowerdir|upperdir|workdir'

Наблюдение за copy-up

Создадим файл в container и найдём его в upperdir на host.

bash
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
text
-rw-r--r-- 1 root root 24 Jul 29 15:40 /var/lib/docker/overlay2/9f8e.../diff/newfile.txt
новые данные

Файл, созданный внутри container, физически лежит в его writable layer на host.

Теперь изменим файл, унаследованный из образа:

bash
docker exec cow-demo sh -c 'echo "# изменено" >> /etc/hostname'

if [ -n "$UPPER" ]; then
    sudo ls -l "$UPPER/etc/hostname"
fi
text
-rw-r--r-- 1 root root 25 Jul 29 15:41 /var/lib/docker/overlay2/9f8e.../diff/etc/hostname

Файл появился в upperdir, хотя мы его не создавали — это copy-up: перед изменением он был скопирован из нижнего слоя.

Измерение стоимости copy-up

bash
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 не нужен, файл уже в верхнем слое.

Теперь то же самое для файла из образа. Соберём образ с крупным файлом:

bash
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"
'
text
--- первое изменение файла из образа (нужен copy-up) ---
real	0m 0.42s
--- второе изменение того же файла (copy-up уже сделан) ---
real	0m 0.00s

Разница на два порядка. Записан один байт, скопировано 200 MB.

Точные цифры зависят от диска, но соотношение сохраняется всегда.

Whiteout своими глазами

bash
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
text
-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. Файл «удалён» добавлением специального маркера, а не удалением данных.

bash
docker rm -f wo-demo

Демонстрация: удаление не уменьшает образ

bash
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}}'
text
TAG      SIZE
good     97.2MB
bad      254MB

Оба образа содержат ровно ноль полезных файлов сверх базового. Разница — 157 MB.

Посмотрим, где они:

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

bash
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
text
--- содержимое merged ---
a.txt  b.txt  common.txt
--- какой common.txt победил ---
из нижнего слоя 2 (перекрывает)

lower2 указан в lowerdir первым, поэтому он верхний и перекрывает lower1.

Теперь copy-up:

bash
echo "изменено" >> /tmp/ovl/merged/a.txt

echo "--- в upperdir появилась копия ---"
ls -l /tmp/ovl/upper/
echo "--- оригинал в lower1 не изменился ---"
cat /tmp/ovl/lower1/a.txt
text
--- в upperdir появилась копия ---
-rw-r--r-- 1 root root 28 Jul 29 15:52 a.txt
--- оригинал в lower1 не изменился ---
из нижнего слоя 1

И whiteout:

bash
sudo rm /tmp/ovl/merged/b.txt
ls -l /tmp/ovl/upper/b.txt
ls /tmp/ovl/lower2/
text
c--------- 1 root root 0, 0 Jul 29 15:53 /tmp/ovl/upper/b.txt
b.txt  common.txt

Whiteout в upperdir, оригинал в lower2 на месте. Ровно то же, что делает Docker.

Уборка:

bash
sudo umount /tmp/ovl/merged
sudo rm -rf /tmp/ovl

Размер writable layer

bash
docker ps -s --filter name=cow-demo --format 'table {{.Names}}\t{{.Size}}'
text
NAMES      SIZE
cow-demo   25B (virtual 178MB)

Первое число — размер writable layer, virtual — вместе со слоями образа. Флаг -s замедляет команду, поэтому по умолчанию выключен.

Уборка

bash
docker rm -f cow-demo
docker rmi cowtest:1 sizetest:bad sizetest:good 2>/dev/null || true
rm -rf /tmp/cowtest

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

Задание. Докажите измерениями три утверждения этого урока.

  1. Copy-up стоит дорого. Постройте образ с файлом 300 MB. Измерьте время изменения одного байта в нём при первой и второй записи. Объясните разницу.
  2. Удаление не уменьшает образ. Соберите два образа, отличающихся только тем, что в одном временный файл удаляется в отдельной инструкции, а в другом — в той же. Сравните размеры и покажите, в каком слое остались данные.
  3. 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.

Решение

Сначала выполните задание самостоятельно.

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

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

text
=== 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 для всего, что нужно только на этапе сборки.

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

bash
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. Что произойдёт при попытке записи в файл, находящийся в нижнем слое?
  2. Почему изменение одного байта в файле размером 1 GB занимает заметное время только в первый раз?
  3. Как реализовано удаление файла из неизменяемого слоя?
  4. Почему RUN rm -rf /var/lib/apt/lists/* отдельной инструкцией делает образ больше?
  5. Какой слой окажется наверху при lowerdir=/l3:/l2:/l1?

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

  1. Как найти writable layer работающего container на диске?
  2. Как узнать, сколько места занимает writable layer, не считая слоёв образа?
  3. Как определить, какой слой образа ответственен за его размер?

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

  1. Приложение стартует 40 секунд при первом запуске container и 2 секунды при последующих обращениях к тем же данным. Какое объяснение стоит проверить первым?
  2. В образе нашли приватный ключ, хотя в Dockerfile есть RUN rm для него. Как это возможно и что делать?

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

  1. OverlayFS объединяет слои образа (lowerdir) и слой записи (upperdir) в единый вид (merged).
  2. Файл ищется сверху вниз; каталоги сливаются, файлы перекрываются.
  3. В lowerdir первым указывается верхний слой.
  4. Copy-up копирует файл из нижнего слоя в верхний целиком перед первой записью.
  5. Первая запись в унаследованный файл дорога, последующие — нет.
  6. Удаление файла нижнего слоя реализуется whiteout — character device 0:0 нулевого размера.
  7. Данные удалённого файла остаются в слое и скачиваются при docker pull.
  8. RUN rm отдельной инструкцией увеличивает образ, а не уменьшает.
  9. Секрет, удалённый в следующем слое, извлекается из образа.
  10. Интенсивная запись должна идти в volume, а не в writable layer.

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

ИсточникСсылкаЧто подтверждает
Use the OverlayFS storage driverhttps://docs.docker.com/engine/storage/drivers/overlayfs-driver/Структура lowerdir/upperdir/workdir/merged, механизм copy-up, whiteout, ограничения
Docker storage drivershttps://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 Specificationhttps://github.com/opencontainers/image-spec/blob/main/layer.mdПредставление удалений в слоях образа (.wh. файлы в tar)
Building best practiceshttps://docs.docker.com/build/building/best-practices/Рекомендация удалять временные файлы в той же инструкции
Build secretshttps://docs.docker.com/build/building/secrets/Почему секреты нельзя удалять последующей инструкцией
docker ps referencehttps://docs.docker.com/reference/cli/docker/container/ls/Флаг -s и значение virtual

Навигация

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

Markdown на GitHub ↗