Образы изнутри: слои, OverlayFS и storage driver

Рейтинг: 67.6% · 8 голосов
Практический курс по Docker: образы, контейнеры, тома, сети, Compose и продакшен. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
Marina_DevOps
Сообщения: 54
Зарегистрирован: 11 май 2026, 05:31

Образы изнутри: слои, OverlayFS и storage driver

Сообщение Marina_DevOps »

Оглавление курса (46)
  1. Что такое Docker и какие задачи он решает
  2. Установка Docker и запуск первого контейнера
  3. Образы: слои, теги и реестр Docker Hub
  4. Пишем свой Dockerfile
  5. Тома и хранение данных: volumes и bind mounts
  6. Сети в Docker: связываем контейнеры между собой
  7. Переменные окружения и конфигурация контейнеров
  8. Docker Compose: поднимаем многоконтейнерное приложение
  9. Оптимизация образов: multi-stage сборка, размер и кэш слоёв
  10. Логи, отладка и мониторинг контейнеров
  11. Базовая безопасность контейнеров
  12. Подготовка к продакшену: что важно учесть
  13. Dockerfile глубже: ENTRYPOINT и CMD, HEALTHCHECK, .dockerignore, запуск не от root
  14. Реестры образов: приватные registry, push и pull, теги и digest, imagePullSecrets
  15. BuildKit и buildx: multi-arch сборки, секреты сборки, экспорт кэша
  16. Docker в CI/CD: автосборка, сканирование образов (Trivy, Docker Scout), публикация
  17. Итоговый проект и куда расти: от Dockerfile до прода, обзор оркестрации (Kubernetes, Podman, OCI)
  18. Архитектура Docker: dockerd, containerd, runc и OCI
  19. Как работает изоляция: namespaces и cgroups
  20. Образы изнутри: слои, OverlayFS и storage driver (вы здесь)
  21. Жизненный цикл контейнера и политики перезапуска
  22. Отладка контейнеров: exec, attach, nsenter, debug
  23. Сети Docker глубже: bridge, overlay, macvlan и iptables
  24. Docker Compose глубже: profiles, healthcheck-зависимости, override
  25. Данные и тома глубже: драйверы, бэкап, права
  26. Секреты и конфигурация: как не светить пароли
  27. Ограничение ресурсов: CPU, память, OOM и cgroups
  28. Здоровье и автозапуск: HEALTHCHECK, init и сигналы
  29. Логирование контейнеров: драйверы, ротация, сбор
  30. Мониторинг контейнеров: stats, cAdvisor, Prometheus
  31. Безопасность глубже: rootless, user namespaces, seccomp, capabilities
  32. Supply chain: сканирование, SBOM и подпись образов
  33. Глубокая оптимизация образов: distroless, scratch, кэш
  34. Multi-arch и сборка под ARM: buildx и эмуляция
  35. Реестры в продакшене: Harbor и облачные registry
  36. Docker без Docker: Podman, containerd, nerdctl, Buildah
  37. От Compose к оркестрации: когда контейнеров становится много
  38. Docker Swarm: встроенная оркестрация
  39. Сборка образов в CI без демона: kaniko, BuildKit, Buildah
  40. Траблшутинг Docker: типичные ошибки и как чинить
  41. Производительность и эксплуатация Docker-хоста
  42. Docker Desktop, WSL2 и альтернативы на Windows и Mac
  43. Контейнеризация приложений: бэкенд, фронтенд, БД
  44. Docker и Kubernetes: containerd, миграция, kompose
  45. Лучшие практики и антипаттерны Docker
  46. Сквозной проект: production-ready стек и путь дальше
Ты собрал образ, видишь его в

Код: Выделить всё

docker images
, он весит 400 МБ. Но что это на самом деле физически? Где оно лежит на диске? Почему два образа на одной базе занимают меньше, чем сумма их размеров? Почему

Код: Выделить всё

docker build
иногда пролетает за секунду, а иногда перекомпилирует полпроекта? И почему запись большого файла внутри контейнера может неожиданно тормозить? Все эти вопросы упираются в одну тему - как устроено хранение образов. В этом уроке мы вскроем устройство docker образа до самого дна: слои как diff, copy-on-write через docker overlayfs, content-addressable storage и то, как overlay2 (дефолтный docker storage driver) собирает все это в работающую файловую систему контейнера.

Это не academic-теория. Понимание слоев напрямую экономит тебе гигабайты на серверах, минуты на CI и нервы при дебаге "почему контейнер сожрал весь диск".

Слой - это не папка, это diff

Главное заблуждение новичка: образ - это как zip-архив с файловой системой. Нет. Docker образ устройство имеет послойное: образ - это упорядоченный стек слоев, и каждый слой - это набор ИЗМЕНЕНИЙ относительно предыдущего. Diff, как коммит в git.

Возьмем простой Dockerfile:

Код: Выделить всё

FROM debian:12
RUN apt-get update && apt-get install -y nginx
COPY ./site /var/www/html
CMD ["nginx", "-g", "daemon off;"]
Каждая инструкция, меняющая файловую систему (FROM, RUN, COPY, ADD), порождает отдельный слой. FROM приносит слои базового образа. RUN добавляет слой, где лежат разница после установки nginx - новые бинарники, конфиги, библиотеки. COPY кладет слой с твоим сайтом. А CMD, ENV, LABEL, WORKDIR, EXPOSE файловую систему не трогают - они меняют только метаданные (конфиг образа) и слой НЕ создают.

Ключевое следствие: слой хранит не итоговое состояние, а delta. Если в слое 2 ты удалил файл, добавленный в слое 1, то файл физически остается в слое 1, а в слое 2 появляется специальный маркер удаления - whiteout-файл (символьное устройство с major/minor 0/0). Поэтому классическая ошибка:

Код: Выделить всё

RUN curl -O https://example.com/huge-archive.tar.gz
RUN tar xzf huge-archive.tar.gz
RUN rm huge-archive.tar.gz
весит как с архивом, так и без. Архив осел в первом слое навсегда, а rm только спрятал его whiteout-ом. Правильно - в одной инструкции RUN: скачать, распаковать, удалить, и все это до закрытия слоя.

Изображение

Content-addressable storage: имя файла - это его содержимое

Как docker понимает, что слой "тот самый" и его не надо качать заново? Через адресацию по содержимому (content-addressable storage). Каждый слой и каждый конфиг идентифицируется по sha256-хэшу своего содержимого. Имя объекта - это и есть его хэш. Изменилось содержимое хоть на байт - изменился хэш - это уже другой объект.

Тут важно различать два хэша, на которых все спотыкаются:
  • Digest слоя (sha256 от сжатого tar-архива слоя) - то, что фигурирует в манифесте и при

    Код: Выделить всё

    docker pull
    . По нему реестр понимает, какие блобы уже есть локально и качать их не нужно.
  • ChainID / diffID - хэши, по которым docker раскладывает слои локально и строит из них стек. diffID - это хэш РАСПАКОВАННОГО содержимого слоя.
Венчает все это манифест - небольшой JSON, который перечисляет: конфиг образа (тоже по sha256) и список слоев в порядке наложения. А ID образа, который ты видишь как

Код: Выделить всё

sha256:abc123...
- это хэш конфига образа. Поэтому два функционально одинаковых образа с разным конфигом (например, разный CMD) будут иметь разный image ID, но могут делить ВСЕ слои.

Manifest list (он же OCI image index) - это уровень выше, "толстый манифест" для multi-arch. Когда ты делаешь

Код: Выделить всё

docker pull nginx
на ARM-маке и на x86-сервере, тег один, а образы разные. Manifest list - это оглавление, которое для каждой платформы (linux/amd64, linux/arm64, linux/arm/v7) указывает свой манифест. Docker по платформе текущей машины сам выбирает нужный. Посмотреть руками:

Код: Выделить всё

docker manifest inspect --verbose nginx:latest
Увидишь поле mediaType со значением application/vnd.docker.distribution.manifest.list.v2+json (или OCI-аналог application/vnd.oci.image.index.v1+json) и массив manifests с блоками platform: {architecture, os}. Это и есть multi-arch под капотом - один тег, много манифестов.

OverlayFS: как слои превращаются в живую файловую систему

Слои лежат на диске read-only. Но контейнеру нужна обычная writable файловая система. Эту магию делает docker overlay2 поверх ядерного OverlayFS - union-файловой системы, которая накладывает несколько каталогов друг на друга в один.

У OverlayFS четыре действующих лица:
  • lowerdir - нижние слои, read-only. Это все слои образа. Их может быть несколько, перечислены через двоеточие. overlay2 нативно держит до 128 lower-слоев.
  • upperdir - единственный верхний слой, read-write. Это слой контейнера, куда уходят ВСЕ изменения во время работы.
  • workdir - служебный каталог самого OverlayFS для атомарных операций. Туда лазить не надо.
  • merged - результирующая точка монтирования. Именно ее видит процесс внутри контейнера как свой корень "/".
Правила наложения простые: если файл есть и в lower, и в upper - побеждает upper (верхний перекрывает нижний). Если файла в upper нет - читается из lower. Удаление в upper - это whiteout, который прячет файл из нижних слоев в merged-представлении, физически его не трогая.

Посмотреть на реальный mount запущенного контейнера:

Код: Выделить всё

docker inspect --format '{{ .GraphDriver.Data }}' my-container
Вывод (для наглядности разбит):

Код: Выделить всё

map[
  LowerDir:/var/lib/docker/overlay2/a1b2.../diff:/var/lib/docker/overlay2/c3d4.../diff
  MergedDir:/var/lib/docker/overlay2/f5e6.../merged
  UpperDir:/var/lib/docker/overlay2/f5e6.../diff
  WorkDir:/var/lib/docker/overlay2/f5e6.../work
]
Разбор по полям. LowerDir - цепочка каталогов diff каждого слоя образа, читается справа налево по приоритету. UpperDir - каталог diff слоя контейнера, сюда пишутся изменения. MergedDir - то самое объединенное представление, корень контейнера. WorkDir - служебка. Все это живет в /var/lib/docker/overlay2 - запомни этот путь, мы туда еще вернемся при чистке диска.

Copy-on-write: почему запись в контейнер дорогая

Теперь самое практичное - механика copy-on-write (CoW). Нижние слои read-only, значит изменить файл из образа напрямую нельзя. Что делает OverlayFS при первой записи в такой файл? Копирует его целиком из lower в upper (copy_up), и только потом меняет копию. Это и называется copy-up.

Главный нюанс: copy-up происходит на уровне ВСЕГО ФАЙЛА, не блока. Открыл на запись файл лога на 2 ГБ, дописал в конец одну строку - OverlayFS сперва скопирует все 2 ГБ из нижнего слоя в upperdir, и лишь затем добавит строку. Первая запись будет дорогой и по времени, и по месту. Последующие записи в уже скопированный файл идут напрямую в upper и быстры.

Отсюда практические выводы, которые отличают senior от джуна:
  • Данные, которые активно пишутся (БД, логи, загрузки), НЕЛЬЗЯ держать в writable-слое контейнера. Им место в volume или bind-mount - том монтируется в обход OverlayFS, без copy-on-write накладных расходов.
  • Запись в существующий большой файл из образа - антипаттерн. Если приложение модифицирует крупный файл, унаследованный из слоя, ты платишь copy-up.
  • Слой контейнера эфемерен. Удалил контейнер - upperdir исчез вместе со всем, что туда писалось. Это by design.
docker history и docker inspect: читаем слои руками

Код: Выделить всё

docker history
показывает слои образа сверху вниз, с командой, создавшей каждый, и его размером:

Код: Выделить всё

docker history nginx:latest

Код: Выделить всё

IMAGE          CREATED       CREATED BY                                      SIZE
a6bd71f48f68   2 weeks ago   CMD ["nginx" "-g" "daemon off;"]                0B
<missing>      2 weeks ago   EXPOSE map[80/tcp:{}]                           0B
<missing>      2 weeks ago   COPY docker-entrypoint.sh / # buildkit          4.62kB
<missing>      2 weeks ago   RUN /bin/sh -c set -x && apt-get update ...     63.8MB
<missing>      2 weeks ago   /bin/sh -c #(nop) ADD file:abc... in /          74.8MB
Разбор. SIZE - вклад именно этого слоя, а не накопительный. Слои с 0B (CMD, EXPOSE) - метаданные, они файловую систему не меняют.

Код: Выделить всё

<missing>
в колонке IMAGE - это не ошибка: промежуточные слои не имеют собственного image ID (он есть только у образа целиком, у его конфига). Толстые слои сразу видно - вон RUN с apt отъел 63.8 МБ. Это первое место, куда смотришь при оптимизации размера. Полную команду, если она обрезана, добавь флагом

Код: Выделить всё

--no-trunc
.

Код: Выделить всё

docker inspect
дает машинно-читаемые детали, включая массив RootFS.Layers - это список diffID всех слоев образа в порядке наложения:

Код: Выделить всё

docker inspect --format '{{ json .RootFS.Layers }}' nginx:latest
Совпадающие diffID между разными образами - наглядное доказательство, что docker слои переиспользуются, а не дублируются.

Кэш сборки живет на уровне слоев

Послойность - это не только про хранение, но и про скорость сборки. BuildKit (дефолтный движок сборки в современном docker) кэширует результат каждой инструкции как слой. На повторной сборке для каждого шага он проверяет: изменился ли вход? Для RUN вход - сама команда. Для COPY/ADD - контрольная сумма копируемых файлов. Если вход тот же и предыдущий слой взят из кэша - шаг берется из кэша мгновенно (CACHED в выводе). Как только хоть один шаг "промахнулся" - кэш инвалидируется для него и ВСЕХ последующих, они пересобираются.

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

Код: Выделить всё

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Зависимости меняются редко, поэтому слой переиспользуется почти всегда. Если бы ты сделал

Код: Выделить всё

COPY . .
до установки зависимостей, любая правка в любом файле инвалидировала бы кэш установки и тянула npm заново на каждой сборке. На больших проектах это разница между сборкой в 8 секунд и в 4 минуты.

Чистим диск: prune и df

Слои, кэш сборки, остановленные контейнеры и их upperdir-ы копятся в /var/lib/docker и однажды забивают раздел. Сначала смотрим, что сколько занимает:

Код: Выделить всё

docker system df

Код: Выделить всё

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          24        5         8.91GB    6.12GB (68%)
Containers      9         2         145MB     120MB (82%)
Local Volumes   12        3         3.4GB     2.1GB (61%)
Build Cache     310       0         5.7GB     5.7GB (100%)
Поля: ACTIVE - используется живыми контейнерами, RECLAIMABLE - сколько можно безопасно освободить. Видишь Build Cache на 5.7 ГБ, весь reclaimable - типичная картина на CI-раннере. Детализация по слоям -

Код: Выделить всё

docker system df -v
.

Чистка по нарастающей агрессивности:

Код: Выделить всё

docker image prune          # только dangling-образы (<none>)
docker image prune -a       # все образы без запущенных контейнеров
docker builder prune        # кэш сборки BuildKit
docker system prune         # контейнеры, сети, dangling-образы, build cache
docker system prune -a --volumes   # плюс все неиспользуемые образы И volume
Грабли:

Код: Выделить всё

docker system prune -a --volumes
на проде снесет volume, которые сейчас не примонтированы к запущенному контейнеру - вместе с данными БД, если контейнер был остановлен. Флаг

Код: Выделить всё

--volumes
на боевом сервере - только осознанно. И никогда не удаляй файлы в /var/lib/docker/overlay2 руками через rm: ты разъедешься с метаданными демона и получишь битое состояние. Только через docker-команды.

Обзор storage drivers: почему именно overlay2

overlay2 - дефолт на всех современных ядрах Linux, и в 99% случаев трогать его не нужно. Но знать соседей полезно:
  • overlay2 - текущий стандарт. Эффективен по inode, держит до 128 слоев, работает поверх ext4/xfs (xfs требует ftype=1). Выбор по умолчанию.
  • fuse-overlayfs - реализация в user-space, используется в rootless-режиме, когда ядерный overlay недоступен непривилегированному пользователю.
  • btrfs / zfs - CoW на уровне самой ФС, снапшоты вместо overlay. Нишево, под соответствующую файловую систему хоста.
  • vfs - без CoW вообще, каждый слой копируется целиком. Чудовищно жрет место, но не требует поддержки ядра. Используется для тестов и в экзотике (docker-in-docker).
  • aufs, devicemapper, overlay(v1) - устаревшие/удаленные, в новых версиях их уже нет. На собеседовании достаточно знать, что overlay2 их вытеснил.
Проверить текущий драйвер:

Код: Выделить всё

docker info --format '{{ .Driver }}'
. На Docker Desktop (Mac/Windows) все это крутится внутри Linux-VM (на Windows - через WSL2), так что /var/lib/docker физически живет в виртуалке, а не на диске хоста напрямую - не ищи overlay2 в проводнике. Под капотом современного docker слоями и снапшотами заведует containerd через свои snapshotter-ы (тот же overlayfs-snapshotter), но идеи lowerdir/upperdir/merged ровно те же.

Мини-лаба: пощупать слои руками

Делай на Linux-хосте (или внутри Docker Desktop VM), root нужен для заглядывания в /var/lib/docker.
  • Собери крошечный образ из двух RUN, создающих и удаляющих большой файл в РАЗНЫХ инструкциях. Посмотри

    Код: Выделить всё

    docker history --no-trunc
    - убедись, что размер не упал после rm.
  • Перепиши Dockerfile так, чтобы create+delete были в ОДНОМ RUN. Сравни итоговый размер в

    Код: Выделить всё

    docker images
    . Прочувствуй разницу.
  • Запусти контейнер, найди его UpperDir через

    Код: Выделить всё

    docker inspect --format '{{ .GraphDriver.Data.UpperDir }}'
    . Создай внутри контейнера файл

    Код: Выделить всё

    touch /tmp-test
    и найди его в этом каталоге на хосте - вот он, твой writable-слой вживую.
  • Запусти

    Код: Выделить всё

    docker manifest inspect --verbose alpine
    и найди в выводе блоки platform для разных архитектур - это manifest list multi-arch образа.
  • Забей кэш:

    Код: Выделить всё

    docker builder prune
    , потом

    Код: Выделить всё

    docker system df
    до и после - сравни Build Cache.
Контрольные вопросы
  • Почему

    Код: Выделить всё

    RUN rm bigfile
    в отдельной инструкции не уменьшает размер образа, и что физически появляется в слое?
  • Что произойдет на уровне OverlayFS при первой записи в файл размером 1 ГБ, унаследованный из слоя образа, и как этого избежать?
  • Чем digest слоя отличается от image ID, и почему два образа могут делить слои, но иметь разные ID?
  • Что такое manifest list и как docker по нему выбирает образ под твою архитектуру?
Итог

Образ - это стек read-only слоев-diff-ов, адресуемых по sha256, собранный в живую ФС драйвером overlay2 через lowerdir (слои образа), upperdir (writable-слой контейнера) и merged (то, что видит контейнер). Запись копирует файл целиком (copy-on-write), поэтому изменяемые данные - в volume, а не в слой. Слои - это и единица кэша сборки (порядок инструкций решает скорость), и единица переиспользования на диске. А /var/lib/docker/overlay2 и

Код: Выделить всё

docker system df
/ - твои инструменты, когда диск внезапно кончился. Понял слои - понял docker.
👍1 ❤️3 🔥1 😄 🤔
Аватара пользователя
scanner9
Сообщения: 1
Зарегистрирован: 15 май 2026, 03:49

Re: Образы изнутри: слои, OverlayFS и storage driver

Сообщение scanner9 »

Долго не мог понять почему rm в дочернем RUN не уменьшает образ, а оно вон что - файл остается в нижнем слое, сверху только whiteout. Спасибо, теперь все мои Dockerfile в один RUN склею
👍 ❤️1 🔥 😄 🤔2
Аватара пользователя
anton77
Сообщения: 1
Зарегистрирован: 24 май 2026, 00:09

Re: Образы изнутри: слои, OverlayFS и storage driver

Сообщение anton77 »

А правильно понимаю что если приложение пишет логи в файл прямо в контейнере, то при первой записи в большой лог из образа будет copy-up на весь файл? То есть логам однозначно volume, а не writable-слой?
👍1 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
Как работает изоляция: namespaces и cgroups
Следующая глава →
Жизненный цикл контейнера и политики перезапуска

Все главы курса «Docker: контейнеризация от основ до продакшена»

Поделиться темой: ✈ Telegram VK
Похожие запросы: multi-stage сборка docker как уменьшить образ и почистить кэшкак поднять приватный docker registry push pull и digestharbor реестр образов что это и чем отличается от registry

Вернуться в «Docker с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость