Изучаем внутренние компоненты Docker — Объединённая файловая система

Kate

Administrator
Команда форума
Создавать, запускать, просматривать, перемещать контейнеры и образы с помощью интерфейса командной строки Docker (Docker CLI) проще простого, но задумывались ли вы когда-нибудь, как на самом деле работают внутренние компоненты, обеспечивающие работу интерфейса Docker? За этим простым интерфейсом скрывается множество продвинутых технологий, и специально к старту нового потока курса по DevOps в этой статье мы рассмотрим одну из них — объединённую файловую систему, используемую во всех слоях контейнеров и образов. Маститым знатокам контейнеризации и оркестрации данный материал навряд ли откроет что-то новое, зато будет полезен тем, кто делает первые шаги в DevOps.


Что такое объединённая файловая система?​

Каскадно-объединённое монтирование — это тип файловой системы, в которой создается иллюзия объединения содержимого нескольких каталогов в один без изменения исходных (физических) источников. Такой подход может оказаться полезным, если имеются связанные наборы файлов, хранящиеся в разных местах или на разных носителях, но отображать их надо как единое и совокупное целое. Например, набор пользовательских/корневых каталогов, расположенных на удалённых NFS-серверах, можно свести в один каталог или можно объединить разбитый на части ISO-образ в один целый образ.

Однако технологию объединённого монтирования (или объединённой файловой системы), по сути, нельзя считать отдельным типом файловой системы. Это, скорее, особая концепция с множеством реализаций. Некоторые реализации работают быстрее, некоторые медленнее, некоторые проще в использовании, некоторые сложнее — проще говоря, у разных реализаций разные цели и разные уровни зрелости. Поэтому, прежде чем углубиться в детали, ознакомимся с некоторыми наиболее популярными реализациями объединённой файловой системы:

  • Начнём с исходной объединённой файловой системы, а именно UnionFS. На данный момент поддержка файловой система UnionFS прекращена, последнее изменение кода было зафиксировано в августе 2014 года. Более подробная информация об этой файловой системе приведена здесь: unionfs.filesystems.org.
  • aufs — альтернативная версия исходной файловой системы UnionFS с добавлением множества новых функций. Данную файловую систему нельзя использовать в составе ванильного ядра Linux. Aufs использовалась в качестве файловой системы по умолчанию для Docker на Ubuntu/Debian, однако со временем она была заменена на OverlayFS (для ядра Linux >4.0). По сравнению с другими объединёнными файловыми системами эта система имеет ряд преимуществ, описанных в Docker Docs.
  • Следующая система — OverlayFS — была включена в ядро Linux Kernel, начиная с версии 3.18 (26 октября 2014 года). Данная файловая система используется по умолчанию драйвером overlay2 Docker (это можно проверить, запустив команду docker system info | grep Storage). Данная файловая система в целом обеспечивает лучшую, чем aufs, производительность и имеет ряд интересных функциональных особенностей, например функцию разделения страничного кэша.
  • ZFS — объединённая файловая система, разработанная Sun Microsystems (в настоящее время эта компания называется Oracle). В этой системе реализован ряд полезных функций, таких как функция иерархического контрольного суммирования, функция обработки снимков, функция резервного копирования/репликации или архивирования и дедупликации (исключения избыточности) внутренних данных. Однако, поскольку автором этой файловой системы является Oracle, её выпуск осуществлялся под общей лицензией на разработку и распространение (CDDL), не распространяемой на программное обеспечение с открытым исходным кодом, поэтому данная файловая система не может поставляться как часть ядра Linux. Тем не менее можно воспользоваться проектом ZFS on Linux (ZoL), который в документации Docker описывается как работоспособный и хорошо проработанный..., но, увы, непригодный к промышленной эксплуатации. Если вам захочется поработать с этой файловой системой, её можно найти здесь.
  • Btrfs — ещё один вариант файловой системы, представляющий собой совместный проект множества компаний, в том числе SUSE, WD и Facebook. Данная файловая система выпущена под лицензией GPL и является частью ядра Linux. Btrfs — файловая система по умолчанию дистрибутива Fedora 33. В ней также реализованы некоторые полезные функции, такие как операции на уровне блоков, дефрагментация, доступные для записи снимки и множество других. Если вас не пугают трудности, связанные с переходом на специализированный драйвер устройств памяти для Docker, файловая система Btrfs с её функциональными и производительными возможностями может стать лучшим вариантом.
Если вы хотите более глубоко изучить характеристики драйверов, используемых в Docker, в Docker Docs можно ознакомиться с таблицей сравнения разных драйверов. Если вы затрудняетесь с выбором файловой системы (не сомневаюсь, есть и такие программисты, которые знают все тонкости файловых систем, но эта статья предназначена не для них), возьмите за основу файловую систему по умолчанию overlay2 — именно её я буду использовать в примерах в оставшейся части данной статьи.

Почему именно она?​

В предыдущем разделе мы рассказали о некоторых полезных возможностях файловой системы такого типа, но почему именно она является оптимальным выбором для работы Docker и контейнеров в целом?

Многие образы, используемые для запуска контейнеров, занимают довольно большой объём, например, ubuntu занимает 72 Мб, а nginx — 133 Мб. Было бы довольно разорительно выделять столько места всякий раз, когда потребуется из этих образов создать контейнер. При использовании объединённой файловой системы Docker создаёт поверх образа тонкий слой, а остальная часть образа может быть распределена между всеми контейнерами. Мы также получаем дополнительное преимущество за счёт сокращения времени запуска, так как отпадает необходимость в копировании файлов образа и данных.

Объединённая файловая система также обеспечивает изоляцию, поскольку контейнеры имеют доступ к общим слоям образа только для чтения. Если контейнерам когда-нибудь понадобится внести изменения в любой файл, доступный только для чтения, они используют стратегию копирования при записи copy-on-write (её мы обсудим чуть позже), позволяющую копировать содержимое на верхний слой, доступный для записи, где такое содержимое может быть безопасно изменено.

Как это работает?​

Теперь вы вправе задать мне важный вопрос: как же это всё работает на практике? Из сказанного выше может создаться впечатление, что объединённая файловая система работает с применением некой чёрной магии, но на самом деле это не так. Сейчас я попытаюсь объяснить, как это работает в общем (неконтейнерном) случае. Предположим, нам нужно объединить два каталога (верхний и нижний) в одной точке монтирования и чтобы такие каталоги были представлены унифицированно:

.
├── upper
│ ├── code.py # Content: `print("Hello Overlay!")`
│ └── script.py
└── lower
├── code.py # Content: `print("This is some code...")`
└── config.yaml
В терминологии объединённого монтирования такие каталоги называются ветвями. Каждой из таких ветвей присваивается свой приоритет. Приоритет используется для того, чтобы решить, какой именно файл будет отображаться в объединённом представлении, если в нескольких исходных ветках присутствуют файлы с одним и тем же именем. Если проанализировать представленные выше файлы и каталоги, станет понятно, что такой конфликт может возникнуть, если мы попытаемся использовать их в режиме наложения (файл code.py). Давайте попробуем и посмотрим, что у нас получится:

~ $ mount -t overlay \
-o lowerdir=./lower,\
upperdir=./upper,\
workdir=./workdir \
overlay /mnt/merged

~ $ ls /mnt/merged
code.py config.yaml script.py

~ $ cat /mnt/merged/code.py
print("Hello Overlay!")
В приведённом выше примере мы использовали команду mount с type overlay, чтобы объединить нижний каталог (только для чтения; более низкий приоритет) и верхний каталог (чтение-запись; более высокий приоритет) в объединённое представление в каталоге /mnt/merged. Мы также включили опцию workdir=./workdir. Этот каталог служит местом для подготовки объединённого представления нижнего каталога (lowerdir) и верхнего каталога (upperdir) перед их перемещением в каталог /mnt/merged.

Если посмотреть на выходные данные команды cat, можно заметить, что в объединённом представлении приоритет получили файлы верхнего каталога.

Теперь мы знаем, как объединить два каталога и что произойдёт при возникновении конфликта. Но что произойдёт, если попытаться изменить определенные файлы в объединённом представлении? Здесь в игру вступает функция копирования при записи (CoW). Что именно делает эта функция? CoW — это способ оптимизации, при котором, если две вызывающих программы обращаются к одному и тому же ресурсу, можно дать им указатель на один и тот же ресурс, не копируя его. Копирование необходимо только тогда, когда одна из вызывающих программ пытается осуществить запись собственной "копии" — отсюда в названии способа появилось слово "копия", то есть копирование осуществляется при (первой попытке) записи.

В случае объединённого монтирования это означает, что, если мы пытаемся изменить совместно используемый файл (или файл только для чтения), он вначале копируется в верхнюю ветвь, доступную для записи (upperdir), имеющую более высокий приоритет, чем нижние ветви (lowerdir), доступные только для чтения. Когда файл попадает в ветвь, доступную для записи, его можно безопасно изменить, и его новое содержимое отобразится в объединённом представлении, так как верхний слой имеет более высокий приоритет.

Последняя операция, которую мы, возможно, захотим выполнить, — это удаление файлов. Чтобы "удалить" файл, в ветви, доступной для записи, создается файл whiteout для очистки "удаляемого" файла. На самом деле файл не будет удалён физически. Вернее сказать, что он будет скрыт в объединённом представлении.

Мы много говорили о принципах объединённого монтирования, но как все эти принципы работают на платформе Docker и её контейнерах? Рассмотрим многоуровневую архитектуру Docker. Песочница контейнера состоит из нескольких ветвей образа, или, как мы их называем, слоёв. Такими слоями являются часть объединённого представления, доступная только для чтения (lowerdir), и слой контейнера — тонкая верхняя часть, доступная для записи (upperdir).

Не считая терминологических различий, речь фактически идёт об одном и том же — слои образа, извлекаемые из реестра, представляют собой lowerdir, и, если запускается контейнер, upperdir прикрепляется поверх слоев образа, обеспечивая рабочую область, доступную для записи в контейнер. Звучит довольно просто, не так ли? Давайте проверим, как всё работает!

Проверяем​

Чтобы показать, как Docker использует файловую систему OverlayFS, попробуем смоделировать процесс монтирования Docker контейнеров и слоев образа. Прежде чем мы приступим, нужно вначале очистить рабочее пространство и получить образ, с которым можно работать:

~ $ docker image prune -af
...
Total reclaimed space: ...MB
~ $ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
a076a628af6f: Pull complete
0732ab25fa22: Pull complete
d7f36f6fe38f: Pull complete
f72584a26f32: Pull complete
7125e4df9063: Pull complete
Digest: sha256:10b8cc432d56da8b61b070f4c7d2543a9ed17c2b23010b43af434fd40e2ca4aa
Status: Downloaded newer image for nginx:latest
docker.io/library/nginx:latest
Итак, у нас имеется образ (nginx), с которым можно работать, далее нужно проверить его слои. Проверить слои образа можно, либо запустив проверку образа в Docker и изучив поля GraphDriver, либо перейдя в каталог /var/lib/docker/overlay2, в котором хранятся все слои образа. Выполним обе эти операции и посмотрим, что получится:

~ $ cd /var/lib/docker/overlay2
~ $ ls -l
total 0
drwx------. 4 root root 55 Feb 6 19:19 3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd
drwx------. 3 root root 47 Feb 6 19:19 410c05aaa30dd006fc47d8c23ba0d173c6d305e4d93fdc3d9abcad9e78862b46
drwx------. 4 root root 72 Feb 6 19:19 685374e39a6aac7a346963bb51e2fc7b9f5e2bdbb5eac6c76ccdaef807abc25e
brw-------. 1 root root 253, 0 Jan 31 18:15 backingFsBlockDev
drwx------. 4 root root 72 Feb 6 19:19 d487622ece100972afba76fda13f56029dec5ec26ffcf552191f6241e05cab7e
drwx------. 4 root root 72 Feb 6 19:19 fb18be50518ec9b37faf229f254bbb454f7663f1c9c45af9f272829172015505
drwx------. 2 root root 176 Feb 6 19:19 l

~ $ tree 3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/
3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/
├── diff
│ └── docker-entrypoint.d
│ └── 20-envsubst-on-templates.sh
├── link
├── lower
└── work

~ $ docker inspect nginx | jq .[0].GraphDriver.Data
{
"LowerDir": "/var/lib/docker/overlay2/fb18be50518ec9b37faf229f254bbb454f7663f1c9c45af9f272829172015505/diff:
/var/lib/docker/overlay2/d487622ece100972afba76fda13f56029dec5ec26ffcf552191f6241e05cab7e/diff:
/var/lib/docker/overlay2/685374e39a6aac7a346963bb51e2fc7b9f5e2bdbb5eac6c76ccdaef807abc25e/diff:
/var/lib/docker/overlay2/410c05aaa30dd006fc47d8c23ba0d173c6d305e4d93fdc3d9abcad9e78862b46/diff",
"MergedDir": "/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/merged",
"UpperDir": "/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/diff",
"WorkDir": "/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/work"
}
Если внимательно посмотреть на полученные результаты, можно заметить, что они весьма похожи на те, что мы уже наблюдали после применения команды mount, не находите?

  • LowerDir: это каталог, в котором слои образа, доступные только для чтения, разделены двоеточиями.
  • MergedDir: объединённое представление всех слоев образа и контейнера.
  • UpperDir: слой для чтения и записи, на котором записываются изменения.
  • WorkDir: рабочий каталог, используемый Linux OverlayFS для подготовки объединённого представления.
Сделаем ещё один шаг — запустим контейнер и изучим его слои:

~ $ docker run -d --name container nginx
~ $ docker inspect container | jq .[0].GraphDriver.Data
{
"LowerDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4-init/diff:
/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/diff:
/var/lib/docker/overlay2/fb18be50518ec9b37faf229f254bbb454f7663f1c9c45af9f272829172015505/diff:
/var/lib/docker/overlay2/d487622ece100972afba76fda13f56029dec5ec26ffcf552191f6241e05cab7e/diff:
/var/lib/docker/overlay2/685374e39a6aac7a346963bb51e2fc7b9f5e2bdbb5eac6c76ccdaef807abc25e/diff:
/var/lib/docker/overlay2/410c05aaa30dd006fc47d8c23ba0d173c6d305e4d93fdc3d9abcad9e78862b46/diff",
"MergedDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/merged",
"UpperDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff",
"WorkDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/work"
}

~ $ tree -l 3 /var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff # The UpperDir
/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff
├── etc
│ └── nginx
│ └── conf.d
│ └── default.conf
├── run
│ └── nginx.pid
└── var
└── cache
└── nginx
├── client_temp
├── fastcgi_temp
├── proxy_temp
├── scgi_temp
└── uwsgi_temp
Из представленных выше выходных данных следует, что те же каталоги, которые были перечислены в выводе команды docker inspect nginx ранее как MergedDir, UpperDir и WorkDir (с id 3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd), теперь являются частью LowerDir контейнера. В нашем случае LowerDir составляется из всех слоев образа nginx, размещённых друг на друге. Поверх них размещается слой в UpperDir, доступный для записи, содержащий каталоги /etc, /run и /var. Также, раз уж мы выше упомянули MergedDir, можно видеть всю доступную для контейнера файловую систему, в том числе всё содержимое каталогов UpperDir и LowerDir.

И, наконец, чтобы эмулировать поведение Docker, мы можем использовать эти же каталоги для ручного создания собственного объединённого представления:

~ $ mount -t overlay -o \
lowerdir=/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4-init/diff:
/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/diff:
/var/lib/docker/overlay2/fb18be50518ec9b37faf229f254bbb454f7663f1c9c45af9f272829172015505/diff:
/var/lib/docker/overlay2/d487622ece100972afba76fda13f56029dec5ec26ffcf552191f6241e05cab7e/diff:
/var/lib/docker/overlay2/685374e39a6aac7a346963bb51e2fc7b9f5e2bdbb5eac6c76ccdaef807abc25e/diff:
/var/lib/docker/overlay2/410c05aaa30dd006fc47d8c23ba0d173c6d305e4d93fdc3d9abcad9e78862b46/diff,\
upperdir=/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff,\
workdir=/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/work \
overlay /mnt/merged

~ $ ls /mnt/merged
bin dev docker-entrypoint.sh home lib64 mnt proc run srv tmp var
boot docker-entrypoint.d etc lib media opt root sbin sys usr

~ $ umount overlay
В нашем случае мы просто взяли значения из предыдущего фрагмента кода и передали их в качестве соответствующих аргументов в команду mount. Разница лишь в том, что для объединённого представления вместо /var/lib/docker/overlay2/.../merged мы использовали /mnt/merged.

Именно к этому сводится действие файловой системы OverlayFS в Docker — на множестве уложенных друг на друга слоев может использоваться одна команда монтирования. Ниже приводится отвечающая за это часть кода Docker — заменяются значения lowerdir=...,upperdir=...,workdir=..., после чего следует команда unix.Mount.

// https://github.com/moby/moby/blob/1...3/daemon/graphdriver/overlay2/overlay.go#L580
opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", strings.Join(absLowers, ":"), path.Join(dir, "diff"), path.Join(dir, "work"))
mountData := label.FormatMountLabel(opts, mountLabel)
mount := unix.Mount
mountTarget := mergedDir

rootUID, rootGID, err := idtools.GetRootUIDGID(d.uidMaps, d.gidMaps)
// ...

Заключение​

Интерфейс Docker может показаться чёрным ящиком с большим количеством скрытых внутри непонятных технологий. Эти технологии — пусть непонятные — довольно интересны и полезны. Я вовсе не хочу сказать, что для эффективного использования Docker нужно досконально знать все их тонкости, но, на мой взгляд, если вы потратите немного времени и поймёте, как они работают, это пойдёт вам только на пользу. Ясное понимание принципов работы инструмента облегчает принятие правильных решений — в нашем случае речь идёт о повышении производительности и возможных аспектах, связанных с безопасностью. Кроме того, вы сможете ознакомиться с некоторыми продвинутыми технологиями, и кто знает, в каких областях знаний они могут вам пригодится в будущем!

В этой статье мы рассмотрели только часть архитектуры Docker — файловую систему. Есть и другие части, с которыми стоит ознакомиться более внимательно, например контрольные группы (cgroups) или пространства имен Linux. Если вы их освоите — можно уже задуматься о переходе в востребованный DevOps.

Источник статьи: https://habr.com/ru/company/skillfactory/blog/547116/
 
Сверху