Код: Выделить всё
docker imagesКод: Выделить всё
docker buildЭто не 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;"]
Ключевое следствие: слой хранит не итоговое состояние, а 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

Content-addressable storage: имя файла - это его содержимое
Как docker понимает, что слой "тот самый" и его не надо качать заново? Через адресацию по содержимому (content-addressable storage). Каждый слой и каждый конфиг идентифицируется по sha256-хэшу своего содержимого. Имя объекта - это и есть его хэш. Изменилось содержимое хоть на байт - изменился хэш - это уже другой объект.
Тут важно различать два хэша, на которых все спотыкаются:
- Digest слоя (sha256 от сжатого tar-архива слоя) - то, что фигурирует в манифесте и при . По нему реестр понимает, какие блобы уже есть локально и качать их не нужно.
Код: Выделить всё
docker pull - ChainID / diffID - хэши, по которым docker раскладывает слои локально и строит из них стек. diffID - это хэш РАСПАКОВАННОГО содержимого слоя.
Код: Выделить всё
sha256:abc123...Manifest list (он же OCI image index) - это уровень выше, "толстый манифест" для multi-arch. Когда ты делаешь
Код: Выделить всё
docker pull nginxКод: Выделить всё
docker manifest inspect --verbose nginx:latest
OverlayFS: как слои превращаются в живую файловую систему
Слои лежат на диске read-only. Но контейнеру нужна обычная writable файловая система. Эту магию делает docker overlay2 поверх ядерного OverlayFS - union-файловой системы, которая накладывает несколько каталогов друг на друга в один.
У OverlayFS четыре действующих лица:
- lowerdir - нижние слои, read-only. Это все слои образа. Их может быть несколько, перечислены через двоеточие. overlay2 нативно держит до 128 lower-слоев.
- upperdir - единственный верхний слой, read-write. Это слой контейнера, куда уходят ВСЕ изменения во время работы.
- workdir - служебный каталог самого OverlayFS для атомарных операций. Туда лазить не надо.
- 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
]
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 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
Код: Выделить всё
<missing>Код: Выделить всё
--no-truncКод: Выделить всё
docker inspectКод: Выделить всё
docker inspect --format '{{ json .RootFS.Layers }}' nginx:latest
Кэш сборки живет на уровне слоев
Послойность - это не только про хранение, но и про скорость сборки. BuildKit (дефолтный движок сборки в современном docker) кэширует результат каждой инструкции как слой. На повторной сборке для каждого шага он проверяет: изменился ли вход? Для RUN вход - сама команда. Для COPY/ADD - контрольная сумма копируемых файлов. Если вход тот же и предыдущий слой взят из кэша - шаг берется из кэша мгновенно (CACHED в выводе). Как только хоть один шаг "промахнулся" - кэш инвалидируется для него и ВСЕХ последующих, они пересобираются.
Отсюда золотое правило порядка инструкций: сначала редко меняющееся, потом часто меняющееся. Канон для приложения:
Код: Выделить всё
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Код: Выделить всё
npm ciКод: Выделить всё
COPY . .Чистим диск: 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%)
Код: Выделить всё
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Код: Выделить всё
--volumesОбзор 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 }}'Мини-лаба: пощупать слои руками
Делай на Linux-хосте (или внутри Docker Desktop VM), root нужен для заглядывания в /var/lib/docker.
- Собери крошечный образ из двух RUN, создающих и удаляющих большой файл в РАЗНЫХ инструкциях. Посмотри - убедись, что размер не упал после rm.
Код: Выделить всё
docker history --no-trunc - Перепиши Dockerfile так, чтобы create+delete были в ОДНОМ RUN. Сравни итоговый размер в . Прочувствуй разницу.
Код: Выделить всё
docker images - Запусти контейнер, найди его UpperDir через . Создай внутри контейнера файл
Код: Выделить всё
docker inspect --format '{{ .GraphDriver.Data.UpperDir }}'и найди его в этом каталоге на хосте - вот он, твой writable-слой вживую.Код: Выделить всё
touch /tmp-test - Запусти и найди в выводе блоки platform для разных архитектур - это manifest list multi-arch образа.
Код: Выделить всё
docker manifest inspect --verbose alpine - Забей кэш: , потом
Код: Выделить всё
docker builder pruneдо и после - сравни Build Cache.Код: Выделить всё
docker system df
- Почему в отдельной инструкции не уменьшает размер образа, и что физически появляется в слое?
Код: Выделить всё
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Код: Выделить всё
prune