Виртуальные файловые системы в Linux: зачем они нужны и как они работают? Часть 1
Всем привет! Мы продолжаем запуски новых потоков по уже полюбившимся вам курсам и сейчас спешим сообщить о том, что у нас стартует новый набор по курсу «Администратор Linux», который запустится в конце апреля. К этому событию и будет приурочена новая публикация. С оригиналом материала можно ознакомиться тут.
Виртуальные файловые системы выполняют роль некой волшебной абстракции, которая позволяет философии Linux говорить, что «всё является файлом».
Что такое файловая система? Опираясь на слова одного из первых контрибьюторов и авторов Linux Робера Лава, «Файловая система – это иерархическое хранилище данных, собранное в соответствии с определенной структурой». Как бы то ни было, это определение в равной мере хорошо подходит для VFAT (Virtual File Allocation Table), Git и Cassandra (база данных NoSQL). Так что именно определяет такое понятие, как «файловая система»?
Основы файловой системы
Ядро Linux имеет определенные требования к сущности, которая может считаться файловой системой. Она должна реализовывать методы open() , read() и write() для постоянных объектов, которые имеют имена. С точки зрения объектно-ориентированного программирования, ядро определяет обобщенную файловую систему (generic filesystem) в качестве абстрактного интерфейса, а эти три большие функции считаются «виртуальными» и не имеют конкретного определения. Соответственно, реализация файловой системы по умолчанию называется виртуальной файловой системой (VFS).
Если мы можем открывать, читать и записывать в сущность, то эта сущность считается файлом, как мы видим из примера в консоли сверху.
Феномен VFS лишь подчеркивает наблюдение, характерное для Unix-подобных систем, которое гласит, что «всё является файлом». Подумайте, насколько странно, что тот маленький пример сверху с /dev/console показывает, как на самом деле работает консоль. На картинке изображена интерактивная Bash сессия. Отправка строки в консоль (virtual console device) отображает ее на виртуальном экране. VFS имеет другие, еще более странные свойства. Например, она дает возможность осуществлять поиск по ним.
Знакомые нам системы, такие как ext4, NFS и /proc имеют три важные функции в структуре данных С, которая называется file_operations. Кроме того, определенные файловые системы расширяют и переопределяют функции VFS привычным объектно-ориентированным способом. Как отмечает Роберт Лав, абстракция VFS позволяет пользователям Linux беспечно копировать файлы в или из сторонних операционных систем или абстрактных сущностей, таких как pipes, не беспокоясь об их внутреннем формате данных. Со стороны пользователя (userspace) с помощью системного вызова процесс может копировать из файла в структуры данных ядра с помощью метода read() одной файловой системы, а затем использовать метод write() другой файловой системы для вывода данных.
Определения функций, которые принадлежат к базовым типам VFS, находятся в файлах fs/*.c исходного кода ядра, в то время как подкаталоги fs/ содержат определенные файловые системы. В ядре также содержатся сущности, такие как cgroups , /dev и tmpfs , которые требуются в процессе загрузки и поэтому определяются в подкаталоге ядра init/ . Заметьте, что cgroups , /dev и tmpfs не вызывают «большую тройку» функций file_operations , а напрямую читают и пишут в память.
На приведенной ниже диаграмме показано, как userspace обращается к различным типам файловых систем, обычно монтируемых в системах Linux. Не показаны такие конструкции как pipes , dmesg и POSIX clocks , которые также реализуют структуру file_operations , доступ к которым проходит через слой VFS.
VFS — это «слой оболочки» между системными вызовами и реализациями определенных file_operations , таких как ext4 и procfs . Функции file_operations могут взаимодействовать либо с драйверами устройств, либо с устройствами доступа к памяти. tmpfs , devtmpfs и cgroups не используют file_operations , а напрямую обращаются к памяти.
Существование VFS обеспечивает возможность переиспользовать код, так как основные методы, связанные с файловыми системами, не должны быть повторно реализованы каждым типом файловой системы. Переиспользование кода – широкоприменяемая практика программных инженеров! Однако, если повторно используемый код содержит серьезные ошибки, от них страдают все реализации, которые наследуют общие методы.
/tmp: Простая подсказка
Простой способ обнаружить, что VFS присутствуют в системе – это ввести mount | grep -v sd | grep -v :/ , что покажет все смонтированные ( mounted ) файловые системы, которые не являются резидентами на диске и не NFS, что справедливо на большинстве компьютеров. Одним из перечисленных маунтов ( mounts ) VFS, несомненно, будет /tmp , верно?
Все знают, что хранение /tmp на физическом носителе – безумие! Источник.
Почему нежелательно хранить /tmp на физическом носителе? Потому что файлы в /tmp являются временными, а устройства хранения медленнее, чем память, где создается tmpfs. Более того, физические носители более подвержены износу при перезаписи, чем память. Наконец, файлы в /tmp могут содержать конфиденциальную информацию, поэтому их исчезновение при каждой перезагрузке является неотъемлемой функцией.
К сожалению, некоторые скрипты инсталляции Linux дистрибутивов создают /tmp на устройстве хранения по умолчанию. Не отчаивайтесь, если это произошло и с вашей системой. Выполните несколько простых инструкций с Arch Wiki, чтобы это исправить, и помните о том, что память выделенная для tmpfs , становится недоступной для других целей. Другими словами, система с гигантской tmpfs и большими файлами в ней может израсходовать всю память и упасть. Другая подсказка: во время редактирования файла /etc/fstab , помните о том, что он должен заканчиваться новой строкой, иначе ваша система не загрузится.
Помимо /tmp , VFS (виртуальные файловые системы), которые наиболее знакомы пользователям Linux – это /proc и /sys . ( /dev располагается в общей памяти и не имеет file_operations ). Почему именно эти два компонента? Давайте разберемся в этом вопросе.
procfs создает снимок мгновенного состояния ядра и процессов, которые он контролирует для userspace . В /proc ядро выводит информацию о том, какими средствами оно располагает, например, прерывания, виртуальная память и планировщик. Кроме того, /proc/sys – это место, где параметры, настраиваемые с помощью команды sysctl , доступны для userspace . Статус и статистика отдельных процессов выводится в каталогах /proc/ .
Здесь /proc/meminfo — это пустой файл, который тем не менее содержит ценную информацию.
Поведение /proc файлов показывает, какими непохожими могут быть дисковые файловые системы VFS. С одной стороны, /proc/meminfo содержат информацию, которую можно посмотреть командой free . С другой же, там пусто! Как так получается? Ситуация напоминает знаменитую статью под названием «Существует ли луна, когда на нее никто не смотрит? Реальность и квантовая теория», написанную профессором физики Корнельского университета Дэвидом Мермином в 1985 году. Дело в том, что ядро собирает статистику памяти, когда происходит запрос к /proc , и на самом деле в файлах /proc ничего нет, когда никто туда не смотрит. Как сказал Мермин, «Фундаментальная квантовая доктрина гласит, что измерение, как правило, не выявляет ранее существовавшего значения измеряемого свойства.» (А над вопросом про луну подумайте в качестве домашнего задания!)
Кажущаяся пустота procfs имеет смысл, поскольку располагающаяся там информация динамична. Немного другая ситуация с sysfs . Давайте сравним, сколько файлов размером не менее одного байта есть в /proc и в /sys .
Procfs имеет один файл, а именно экспортированную конфигурацию ядра, которая является исключением, поскольку ее нужно генерировать только один раз за загрузку. С другой стороны, в /sys лежит множество более объемных файлов, многие из которых занимают целую страницу памяти. Обычно файлы sysfs содержат ровно одно число или строку, в отличие от таблиц информации, получаемой при чтении таких файлов, как /proc/meminfo .
Цель sysfs – предоставить свойства доступные для чтения и записи того, что ядро называет «kobjects» в userspace. Единственная цель kobjects – это подсчет ссылок: когда удаляется последняя ссылка на kobject, система восстановит ресурсы, связанные с ним. Тем не менее, /sys составляет большую часть знаменитого «stable ABI для userspace» ядра, которое никто никогда, ни при каких обстоятельствах не может «сломать». Это не означает, что файлы в sysfs статичны, что противоречило бы подсчету ссылок на нестабильные объекты.
Стабильный двоичный интерфейс приложений ядра (kernel’s stable ABI) ограничивает то, что может появиться в /sys , а не то, что на самом деле присутствует в данный конкретный момент. Листинг разрешений на файлы в sysfs обеспечивает понимание того, как конфигурируемые параметры устройств, модулей, файловых систем и т.д. могут быть настроены или прочитаны. Делаем логический вывод, что procfs также является частью stable ABI ядра, хотя это не указано явно в документации.
Файлы в sysfs описывают одно конкретное свойство для каждой сущности и могут быть читаемыми, перезаписываемыми или и то и другое сразу. «0» в файле говорит о том, что SSD не может быть удален.
Вторую часть перевода начнем с того, как наблюдать за VFS с помощью инструментов eBPF и bcc, а сейчас ждем ваши комментарии и традиционно приглашаем на открытый вебинар, который уже 9 апреля проведет наш преподаватель — Владимир Дроздецкий.
Источник
Глубокое погружение в Linux namespaces, часть 2
В предыдущей части мы только окунули пальцы ног в воды namespace и при этом увидели, как это было просто — запустить процесс в изолированном UTS namespace. В этом посте мы осветим User namespace.
Среди прочих ресурсов, связанных с безопасностью, User namespaces изолирует идентификаторы пользователей и групп в системе. В этом посте мы сосредоточимся исключительно на ресурсах user и group ID (UID и GID соответственно), поскольку они играют фундаментальную роль в проведении проверок разрешений и других действий во всей системе, связанных с безопасностью.
В Linux эти ID — просто целые числа, которые идентифицируют пользователей и группы в системе. И каждому процессу назначаются какие-то из них, чтобы задать к каким операциями/ресурсам этот процесс может и не может получить доступ. Способность процесса нанести ущерб зависит от разрешений, связанных с назначенными ID.
User Namespaces
Мы проиллюстрируем возможности user namespaces, используя только пользовательские ID. Точно такие же действия применимы к групповым ID, к которым мы обратимся далее в этому посте.
User namespace имеет собственную копию пользовательского и группового идентификаторов. Затем изолирование позволяет связать процесс с другим набором ID — в зависимости от user namespace, которому он принадлежит в данный момент. Например, процесс $pid может выполняться от root (UID 0) в user namespace P и внезапно продолжает выполняться от proxy (UID 13) после переключения в другой user namespace Q.
User spaces могут быть вложенными! Это означает, что экземпляр пользовательского namespace (родительский) может иметь ноль и больше дочерних пространств имён, и каждое дочернее пространство имён может, в свою очередь, иметь свои собственные дочерние пространства имён и так далее… (до достижения предела в 32 уровня вложенности). Когда создаётся новый namespace C, Linux устанавливает текущий User namespace процесса P, создающего C, как родительский для C и это не может быть изменено впоследствии. В результате все user namespaces имеют ровно одного родителя, образуя древовидную структуру пространств имён. И, как и в случае с деревьями, исключение из этого правила находится наверху, где у нас есть корневой (или начальный, дефолтный) namespace. Это, если вы еще не делаете какую-то контейнерную магию, скорее всего user namespace, к которому принадлежат все ваши процессы, поскольку это единственный user namespace с момента запуска системы.
В этом посте мы будем использовать приглашения командной строки P$ и C$ для обозначения шела, который в настоящее время работает в родительском P и дочернем C user namespace соответственно.
Маппинги User ID
User namespace, по сути, содержит набор идентификаторов и некоторую информацию, связывающую эти ID с набором ID других user namespace — этот дуэт определяет полное представление о ID процессов, доступных в системе. Давайте посмотрим, как это может выглядеть:
В другом окне терминала давайте запустим шелл с помощью unshare (флаг -U создаёт процесс в новом user namespace):
Погодите, кто? Теперь, когда мы находимся во вложенном шелле в C, текущий пользователь становится nobody? Мы могли бы догадаться, что поскольку C является новым user namespace, процесс может иметь иной вид ID. Поэтому мы, возможно, и не ждали, что он останется iffy , но nobody — это не смешно. С другой стороны, это здорово, потому что мы получили изолирование, которое и хотели. Наш процесс теперь имеет другую (хоть и поломанную) подстановку ID в системе — в настоящее время он видит всех, как nobody и каждую группу как nogroup .
Информация, связывающая UID из одного user namespace с другим, называется маппингом user ID. Он представляет из себя таблицы поиска соответствия ID в текущем user namespace для ID в других namespace и каждый user namespace связан ровно одним маппингом UID (в дополнение еще к одному маппингу GID для group ID).
Этот маппинг и есть то, что сломано в нашем unshare шелле. Оказывается, что новые user namespaces начинаются с пустого маппинга, и в результате Linux по умолчанию использует ужасного пользователя nobody . Нам нужно исправить это, прежде чем мы сможем сделать какую-либо полезную работу в нашем новом пространстве имён. Например, в настоящее время системные вызовы (например, setuid ), которые пытаются работать с UID, потерпят неудачу. Но не бойтесь! Верный традиции всё-есть-файл, Linux представляет этот маппинг с помощью файловой системы /proc в /proc/$pid/uid_map (в /proc/$pid/gid_map для GID), где $pid — ID процесса. Мы будем называть эти два файла map-файлами
Map-файлы
Map-файлы — особенные файлы в системе. Чем особенные? Ну, тем, что возвращают разное содержимое всякий раз, когда вы читаете из них, в зависимости от того, какой ваш процесс читает. Например, map-файл /proc/$pid/uid_maps возвращает маппинг от UID’ов из user namespace, которому принадлежит процесс $pid , UID’ам в user namespace читающего процесса. И, как следствие, содержимое, возвращаемое в процесс X, может отличаться от того, что вернулось в процесс Y, даже если они читают один и тот же map файл одновременно.
В частности, процесс X, считывающий UID map-файл /proc/$pid/uid_map , получает набор строк. Каждая строка отображает непрерывный диапазон UID’ов в user namespace C процесса $pid , соответствующий диапазону UID в другом namespace.
Каждая строка имеет формат $fromID $toID $length , где:
- $fromID является стартовым UID диапазона для user namespace процесса $pid
- $lenght — это длина диапазона.
- Трансляция $toID зависит от читающего процесса X. Если X принадлежит другому user namespace U, то $toID — это стартовый UID диапазона в U, который мапится с $fromID . В противном случае $toID — это стартовый UID диапазона в P — родительского user namespace процесса C.
Например, если процесс читает файл /proc/1409/uid_map и среди полученных строк видно 15 22 5 , то UID’ы с 15 по 19 в user namespace процесса 1409 маппятся в UID’ы 22-26 отдельного user namespace читающего процесса.
С другой стороны, если процесс читает из файла /proc/$$/uid_map (или map-файла любого процесса, принадлежащего тому же user namespace, что и читающий процесс) и получает 15 22 5 , то UID’ы c 15 по 19 в user namespace C маппятся в UID’ы c 22 по 26 родительского для C user namespace.
Давайте это попробуем:
Хорошо, это было не очень захватывающе, так как это были два крайних случая, но это говорит там о нескольких вещах:
- Вновь созданный user namespace будет фактически иметь пустые map-файлы.
- UID 4294967295 не маппится и непригоден для использования даже в root user namespace. Linux использует этот UID специально, чтобы показать отсутствие user ID.
Написание UID Map файлов
Чтобы исправить наш вновь созданный user namespace C, нам просто нужно предоставить наши нужные маппинги, записав их содержимое в map-файлы для любого процесса, который принадлежит C (мы не можем обновить этот файл после записи в него). Запись в этот файл говорит Linux две вещи:
- Какие UID’ы доступны для процессов, которые относятся к целевому user namespace C.
- Какие UID’s в текущем user namespace соответствуют UID’ам в C.
Например, если мы из родительского user namespace P запишем следующее в map-файл для дочернего пространства имён C:
мы по существу говорим Linux, что:
- Что касается процессов в C, единственным UID’ами, которые существуют в системе, являются UID’ы 0 и 3 . Например, системный вызов setuid(9) всегда будет завершаться чем-то вроде недопустимого id пользователя.
- UID’ы 1000 и 0 в P соответствуют UID’ам 0 и 3 в C. Например, если процесс, работающий с UID 1000 в P, переключится в C, он обнаружит, что после переключения его UID стал root 0 .
Владелец пространств имён и привилегии
В предыдущем посте мы упомянули, что при создании новых пространств имён требуется доступ с уровнем суперпользователя. User namespaces не налагают этого требования. На самом деле, еще одной их особенностью является то, что они могут владеть другими пространствами имён.
Всякий раз, когда создаётся не user namespace N, Linux назначает текущий user namespace P процесса, создающего N, владельцем namespace N. Если P создан наряду с другими пространствами имён в одном и том же системном вызове clone , Linux гарантирует, что P будет создан первым и назначен владельцем других пространств имён.
Владелец пространств имён важен потому, что процесс, запрашивающий выполнения привилегированного действия над ресурсом, задействованным не user namespace, будет иметь свои UID привилегии, проверенные в отношении владельца этого user namespace, а не корневого user namespace. Например, скажем, что P является родительским user namespace дочернего C, а P и C владеют собственными network namespace M и N соответственно. Процесс может не иметь привилегий для создания сетевых устройств, включенных в M, но может быть в состоянии это делать для N.
Следствием наличия владельца пространств имён для нас является то, что мы можем отбросить требование sudo при выполнении команд с помощью unshare или isolate , если если мы запрашиваем также создание и user namespace. Например, unshare -u bash потребует sudo , но unshare -Uu bash — уже нет:
К сожалению, мы повторно применим требование прав суперпользователя в следующем посте, так как isolate нуждается в привилегиях root в корневом user namespace, чтобы корректно настроить Mount и Network namespace. Но мы обязательно отбросим привилегии командного процесса, чтобы убедиться, что команда не имеет ненужных разрешений.
Как разрешаются ID
Мы только что увидели процесс, запущенный от обычного пользователя 1000 внезапно переключился на root . Не волнуйтесь, никакой эскалации привилегий не было. Помните, что это просто маппинг ID: пока наш процесс думает, что он является пользователем root в системе, Linux знает, что root — в его случае — означает обычный UID 1000 (благодаря нашему маппингу). Так что в то время, когда пространства имён, принадлежащие его новому user namespace (подобно network namespace в C), признают его права в качестве root , другие (как например, network namespace в P) — нет. Поэтому процесс не может делать ничего, что пользователь 1000 не смог бы.
Всякий раз, когда процесс во вложенном user namespace выполняет операцию, требующую проверки разрешений — например, создание файла — его UID в этом user namespace сравнивается с эквивалентным ID пользователя в корневом user namespace путём обхода маппингов в дереве пространств имён до корня. В обратном направлении происходит движение, например, когда он читает ID пользователей, как мы это делаем с помощью ls -l my_file . UID владельца my_file маппится из корневого user namespace до текущего и окончательный соответствующий ID (или nobody, если маппинг отсутствовал где-либо вдоль всего дерева) отдаётся читающему процессу.
Групповые ID
Даже если мы оказались root в C, мы до сих пор ассоциированы с ужасной nogroup в качестве нашего ID группы. Нам просто нужно сделать то же самое для соответствующего /proc/$pid/gid_map . Прежде чем мы сможем это сделать, нам нужно отключить системный вызов setgroups (в этом нет необходимости, если у нашего пользователя уже есть CAP_SETGID capability в P, но мы не будем предполагать этого, поскольку это обычно идёт вместе с привилегиями суперпользователя), написав «deny» в файл proc/$pid/setgroups :
Реализация
Как вы можете видеть, есть много сложностей, связанных с управлением user namespaces, но реализация довольно проста. Всё, что нам нужно сделать, это написать кучу строк в файл — муторно было узнать, что и где писать. Без дальнейших церемоний, вот наши цели:
- Клонировать командного процесса в его собственном user namespace.
- Написать в UID и GID map-файлы командного процесса.
- Сбросить все привилегии суперпользователя перед выполнением команды.
1 достигается простым добавлением флага CLONE_NEWUSER в наш системный вызов clone .
Для 2 мы добавляем функцию prepare_user_ns , которая осторожно представляет одного обычного пользователя 1000 в качестве root .
И вызовем его из основного процесса в родительском user namespace прямо перед тем, как мы подадим сигнал командному процессу.
Для шага 3 мы обновляем функцию cmd_exec , чтобы убедиться, что команда выполняется от обычного непривилегированного пользователя 1000 , которого мы предоставили в маппинге (помните, что root пользователь 0 в user namespace командного процесса — это пользователь 1000 ):
И это всё! isolate теперь запускает процесс в изолированном user namespace.
В этом посте было довольно много подробностей о том, как работают User namespaces, но в конце концов настройка экземпляра была относительно безболезненной. В следующем посте мы рассмотрим возможность запуска команды в своём собственном Mount namespace с помощью isolate (раскрывая тайну, стоящую за инструкцией FROM из Dockerfile ). Там нам потребуется немного больше помочь Linux, чтобы правильно настроить инстанс.
Источник