Контейнеры Linux и Виртуализация: с точки зрения ядра

Шашанк Мохан Джейн

Copyright © 2020 Шашанк Мохан Джейн

Данный документ предоставляется по лицензии Creative Commons Attribution 3.0 License, за исключением разделов со специальными оговорками.

http://creativecommons.org/licenses/by/3.0/legalcode

Первая публикация на английском языке: 15 Октября 2020

Опубликовано Apress, Berkeley, CA

North Richland Hills, TX, USA

ISBN 978-1-4842-6282-5

eBook ISBN 978-1-4842-6283-2

https://doi.org/10.1007/978-1-4842-6283-2

www.apress.com

2020-10-18

 Состав исполнителей

Издания на английском языке

Автор

Шашанк Мохан Джейн

Директор- распорядитель, Apress Media LLC

Велмоуид Спахр

Редактор приобретения

Спандейна Чаттерджи

Редактор по разработке

Мэтью Муди

Координирующий редактор

Ширкант Вишвейкарма

Разработка обложки

eStudioCalamar

Pixabay

 Об авторе

Шашанк Мохан Джейн работал в области ИТ отрасли почти 20 лет, причём в основном в областях облачных вычислений и распределённых систем. Он имеет острый интерес к технологиям виртуализации, безопасности и сложных, динамичных систем. Шашанк имеет 25 программных патентов (и ещё много для публикации) на своё имя в области облачных вычислений, IoT и машинного обучения. Он выступал на большом числе известных конференций по облачным решениям. Шашанк также обладает сертификатами Sun, Microsoft и ядра Linux.

 О рецензенте

Суреш Винкейтейсубреймейньян имеет степень доктора философии Индийского института исследований в области криминалистики, сжатия и шифрования изображений. Он на протяжении 20 лет был близок к опыту машинного обучения и анализа данных. Области его интересов включали распознавание кстественных языков, сложных сетевых сред и вычислительного распознавания. В настоящее время он является ведущим специалистом по анализу данных в Walmart Labs. Ранее он выступал экспертом в R&D по исследованиям и разработкам SAP Labs и Accenture AI.

 Благодарности

Моим родителям, жене и дочери за то, что они проявили ко мне терпение при создании этой книги и всегда вносили положительный вклад.

 Введение

Мотивация этой книги восходит корнаями к словам лауреата Нобелевской системы и знаменитого учёного Ричарда Фейнмана: "То, чего я не спопобен создать, я не понимаю."

Основная идея этой книги состоит в разработке глубокого понимания мира виртуализации и, в особенности, прыгнуть вниз в кроличью нору, что касается контейнеров Linux. Читатели получат некое понимание того, что происходит на уровне операционной системы Linux, когда дело доходит до обсуждения виртуализации и контейнеров Linux. Эта книга изучает те структуры данных, которые вовлечены в ту изоляцию, которую предоставляют контейнеры Linux, а также различные механизмы управления ресурсами.

Эта книга будет полезна для людей, работающих в области облачных вычислений. Будь они разработчиками или DevOps, эта книга может взять читателя в путешествие по тому что в действительности происходит под капотом. Она не покрывает уровень API, но рассматривает что происходит ниже этого API при использовании контейнеров Linux. Прочитав эту книгу и пройдя её примеры, читатели получат пристойное понимание того как рабоатет мир контейнеров и будут способны лучше оптимизировать свои развёртывания и оптимизировать их.

 Содержание

О книге

Состав исполнителей

Об авторе

О техническом рецензенте

Благодарности

Введение

Глава 1. Основы виртуализации

История виртуализации

Что такое виртуализация?

Виртуализация на основе ВМ

Виртуализация на основе контейнеров

Гипервизоры

VMM

Модель устройства

Виртуализация памяти

Таблицы теневых страниц

Вложенные таблицы страниц с аппаратной поддержкой

Виртуализация ЦПУ

Двоичная трансляция в случае полной виртулизации

Паравиртуализация

Виртуализация ввода/ вывода

Полная виртуализация

Паравиртуализация

Глава 2. Гипервизоры

Набор интрукций Intel Vt-x

QEMU

Создание ВМ при помощи модуля KVM

Взаимодействие данных на основе Vhost

Что представляет собой eventfd

Альтернативные механизмы виртуализации

Unikernels

Проект Dune

novm

Итоги подходов альтернативной виртуализации

Глава 3. Пространства имён

Типы пространств имён

UTS

PID

Mount

Network

IPC

Cgroup

Time

Пространства имён Linux

Добавление устройства в Пространство имён

Выводы

Глава 4. Cgroups

Создание простой cgroup

Типы Cgroup

Cgroup CPU

Cgroup блочного ввода/ вывода

Понимание Fairness

Понимание Throttling

Глава 5. Многоуровневая файловая система

Пример файловой системы

Несколько слов о Псевдофайловых системах

Многоуровневые Файловые системы

Единая файловая система

OverlayFS

Глава 6. Создание простой контейнерной инфраструктуры

Пространство имён UTS

Установка Golang

Сборка контейнера при помощи Пространства имён

Добавление дополнительных пространств имён

Запуск программы оболочки внутри контейнера

Предоставление файловой системы Root

Монтирование файловой системы Proc

Включение сетевой среды для контейнера

Небольшой пример построения виртуальной сети

Включение Cgroup для контейнера

Выводы

Указатель

Глава 1. Основы виртуализации

Содержание

Глава 1. Основы виртуализации

История виртуализации

Что такое виртуализация?

Виртуализация на основе ВМ

Виртуализация на основе контейнеров

Гипервизоры

VMM

Модель устройства

Виртуализация памяти

Таблицы теневых страниц

Вложенные таблицы страниц с аппаратной поддержкой

Виртуализация ЦПУ

Двоичная трансляция в случае полной виртулизации

Паравиртуализация

Виртуализация ввода/ вывода

Полная виртуализация

Паравиртуализация

Эта книга поясняет основы виртуализации и поможет вам создавать ваши собственные инфраструктуры контейнеров, подобные Docker, но в облегчённой версии. Прежде чем мы приступим к этому процессу, нам потребуется разобраться с тем, как само ядро Linux поддерживает виртуализацию и как собственно развитие ядра Linux и ЦПУ способствует развитию виртуальных машин в плане производительности, что в свою очередь повлекло к созданию контейнерных технологий.

Цель данной главы состоит в том, чтобы пояснить что представляет из себя виртуальная машина (ВМ) и что происходит под капотом. Мы также рассмотрим некоторые основы гипервизоров, которые дают возможность запуска виртуальной машины в системе.

История виртуализации

До наступления эры виртуализации единственный путь полной подготовки физических серверов пролегал через ИТ. Это был дорогостоящий и трудоёмкий процесс. Одним из недостатков такого метода был тот, что такие ресурсы машины как ЦПУ, память и диски оставались не полностью используемыми. Для обхода этого начало набирать обороты понятие виртуализации.

История виртуализации восходит к 1960-м годам, когда Джим Рымарчик, который был проргаммистом в IBM приступил к виртуализации мейнфреймов IBM. Для внутреннего использования IBM разработал мейнфрейм CP-40. Эта система эволюционировала в CP-67, в которой применялась технология нескольких разделов для одновременного запуска нескольких приложений. {Прим. пер.: авторы перевода застали систему виртуальных машин на серии мейнфреймов IBM 360/370 в их исполнении в серии ЕС ЭВМ.} В конце концов пришёл Unix, который позволял запускать несколько программ на оборудовании x86. Тем не менее, оставалась проблема переносимости. В ранние 90-е Sun Microsystems представила Java, которая позволила расправить крылья парадигме "написанное один раз запускать повсеместно". Теперь пользователь мог написать некую программу на Java, которая могла запускаться на широком диапазоне аппаратных архитектур. Java осуществила это, вводя промежуточный код (носящий название кода байт), который затем можно было бы выполнять с среде времени выполнения Java в различных аппаратных архитектурах. Это знаменовало появление виртуализации на уровне процессов, посредством которой среда времени исполнения виртуализировала соответствующий уровень POSIX.

В 1990-е в дело вмешалась VMware и запустила собственную модель виртуализации. Она была связана с виртуалтзацией фактического оборудования, такого как ЦПУ, память, диски и тому подобное. Это означало, что поверх программного обеспечения VMware (также носящего название гипервизор), мы способны запускать собственно операционные системы (именуемые гостевыми). Это означало, что разработчики не были ограничены запуском лишь программ Java, но были способны запускать любую программу, предназначенную для работы в своей гостевой операционной системе. Примерно в 2001 году VMware запустила свои серверы ESX и GSX. GSX был гипервизором 2 типа, а потому ему требовалась некая подобная Windows оперционная система для запуска гостей. ESX являл собой гипервизор 1 типа, который позволял гостевым ОС запускаться непосредственно поверх такого гипервизора.

Что такое виртуализация?

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

На верхнем уровне имеются две основные технологии виртуализации, основывающиеся на своём уровне абстракции.

На основе Виртуальных Машин (ВМ)

На сонове контейнеров

Помимо этих двух техник виртуализации имеются и прочие технологии, например, юникёрны (unikernels), которые являются облегчёнными ВМ с единственной целью. IBM в настоящее время предпринимает попытки запускать юникёрны в качестве процессов в таких проектах как Nabla. {Прим. пер.: начиная с версии 2004 Windows 10 (build 18917) также поддерживает запуск облегчённых ВМ Linux (WSL 2, Подсистемы Windows для Linux 2 типа), подробнее см. наш перевод изданной в сентябре 2020 книги Изучаем подсистемы Windows для Linix Прэйтика Сингха.} В данной книге мы в основном рассматриваем виртуализацию лишь на основе ВМ и контейнеров.

Виртуализация на основе ВМ

Такой подход на основе ВМ выполняет виртуализацию всей ОС целиком. Те абстракции, которые предоставляются в соответствующую ВМ, это виртуальные устройства такие как виртуальные диски, виртуальные ЦПУ и виртуальные NIC {Прим. пер.: а также виртуальные GPU и т.д.}. Иначе говоря, мы можем постулировать, что это полная ISA (instruction set architecture, архитектура набора инструкций); например, ISA x86.

При помощи виртуальных машин множество ОС способно совместно использовать ресурся одного и того же оборудования, причём с виртуальным представлением каждого из таких ресурсов, доступных для своей ВМ. Например, такая ОС в своей виртуальной машине (также именуемой гостевой) способны продолжать операции ввода/ вывода на неком диске (в данном случае это какой- то виртуальный диск), полагая что именно они являются единственной ОС в данной физической машине (также носящей название хоста), хотя в действительности они разделяют его со множеством виртуальных машин, а также с ОС самого хоста.

ВМ очень схожи с прочими процессами в ОС своего хоста. ВМ исполняется в неком аппаратно изолированном виртуальном адресном пространстве и на более низком уровне привилений, чем ОС их хоста. Самое основное отличие между неким процессом и какой- то ВМ состоит в ABI (Application Binary Interface, Двоичном интерфейсе приложения), выставляемом самам хостом в соответствующую ВМ. В случае некого процесса такой выставляемый ABI конструируется подобно сетевым сокетам, FD (дескрипторам файлов) и тому подобному, в то время как в окончательно завершённой виртуализации ОС, её ABI будет обладать неким виртуальным диском, виртуальным ЦПУ, виртуальными сетевыми картами, и так далее.

Виртуализация на основе контейнеров

Такая форма виртуализации на выполняет абстракцию своего оборудования, а вместо этого применяет технологии внутри своего ядра Linux для изоляции путей доступа к различным ресурсам. Она высекает некие логические границы внутри одной и той же операционной системы. В качестве примера, мы получаем отдельную корневую файловую систему, отдельное дерево процессов, отдельную сетевую подсистему, и так далее.

Гипервизоры

Для виртуализации самой ОС применяется специальная порция программного обеспечения, носящего название гипервизора. Гипервизор сам по себе облдает двумя частями:

Virtual Machine Monitor (VMM) (Монитор Виртуальной Машины): Используется для отделения и эмуляции набора привилегированных инструкций (которые может исполнять лишь само ядро его операционной системы).

Device model (Модель Устройства): Используется для виртуализации имеющихся устройств ввода/ вывода.

VMM

Поскольку в некой виртуальной машине соответствующее оборудование не доступно напрямую (хотя в некоторых случаях это может иметь место {Прим. пер.: проброс оборудования или его частей, разделяемых на аппаратном уровне}), имеющийся VMM отлавливает привилегированные инструкции, которые запрашивают доступ к оборудованию (например,к диску/ сетевой карте) и исполняют эти инструкции от имени такой виртуальной машины.

Такой VMM обязан соответствовать трём свойствам (Popek and Goldberg, 1973):

Изоляция: Обязан изолировать госткй (ВМ) друг от друга.

Эквивалентности: Должен вести себя точно так же, как при виртуализации, так и без неё. Это означает, что мы запускаем большинство (почти все) инструкции в имеющемся физическом оборудовании без какой бы то ни было трансляции, и тому подобного.

Производительность: Обязано выполняться так же хорошо, как это делалось бы без какой бы то ни было виртуализации. Это опять- таки означает, что имеющиеся издержки запуска некой ВМ минимальны.

Некоторые функциональные возможности VMM таковы:

Не позволяет доступ ВМ к привилегированным состояниым; то есть, такие вещи как манипулирование значением состояния регистров хоста не допускаются из соответствующей ВМ. Имеющийся VMM будет всегда отлавливать и эмедировать подобные вызовы.

Обрабатывает исключительные состояния и прерывания. Когда некий сетевой вызов (то есть запрос) испускается изнутри некой виртуальной машины, он будет отловлен в имеющемся VMM и сэмулирован им. По получению отклика от физической сетевой среды/ NIC, физический ЦПУ выработает некое прерывание и доставит его в реальную виртуальную машину, которой оно предназначено.

Обрабатывает виртуализацию ЦПУ запуская большинство инструкций естественным образом (внутри соответствующего виртуального ЦПУ этой ВМ) и отлавливая лишь определённые привилегированные инструкции. Это означает, что производительность почти такова, как если бы родной код выполнялся бы напрямую на физическом оборудовании.

Обрабатывает устновленный в памяти ввод/ вывод осуществляя трансляцию своих вызовов в сответствующую виртуальную память своего гостя, относящуюся к устройству, в реальную память физического устройства. Для этого установленный VMM обязан контролировать все соответствия физической памяти (Гостевой физической памяти в физическую память Хоста). Более детально мы обсудим это в последующем разделе данной главы.

Модель устройства

Модель устройства соответствующего гипервизора обрабатывает установленную виртуализацию ввода/ вывода опять же через отлавливание и эмуляцию с последующей доставкой прерываний обратно в конкретную виртуальную машину.

Виртуализация памяти

Одним из критически важных вызовов при виртуализации является то как преврщать память в виртуальную. Получаемая гостевая ОС должна обладать тем же поведением, что и ОС без виртуализации. Это означает, что наша гостевая ОС должна быть слана так, то по крайней мере она считает что управляет своей памятью.

В случае виртулизации нашей гостевой ОС не может быть предоставлен прямой доступ к имеющейся физической памяти. Что это означает, так это то, то что наша гостевая ОС не способна манипулировать аппаратными таблицами страниц, ибо это могло пы приводить к получению таким гостем контроля над своей физической системой.

Прежде чем мы окунёмся в то как управляться с этим, потребуется некое базовое понимание виртуализации памяти, даже в контексте взаимодействия с обычными ОС и оборудованием.

Конкретная ОС снабжает свои процессы неким виртуальным представлением памяти; всякий доступ к установленой физической памяти перехватывается и обрабатывается имеющейся аппаратной компонентой, носящей название MMU (Memory Management Unit, Диспетчер памяти). Сама ОС устанавливает значение регистра CR3 (через привилегированную инструкцию) и имеющийся MMU применяет эту запись для прохождения по соответствующим таблицам памяти для определения физического соответствия. Сама ОС также заботится об изменении такого соответствия когда происходит выделение или высвобождение физической памяти.

Теперь, в случае виртуальных гостей, их поведение должно быть аналогичным. Такой гость не должен получать прямого доступа к физической памяти, а вместо этого должен перехватываться и обрбатываться имеющимся VMM.

В целом, при исполнении некой гостевой ОС имеются вовлечёнными три абстракции памяти:

Гостевая виртуальная память: Это именно то, что видно самому процессу гостевой ОС в качестве пространства исполнения.

Гостевая физическая память: Это то, что видит гостевая ОС

Системная физическая память: Это то, что наблюдает VMM.

Для обработки этого имеются два возможных подхода:

Таблицы теневых страниц

Вложенные таблицы страниц с аппаратной поддержкой

Таблицы теневых страниц

В случае теневых таблиц страниц, Гостевая виртуальная память ставится в соответствие напрямую Системной Физической памяти через VMM хоста. Это улучшает производителность путём исключения одного дополнительного уровня трансляции. Однако этот подход обладает недостатками. Когда в таблице гостевых страниц имеется изменение, необходимо обновлять таблицы теневых страниц. Это подразумвает, что для обработки в VMM должны иметься некая ловушка и эмуляция. Имеющийся VMM может выполнять это помечая свои гостевые страницы доступными лишь для чтения. Тем самым, любые попытки записи его гостевой ОС вызовут перехват и VMM сможет затем обновить свои теневые страницы.

Вложенные таблицы страниц с аппаратной поддержкой

Для данной задачи Intel и AMD предоставляют решение через аппаратные расширения. Intel предоставляет нечто, носящее название EPT (Extended Page Table, Расширенной таблицы страниц), что делает возможным для MMU проходить по двуи таблицам страниц.

Первый проход заключается в соответствии из Гостевой Виртуальной в Гостевую Физическую память,а второй проход выполняет соответстиве из Гостевой Физической в Системную Физическую память. Поскольку все трансляции теперь происходят на аппаратном урове, нет необходимости пддержки теневых страниц таблиц. Гостевые таблицы страниц сопровождаются самой гостевой ОС,а все прочие таблицы боддерживаются VMM хоста.

При использовании теневых таблиц страниц, значения кэша TLB (translation look-aside buffer, буфера предыстории процесса, являющегося частью MMU) необходимо сбрасывать при переключении контекста, то есть при запуске другой ВМ. Тогда как в случае EPT оборудование представляет некий идентификатор ВМ через соответствующий идентификатор адресного пространства, что означает, что TLB способен обладать соответствиями для различных ВМ одновременно, что является ускорением производительности.

Виртуализация ЦПУ

Прежде чем мы рассмотрим виртуализацию ЦПУ, было бы познавательно разобраться как в архитектуре x86 строятся кольца защиты.Такие кольца делают возможным для ЦПУ защищать память и контролировать права, а также определять какой код исполняется на каком уровне.

Архитектура x86 пользуется понятием колец защиты. Само ядро исполняется в наиболее привилегированном режиме, Кольце 0, а соответствующее пространство пользователя, используемое для выполнения процесов запускается в Кольце 3.

Имеющееся оборудование требует, чтобы все привилегированные инструкции исполнялись в Кольце 0. Когда в Кольце 3 предпринимается любая попытка запуска некой привилегированной инструкции, имеющийся ЦПУ вырабатывает отказ. Ядро хоста регистрирует обработчики отказов и, на основании типа отказа, вызыввается некий обработчик отказа. Соответствующий обработчик отказа выполняет контроль корректности этой ошибки и обрабатывает её. Если проверка корректности прошла, этот обработчик ошибок управляется с этим исполнением от имени самого процесса. В случае виртуализации на основе ВМ, такая ВМ запускается как некий процесс в ОС самого хоста, а потому если этот отказ не будет обработан, будет уничтожена вся ВМ целиком.

На верхнем уровне привилегированные инструкции из 3 Кольца контролируются неким регистром сегмента кода через надлежащий бит CPL (code privilege level, уровень привилегированности кода). Все вызовы из Кольца 3 управляемо пропускаются в Кольцо 0. Например, некой подобной syscall инструкции (из пространства пользователя) может выполняться какой- то системный вызов, который, в свою очередь, устанавливает значение верного уровня CPL и испольняет необходимый код ядра с более высоким уровнем полномочий. Любая попытка непосредственного вызова еода с высокими полномочиями из более верхнего кольца приводит к аппаратному отказу.

Та же самае концепция применяется к некой виртуальной ОС. В этом случае соответствующий гость понижается в полномочиях и запускается в Кольце 1, а процесс этого гостя запускается в Кольце 3. VMM хоста сам по себе исполняется в Кольце 0. Для всех полностью виртуальных гостей все любые привилегированные инструкции должны улавливаться и эмклироваться. Именно VMM эмулирует такую перехваченную инструкцию. Помимо таких привилегированных инструкций, секретные инструкции также подлежат перехвату и эмуляции через VMM хоста.

Более ранние версии ЦПУ x86 не обладали возможностями иртуализации, что подразумевает, что не все секретные инструкции являются привилегированными. Такие инструкции, как SGDT, SIDT и ряд прочих, могут выполняться в Кольце 1 без их перехвата. Это может оказаться пагубным при запуске некой гостевой ОС, поскольку такой код позволяет соответствующему гостю заглядывать в структуры данных ядра хоста. Эту проблему можно решать двумя способами:

Двоичная трансляция в случае полной виртулизации

Паравиртуализацией в случае гипервызовов XEN

Двоичная трансляция в случае полной виртулизации

В этой ситуации соответствующая гостевая ОС применяется без каких бы то ни было изменений. Соответствующие инструкции отлавливаются и эмулируются для надлежащей целевой среды. Это вызывает большое число накладных расходов, поскольку должно перехватываться большое число инструкций в соответствующем хосте/ гипервизоре с последующей эмуляцией.

Паравиртуализация

Во избежание относящихся к двоичной трансляции проблем при использовании полной виртулизации мы применяем паравиртуализацию, при которой соответствующий гость осведомлён, что он запущен в виртуальной среде и его взаимодействие с хостом оптимизировано для избегания затратного отлавливания. Например, код драйвера устройства изменяется и расщепляется на две части. Одна из них выступает в качестве серверной (которая располагается в самом гипервизоре), а другая выступает интерфейсом, который пребывает в госте. Сам гость и драёверы хоста теперь взаимодействуют поверх буферов колец. Соответствующий буфер кольца получает выделение в памяти этого гостя. Теперь этот гость способен накапливать/ агрегировать данные внутри своего буфера кольца и осуществлять один гипервызов (то есть вызов к своему гипервызору, также именуемый толчком, kick) для передачи сигнала что все данные готовы опустошения. Это избегает затратных перехватов из соответствующего гостя их хостом и приводит к выигрышу в производительности.

В 2005 x86 окончательно превратились в поддерживающие виртуализацию. Они ввели ещё одно дополнительное кольцо, именуемое Кольцом -1, которое также носит название режим корня VMX (virtual machine extensions, расширения виртуальной машины). Необходимый VMM запускается в режиме корня VMX, а все гости исполняются в не корневом режиме.

Это означает, что все гости могут запускаться в Кольце 0 и, для большинства инструкций нет ловушек. Необходимые для выполнения привилегированные/ секретные инструкции исполняются через VMM в режиме корня через соответствующую ловушку. Мы называем такие переключения Выходами ВМ (VM Exits, то есть установленный VMM приступает к исполнению инструкций из соответствующего гостя) и Входами ВМ (VM Entries, соответствующая ВМ получает управление из установленного VMM).

Помимо этого, такие допускающие виртуализацию ЦПУ управляют некой структурой данных, именуемой VMCS (VM control structure, Управляющая структура ВМ) и именно она обладает состоянием соответствующей ВМ и её регистров. Надлежащий ЦПУ применяет эти сведения на протяжении Входов и Выходов в ВМ. Такая структура VMCS подобна task_struct, той структуре данных, которая применятеся для предствления процесса. Один указатель VMCS указывает на активную в данный момент VMCS. Когда имеется некое прерывание для VMM хоста, VMCS предоставляет значение состояния всех регистров этого гостя, например, причину выхода и т.п..

У аппаратной виртуализации имеются два преимущества:

Отсутствие двоичной трансляции

Никаких изменений ОС

Основная проблема заключается в том, что Входы и Выходы ВМ свё ещё тяжёлые вызовы, вовлекающие в себя большое число циклов ЦПУ, поскольку должно сохраняться и восстанавливаться полное состояние ВМ. На соуращение необходимого числа циклов на Входы и Выходы были направлены значительные усилия. Применение драйверов паравиртуализации способствкет смягчению части этих проблем с производительностью. Подробности мы поясняем в своём следующем разделе.

Виртуализация ввода/ вывода

Обычно в целом имеются две модели виртуализации ввода/ вывода:

Полная виртуализация

Паравиртуализация

Полная виртуализация

При полной виртуализации соответствующий гость не осведомлён о том, что он запущен в некотором гипервизоре и ОС этого гостя не нуждается ни в каких изменениях для запуска в гипервизоре. Всякий раз, когда такой гость выполняет вызовы ввода/ вывода, они перехватываются в его гипервизоре и сам гипервизор выполняет этот ввод/ вывод в соответствующем устройстве.

Паравиртуализация

В данном случае ОС соответствующего гостя осведомлена о том, что она запущена в виртуальной среде и для заботы о необходимом вводе/ выводе в соответствующем госте загружены специальные драйверы. Соответствующие системные вызовы для ввода/ вывода заменяются гипервызовами.

Рисунок 1-1 отображает различия между паравиртуализацией и полной виртуализацией.

Рисунок 1-1

Различие драйверов полной виртуализации и паравиртуализации

При сценарии паравиртуализации, драйверы соответствующей стороны гостя носят название драйверов интерфейса (frontend), а драйверы стороны хоста именуются драйверами сервера (backend). Для реализации драйверов паравиртуализации стандартом выступает Virtio. Соответствующие драйверы интерфейса сетевых устройств и ввода/ вывода гостевой стороны реализуются на основе стандарта Virtio и такие драйверы интерфейса осведомлены о том, что они запущены в некой виртуальной среде. Они работают в тандеме с серверными драйверами Virtio своего гипервизора. Такой механизм работы драйверов интерфейса и сервера способствуют достижению высокой производительности сетевых и дисковых операций и является основной причиной для большинства преимуществ производительности, которыми обладает паравиртуализация.

Как уже упоминалось, соответствующие драйверы интерфейса в гостях реализуют некий общий набор взаимодействий, который предписывается стандартом Virtio. Когда из соответствующего процесса в госте должен выполняться некий вызов ввода/ вывода, этот драйвер вызывает API своего драйвера интерфейса и этот драйвер передаёт пакеты данных в надлежащий драйвер сервера через виртуальную очередь (virtqueue).

Драйверы сервера могут работать двумя путями:

Они могут применять эмуляцию QEMU, что означает, что QEMU эмулирует соответствующие вызовы необходимого устройства через системные вызовы из пространства пользователя. Это подразумевает, что гипервизор позволяет программам QEMU соответствующего пространства пользователя выполнять реальные обращения к устройству.

Они могут применять механизм vhost, в то же время избегая эмуляции QEMU, вместо этого реальные вызовы устройства выполняет ядро самого гипервизора.

Как уже упоминалось, взаимодействие между драйверами интерфейса и сервера Virtio осуществляются через абстракции виртуальной очереди. Соответствующая виртуальная очередь предоставляет для взаимодействия некий API, который делает возможным заполнять очередь буфера и разгружать её. В зависимости от типа драйвера, они могут использовать ноль или более очередей. В случае сетевого драйвера он применяет две виртуальные очереди - одну очередь для запросов,а вторую для получаемых пакетов. В случае блочного драйвера Virtio, он применяет лишь одну виртуальную очередь.

Рассмотрим такой пример некого потока сетевых пакетов, когда соответствующий гость желает отправить некоторые данные через сетевую среду:

Гость выполняет инициализацию сетевого пакета, записываемого через ядро соответствующего гостя.

Соответствующие драйверы паравиртуализации (Virtio) в госте принимают эти буферы и помещают их в необходимую виртуальную очередь (tx).

Сервером этой виртуальной очереди выступает поток исполнителя и именно он получает эти буферы.

Полученные буферы затем записываются в файловый дескриптор устройства ответвления. Это устройство ответвления можно подключить к программному мосту, например, к мосту OVS или Linux.

Обратная сторона этого моста имеет некий физический интерфейс, который затем выводится через установленный физический уровень.

В данном примере, когда гость получает пакеты в очередь tx, ему требуется некий механизм информирования стороны самого хоста что у того имеются пакеты для обработки. В Linux имеется интересный механизм с названием eventfd, который применяется для уведомления своей стороны хоста, что имеются некие события. Сам хост отслеживает изменения eventfd.

Некий аналогичный механизм применяется для отправки пакетов обратно соответствующему гостю.

Как вы могли видеть в предыдущих разделах, индустрия оборудования навёрстывает пространство виртуализации и предоставляет всё больше и больше аппаратной виртуализации, будь они предназначены для ЦПУ (введение нового кольца), а также инструкций vt-x, или же в отношении памяти (расширенные таблицы страниц).

Аналогично, для виртуализации ввода/ вывода, оборудование обладает неким механизмом с названием устройства управления памятью (memory management unit, MMU) ввода/ вывода, которое аналогично устройству управления памяти ЦПУ, но предназначено лишь для основанной на вводе/ выводе памяти. Это понятие аналогично MMU ЦПУ, однако здесь доступ к памяти перехватывается и ставится в соответствие разным гостям. Гостям устанавливается соответствие к различной физической памяти и доступ контролируется самими оборудованием MMU ввода/ вывода. Это предоставляет ту изоляцию, которая необходима для доступа к устройству.

Данная функциональная возможность может применяться совместно с чем- то, имеющим название SRIOV (single root I/O virtualization, виртуализацией ввода/ вывода с единственным корнем), которая позволяет неким совместимым с SRIOV устройствам разбиваться на множество виртуальных функций. Самая основная мысль состоит в обходе самого гипервизора в пути данных и применении некого механизма проброса (pass-through), в то время как ВМ напрямую взаимодейтсвует с соответствующими устройствами. Подробности SRIOV выходят за пределы тем данной книги. Заинтересованный читатель может проследовать по приводимым ниже ссылкам для получения дополнительных сведений.

https://blog.scottlowe.org/2009/12/02/what-is-sr-iov/

https://fir3net.com/Networking/Protocols/what-is-sr-iov-single-root-i-o-virtualization.html

{Прим. пер.: также см.наш перевод главы по этой теме из книги Технология PCI Express 3.0 Майка Джексона, Рави Бадрака}.

Глава 2. Гипервизоры

Содержание

Глава 2. Гипервизоры

Набор интрукций Intel Vt-x

QEMU

Создание ВМ при помощи модуля KVM

Взаимодействие данных на основе Vhost

Что представляет собой eventfd

Альтернативные механизмы виртуализации

Unikernels

Проект Dune

novm

Итоги подходов альтернативной виртуализации

В своей предыдущей главе мы обсудили что представляет из себя виртуализация и рассмотрели основные типы виртуализации - основанную на ВМ и основанную на контейнерах. В основанной на ВМ виртуализации мы кратко обсудили собственно роль т важность имеющегося гипервизора, который содействует созданию виртуальных машин.

В этой главе мы глубже окунёмся в гипервизоры. Основная часть этой главы поясняет виртуализацию с применением таких компонентов, как Linux Kernel Virtual Machine (KVM) и Quick Emulator (QEMU). Основываясь на этих компонентах, мы далее рассмотрим как создаются ВМ и как содействует потоку данных между самим гостем и его хостами.

Linux предоставляет функциональные возможности гипервизора при помощи QEMU в соответствующем пространстве пользователей и особом модуле ядра с названием KVM (Kernel Virtual Machine Linux). Этот KVM применяет набор инструкций расширения vt-x Intel для изолирования ресурсов на соответствующем аппаратном уровне. Поскольку QEMU является процессом в пространстве пользователя, имеющееся ядро трактует его с точки зрения планирования аналогично прочим процессам.

Прежде чем мы обсудим QEMU и KVM, давайте коснёмся vt-x Intel и особенного для него набора инструкций.

Набор инструкций Intel Vt-x

Технология виртуализации Intel (VT) представляется в двух разновидностях:

Vt-x (для архитектур Intel x86 IA-32 и 64-бит)

Vt-i (для линейки процессора Itanium)

По функциональности они схожи. Чтобы понять необходимость поддержки виртуализации на уровне ЦП, давайте по быстрому рассмотрим как программы и ОС взаимодействуют с ЦПУ, а также как как с ЦПУ взаимодействуют программы в ВМ.

В случае запускаемых в хосте обычных программ его ОС транслирует инструкции программ в инструкции ЦПУ которые и выполняются самим ЦПУ.

В случае виртуальной машины для запуска программ внутри соответствующей виртуальной машины, ОС этого гостя транслирует инструкции программы в инструкции виртуального ЦПУ, а её гипервизор затем конвертирует их в инструкции для своего физического ЦПУ.

Как мы можем видеть, для ВМ такие инструкции программ транслируются дважды - инструкции программ транслируются в инструкции виртуального ЦПУ, а инструкции виртуального ЦПУ транслируются в инструкции физического ЦПУ.

Это в результате приводит к большим накладным расходам и замедляет такую виртуальную машину. Виртуализация ЦПУ, такая как функциональность vt-x, делает возможным полное абстрагирование всего мастерства ЦПУ целиком в соответствующую виртуальную машину с тем, чтобы всё такое программное обеспечение в этой ВМ могло запускаться без потерь в производительности; оно запускается так, как если бы оно исполнялось в выделенном ЦПУ.

Функциональность vt-x также разрешает ту проблему, когда инструкции архитектуры x86 не могут превращаться в виртуальные. Согласно принципу виртуализации Поупека и Голдберга, все секретные инструкции также должны быть и привилегированными. Привилегированные инструкции приводят к их перехвату в режиме пользователя. В x86 ряд инструкций являются конфиденциальными, но не привилегированными. Это означает, что их запуск из пространства пользователя может не приводить их перехвату. По сути, это означает что они не подлежат виртуализации. Неким примером такой инструкции выступает POPF.

vt-x упрощает построение программного обеспечения VMM закрывая прорехи виртуализации разработкой:

Сжатием колец: Вплоть до введения vt-x соответствующая ОС гости исполнялась бы в Кольце 1, а прикладные приложения этой гостевой ОС запускались бы в Кольце 3. Для выполнения имеющихся привилегированных инструкций в такой гостевой ОС нам бы требовались более высокие полномочия, которые по умолчанию не доступны этому гостю (по причинам безопасности). Тем самым, для исполнения таких инструкций нам требуется отлавливать их в своём гипервизоре (который запущен в Кольце 0 с большими полномочиями), который способен исполнять эти привилегированные инструкции от имеют соответствующего гостя. Это носит название сжатия колец или депривилегиризации. vt-x обходит это запуская соответствующую гостевую ОС прямо в Кольце 0.

не перехватываемые инструкции: Подобные POPF инструкции в x86, которые в идеале подлежат отлову со стороны установленного гипервизора, поскольку они являются чувствительными инструкциями, на самом деле не перехватываются. Именно это является проблемой, ибо нам необходим программируемый контроль над сдвигом в свой гипервизор всех конфиденциальных инструкций. vt-x решает это запуская соответствующую гостевую ОС в Кольце 0, в котором такие инструкции как POPF могут отлавливаться установленным гипервизором, который выполняется в Кольце -1.

Чрезмерный перехват: В отсутствии vt-x все конфиденциальные и привилегированные инструкции отлавливаются установленным в Кольце 0 гипервизоре. При помощи vt-x это становится настраиваемым и именно от VMM зависит какие инструкции вызывают перехват, а какие могут быть безопасно обработаны в Кольце 0. Подробности этого процесса выходят за рамки данной книги.

vt-x добавляет два дополнительных режима - режим вне корня (non-root, в Кольце -1), в котором выполняется VMM и режим корня (root, в Кольце 0), именно там запускается гостевая ОС.

Чтобы понять как эти два режима вовлекаются в исполнение программы, давайте рассмотрим некий пример. Скажем, какая- то программа выполняется в ВМ и, на протяжении этого исполнения, она осуществляет системный вызов для ввода/ вывода. Как это уже обсуждалось в нашей предыдущей главе, гостевые программы из пространства пользователя исполняются в Кольце 3. Когда программа делает некий вызов ввода/ вывода (который является системным вызовом), эти инструкции исполняются на уровне ядра гостевой ОС (Кольцо 0). Такая гостевая ОС сама по себе не способна обрабатывать вызовы ввода/ вывода, а потому она делегирует их установленному VMM (Кольцо -1). Когда исполнение передаётся из Кольца 0 в Кольцо -1, это носит название Выхода ВМ (VMexit), а когда соответствующее выполнение возвращается обратно из Кольца -1 в Кольцо 0, это именуется ВМ входом (VMEntry). Это отображено на Рисунке 2-1.

Рисунок 2-1

Исполнение программы в госте с неким вызовом ввода/ вывода

[Замечание]
Замечание

Прежде чем мы углубимся в QEMM, в качестве пометки на полях, мы бы хотели привлечь ваше внимание к неким интересным проектам в виртуализации, таким как Dune, которые запускают некий процесс внутри среды собственно ВМ, вместо некой завершённой ОС. В режиме корня именно VMM запускает это. Именно в этом режиме запускается KVM.

QEMU

QEMU запускается как процесс пользователя и обрабатывается имеющимся модулем ядра KVM. Он применяет расширения vt-x для предоставления своему гостю некой изолированной среды с точки зрения памяти и ЦПУ. Этот процесс QEMU владеет оперативной памятью своего гостя и либо отображается в памяти через файл, либо является анонимным. Виртуальные ЦПУ планируются на имеющихся физических ЦПУ.

Самое основное отличие между обычным процессом и процессом QEMU является тот код, который выполняется в этих потоках (thread). В случае своего гостя, поскольку он является соответствующей виртуальной машиной, его код выполняет программный BIOS и соответствующая операционная система.

Рисунок 2-2 отображает как QEMU взаимодействует со своим гипервизором.

Рисунок 2-2

Взаимодействие QEMU с гипервизором

QEMU также выделяет некий обособленный поток для ввода/ вывода. Этот поток запускает некий цикл событий на основе не блокируемого механизма Он регистрирует необходимые файловые дескрипторы для ввода, вывода. Для снабжения гостей устройствами virtio, QEMU может пользоваться такими паравиртуальными драйверами, как virtio, например, virtio-blk для блочных устройств и virtio-net для сетевых устройств. Рисунок 2-3 показывает те специфические компоненты, которые обеспечивают взаимодействие между соответствующим гостем и его хостом (гипервизором).

Рисунок 2-3

Как некое виртуальное устройтсво из гостевой ОС взаимодействует со своим физическим устройством на уровне своего гипервизора. Такой гость обладает неким интерфейсным драйвером устройства, в то время как его хост обладает каким- то серверным драйвером устройства и они оба совместно обеспечивают взаимодействие между ОС гостя и хоста.

На Рисунке 2-3 вы видите, что соответствующий гость внутри имеющегося процесса QEMU реализует свой драйвер интерфейса, в то время как его хост реализует необходимые серверные драйверы. Всё взаимодействие между драйверами интерфейса и сервера происходит через особые структуры данных с названием виртуальные очереди virtqueues. Все возникающие у соответствующего гостя пакеты сначала помещаются в такую виртуальную очередь драйвер стороны его хоста получает оповещение через некий гипервызов для опустошения соответствующего пакета под его реальную обработку в надлежащем устройстве. Может присутствовать два варианта потока таких пакетов, а именно:

Пакеты из соответствующего гостя получаются установленным QEMUи затем выполняется их активная доставка в надлежащий драйвер сервера в самом хосте. Одним из примеров является virtio-net.

Соответствующий пакет из своего гостя напрямую появляется в их хосте через то, что носит название драйвера vhost. Таким образом обходится уровень QEMU и он относительно быстрее.

Создание ВМ при помощи модуля KVM

Для создания некой ВМ, надлежит выполнить некий набор вызовов ioctl в имеющийся модуль ядра KVM, который выставляет для такого гостя некое устройство /dev/kvm. Упрощённо говоря, это именно те вызовы из пространства пользователя, которые создают и запускают ВМ:

KVM CREATE VM: Данная команда создаёт некую новую ВМ, которая не имеет никаких виртуальных ЦПУ и никакой памяти.

KVM SET USER MEMORY REGION: Эта команда устанавливает соответствие для необходимой памяти пространства пользователя под создаваемую ВМ.

KVM CREATE IRQCHIP / KVM CREATE VCPU Такая команда создаёт некий аппаратный компонент, такой как виртуальный ЦПУ и устанавливает его соответствие с функциональными возможностями vt-x.

KVM SET REGS / SREGS / KVM SET FPU / KVM SET CPUID / KVM SET MSRS / KVM SET VCPU EVENTS / KVM SET LAPIC: Эти команды выполняют настройку оборудования.

KVM RUN: Данная команда запускает созданную ВМ.

KVM RUN запускает созданную ВМ и внутренне это соответствующая инструкция VMLaunch, вызываемая установленным модулем ядра KVM, который помещает необходимый код исполнения этой ВМ в не корневой режим. Это всё слегка чуть упрощённо, поскольку этот модуль выполняет намного больше для настройки соответствующей ВМ, в том числе установку необходимого VMCS (VM Control Section, Управляющего раздела ВМ) и тому подобного.

Взаимодействие данных на основе Vhost

Любые рассуждения относительно гипервизоров были бы несовершенными без демонстрации конкретного примера. Вы рассмотрим некий пример потока сетевых пакетов (изображённый на Рисунке 2-4) в контексте соответствующих драйверов устройств vhost-net. Когда мы применяем механизм vhost, соответствующий QEMM уходит с переднего плана данных и между конкретным гостем и хостом имеется непосредственное взаимодействие. QEMM остаётся в плоскости управления, где он настраивает устройство vhost в самом ядре при помощи команды ioctl:

/dev/vhost-net device

Когда это устройство проинициализировно, для этого конкретного процесса QEMM создаётся некий поток ядра. Этот поток обрабатывает соответствующий ввод/ вывод для этого конкретного гостя. Этот поток ожидает сообщений на стороне самого хоста, в соответствующей виртуальной очереди. Когда возникает некое событие для опустошения данных (в терминологии virtio это носит название толчка, kick), этот поток ввода/ вывода потребляет соответствующий пакет из очереди tx (передачи) своего гостя. Этот поток затем передаёт эти данные в соответствующее устройство ответвления, которое делает его доступным для лежащего в основе моста/ коммутатора для передачи его вниз по потоку в механизм перекрытия (оверлея) или маршрутизации.

Установленный модуль ядра KVM для такого гостя регистрирует соответствующий eventfd. Это файловый дескриптор, который регистрируется для этого гостя (со стороны QEMU) при помощи модуля ядра KVM. Этот FD регистрируется для события выхода из гостевого ввода/ вывода (толчка), которое опустошает эти данные.

Что представляет собой eventfd

Итак, что же такое eventfd? Это механизм взаимодействия между процессами (IPC, interprocess communication), который предлагает механизм ожидания- уведомления между программами пространства пользователя или между самим ядром и имеющимся пространством пользователя. Основная идея проста. Точно так же как мы имеем FD для файлов, мы можем создавать файловые дескрипторы для событий. Основное преимущество здесь состоит в том, что такой FD может затем трактоваться как и прочие FD и способен регистрироваться при помощи таких механизмов как poll, select и epoll. Эти механизмы могут затем обеспечивать систему уведомлений после записи в эти FD.

Соответствующий потребляющий поток может быть выполнен для ожидания в неком объекте epoll через epoll_wait. После того как производящий поток выполняет запись в надлежащем FD, обсуждаемый механизм epoll оповестит своего потребителя (снова в зависимости от переключаемого фронтом или уровнем) соответствующего события.

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

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

Сам хост использует eventfd применяя ioeventfd для отправки данных из соответствующего гостя и irqfd для получения некого прерывания из своего хоста в этого гостя.

Другим вариантом применения eventfd выступает нехватка памяти (OOM, out of memory) cgroup. Это работает следующим образом: всякий раз, когда соответствующий процесс превосходит установленный предел memcg, уничтожитель OOM может решать уничтожать ли его, или, если такое поведение отключено, его ядро может выполнить следующее:

Создать необходимый eventfd.

Записать соответствующее событие OOM в этот eventfd.

Поток этого процесса будет заблокирован до тех пор пока не будет сгенерировано такое событие. После выработки такого события, этот поток пробуждается для реакции на такое уведомление OOM.

Основное отличие между eventfd и конвейером Linux является то, что для конвейера требуются два файловых дескриптора, в то время как для eventfd нужен один.

Соответствующий поток ввода/ вывода vhost отслеживает такой eventfd. Всякий раз, когда в соответствующем госте происходит событие ввода/ вывода, уведомляется надлежащий поток ввода/ вывода что ему надлежит опустошить свой буфер из очереди tx.

Аналогично ioeventfd имеется и некий irqfd. Установленный QEMM пространства пользователя также регистрирует этот FD (irqfd) для своего гостя. Драйвер этого гостя ожидает изменений в таких FD. Основная причина их применения состоит в передачи прерываний обратно в соответствующего гостя для уведомления драйвера стороны этого гостя для обработки своих пакетов. Возвращаясь к предыдущему примеру, когда такие пакеты следует отправлять обратно соответствующему гостю, поток ввода/ вывода заполняет буферы своей очереди rx (очередь получения, receive) для гостя и вставка необходимого прерывания выполняется в гостя через irqfd. На обратном пути потока пакетов, эти получаемые самим хостом через имеющийся физический интерфейс пакеты помещаются в соответствующее ответвляющее устройство. Тот поток, который взаимодействует с соответствующим ответвляющем устройстве получает пакеты для заполнения буферов rx для своего гостя. Затем он уведомляет драйвер этого гостя через irqfd. Смотрите на Рисунок 2-4.

Рисунок 2-4

Поток сетевых пакетов

Альтернативные механизмы виртуализации

После тоо как мы рассмотрели виртуализацию через механизмы на основе ВМ, настало время вкратце взглянуть на прочие возможности виртуализации, которые выходят за рамки изоляции контейнера, такие как механизмы на основе пространства имён/ cgroup, которые мы имеем в Docker. Мотивы чтобы разобраться с тем как это можно сделать таковы:

Снижение взаимодействий, выставляемых различными уровнями, такими как VMM для снижения направлений атак. Такие направления атак могут иметь форму применения в своих интересах, например, использования памяти, когда устанавливается вредоносное программное обеспечение или контроль над всей системой путём повышения полномочий.

Применение аппаратных средств изоляции для изолирования различных запускаемых нами контейнеров/ процессов.

Суммируя, можем ли мы получать уровни изолированности ВМ при снижении или минимизации выставляемых машинных интерфейсов с предоставлением скорости, аналогичной имеющейся у контейнеров.

Мы уже обсуждали как ВМ при помощи VMM, изолируют такие рабочие потоки. Этот VMM выставляет модель машины (интерфейс x86), в то время как контейнер выставляет интерфейс POSIX. Устанавливаемый VMM, при помощи аппаратной виртуализации, способен изолировать ЦПУ, память и ввод/ вывод (vt-d, SRIOV и IOMMU). Разделяющие своё ядро контейнеры предоставляют эту функциональность через пространства имён и cgroup, но всё ещё рассматриваются как более слабая альтернатива технологиям изоляции на основе оборудования.

Итак, имеется ли некий способ сблизит эти два понятия? Одна из основных целей будет состоять в снижении вектора атак при использовании подхода минимизации взаимодействия. Что это означает, так это то, что вместо выставления в прикладные приложения полного POSIX или полного машинного интерфейса в соответствующую гостевую ОС, мы предоставляем лишь то, что необходимо такому прикладному приложению/ ОС. Именно здесь мы начинаем видеть ту эволюцию, при которой начали появляться юникёрны и библиотечные ОС.

Unikernels

Юникёрны (Unikernel) предоставляют свой механизм, через необходимый набор инструментария, для подготовки какой- то минимальной ОС. Это означает, что если её приложению требуется лишь сетеые API, тогда устройства клавиатуры, мыши и их драйверы не вкллючаются в пакет. Это значительно снижает вектор атак.

Одной из основных проблем для юникёрнов является то, что их приходится собирать для различных моделей драйверов устройств. С появлением виртуализации ввода/ вывода и драйверов virtio эта задача до некоторой степени решена, поскольку теперь юникёрны могут собираться в точности с теми устройствами virtio и их драйверами, необходимыми таким прикладным приложениям в соответствующем госте. Это означает, что такой гость может быть неким юникёрном (Библиотечной ОС), сидящим поверх, допустим, какого- то гипервизора, например, KVM. Это всё ещё имеет ограничения, поскольку QEMU или часть пространства пользователя всё ещё имеет порядочный объём базового кода, причём он весь является предметом применения.

Для достижения дальнейшей минимизации, одним из предложений было упаковка самого VMM вместе с его юникёрном. Такой код VMM ограничен до необходимой функциональности и содействует взаимодействию на основе памяти между самим гостем и его VMM. При такой модели в основном гипервизоре может располагаться множество VMM. Основная роль VMM состоит в обеспечении ввода/ вывода и создании гостевого юникёрна при помощи возможностей аппаратной изоляции.

Сам по себе юникёрн представляет некий отдельный процесс с возможностью множества потоков, как это показано на Рисунке 2-5.

Рисунок 2-5

Юникёрн это отдельный процесс с возможностями множества потоков

На Рисунке 2-5 мы можем наблюдать что на изображении слева запущена комбинация VMM и QEMU для исполнения поверх юникёрнов, в то время как изображение справа показывает VMM (монитор), такой как UKVM, упакованный вместе с его юникёрном. Итак, в целом мы имеем снижение необходимого кода (соответствующего QEMU) и тем самым получаем устранение значительного вектора атак. Именно это соответствует подходу с минимальным интерфейсом, о котором мы говорили ранее.

Проект Dune

Внимательный читатель легко поймёт, что изоляция vt-x в основной памяти и ЦПУ не предполагает в его гостевой памяти только кода гостевой ОС. Технически мы можем предоставлять различные механизмы песочницы поверх такой аппаратной изоляции. Именно этим и занимается Проект Dune. Помимо аппаратной изоляции vt-x, Dune раскручивает не некую гостевую ОС, а процесс Linux. Это означает, что такой процесс запускается в Кольце 0 своего ЦПУ и обладает доступ к выставляемому в него машинному интерфейсу. Такой процесс можно поместить в песочнице через:

Запуск доверенного кода такого процесса в Кольце 0. Именно он в основном та библиотека, которая в Dune именуется libdune.

Запуск не доверенного кода в Кольце 3.

Архитектура Dune показана на Рисунке 2-6.

Рисунок 2-6

Архитектура Dune

Для самораскрутки своего процесса Dune создаёт некую операционную среду, которая влечёт за собой установку необходимых таблиц страниц (значение регистра CR3 указывает на значение корня). Она также устанавливает значение IDT для соответствующих исключительных ситуаций оборудования. И доверенный и не обладающий доверенностью код работают в одном и том же адресном пространстве, в то время как страницы таблиц доверенного кода защищаются битами супервизора в записях таблицы страниц. Необходимые ловушки системных вызовов в том же самом процессе вставляются при помощи гипервызовов в соответствующий VMM. Для получения дополнительных сведений по Dune обратитесь к http://dune.scs.stanford.edu/.

novm

novm это другой тип аппаратного контейнера применяемого в этом проекте. (Он также применяет API KVM для создания соответствующей ВМ путём применения соответствующего файла устройства /dev/kvm.) Вместо предоставления какого- то дискового интерфейса в эту ВМ, novm представляет в свою ВМ интерфейс некой файловой системы (9p). Это делает возможным упаковку того программного обеспечения, которое мы пожелаем для предоставления в качестве некого контейнера. Нет никакого BIOS и необходимый VMM просто помещается в такую ВМ в 32- битном защищённом режиме напрямую. Это делает такой процесс предоставления более быстрым, потому как не требуются такие шаги как зондирование устройства.

Итоги подходов альтернативной виртуализации

Таким образом, данная глава рассмотрела три подхода: один подход содержит юникёрнел с минимальным интерфейсом ОС, второй подход избавляется от взаиможействия ОС и запускает процесс напрямую в Кольце 0, а третий подход вместо этого предоставляет некую файловую систему в соответствующую ВМ вместо блочных устройств напрямую и оптимизированные вопросы запуска.

Эти подходы предоставляют хорошую изоляцию на аппаратном уровне и очень быстрое время раскрутки и могут отлично соответствовать для запуска без серверных рабочих нагрузок и прочих облачных рабочих потоков.

Это всё? Естественно нет. Теперь у нас имеются такие компании как Cloudflare и Fastly, которые пытаются решить задачи виртуализации предлагая изоляцию внутри некого процесса. Основное намерение состоит в том, чтобы воспользоваться возможностями определённых языков программирования чтобы получить:

Изоляцию потока кода через управление целостностью потока

Изоляцию памяти

Безопасновть на основе возможностей

Далее мы можем применять эти примитивы для сборки песочниц внутри каждого из процессов. Таким образом мы способны получать даже более быстрые времена запуска необходимого кода, который мы желаем выполнить.

В этом пространстве лидером выступает WebAssembly. Основная идея состоит в запуске WebAssembly, кратко модулей Wasm, внутри того же самого процесса (среда времени исполнения WASM). Все модули изолированы друг от друга, а потому мы получаем по одной песочнице для арендатора. Это отлично соответствует парадигмам безсерверного вычисления и возможно предотвращает такие проблемы как запуск по холодному.

Стоит отметить, что имеется новая функциональная возможность с названием способности подключения в горячем режиме, которая делает доступными динамически необходимые устройства для соответствующих гостей. Это позволяет разработчикам, например, динамически изменять размеры их блочных устройств без перезапуска соответствующего гостя. Также имеется модуль hotplug-dimm, который позволяет разработчикам изменять размер доступной соответствующему гостю оперативной памяти.

Глава 3. Пространства имён

Содержание

Глава 3. Пространства имён

Типы пространств имён

UTS

PID

Mount

Network

IPC

Cgroup

Time

Пространства имён Linux

Добавление устройства в Пространство имён

Выводы

В этой главе мы затронем важные стороны контейнеров Linux,носящих название пространства имён Linux. Пространства имён позволяют нашему ядру предоставлять изоляцию через ограничение видимости соответствующих ресурсов ядра, например точек монтирования, сетевых подсистем между процессами, относящихся к различным пространствам имён. В качестве примеров таких зон видимости пространств имён выступают точки монтирования и сетевые подсистемы.

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

Имеющаяся виртуализация на основе ВМ эмулирует установленное оборудование и в качестве некого уровня абстракции предоставляет некую ОС. Это означает, что в качестве такого представления загружается много кода ОС и драйверов устройств. С другой стороны, контейнеры виртуализируют имеющуюся ОС саму по себе. Это означает, что имеются структуры данных внутри самого ядра, которые способствуют такому разделению. В большинстве случаев мы не представляем сто происходит за этими покровами.

Контейнеры Linux создаются из трёх основных примитивов ядра Linux:

Пространств имён Linux

cgroups

Многоуровневых файловых систем

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

Так как может иметься множество приложений пространства пользователя, которые выполняются одновременно в единственном ядре Linux, нам требуется некий способ предоставления изоляции между этими приложениями пространства пользователя. В качестве изоляции мы подразумеваем, что не существует никакого вида песочницы определённого индивидуального приложения с тем, чтобы определённые ресурсы в этом приложении были бы ограничены такой песочницей. Например, мы бы желали иметь некую песочницу файловой системы, что означало бы что внутри такой песочницы мы бы обладали своим собственным представлением этих файлов. Тем самым, было бы возможно запускать под тем же самым ядром Linux без их взаимодействия друг с другом множество подобных песочниц.

Та техника, за счёт которой достигаются подобные песочницы посредством особых структур данных в соответствующем ядре Linux, носит название пространства имён (namespace).

Типы пространств имён

В этом разделе мы поясним различные имеющиеся внутри ядра Linux пространства имён и обсудим как они реализуются внутри самого ядра.

UTS

Это пространство имён позволяет неком прцессу наблюдать какое- то отдельное имя хоста, отличающееся от реального имеющегося глобального пространства имён.

PID

Все процессы внутри этого пространства имён PID обладают различными деревьями процессов. Они имеют некий процесс init с PID 1. Однако, на уровне установленной структуры данных, такой процесс относится к одному глобальному дереву процессов, который видим только с уровня самого хоста. Такие инструменты как ps или непосредственное применение файловой системы /proc изнутри соответствующего пространства имён перечислят лишь те процессы и относящиеся к ним ресурсы, которые относятся к соответствующему дереву процессов внутри рассматриваемого пространства имён.

Mount

Это одно из наиболее важных пространств имён. Оно контролирует какие точки монтирования должен видеть некий процесс. Когда некий процесс пребывает внутри какого- то пространства имён, он способен видеть только точки монтирования из этого пространства.

небольшой обход может оказаться полезным для пояснения того, как работает распространение монтирований в контейнерах. Некое монтирование в своём ядре представляется структурой данных с названием vfsmount. Все монтирования формируют деревоподобную структуры с некой дочерней структурой монтирования, удерживающей ссылку на свою родительскую структуру монтирования.

struct vfsmount {
      struct list_head mnt_hash;
      struct vfsmount *mnt_parent;    /* fs в которой мы выполняем монтирование */
      struct dentry *mnt_mountpoint;  /* dentry точки монтирования */
      struct dentry *mnt_root;        /* корень монтируемого дерева*/
      struct super_block *mnt_sb;     /* указатель на суперблок */
      struct list_head mnt_mounts;    /* список потомков,
                                         закреплённых здесь */
      struct list_head mnt_child;     /* и проходящих через их
                                         mnt_child */
      atomic_t mnt_count;
      int mnt_flags;
      char *mnt_devname;              /* название устройства, т.е.
                                         /dev/dsk/hda1 */
      struct list_head mnt_list;
};

При каждом осуществлении операции монтирования создаётся некая структура vfsmount и заполняются dentry этой точки монтирования, а также dentry самого дерева монтирования. Некая dentry представляет собой структуру данных, которая устанавливает соответствие значения inode его файловому имени.

Помимо монтирования имеется некая привязка монтирования, которая позволяет каталогу (вместо устройства) быть смонтированным в точке монтирования. Такой процесс привязки монтирования имеет результатом создание структуры vfsmount, которая указывает на значение dentry этого каталога.

Контейнеры работают по принципу привязки монтирований. Поэтому, когда для некого контейнера создаётся том, в действительности это некая привязка монтирования какого- то каталога внутри его хоста к точке монтирования внутри файловой системы такого контейнера. Так как это монтирование происходит внутри надлежащего пространства имён, необходимая структура vfsmount пребывает в сфере пространства имён этого монтирования. Это означает, что создавая некую привязку монтирования каталога мы способны выставлять некий том внутри того пространства имйн,которое содержит этот контейнер.

Network

Сетевое пространство имён предоставляет контейнеру некий набор сетевых подсистем. Это подразумевает, что соответствующий процесс внутри этого сетевого пространства имён будет видеть различные сетевые интерфейсы, маршруты и ip таблицы. Оно отделяет сетевые ресурсы своего контейнера от сетевых ресурсов его хоста. Мы изучим его более подробно при рассмотрении примера потока пакетов между двумя контейнерами из одного и того же пространства имён в одном и том же хосте, а также между контейнерами из различных пространств имён в одном и том же хосте.

IPC

Это пространство определяет рамки построений IPC, например, очередей сообщений POSIX. IPC допускается между двумя процессами в пределах одного пространства имён, но оно будет ограниченным когда два процесса из двух различных пространств имён попытаются взаимодействовать через IPC.

Cgroup

Это пространство имён ограничивает зону видимости значений cgroup файловой системы на значение той cgroup, к которой относится данный процесс. Без данного ограничения процесс имел бы возможность заглядывать в установленные глобальные cgroup через установленную иерархию /proc/self/cgroup. Это пространство имён действенно создаёт виртуальную среду самой по себе данной cgroup.

Помимо упомянутых здесь пространств имён, на момент написания данных строк в сообществе Linux обсуждается еще одно пространство имён с названием пространства имён time.

Time

Пространство имён time имеет два главных варианта применения:

Изменяет значение даты и времени внутри контейнера

Выравнивает значение часов для восстанавливаемого из некой контрольной точки контейнера

Имеющееся ядро предоставляет доступ к различным часам: CLOCK_REALTIME, CLOCK_MONOTONIC и CLOCK_BOOTTIME. Последние двое часов являются монотонными, но отправные точки для них не определяются чётко (в настоящее время это время запуска системы, однако POSIX утверждает "с неустановленного момента в прошлом") и различаются для каждой системы. Когда некий контейнер мигрирует из одного узла в другой, все значения часов восстанавливаются в их непротиворечивые состояния. Иначе говоря, они продолжают идти с того самого момента, когда они были сброшены.

Пространства имён Linux

Теперь, когда вы получили базовое представление относительно пространств имён, мы можем изучить подробности относительно того как установленные в самом ядре Linux структуры данных делают возможным такое разделение, когда речь заходит о контейнерах. Для таких структур данных применяется термин пространств имён Linux.

Само ядро представляет всякий процесс в виде структуры данных task_struct. Когда мы подробнее рассмотрим эту структуру данных и перечисли некоторых из её участников, мы обнаружим следующее:

/* Предварительное задание участников task_struct (сортированы в алфавитном порядке): */
struct ;audit_context;
struct ;backing_dev_info;
struct ;bio_list;
struct ;blk_plug;
struct ;capture_control;
struct ;cfs_rq;
struct ;fs_struct;
struct ;futex_pi_state;
struct ;io_context;
struct ;mempolicy;
struct ;nameidata;
struct ;nsproxy;
struct ;perf_event_context;
struct ;pid_namespace;
struct ;pipe_inode_info;
struct ;rcu_node;
struct ;reclaim_state;
struct ;robust_list_head;
struct ;root_domain;
struct ;rq;
struct ;sched_attr;
struct ;sched_param;
struct ;seq_file;
struct ;sighand_struct;
struct ;signal_struct;
struct ;task_delay_info;
struct ;task_group;

Структурой, удерживающей различные пространства имён, которые относятся к такой задаче упроцесса) выступает nsproxy.

struct nsproxy {
       atomic_t count;
       struct uts_namespace *uts_ns;
       struct ipc_namespace *ipc_ns;
       struct mnt_namespace *mnt_ns;
       struct pid_namespace *pid_ns_for_children;
       struct net           *net_ns;
       struct time_namespace *time_ns;
       struct time_namespace *time_ns_for_children;
       struct cgroup_namespace *cgroup_ns;
};
extern struct nsproxy init_nsproxy;

Приведённая nsproxy содержит восемь структур данных пространств имён. Одной пропущенной является пространство имён самого пользователя, которое является частью структуры данных cred в соответствующей task_struct.

Для помещения задач в конкретные пространства имён имеются три системных вызова. Это clone, unshare и setns. Вызовы clone и setns получают в результате создание объекта nsproxy с последующим добавлением особого пространства имён, необходимого для такой задачи.

Мы обсудим в этой главе сетевое пространство имён. Сетевое пространство имён представлено структурой net. Здесь показана часть этой структуры данных:

struct net {
       /* First cache line can be often dirtied.
        * Do not place read-mostly fields here.
        */
       refcount_t           passive;       /* Для решения когда это сетевое
                                           * пространство имён надлежит освободить.
                                           */

       refcount_t           count;        /* Для решения когда это сетевое
                                           *  пространство имён надлежит отключить.
                                           */
       spinlock_t           rules_mod_lock;

       unsigned int         dev_unreg_count;

       unsigned int         dev_base_seq;   /* защищено через rtnl_mutex */
       int                  ifindex;
       spinlock_t          nsid_lock;
       atomic_t            fnhe_genid;

       struct list_head    list;            /* список сетевых пространств имён */
       struct list_head    exit_list;       /* Для привязки вызова выхода из каждой сети
                                            * методы при уничтожении net (
                                            * pernet_ops_rwsem read locked),
                                            * или операций для отмены регистрации каждой сети
                                            * (pernet_ops_rwsem write locked).
                                            */
       struct llist_node   cleanup_list;   /* пространства имён в уничтожаемой строке */

#ifdef CONFIG_KEYS
       struct key_tag          *key_domain; /* Ключ домена тега операции */
#endif
       struct user_namespace   *user_ns;    /* Пространство имён пользователя владельца */
       struct ucounts           *ucounts;
       struct idr               netns_ids;

       struct ns_common    ns;

       struct list_head    dev_base_head;
       struct proc_dir_entry    *proc_net;
       struct proc_dir_entry    *proc_net_stat;

#ifdef CONFIG_SYSCTL
       struct ctl_table_set      sysctls;
#endif

       struct sock            *rtnl;        /* rtnetlink socket */
       struct sock            *genl_sock;

       struct uevent_sock     *uevent_sock;  /* uevent socket */

       struct hlist_head      *dev_name_head;
       struct hlist_head      *dev_index_head;
       struct raw_notifier_head     netdev_chain;

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

struct netns_ipv4 {
#ifdef CONFIG_SYSCTL
      struct ctl_table_header    *forw_hdr;
      struct ctl_table_header    *frags_hdr;
      struct ctl_table_header    *ipv4_hdr;
      struct ctl_table_header    *route_hdr;
      struct ctl_table_header    *xfrm4_hdr;
#endif
      struct ipv4_devconf        *devconf_all;
      struct ipv4_devconf        *devconf_dflt;
      struct ip_ra_chain __rcu *ra_chain;
      struct mutex         ra_mutex;
#ifdef CONFIG_IP_MULTIPLE_TABLES
      struct fib_rules_ops  *rules_ops;
      bool                       fib_has_custom_rules;
      unsigned int               fib_rules_require_fldissect;
      struct fib_table __rcu    *fib_main;
      struct fib_table __rcu    *fib_default;
#endif
      bool                  fib_has_custom_local_routes;
#ifdef CONFIG_IP_ROUTE_CLASSID
      Int                   fib_num_tclassid_users;
#endif

      struct hlist_head    *fib_table_hash;
      bool                  fib_offload_disabled;
      struct sock          *fibnl;

      struct sock  * __percpu    *icmp_sk;
      struct sock         *mc_autojoin_sk;

      struct inet_peer_base      *peers;
      struct sock  * __percpu    *tcp_sk;
      struct fqdir         *fqdir;
#ifdef CONFIG_NETFILTER
      struct xt_table      *iptable_filter;
      struct xt_table      *iptable_mangle;
      struct xt_table      *iptable_raw;
      struct xt_table      *arptable_filter;
#ifdef CONFIG_SECURITY
      struct xt_table      *iptable_security;
#endif
      struct xt_table      *nat_table;
#endif

      int sysctl_icmp_echo_ignore_all;
      int sysctl_icmp_echo_ignore_broadcasts;
      int sysctl_icmp_ignore_bogus_error_responses;
      int sysctl_icmp_ratelimit;
      int sysctl_icmp_ratemask;
      int sysctl_icmp_errors_use_inbound_ifaddr;

      struct local_ports ip_local_ports;

      int sysctl_tcp_ecn;
      int sysctl_tcp_ecn_fallback;

      int sysctl_ip_default_ttl;
      int sysctl_ip_no_pmtu_disc;
      int sysctl_ip_fwd_use_pmtu;
      int sysctl_ip_fwd_update_priority;
      int sysctl_ip_nonlocal_bind;
      int sysctl_ip_autobind_reuse;
      /* Следует ли нам разрушать выводимые пакеты при изменении устройства маршрута? */
      int sysctl_ip_dynaddr;

Именно таким образом все необходимые ip таблицы и правила маршрутизации помещаются в сферу действия соответствующего сетевого пространства имён.

Другими относящимися здесь к делу структурами данных выступают net_device (именно таким образом само ядро представляет соответствующую карту/ устройство) и sock (представляемая ядром структура данных сокета). Эти две структуры позволяют самому устройству входить в сферу действия сетевого пространства имён, а также и соответствующему сокету быть представленным в этом пространстве имён. Обе эти структуры могут быть в один и тот же момент времени быть частью только одного пространства имён. При помощи утилиты iproute2 мы способны перемещать такое устройство в другие пространства имён.

Вот некоторые команды пространства пользователя для обработки имеющихся сетевых пространств имён:

Ip netns add testns: Добавляет сетевое пространство имён

Ip netns del testns: Удаляет обозначенное пространство имён

Ip netns exec testns sh: Исполняет оболочку внутри указанного testns пространства имён

Добавление устройства в Пространство имён

Прежде всего создадим пару устройств veth (эти устройства могут применяться для соединения двух пространств имён):

ip link add veth0 type veth peer name veth1

Затем добавим один конец созданной пары veth в сетевое пространство имён testns:

ip link set veth1 netns testns

Другой конец (veth0) пребывает в пространстве имён самого хоста и потому весь отправляемый в veth0 обмен завершается в veth1 из пространства имён testns.

Допустим мы запускаем некий HTTP сервер в пространстве имён testns, что означает, что сокет ожидания относится к сфере действий пространства имён testns, как это пояснялось ранее в нашей структуре данных sock. Поэтому подлежащий доставке в соответствующие IP и порт приложения внутри пространства имён testns пакет TCP будет доставлен в необходимый сокет, относящийся к сфере действий данного пространства имён.

Именно таким образом имеющееся ядро выполняет виртуализацию самой операционной системы и различных подсистем, таких как сетевая среда, IPC, монтирования и тому подобное.

Выводы

В этой главе мы изучили пространства имён Linux и то как они обеспечивают изоляцию между приложениями на основе пространства пользователя. Мы также заглянули в различные структуры данных Linux, лежащих в основе ядра и применяемых для реализации пространств имён. Пройдя далее, мы рассмотрим как ядро Linux предоставляет пределы ресурсов для различных процессов пространства пользователя с тем, чтобы один процесс не пожирал все ресурсы своей операционной системы.

Глава 4. Cgroups

Содержание

Глава 4. Cgroups

Создание простой cgroup

Типы Cgroup

Cgroup CPU

Cgroup блочного ввода/ вывода

Понимание Fairness

Понимание Throttling

В предыдущей главе мы изучили как кправлять видимостью процессов Linux применяя пространства имён и рассмотрели как они реализуются внутри самого ядра. В этой главе мы коснёмся другой важной стороны - контроля ресурсов - который позволяет нам применять квоты к различным ресурсам операционной системы.

Мы изучили пространства имён, поэтому мы можем ограничивать имеющуюся видимость ресурсов для процессов, что мы выполняем помещая такие процессы в обособленные пространства имён. Мы также рассмотрели вовлекаемые в это из ядра структуры данных чтобы разобраться как реализуется внутри своего ядра Linux некое пространство имён.

Теперь задайте себе вопрос, вполне ли достаточно для виртуализации ограничения видимости или же требуется нечто ешё. Допустим, мы запустили процессы tenant1 в одном пространстве имён и процессы tenant2 в отдельном пространстве имён. Хотя эти процессы и не имеют возможности доступа к ресурсам друг друга (точкам монтирования, деревьям процессов и тому подобному), поскольку такие ресурсы относятся к сфере действий своих персональных пространств имён, мы не достигаем истинной изоляции только через такие сферы.

например, что воспрепятствует tenant1 от запуска некого процесса, который способен сожрать весь ЦПУ в неком бесконечном цикле? Неудачный код может оставлять утечки памяти (скажем, к примеру, он потребляет громадный кусок страниц кэша своей ОС). Неверно ведущий себя процесс может создавать тонны процессов через ветвления, запуская некую бомбу ветвлений и сокрушая своё ядро.

Это означает, что нам требуется ввести некий способ контроля ресурсов для процессов в пределах заданного пространства имён. Это достигается через механизм, носящий название групп контроля (control groups), обычно именуемых cgroups. cgroups работают под понятием контроллеров cgroup и представляются в файловой системе с названием cgroupfs в самом ядре Linux.

В настоящее время применяется cgroup v2 версия cgroups. Мы изучим некоторые подробности того, как работают cgroups, а также некоторые контроллеры cgroup, которые можно обнаружить в коде ядра. Мы также рассмотрим как cgroups реализуются внутри ядра Linux. Но перед этим вкратце рассмотрим что являют собой cgroups.

Прежде всего, для применения cgroup нам требуется смонтировать соответствующую файловую систему cgroup в некой точке монтирования следующим образом:

mount -t cgroup2 none $MOUNT_POINT

Основное отличие между cgroup версий v1 и v2 состоит в том, что при монтировании в v1 нам приходится определять параметры самого монтирования для задания включённых необходимых контроллеров, в то время как в cgroup v2 не могут передаваться никакие параметры.

Создание простой cgroup

Давайте создадим некий образец cgroup с названием mygrp. Для создания cgroup нам вначале требуется создать некую папку, в которой будут храниться артефакты cgroup следующим образом:

mkdir mygrp

Теперь мы можем создать некую cgroup при помощи приводимой ниже команды (Обратите внимание: cgroup2 поддерживается в ядре с версией 4.12.0-rc5 и далее. Я работаю в Ubuntu 19.04, которая имеет ядро версии Ubuntu 19.04 с ядром Linux 5.0.0-13.)

mount -t cgroup2 none mygrp

Рисунок 4-1

Мы создали каталог с названием mygrp, затем смонтировали в нём файловую систему cgroup v2. При входе вовнутрь этого каталога mygrp мы можем наблюдать там множество файлов:

cgroup.controllers: Этот файл содержит все поддерживаемые контроллеры. Появятся все контроллеры, которые не смонтированы в cgroup v1. В настоящий момент в своей системе я имею cgroup v1, смонтированный через systemd. Мы можем видеть, что все контроллеры пребывают там.

Рисунок 4-2

Только после размонтирования этих контроллеров из v1 v2 должна показать эти контроллеры. Иногда нам требуется добавить параметр запуска ядра systemd.unified_cgroup_hierarchy=1 и перезапустить само ядро для ввода в действие этих изменений. После внесения этих изменений в своей машине я вижу такие контроллеры:

Рисунок 4-3

Cgroup.procs: Этот файл содержит значения процессов внутри корня cgroup. Когда такая cgroup только что создана, там не будет никаких PID. Записывая соответствующие PID в этот файл они превращаются в часть данной cgroup.

Cgroup.subtree_control: Содержит контроллеры, которые включены для имеющейся промежуточной подгруппы.

Включая и отключая контроллеры в этой промежуточной подгруппе некого предка выполняется лишь записью в её файл cgroup.subtree_control. Итак, к примеру, включение контроллера памяти выполняется следующим образом:

echo "+memory" > mygrp/cgroup.subtree_control

А его отключение осуществляется так:

echo "-memory" > mygrp/cgroup.subtree_control

cgroup.events: Это файл интерфейса сердцевины cgroup. Этот файл интерфейса уникален для не являющихся корневыми подгрупп. Такой файл cgroup.events отражает общее число подключённых к данной подгруппе процессов и он составляется единственным элементом - populated: value. Величина value равна 0 когда нет подключённых к этой подгруппе или ей потомкам процессов и 1, когда имеется один или более процессов, подключённых к этой подгруппе или её потомков.

Помимо этих файлов также создаются особые для контроллера файлы. Например, для контроллеров памяти создаётся файл memory.events, который можно отслеживать на предмет таких событий как OOM. Аналогично, контроллер PID имеет такие файлы как pids.max во избежание ситуаций, подобных бомбе ветвлений.

В своём случае я создал некую дочернюю cgroup (child) под mygrp. В этом каталоге child мы можем наблюдать следующие файлы:

Рисунок 4-4

Мы можем наблюдать такие относящиеся к контроллерам файлы как memory.max. Имеющийся файл взаимодействий с названием memory.events перечисляет различные имевшие место события, например, oom, которые могут быть включёнными и отключёнными:

Рисунок 4-5

Наш следующий раздел пояснит как реализуются cgroups внутри ядра и как они допускают контроль ресурсов.

Типы Cgroup

Имеются различные типы cgroups, причём они основаны на тех ресурсах, которыми мы бы желали управлять. Вот те две cgroups, которые мы рассмотрим:

CPU: Предоставляет ограничения ЦПУ процессам пространства пользователя

Block I/O: Предоставляет ограничения ввода/ вывода блочных устройств процессов пространства пользователя

Cgroup CPU

Давайте рассмотрим как реализованы cgroup с точки зрения ядра. Cgroup ЦПУ могут реализовываться поверх двух планировщиков:

Совершенно честный планировщик

Планировщик реального масштаба времени

В этой главе мы обсудим только Совершенно честный планировщик CFS (completely fair scheduler). Cgroup ЦПУ предоставляет два типа контроля ресурса ЦПУ:

cpu.shares: Содержит некое целое значение, которое определяет относительную долю времени ЦПУ, доступного имеющимся в этой cgroup задачам. Например, задачи в двух cgroups, которые имеют установленными в 100 cpu.shares получат равное время ЦПУ, однако задачи в cgroups, которые обладают cpu.shares, установленным в 200 получат в два раза больше времени ЦПУ чем те задачи, в cgroup которых cpu.shares установлен равным 100. Залаваемое в файле cpu.shares значение должно быть 2 или выше.

cpu.cfs_quota_us: Определяет общее значение времени в микросекундах (μs, представляемое здесь как "us") на протяжении которого все задачи в cgroup способны работать за один период (который определяется через cpu.cfs_period_us). Как только все задачи в некой cgroup использует всё заданное этой квотой время, они останавливаются на тот промежуток времени, который определяется значением периода и не допускается к исполнения в следующем периоде.

cpu.cfs_period_us: Это период, из которого выделяются квоты ЦПУ для cgroup (cpu.cfs_quota_us), а значения параметров квоты и периода действуют на основе применения к каждому ЦПУ. Рассмотрим такие примеры:

Чтобы позволить соответствующей cgroup быть способной выполнять доступ к отдельному ЦПУ на 0.2 секунды на протяжении каждой секунды, установите cpu.cfs_quota_us в 200000, а cpu.cfs_period_us в 1000000.

Чтобы позволить некому процессу задействовать отдельный ЦПУ на 100%, установите cpu.cfs_quota_us в 1000000 и cpu.cfs_period_us равным 1000000.

Чтобы допускать некому процессу использовать 100% два ЦПУ, установите cpu.cfs_quota_us равной 2000000, в то время как cpu.cfs_period_us равным 1000000.

Чтобы разобраться с обоими этими механизмами контроля, мы можем рассмотреть основные стороны планировщика задач CFS Linux. Основная цель этого планировщика состоит в предоставлении справедливой доли имеющихся ресурсов ЦПУ всем запущенным в этой системе задачам.

Мы можем разбить эти задачи на два типа:

Задачи интенсивного ЦПУ: Такие задачи как шифрование, машинное обучение, обработка запросов и тому подобное

Задачи с интенсивным вводом/ выводом: Задачи, которые применяют дисковый и сетевой ввод/ вывод, такие как клиенты баз данных

Этот планировщик должен отвечать за планирование обоих видов задач. CFS применяет понятие vruntime. vruntime является участником структуры sched_entity, который в свою очередь выступает участником структуры task_struct (каждый процесс процесс представляется в Linux структурой task_struct):

struct task_struct {
        int prio, static_prio, normal_prio; unsigned int rt_priority;
        struct list_head run_list;
        const struct sched_class *sched_class;
        struct sched_entity se;
        unsigned int policy; cpumask_t cpus_allowed; unsigned int time_slice;
        }

struct sched_entity {
       /* Для балансировки нагрузки: */
       struct load_weight        load;
       struct rb_node            run_node;
       struct list_head          group_node;
       unsigned int              on_rq;

       u64                       exec_start;
       u64                       sum_exec_runtime;
       u64                       vruntime;
       u64                       prev_sum_exec_runtime;

       u64                       nr_migrations;

       struct sched_statistics   statistics;

#ifdef CONFIG_FAIR_GROUP_SCHED
       Int                   depth;
       struct sched_entity      *parent;
       /* rq в которой (должен быть) поствлен в очередь этот логический элемент: */
       struct cfs_rq            *cfs_rq;
       /* rq "владелец" этого логического элемента/группы: */
       struct cfs_rq            *my_q;
       /* кэшированное значение my_q->h_nr_running */
       unsigned long            runnable_weight;

task_struct обладает ссылкой на sched_entity, которая содержит ссылку на vruntime.

vruntime вычисляется с применением следующих шагов:

Вычисляется значение времени, затрачиваемого этим процессом в своём ЦПУ.

Взвешивается значение вычисленного времени исполнения относительно значения исполняемых процессов.

Само ядро использует функцию update_curr, определяемую в файле https://elixir.bootlin.com/linux/latest/source/kernel/sched/fair.c.

/*
 * Обновить текущие статистики времени исполнения текущей задачи.
 */
static void update_curr(struct cfs_rq *cfs_rq)
{
       struct sched_entity *curr = cfs_rq->curr;
       u64 now = rq_clock_task(rq_of(cfs_rq));
       u64 delta_exec;

       if (unlikely(!curr))
              return;

       delta_exec = now - curr->exec_start;
       if (unlikely((s64)delta_exec <= 0))
               return;

       curr->exec_start = now;

       schedstat_set(curr->statistics.exec_max,
              max(delta_exec, curr->statistics.exec_max));

       curr->sum_exec_runtime += delta_exec;
       schedstat_add(cfs_rq->exec_clock, delta_exec);

       curr->vruntime += calc_delta_fair(delta_exec, curr);
       update_min_vruntime(cfs_rq);

       if (entity_is_task(curr)) {
              struct task_struct *curtask = task_of(curr);

              trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
              cgroup_account_cputime(curtask, delta_exec);
              account_group_exec_runtime(curtask, delta_exec);
       }

       account_cfs_rq_runtime(cfs_rq, delta_exec);
}

Эта функция вначале вычисляет значение delta_exec, что является значением времени, проводимое данной текущей задачей в своём ЦПУ.

Эта delta_exec затем передаётся в качестве параметра в другой вызов функции с названием calc_delta_fair.

Этот вызов вернёт взвешенное значение данного процесса времени исполнения в отношении к общему числу исполняемых процессов. После того как vruntime вычислен, он сохраняется как часть структуры sched_entity.

Кроме того, выступая как часть обновления vruntime для данной задачи, наша функция update_curr вызывает update_min_vruntime. Она вычисляет наименьшее значение vruntime по всем исполняемым процессам и добавляем его в красно чёрное дерево в самый левый узел. Планировщик CFS сможет затем просмотреть своё красно чёрное дерево для планирования того процесса, который обладает наименьшим vruntime.

В своей основе планировщик CFS составляет графики своих эврестических составляет расписание, причём в нём задачи с интенсивным вводом/ выводом в нём более частые, но при этом предоставляет больше времени в единственном запуске для задач с интенсивным потреблением ЦПУ. С этим также можно разобраться на основе обсуждённого ранее понятия vruntime. Поскольку задачи ввода, вывода в основном ожидают сеть/ диск, их vruntime имеет тенденцию иметь значение ниже чем у задач ЦПУ. Это означает, что задачи ввода/ вывода будут планироваться более частыми. Соответствующие задачи с интенсивными задачами ЦПУ получат большее время, раз уж они запланированы на выполнение своей работы. Тем самым, CFS пытается достигать справедливого планирования задач.

Давайте прервёмся на минутку и задумаемся о потенциальных проблемах к которым может приводить такое планирование.

Допустим, у вас имеются два процесса, A и B, относящихся к различным пользователям. Каждый из этих процессов получает долю в 50% от своего ЦПУ. Теперь, скажем, владеющий процессом A пользователь запускает другой процесс, именуемый A1. Теперь CFS получит долю в 33% для каждого из процессов. На практике это означает что пользователи процессов A и A1 теперь получат 66% своего ЦПУ. Классическим примером является некая база данных, например, PotgreSQL, которая создаёт процессы для соединений. По мере роста соединений растёт и общее число процессов. Когда выполняется справедливое планирование, каждое соединение будет иметь тенденцию выбирать свою долю от прочих процессов не Postgre, запущенных в той же самой машине.

Эта задача приводит к тому что мы именуем групповым планированием. Чтобы разобраться с этим, давайте рассмотрим другую структуру данных ядра:

/* Относящиеся к CFS поля в очереди запуска */
struct cfs_rq {
       struct load_weight    load;
       unsigned int          nr_running;
       unsigned int          h_nr_running;    /* SCHED_{NORMAL,BATCH,IDLE} */
       unsigned int          idle_h_nr_running; /* SCHED_IDLE */

       u64                   exec_clock;
       u64                   min_vruntime;
#ifndef CONFIG_64BIT
       u64                   min_vruntime_copy;
#endif

       struct rb_root_cached tasks_timeline;

       /*
        * 'curr' указывает на исполняемый в данный момент логический элемент в этой cfs_rq.
        * В противном случае он устанавливается в NULL (т.е. когда ничто в данный момент не исполняется).
        */
       struct sched_entity *curr;
       struct sched_entity *next;
       struct sched_entity *last;
       struct sched_entity *skip;

Эта структура удерживает значение числа запущенных задач в участнике nr_running. Участник curr является указателем на текущий исполняемый спланированный логический элемент или соответствующую задачу.

Кроме того, sched_entity теперь представляется как некая иерархическая структура:

struct sched_entity {
/* Для балансировки нагрузки: */
struct load_weight     load;
struct rb_node         run_node;
struct list_head       group_node;
unsigned int           on_rq;

u64                    exec_start;
u64                    sum_exec_runtime;
u64                    vruntime;
u64                    prev_sum_exec_runtime;

u64                    nr_migrations;

struct sched_statistics     statistics;

#ifdef CONFIG_FAIR_GROUP_SCHED
       Int                        depth;
       struct sched_entity      *parent;
       /* rq в которую (должен) ставиться этот логический элемент: */
       struct cfs_rq            *cfs_rq;
       /* rq "владелец" этого логического элемента/группы: */
       struct cfs_rq            *my_q;
       /* кэшированное значение my_q->h_nr_running */
       unsigned long             runnable_weight;
#endif

#ifdef CONFIG_SMP
       /*
        * Отслеживание средней нагрузки для каждого логического элемента.
        *
        * Поместить в отдельную строку кэширования с тем, чтоб он не
        * сталкивался с в основном считываемыми значениями, приведёнными выше.
        */
          struct sched_avg avg;
#endif
};

Это означает, что теперь могут иметься sched_entities, которые не ассоциированы с неким процессом (task_struct). Вместо этого, такие записи могут представлять группу процессов. Каждая sched_entities теперь сопровождает некую собственную очередь исполнения. Процесс может перемещаться в дочерний логический элемент планирования, что означает, что он будет частью очереди исполнения, которая имеет эту дочернюю запись расписания. Эта очередь исполнения может представлять все процессы в соответствующей группе.

Наш поток кода в расписании будет выполнять следующее.

Pick_next_entity вызывается для подбора наилучшего кандидата в расписании. Мы предполагаем, что в данный момент имеется лишь одна исполняемая группа. Это означает, что ассоциированное с процессом sched_entity красно чёрное дерево пустое. Этот метод теперь попытается получить соответствующего потомка текущей sched_entity. Он проверяет значение cfs_rq, который обладает всеми процессами находящихся в очереди группы процессов. Эти процессы запланированы.

Значение vruntime основывается на весах процессов внутри определённой группы. Это позволяет нам выполнять справедливое планирование предотвращать воздействие процессов внутри некой группы на использование ЦПУ процессами внутри прочих групп.

После того как мы разобрались как процессы могут помещаться в группы, давайте рассмотрим как усиление полосы пропускания способно применяться в такой группе. Свою роль играет другая структура данных с названием cfs_bandwidth, которая определена в sched.h:

struct cfs_bandwidth {
#ifdef CONFIG_CFS_BANDWIDTH
       raw_spinlock_t         lock;
       ktime_t                period;
       u64                quota;
       u64                runtime;
       s64                hierarchical_quota;

       u8                 idle;
       u8                 period_active;
       u8                 distribute_running;
       u8                 slack_started;
       struct hrtimer         period_timer;
       struct hrtimer         slack_timer;
       struct list_head   throttled_cfs_rq;

       /* Статистики: */

       Int                nr_periods;
       Int                nr_throttled;
       u64                throttled_time;
#endif
};

Эта структура продолжает отслеживать значения квот времени исполнения для соответствующей группы. Функция cff_bandwith_used применяется для возврата некого Булева значения когда проверка выполняется в методе account_cfs_rq_runtime файла реализации справедливого планирования. Когда не остаётся никаких квот времени исполнения, активируется метод throttle_cfs_rq. Он удаляет из очереди все задачи из очереди исполнения sched_entity и устанавливает флаг дросселирования. Здесь отображена реализация этой функции:

static void throttle_cfs_rq(struct cfs_rq *cfs_rq)
{
       struct rq *rq = rq_of(cfs_rq);
       struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);
       struct sched_entity *se;
       long task_delta, idle_task_delta, dequeue = 1;
       bool empty;

       se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))];

       /* при дросселировании заморозить иерархию средних запущенных */
       rcu_read_lock();
       walk_tg_tree_from(cfs_rq->tg, tg_throttle_down, tg_nop, (void *)rq);
       rcu_read_unlock();

       task_delta = cfs_rq->h_nr_running;
       idle_task_delta = cfs_rq->idle_h_nr_running;
       for_each_sched_entity(se) {
           struct cfs_rq *qcfs_rq = cfs_rq_of(se);
           /* логический элемент дросселирования или throttle-on-deactivate */
           if (!se->on_rq)
                  brea;

           if (dequeue) {
                  dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP);
           } else {
                  update_load_avg(qcfs_rq, se, 0);
                  se_update_runnable(se);
           }

           qcfs_rq->h_nr_running -= task_delta;
           qcfs_rq->idle_h_nr_running -= idle_task_delta;

           if (qcfs_rq->load.weight)
                  dequeue = 0;
       }
       if (!se)
           sub_nr_running(rq, task_delta) ;

       cfs_rq->throttled = 1;
       cfs_rq->throttled_clock = rq_clock(rq);
       raw_spin_loc(&cfs_b->lock);
       empty = list_empty(&cfs_b->throttled_cfs_rq);

       /*
       * Добавляем в _head_ of данного списка, с тем, чтобы уже запущенные
       * distribute_cfs_runtime не видели нас. Если disribute_cfs_runtime не
        * запущен, добавьте его в хвост чтобы не оставались голодными последующие очереди.
        */
       if (cfs_b->distribute_running)
             list_add_rcu(&cfs_rq->throttled_list, &cfs_b->throttled_cfs_rq);
       else
             list_add_tail_rcu(&cfs_rq->throttled_list, &cfs_b->throttled_cfs_rq);

       /*
        * Когда мы впервые дросселируем задачу, убедитесь что запущен
        * таймер имеющейся полосы пропускания.
        */
       if (empty)
             start_cfs_bandwidth(cfs_b);

       raw_spin_unlock(&cfs_b->lock);
}

Это объясняет как наши cgroup ЦПУ делают возможным группирование задач процессов и могут применять механизмы долей ЦПУ для применения справедливого планирования внутри некой группы. Это также объясняет как внутри некой группы осуществляется квотирование и применение полос пропускания. Теперь мы обсудим иной тип cgroup, который принуждает к ограничению пределов блочного ввода/ вывода.

Cgroup блочного ввода/ вывода

Основная цель cgroup блочного ввода/ вывода двоякая:

Доставлять справедливость в индивидуальные cgroup: применение планировщика вызывается полностью справедливыми очередями.

Выполнять дросселирование блочного ввода, вывода: принудительно устанавливает квоты на блочный ввод/ вывод (байты, а также iops) для cgroup.

Прежде чем погружаться в подробности того как реализуются cgroup для блочного ввода/ вывода, мы предпримем небольшой обход расследования того как работает блочный ввод/ вывод Linux. Рисунок 4-6 представляет собой схему на верхнем уровне того как протекает запрос блочного ввода/ вывода через пространство пользователя в его устройство.

Рисунок 4-6

Поток запроса блочного ввода/ вывода через пространство пользователя в его устройство

Соответствующее приложение осуществляет некий запрос на считывание/ запись либо через свою файловую систему, либо через файлы установления соответствия в памяти. При вызове на основе файловой системы, имеющаяся виртуальная файловая система (VFS, virtual file system) обрабатывает такой системный вызов и активизирует лежащую в её основе зарегистрированную файловую систему.

Следующим уровнем выступает блочный уровень, в котором и собираются реальные запросы ввода/ вывода. Внутри этого блочного уровня имеются три важных структуры данных:

Request_queue: Архитектура с единственной очередью это когда для каждого устройства имеется одна очередь запросов. Это именно та очередь, в которой сам блочный уровень в тандеме со своим планировщиком ввода/ вывода выстраивает очередь запросов. Имеющийся драйвер устройства осушает такую очередь запросов и представляет такие запросы в реальное устройство.

Request: Сам request (запрос) представляет отдельный запрос на ввод/ вывод , который надлежит доставить в соответствующее устройство ввода/ вывода. Этот requestсостоит из списка структур bio.

Bio: Структура bio является базовым контейнером для блочного ввода/ вывода. Такая структура bio располагается внутри своего ядра. Будучи определённой в файле <linux/bio.h>, эта структура представляет находящиеся на лету (активные) операции ввода/ вывода как список неких сегментов. Сегмент представляет собой фрагмент буфера, который непрерывен в памяти.

В виде диаграммы bio отражён на Рисунке 4-7.

Рисунок 4-7

Структура bio, представляющая наход\щиеся на лету (активные) операции блочного ввода/ вывода как список сегментов

bio_vec представляет некий особый сегмент и обладает указателем на ту страницу, которая содержит блочные данные по конкретному смещению.

Все запросы представляются в имеющуюся очередь запросов и осушаются соответствующим драйвером устройства. Здесь показаны важные структуры данных, вовлекаемые в реализацию обсуждаемой cgroup блочного ввода/ вывода ядра Linux:

struct blkcg {
   struct cgroup_subsys_state    css;
    spinlock_t                   lock;
   struct radix_tree_root        blkg_tree;
   struct blkcg_gq __rcu        *blkg_hint;
   struct hlist_head             blkg_list;
   struct blkcg_policy_data     *cpd[BLKCG_MAX_POLS];
   struct list_head all_blkcgs_node; #ifdef
   CONFIG_CGROUP_WRITEBACK
   struct list_head              cgwb_list;
   refcount_t                    cgwb_refcnt;
#endif
};

Эта структура представляет cgroup рассматриваемого нами блочного ввода/ вывода. Каждой cgroup блочного ввода/ вывода ставится в соответствие некая очередь запросов, как мы поясняли ранее.

/* ассоциация между cgroup blk и очередью запросов */
struct blkcg_gq {
   /* Указатель на ассоциированную request_queue */
   struct request_queue    *q;
   struct list_head         q_node;
   struct hlist_node        blkcg_node;
   struct blkcg            *blkcg;

/*
*  Каждая blkg переполняется по отдельности и такое состояние переполнения
*  распространяется соответствующему bdi_writeback_congested.
*/

   struct bdi_writeback_congested     *wb_congested;

   /* все не корневые blkcg_gq's гарантированно обязаны обладать доступом к предку */
   struct blkcg_gq        *parent;
   /* запросить список выделения для этой пары blkcg-q */
   struct request_list     rl;

   /* счётчик ссылок */
   atomic_t               refcnt;
   /* доступна ли эта blkg? защищается и blkcg, и q locks */

   Bool                       online;

   struct blkg_rwstat         stat_bytes;
   struct blkg_rwstat         stat_ios;

   struct blkg_policy_data   *pd[BLKCG_MAX_POLS];

   struct rcu_head           rcu_head;

atomic_t                     use_delay;
atomic64_t                   delay_nsec;
atomic64_t                   delay_start;
u64                          last_delay;
int                          last_use;
};

Все очереди запросов ассоциируются с какой- то cgroup блочного ввода/ вывода.

Понимание Fairness

Под справедливостью (fairness) мы имеем ввиду, что каждая cgroup получает справедливую долю значения выдачи ввода/ вывода в это устройство. Для осуществления этого следует настроить планировщик CFQ (Complete Fair Queuing). В отсутствии cgroup планировщик CFQ назначает каждому процессу некую очередь, а затем предоставляем расслоение по времени для каждой из очередей, тем самым обрабатывая справедливость.

Некое дерево обслуживания это список активных очередей/ процессов над которым работает планировщик. Итак, в своей основе, планировщик CFQ обслуживает запросы из всех очередей в дереве обслуживания.

При работе с cgroup вводится понятие CFQ групп. Теперь вместо планирования по процессам собственно планирование выполняется на уровне групп. Это означает,что каждая cgroup обладает множеством деревьев обслуживания в которых планируются очереди этих групп. Затем имеется некое глобальное дерево обслуживания в котором планируются сами группы CFQ.

Группа CFQ определяется следующим образом:

struct cfq_group {
   /* должен быть самым первым участником */
   struct blkg_policy_data pd;

   /* участник группы service_tree */
   struct rb_node rb_node;

   /* ключ группы service_tree */
u64 vdisktime;

    /*
    * Значение числа cfqgs и сумма их весов под этой cfqg.
    * Она покрывает эти leaf_weight cfqg е веса всех потомков,
    * однако не покрывает веса всех последующих потомков.
    *
    * Когда cfqg присутствует в дереве обслуживания, она активна. Некая активная cfqg
    * также активирует своих предков и вносит вклад в значение children_weight
    * своего предка.
    */
   int nr_active;

   unsigned int children_weight;

    /*
    * vfraction это доля vdisktime которые озаглавлены в этой
    * cfqg. Она определяется составлением значений отношений
    * проходом вверх по этой cfqg к корню.
    *
    * Она пребывает в фиксированной точке с/ CFQ_SERVICE_SHIFT, а значение суммы всех
    * vfractions в неком дереве обслуживания примерно равно 1. Сумма может слегка отличаться
    * по причине ошибок округления и отклонений, вызванных входом и выходом
    * cfqgs из дерева обслуживания.
    */
   * unsigned int vfraction;

   /*
    * Имеются два веса - (внутренний) вес это вес всех этих
    * cfqg относительно cfqgs одного уровня. leaf_weight это значение веса
    * такой cfqg относительно всех дочерних cfqgs. Для значения корневой cfqg, оба
    * веса выдерживаются синхронными для обратной совместимости.
    */

   unsigned int weight;
   unsigned int new_weight;
   unsigned int dev_weight;
   unsigned int leaf_weight;
   unsigned int new_leaf_weight;
   unsigned int dev_leaf_weight;

   /* число cfqq находящихся в данный момент вэтой группе */
   int nr_cfqq;
   /*
    * Среднее число занятых очередей в группе. Полезно при расчёте среза рабочей группы.
    * Мы создаём массив для каждого класса prio, но во время выполнения он используется
    * только для классов RT и BE, а слот для класса IDLE остаётся не использованным.
    * Это в первую очередь сделано во избежание путаницы и предупреждающих сообщений gcc.
    */

   unsigned int
   busy_queues_avg[CFQ_PRIO_NR]; /*
    *rr перечисляет все очереди с запросами. Мы поддерживаем деревья обслуживания для
    *классов RT и BE. Эти деревья подразделяются на подклассы
    * на базе SYNC, SYNC_NOIDLE и ASYNC в типе рабочей нагрузки. Для
    * класса IDLE нет никаких подклассов и все имеющиеся очереди CFQ идут в
    * некое отдельное дерево обслуживания service_tree_idle.
    * Сч1тчики встраиваются в cfq_rb_root
    */

   struct cfq_rb_root service_trees[2][3];
   struct cfq_rb_root service_tree_idle;

   u64 saved_wl_slice;
   enum wl_type_t saved_wl_type;
   enum wl_class_t saved_wl_class;

     /* число запросов, которые пребывают в списке отправки или внутри драйвера */
   int dispatched;
   struct cfq_ttime ttime;
   struct cfqg_stats stats;       /* статистики для этой cfqg */

   /* асинхронная очередь для каждого варианта приоритета */ struct
   cfq_queue *async_cfqq[2][IOPRIO_BE_NR]; struct
   cfq_queue *async_idle_cfqq;

};

Каждая группа CFQ содержит некое значение "веса ввода/ вывода", которое можно настраивать в cgroup. Значения vdisktime CFQG (групп CFQG) определяются их положением в "дереве обслуживания cfqg", а затем они изменяются в соответствии с "весом ввода/ вывода".

Понимание Throttling

Дросселирование (throttling) предоставляет некое средство для применения ограничения ресурсов к имеющемуся блочному вводу/ выводу. Это делает возможным для ядра осуществлять контроль над значением максимального блочного ввода/ вывода, который может получать процесс пространства пользователя. Имеющееся ядро реализует его через cgroup блочного ввода/ вывода.

Дросселирование имеющегося блочного ввода/ вывода для cgroup выполняется с применением некого набора различных функций. Самой первой функцией является blk_throttl_bio и она определена в blk-throttle.c:

bool blk_throtl_bio(struct request_queue *q, struct blkcg_gq
            *blkg, struct bio *bio)
{
   struct throtl_qnode *qn = NULL;
   struct throtl_grp *tg = blkg_to_tg(blkg ?: q->root_blkg); struct throtl_service_queue *sq; bool rw = bio_data_dir(bio);
   bool throttled = false;
   struct throtl_data *td = tg->td;
   WARN_ON_ONCE(!rcu_read_lock_held());

   /* см. throtl_charge_bio() */
   if (bio_flagged(bio, BIO_THROTTLED) || !tg->has_rules[rw])
   goto out;

   spin_lock_irq(q->queue_lock);

   throtl_update_latency_buckets(td);

   if (unlikely(blk_queue_bypass(q)))
   goto out_unlock;

   blk_throtl_assoc_bio(tg, bio);
   blk_throtl_update_idletime(tg);

   sq = &tg->service_queue;

   again :

     while (true) {
        if (tg->last_low_overflow_time[rw] == 0) tg->last_low_overflow_time[rw] = jiffies;
         throtl_downgrade_check(tg);
         throtl_upgrade_check(tg);
        /* throtl выполнен как FIFO - если bios уже в очереди, он должен там присутствовать */
        if (sq->nr_queued[rw])
            break;

        /* прерываем очередь в случае превышения предела */
        if (!tg_may_dispatch(tg, bio, NULL)) {
            tg->last_low_overflow_time[rw] = jiffies;
            if (throtl_can_upgrade(td, tg)) {
                   throtl_upgrade_state(td);
                goto again;
            }
            break;
   }

   /* находясь в рамках предела, давайте выполним начисление и отправим напрямую */
   throtl_charge_bio(tg, bio);
   /*
    * Необходимо подрезать сечение, даже когда bio не были поставлен в очередь,
    * в противном случае может получиться, что bio находится вне очереди
    * длительное время и сечение продолжит расширяться, а обрезка не
    * вызывается очень долго. Теперь, если пределы внезапно уменьшаются,
    * мы принимаем во внимание все операции ввода/ вывода, отправленные до сих пор с новой
    * низкой скоростью, а * вновь поставленные в очередь операции ввода/ вывода получают действительно
    * продолжительное время отправки.
    *

    * Так что продолжайте подрезать сечение, даже если bio не стоит в очереди. */
   throtl_trim_slice(tg, rw);

    /*
    * @bio передаётся через этот уровень без дросселирования.
    * Идём вверх по лестнице.  Если мы уже на самом верху,
    * можно выполнять непосредственно.
    **/
   qn = &tg->qnode_on_parent[rw];

   sq = sq->parent_sq;
   tg = sq_to_tg(sq);
   if (!tg)
       goto out_unlock;
   }

   /* out-of-limit, ставим в очередь к @tg */
   throtl_log(sq, "[%c] bio. bdisp=%llu sz=%u bps=%lluiodisp=%u iops=%u queued=%d/%d",

      rw == READ ? 'R' : 'W',
     tg->bytes_disp[rw], bio->bi_iter.bi_size,
      tg_bps_limit(tg, rw),
     tg->io_disp[rw], tg_iops_limit(tg, rw),
      sq->nr_queued[READ], sq->nr_queued[WRITE]);

   tg->last_low_overflow_time[rw] = jiffies;

   td->nr_queued[rw]++;
   throtl_add_bio_tg(bio, qn, tg);
   throttled = true;

     /*
     * Обновите время отправки @tg и принудительно отправьте расписание если @tg
     * был пуст до @bio. Принудительное планирование вряд ли
     * вызовет неоправданную задержку, поскольку @bio, скорее всего, будет отправлен напрямую,
     * если запланированное время @tg не пребывает в будущем.
     */
       if (tg->flags & THROTL_TG_WAS_EMPTY) {
           tg_update_disptime(tg);
           throtl_schedule_next_dispatch(tg->service_queue.parent_sq, true);
      }

    out_unlock:
      spin_unlock_irq(q->queue_lock);

    out:
     bio_set_flag(bio, BIO_THROTTLED);

    #ifdef CONFIG_BLK_DEV_THROTTLING_LOW
      if (throttled || !td->track_bio_latency) bio->
          bi_issue.value |= BIO_ISSUE_THROTL_SKIP_LATENCY;
     #endif

    return throttled;
   }

Следующий фрагмент кода проверяет может ли данный bio быть отправлен для активной доставки в драйвер своего устройства:

   if (!tg_may_dispatch(tg, bio, NULL)) { tg->last_low_overflow_time[rw] = jiffies;
               if (throtl_can_upgrade(td, tg)) {
                       throtl_upgrade_state(td);
                   goto again;
               }
               break;
   }

Здесь приводится определение tg_may_dispatch:

static bool tg_may_dispatch(struct throtl_grp *tg, struct bio  *bio, unsigned long *wait)
{
    bool rw = bio_data_dir(bio);
    unsigned long bps_wait = 0, iops_wait = 0, max_wait = 0;

    /*
     * В настоящий момент вся машина состояний группы целиком зависит от первого bio
     * находящегося в очереди в списке bio этой группы. Поэтому не следует вызывать
     * эту функцию с другим bio когда в очереди имеются прочие bio.
     * /
     BUG_ON(tg-&service_queue.nr_queued[rw] &&
       bio != throtl_peek_queued(&tg-&service_queue.queued[rw]));

     /* Когда tg-&bps = -1, BW неограничен */
        if (tg_bps_limit(tg, rw) == U64_MAX &&
             tg_iops_limit(tg, rw) == UINT_MAX) {
            if (wait)
                 *wait = 0;
            return true;
       }

     /*
     * Когда предыдущий срез закончился, начните новый, в противном случае
     * обновите/расширьте уже имеющийся срез чтобы быть уверенным, что он по крайней мере пребывает в интервале
     * throtl_slice достаточно давно. Новый срез запускается только для пустой группы
     * дросселирования. Когда в очереди имеется bio, это означает, что должен иметься некий
     * активный срез и вместо этого его надлежит расширить.
     * /

    if (throtl_slice_used(tg, rw) &&
       !(tg-&service_queue.nr_queued[rw]))
           throtl_start_new_slice(tg, rw);
       else {
            if (time_before(tg-&slice_end[rw],
               jiffies + tg-&td-&throtl_slice))
                 throtl_extend_slice(tg, rw,
                   jiffies + tg-&td-&throtl_slice);
    }

    if (tg_with_in_bps_limit(tg, bio, &bps_wait) &&
        tg_with_in_iops_limit(tg, bio, &iops_wait)) {
         if (wait)
           *wait = 0 ;
        return true;
   }

   max_wait = max(bps_wait, iops_wait);

   if (wait)
         *wait = max_wait;

    if (time_before(tg-&slice_end[rw], jiffies + max_wait))
       throtl_extend_slice(tg, rw, jiffies + max_wait);

    return false;

Фрагмент кода

   if (tg_with_in_bps_limit(tg, bio, &bps_wait) &&
      tg_with_in_iops_limit(tg, bio, &iops_wait)) {
        if (wait)
         *wait = 0;
        return true;
   }

определяет пребывает ли этот bio внутри установленных пределов данной cgruop или нет. Как видно, он проверяет для данной cgroup как ограничение в байтах в секунду, так и предел числа вводав/ выводов в секунду.

Когда предел не превышен, этот bio загружается в данную cgroup первым:

/* находясь в рамках, давайте заполним и отправим непосредственно and dispatch directly */ throtl_charge_bio(tg, bio);

static void throtl_charge_bio(struct throtl_grp *tg, struct bio *bio) {
   bool rw = bio_data_dir(bio);
   unsigned int bio_size =
                           throtl_bio_data_size (bio);

    /* Заносим этот bio в его группу */
    tg->bytes_disp[rw] += bio_size;
    tg->io_disp[rw]++;
    tg->last_bytes_disp[rw] += bio_size;
    tg->last_io_disp[rw]++;

     /*
    * BIO_THROTTLED применяется для предотвращения дросселирования того же самого bio
    * более одного раза, поскольку дросселированный bio проследует через blk-throtl
    * второй раз когда он в конечном счёте будет выдан. Установите его когда bio
    * загружается до tg.
    */

     if (!bio_flagged(bio, BIO_THROTTLED))
          bio_set_flag(bio, BIO_THROTTLED);
     }

Данная функция загружает этот bio (в bytes и iops) в группу throttle. Затем она передаёт этот bio вверх своему предку, что видно из приводимого ниже кода:

	/*
     * @bio передаётся через этот уровень без дросселирования.
     * Поднимаемся вверх по лестнице.  Если мы уже на самом верху,
     * Можно выполнять непосредственно.
     */
     qn = &tg->qnode_on_parent[rw];
     sq = sq->parent_sq;
     tg = sq_to_tg(sq);

Если превышены ограничения, этот код принимает другой ход. Вызывается следующий фрагмент кода:

throtl_add_bio_tg(bio, qn, tg);
throttled = true;

Давайте взглянем на функцию throtl_add_bio_tg более внимательно:

     /**
     * throtl_add_bio_tg - добавить bio в предписанный throtl_grp
     * @bio: подлежащий добавлению bio
     * @qn: qnode для применения
     * @tg: целевая throtl_grp
     *

    Добавить @bio в service_queue @tg при помощи @qn. Если @qn не задан,
    применяется tg->qnode_on_self[].
    */

static void throtl_add_bio_tg(struct bio *bio, struct
throtl_qnode *qn,
             struct throtl_grp *tg)
{

    struct throtl_service_queue *sq = &tg->service_queue; bool rw = bio_data_dir(bio);
    if (!qn)
          qn = &tg->qnode_on_self[rw];

     /*
     * Если @tg в настоящий момент не обладает никакими очередями bio в том же самом
     * направлении, устанавливаемая очередь @bio может измениться когда должен быть отправлен
     * @tg. Отметьте что @tg был пустым.  Это автоматически
     * очищается при следующем tg_update_disptime().
     */
   if (!sq->nr_queued[rw])
       tg->flags |= THROTL_TG_WAS_EMPTY;

   throtl_qnode_add_bio(bio, qn, &sq->queued[rw]);

   sq->nr_queued[rw]++;
   throtl_enqueue_tg(tg);
   }

Данная функция добавляет полученный bio в очередь обслуживания throttle. Такая очередь действует как механизм для дросселирования запросов bio. Этот запрос на обслуживание затем позднее осушается.

/**

     *blk_throtl_drain - осушить дросселированные bio
     *@q: request_queue чтобы осушить дросселированные bio для
     *отправки всех дросселированных в данный момент bio в @q через
     - >make_request_fn().
    */

   void blk_throtl_drain(struct request_queue *q)
   __releases(q->queue_lock) __acquires(q->queue_lock)
{
   struct throtl_data *td = q->td; struct blkcg_gq *blkg;

   struct cgroup_subsys_state *pos_css;
   struct bio *bio;
   int rw;

   queue_lockdep_assert_held(q);

   rcu_read_lock();
     /*
     * Осушить все tg при выполнении проход в обратном порядке по дереву blkg с тем,
     * чтобы все bio распространились в td->service_queue.  Было бы лучше
     * непосредственно пройти по дереву service_queue, но проход по blkg
     * выполняется проще.
     */

   blkg_for_each_descendant_post(blkg, pos_css, td->queue->root_blkg)
      tg_drain_bios(&blkg_to_tg(blkg)->service_queue);

    /* наконец, передаём bio из tg верхнего уровня в установленный td */
    tg_drain_bios(&td->service_queue);

    rcu_read_unlock();
    spin_unlock_irq(q->queue_lock);

   /* все bio теперь должны пребывать в td->service_queue, выдаём их
   */ for (rw = READ; rw <= WRITE; rw++)
          while ((bio = throtl_pop_queued(&td->service_queue.queued[rw],
                                 NULL)))
                    generic_make_request(bio);
    spin_lock_irq(q->queue_lock) ;

Глава 5. Многоуровневая файловая система

Содержание

Глава 5. Многоуровневая файловая система

Пример файловой системы

Несколько слов о Псевдофайловых системах

Многоуровневые Файловые системы

Единая файловая система

OverlayFS

В своих предыдущих главах мы изучили пространства имён и cgroup. В этой главе мы прикоснёмся к другой интересной стороне экосистемы контейнеров, которой выступает многоуровневая файловая система. Мы обсудим как она делает возможным совместное использование файлов в своём хосте и как это способствует запуску большого числа контейнеров в таком хосте.

В предыдущих главах мы решали вопросы изоляции процессов через пространства имён Linux и контроль ресурсов для индивидуальных процессов через cgroup. Теперь мы окунёмся в вопросы многоуровневых файловых систем, которые составляют третий строительный блок наших контейнеров Linux, после пространства имён и cgroup.

Давайте начнём с обсуждения того что представляет из себя файловая система.

Пример файловой системы

Философия Linux трактует всё как файл. Например, сокет, конвейер и блочные устройства, все они в Linux представляются как файлы.

Файловые системы в Linux действует как контейнеры для абстрагирования лежащего в основе хранилища в случае блочных устройств. Для не блочных устройств, таких как сокеты и конвейеры, в памяти имеются файловые системы, которые обладают операциями, способными осуществляться при помощи API стандартной файловой системы.

Linux формализует все файловые системы при помощи некого уровня, носящего название VFS (Virtual File System). Все файловые системы регистрируются при помощи VFS. VFS обладает следующими важными структурами данных:

File: Представляет собой некий открытый файл и собранные в нём сведения, такие как смещение и тому подобное. Пространство пользователя обязано обрабатывать некий открытый файл через структуру с названием файловый дескриптор. Она выступает обработчиком, применяемым для взаимодействия с соответствующей файловой системой.

Inode: Это соответствие 1:1 к конкретному файлу. Такой inode является одной из наиболее критически важных структур и он содержит все метаданные относительно своего файла. Например, они содержат сведения о том, в каких блоках данных хранится информация файла и какие полномочия установлены для этого файла. Эти сведения являются частью такого inode. Inode также хранятся на диске особой файловой системой, однако они представляются в памяти как часть уровня своей VFS. За перечисление соответствующей структуры inode VFS ответственна сама файловая система.

Dentry: Это соответствие между именем файла и inode. Это размещаемая в памяти структура и она не хранится на диске. В основном она относится к поиску и проходу по пути.

Superblock: Эта структура удерживает все необходимые сведения относительно соответствующей файловой системы, в том числе, сколько в ней имеется блоков, значение имени устройства и тому подобное. Эта структура перечисляется и заносится в память во время операции монтирования.

Каждая из этих структур данных содержит указатели для своих специфических операций. Скажем, file имеет file_ops для считывания и записи, а superblock через super_ops обладает операциями для монтирования, размонтирования и тому подобного.

Определённая операция монтирования создаёт некую структуру данных vfsmount, которая содержит ссылки на новую структуру данных суперблока, создаваемую в той файловой системе, которая подлежит монтированию на данном диске. dentry обладает ссылкой на такую vfsmount. Именно этим VFS отличается от каталога и точки монтирования. В процессе прохода, в dentry отыскивается vfsmount, в смонтированном устройстве используется inode номер 2 (inode 2 зарезервирован для значения корневого каталога).

Итак, как эти части подгоняются друг к другу в случае блочного устройства? Допустим, определённый процесс пространства пользователя выполняет вызов на считывание файла. Этот системный вызов выполняется в установленной ядро. Его VFS проверяет значение пути и определяет имеются ли кэшированными dentry в его корне. По мере прохода и поиска правильной dentry, она определяет местоположение соответствующего inode для подлежащего открытию файла. После нахождения местоположения inode, проверяются права доступа и загруженные в кэш страниц ОС с соответствующего диска блоки данных. Эти же самые данные перемещаются в пространство пользователя выполнившего запрос процесса.

Кэш страниц является занятной оптимизацией в ОС. Все считывания и записи (за исключением непосредственного ввода/ вывода) происходят поверх такого кэша страниц. Этот кэш страниц сам по себе представляется некой структурой данных с наименованием address_space. Это address_space содержит некое дерево страниц памяти, а inode этого файла содержит некую ссылку к этой структуре данных address_space.

Рисунок 5-1

Соответствие некого файла в кэше страниц

Рисунок 5-1 показывает как устанавливается соответствие файлу в имеющемся кэше страниц. Именно это также выступает основным ключом к пониманию того, как работают такие операции как mmap для установки соответствия файлам. Мы обсудим это когда будем рассматривать такую файловую систему как tmpfs и совместное использование памяти примитивами IPC.

Когда запрашиваемый на считывание файл пребывает в установленном кэше страниц (что определяется через соответствующую структуру address_space в inode файла), эти данные обслуживаются оттуда.

Всякий раз, когда выполняется вызов на запись в соответствующем файле через его файловый дескриптор, эти записи вначале выполняются в такой кэш страниц. Значения страниц памяти помечаются как грязные (dirty) и ядро Linux применяет механизм кэширования с обратной записью, что означает, что имеются некие потоки в фоновом режиме (с названиемpdflush), которые осушают этот кэш страниц и выполняют запись на физический диск через надлежащий блочный драйвер. Этот механизм пометки страниц грязными не происходит на самом уровне страниц. Страницы могут иметь в размере 4кБ и даже минимальное изменение затем вызовет запись страницы целиком.

Во избежание этого, имеются некие структуры, которые обладают более тонкой грануляцией и представляют в памяти дисковые блоки. Эти структуры носят название заголовков буфера (buffer heads). Например, когда значение размера блока равно 512 байт, имеется восемь заголовков буфера и одна страница в кэше страниц.

Таким образом, могут помечаться как грязные отдельные блоки и записываться часть данных.

Такие буферы могут в явном фиде сбрасываться на диск через следующие системные вызовы:

Sync(): Сбрасывает на диск все грязные записи.

Fsync(fd): Сбрасывает на диск только грязные буферы данных соответствующего файла, включая сами изменения в inode.

Fdatasync(fd): Сбрасывает на диск только грязные буферы данных соответствующего файла. Не выполняет сброс inode.

Вот некий образец того как работает процесс синхронизации:

Осуществляется проверка является ли грязным сам суперблок.

Выполняется обратная запись суперблока.

Выполняется итерация по всем inode из имеющегося списка inode:

Если соответствующий inode грязный, выполняется его обратная запись.

Если соответствующий кэш страниц этого inode является грязным, выполняется обратная запись.

Очищается значение флага dirty.

Рисунок 5-2 отображает различные уровни файловой системы под установленным ядром.

Рисунок 5-2

Различные уровни файловой системы под установленным ядром

Примеры различных видов файловых систем включают:

Ext4: Эта файловая система применяется для доступа к лежащей в её основе блочным устройствам.

ProcFS: Это некая располагаемая в памяти файовая система и она используется для предоставления функциональных возможностей. Она также носит название псевдо файловой системы (pseudo file system).

Btrfs: Эта файловая система применяется для доступа к лежащей в её основе блочным устройствам.

Несколько слов о Псевдофайловых системах

Напомним, что общей философией Linux является то, что всё является файлами. Исходя из этого, имеются некоторые файловые системы, которые предоставляют доступ к некоторым ресурсам ядра через файловый интерфейс. Мы называем их псевдофайловыми системами. Одной из таких файловых систем выступает procfs.

Такая файловая система procfs монтируется в rootfs в каталоге proc. Все сведения в procfs не сохраняются на постоянной основе и все операции происходят в памяти.

Некоторые выставляемые через procfs структуры поясняются в приводимой ниже таблице:

Таблица 5-1. Выставляемые procfs структуры данных

Структура
Описание

/proc/cpuinfo

Такие подробности ЦПУ как число ядер, размер ЦПУ, производитель и т.п.

/proc/meminfo

Сведения о физической памяти

/proc/interrupts

Сведения о прерываниях и обработчиках

/proc/vmstat

Статистики виртуальной памяти

/proc/filesystems

Активные в ядре фаловые системы

/proc/mounts

Смонтированные в настоящее врмя устройства; они будут специфичными для своего монтируемого пространства имён

/proc/uptime

Время, начиная с запуска ядра

/proc/stat

Статистики системы

/proc/net

Сведения о физической памяти

/proc/pid/cmdline

Относящиеся к сетевой среде структуры, такие как сокеты TCP, файлы и т.п. proc также выставляет через эти файлы некоторые специфичные для процесса сведения

/proc/pid/environ

Название командной строки данного процесса

/proc/pid/mem

Переменные среды данного процесса

/proc/pid/maps

Соответствие установленной виртуальной памяти

/proc/pid/fdinfo

Открытые файловые дескрипторы данного процесса

/proc/pid/task

Подробности его дочерних процессов

Многоуровневые Файловые системы

Теперь, когда вы обладаете лучшим пониманием файловой системы в Linux, настало время рассмотреть многоуровневые файловые системы в Linux.

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

Рассмотрим некий пример исполнения десяти баз данных Cassandra в одном и том же хосте, причём каждая база данных запускается в своём собственном пространстве имён. Когда у нас имеются отдельные файловые системы для всех различных inode баз данных, мы не пользуемся такими преимуществами:

Разделение памяти

Совместное использование диска

Поскольку в случае некой многоуровневой файловой системы все файлы разбиваются по уровням, каждый уровень является доступной только на чтение файловой системой. Так как эти уровни совместно применяются по имеющемся в одном и том же хосте контейнерам, они обладают тенденцией оптимально использовать хранилище. Кроме того, благодаря тому, что inode одни и те же, все они ссылаются на один и тот же кэш страниц ОС. Это превращает вещи в оптимальные во всех отношениях.

Сравним это с подготовкой на основе ВМ, когда всякая rootfs предоставляется как некий диск. Это означает, что все они обладают различными представлениями inode в своём хосте и нет никакого оптимального хранения по сравнению со случаем контейнеров.

Гипервизоры также обладают тенденцией достигать оптимальности при помощи подобных KSM (Kernel Same Page Merging Слияние одинаковых страниц ядра) чтобы они имели возможность выполнять дедупликацию одних и тех же страниц по виртуальным машинам.

Дадее мы обсудим понятие Объединённых файловых систем (union file systems)

Единая файловая система

Согласно Wikipedia, Единая файловая система это некая служба файловой системы для Linux, FreeBSD и NetBSD, которая реализует некое единое монтирование для прочих файловых систем. Она делает возможным прозрачное перекрытие файлов и каталогов отдельных файловых систем, именуемых ветвлениями (branches), формируя единую связную файловую систему. Всё содержимое каждого каталога, которое обладает одним и тем же путём внутри слитых ветвлений будут видны совместно в неком отдельном объединённом каталоге внутри своей новой виртуальной файловой системы (VFS).

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

OverlayFS

Этот раздел взглянет на OverlayFS как некий образец единой FS. OverlayFS выступала частью ядра Linux начиная с 3.18. Она перекрывает (как и предполагает её название) всё содержимое одного каталога другим. Исходными каталогами могут быть различные диски или файловые системы.

В OverlayFS v1 имелось лишь два уровня, и именно они применялись для создания некого единого уровня, отображаемого на Рисунке 5-3.

Рисунок 5-3

OverlayFS v1 с двумя уровнями (верхним и нижним)

OverlayFS v2 обладает тремя уровнями:

Base: Это базовый уровень. В основном он доступен только на чтение.

Overlay: Этот уровень (перекрытия) предоставляет видимость с базового уровня и делает возможным добавление новых файлов, каталогов. Когда изменяются какие бы то ни было файлы с базового уровня, они сохраняются на своём следующем уровне.

Diff: Все сделанные на уровне перекрытия изменения сохраняются на этом уровне diff (приращений). Все изменения в файлах на своём базовом уровне влекут за собой копирование такого файла с этого базового уровня на уровень приращений. Все изменения затем записываются в этом уровне приращений.

Давайте рассмотрим пример того как работает OverlayFS v2:

root@instance-1: mkdir base diff overlay workdir
root@instance-1: echo "test data" > base/test1
root@instance-1: sudo mount \
>     -t overlay \
>     -o lowerdir=base,upperdir=diff,workdir=workdir \
>     overlay \
>     overlay
root@instance-1:-# I

Рисунок 5-4

Мы создаём некий файл в своём каталоге overlay и можем видеть что они появляются в diff:

root@instance-1:/overlay# touch test2
root@instance-1:/overlay# ls
test1 test2
root@instance-1:-/overlay# cd ../diff
root@instance-1:-/diff# ls
test2

Рисунок 5-5

Теперь мы можем изменить свой файл test1:

Рисунок 5-6

Если мы проверим этот файл в каталоге diff, мы обнаружим уже изменённый файл. Однако когда мы проследуем в каталог base, мы будем наблюдать свой старый файл. Это означает, что мы изменили этот файл в своём каталоге base, он был сначала скопирован в каталог diff, после чего и были осуществлены изменения.

После выполнения этих примеров, когда пользователи пожелают выполнить очистку ресурсов, для размонтирования OverlayFS они могут выполнить такую команду:

root@instance-1: umount overlay

По завершению размонтирования, если пожелаете, эти каталоги также могут быть удалены.

Давайте теперь задумаемся о том как такие механизмы как Docker реализуют данный процесс. В Docker имеется драйвер хранилища Overlay2, о котором вы можете обнаружить дополнительные сведения в https://github.com/moby/moby/blob/master/daemon/graphdriver/overlay2/overlay.go.

Docker создаёт множество уровней чтения (базовые уровни) и один уровень чтения/ записи с названием уровня контейнера (в нашем случае уровня перекрытия, overlay).

Множество уровней чтения может совместно использоваться по различным контейнерам в одном и том же хосте, тем самым достигая очень высокой оптимизации. Как мы уже намекали ранее, поскольку мы обладаем одной и той же файловой системой и теми же самыми inode, наш кэш страниц ОС также совместно используется по всем контейнерам в одном и том же хосте.

В противовес этому, когда мы видим соответствие устройств некого драйвера Docker, так как он снабжает каким- то виртуальным диском для каждого из уровней, мы можем не испытать того совместного использования, который мы получаем при помощи OverlayFS. Но теперь даже применяя устройство сопоставления в Docker мы способны передать параметр –shared-rootfs в этот демон для совместного использования rootfs. Обычно это работает в виде создания некого устройства для самого первого базового образа контейнера с последующей привязкой для последующих контейнеров. Такая привязка позволяет нам сохранять одни и те же inode, а потому кэш страниц является общим.

Глава 6. Создание простой контейнерной инфраструктуры

Содержание

Глава 6. Создание простой контейнерной инфраструктуры

Пространство имён UTS

Установка Golang

Сборка контейнера при помощи Пространства имён

Добавление дополнительных пространств имён

Запуск программы оболочки внутри контейнера

Предоставление файловой системы Root

Монтирование файловой системы Proc

Включение сетевой среды для контейнера

Небольшой пример построения виртуальной сети

Включение Cgroup для контейнера

Выводы

В своих предыдущих главах мы изучили пространства имён и cgroup. В этой главе мы прикоснёмся к другой интересной стороне экосистемы контейнеров, которой выступает многоуровневая файловая система. Мы обсудим как она делает возможным совместное использование файлов в своём хосте и как это способствует запуску большого числа контейнеров в таком хосте.

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

Поскольку мы изучили основы, которые учреждают некий контейнер, настало время взглянуть на то, как написать свой собственный простой контейнер. К концу этой главы вы создадите свой собственный образец контейнера при помощи изоляции пространства имён.

Давайте приступим.

Я проверил упоминающиеся в этой главе команды на Ubuntu 19.04 с ядром Linux 5.0.0-13.

Самая первая изучаемая нами команда носит название unshare. Эта команда позволяет нам перестать совместно использовать некий набор пространств имён в определённом хосте.

Пространство имён UTS

Мы введём новое пространство имён uts и изменим своё имя хоста внутри этого пространства имён.

root@osboxes:~# unshare -u /bin/bash
root@osboxes:~# hostname test
root@osboxes:~# hostname
test
root@osboxes:~# exit
exit
root@osboxes:~# hostname
osboxes

Когда мы вводим новое пространство имён UTS, мы изменяем значение имени хоста на test и это именно то что отражается внутри этого пространства имён. После того как мы покидаем его и повторно входим в пространство имён host, мы получаем значение пространства имён host.

Команда unshare -u /bin/bash создаёт пространство имён uts и запускает наш процесс (/bin/bash) внутри этого пространства имён. Внимательный читатель может заметить, что когда мы не изменяем значение имени хоста после входа в это пространство имён, мы всё ещё будем получать само имя хоста его хоста. Это не желательно, поскольку нам требуется некий способ устанавливать его перед выполнением своей программы внутри такого пространства имён.

Именно здесь мы изучим написание некого контейнера при помощи Golang (также именуемого Go) и затем настроим пространства имён перед запуском своего процесса внутри этого контейнера. Мы будем писать свой контейнер на Golang, а потому нам потребуется иметь Golang установленным в своей ВМ или в той машине, в которой мы работаем. (Для установки Golang посетите https://golang.org/doc/install.)

Golang это самый распространённый язык системного программирования. Он применяется для создания сред исполнения контейнеров, таких как Docker, а также механизмов оркестровки контейнеров, например, Swarm и Kubernetes. Кроме того, он применялся в прочих настройках системного программирования. Перед тем как углубиться в код данной главы, неплохо составить пристойное представление о Golang.

Установка Golang

Вот команды как по- быстрому установить Golang:

root@osboxes:~#wget https://dl.google.com/go/go1.12.7.linux-amd64.tar.gz
oot@osboxes:~# tar -C /usr/local -xzf go1.12.7.linux-amd64.tar.gz

Вы можете включить в root/.profile следующую строчку чтобы добавить исполняемые файлы Golang в переменную PATH:

root@osboxes:~# export PATH=$PATH:/usr/local/go/bin

Затем в своём терминале исполните такую команду:

root@osboxes:~# source ~/.profile

Чтобы убедиться что Go (Golang) установился должным образом, вы можете запустить следующую команду:

root@osboxes:~# go version

Если установка была успешной, вы должны обнаружить такой вывод:

Рисунок 6-1

Теперь мы соберём некий контейнер с всего лишь с пространством имён и затем продолжим изменять свою программу добавляя новые функциональные возможности, например, поддержку оболочки, rootfs, сетевую среду и cgroups.

Сборка контейнера при помощи Пространства имён

Давайте кратко повторим пространств имён Linux прежде чем мы построим свой контейнер. Пространства имён располагаются в самом ядре Linux, подобно ресурсам песочницы ядра, например, файловых систем, деревьев процессов, очередей сообщений и семафоров, а также сетевых компонентов, таких как устройства, сокеты и правила маршрутизации.

Пространства ядра изолируют процессы внутри их собственной песочницы исполнения с тем, чтобы они исполнялись полностью изолировано от прочих процессов в разных пространствах имён.

Имеются шесть пространств имён:

Пространство имён PID: Процессы внутри определённого пространства имён PID обладают различными деревьями процессов. У них имеется процесс init с PID 1.

Пространство имён Mount: Это пространство имён управляет тем какие точки монтирования способен наблюдать процесс. Когда процесс пребывает в неком пространстве имён, он видит лишь точки монтирования внутри этого пространства имён.

Пространство имён UTS: Позволяет процессу видеть другое пространство имён нежели его реальное глобальное пространство имён.

Пространство имён Network: Это пространство имён даёт иное представление сетевой среды в пространстве имён. Такие сетевые конструкции как порты, iptables и тому подобное пребывает в рамках этого пространства имён.

Пространство имён IPC: Это пространство имён ограничивает структуры межпроцессного взаимодействия, такие как конвейеры внутри определённого пространства имён.

Пространство имён User: Данное пространство имён делает возможным обособление представления пользователя и группы внутри своего пространства имён.

Здесь мы не обсуждаем пространство имён cgroup, которое позволяет отделять сферы cgroup в свои собственные пространства имён.

Теперь давайте поработаем своими руками и создадим класс Go с названием myuts.go. Скопируйте приводимый ниже фрагмент кода и воспользуйтесь go build myuts.go для получения исполняемого файла myuts. Также запустите этот исполняемый файл myuts от имени пользователя root.

package main

import (
  "fmt"
  "os"
  "os/exec"
  "syscall"
)

func main() {
  cmd := exec.Command("/bin/bash")
  // Приводимые ниже операторы ссылаются на потоки input, output и error создаваемого процесса(cmd)
  cmd.Stdin = os.Stdin
  cmd.Stdout = os.Stdout
  cmd.Stderr = os.Stderr
  //установка некой переменной среды
  cmd.Env = []string{"name=shashank"}
  // приводимая ниже команда создаёт пространство имён UTS для данного процесса
        cmd.SysProcAttr = &syscall.SysProcAttr{
              Cloneflags: syscall.CLONE_NEWUTS,
  }
  if err := cmd.Run(); err != nil {
        fmt.Printf("Error running the /bin/bash command - %s\n", err)
        os.Exit(1)
  }
}

Это простая программа Go, которая запускает некую оболочку, настраивает необходимые потоки ввода/ вывода для своего процесса и затем устанавливает одну переменную env. Затем она применяет следующую команду:

	cmd.SysProcAttr = &syscall.SysProcAttr{
              Cloneflags: syscall.CLONE_NEWUTS,
 		}

Далее она передаёт флаги CLONE (в данном случае мы передаём лишь флаг клонирования UTS). Такие флаги клонирования контролируют какие пространства имён создаются для данного процесса.

После этого мы собираем и запускаем этот процесс Golang. Мы сможем увидеть было ли создано наше новое пространство имён воспользовавшись своей файловой системой proc и проверив значение proc/<<pid>>/ns:

root@osboxes:~/book_prep# ls -li /proc/self/ns/uts
60086 lrwxrwxrwx 1 root root 0 Apr 13 10:10 /proc/self/ns/uts – > 'uts:[4026531838]'
root@osboxes:~/book_prep# ./myuts
root@osboxes:/root/book_prep# ls -li /proc/self/ns/uts
60099 lrwxrwxrwx 1 root root 0 Apr 13 10:10 /proc/self/ns/uts – > 'uts:[4026532505]'
root@osboxes:/root/book_prep#exit

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

Мы можем обнаружить, что значения uts пространств имён различны.

Добавление дополнительных пространств имён

В своём предыдущем разделе мы отобразили как может быть создано пространство имён UTS. В этом разделе мы добавим дополнительные пространства имён.

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

package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    cmd := exec.Command("/bin/bash")

    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    cmd.Env = []string{"name=shashank"}
    //следующая команда создаёт UTS, PID и IPC, NETWORK и USERNAMESPACES
    cmd.SysProcAttr = &syscall.SysProcAttr{
            Cloneflags: syscall.CLONE_NEWNS |
                    syscall.CLONE_NEWUTS |
                    syscall.CLONE_NEWIPC |
                    syscall.CLONE_NEWPID |
                    syscall.CLONE_NEWNET |
                    syscall.CLONE_NEWUSER,
    }

    if err := cmd.Run(); err != nil {
      fmt.Printf("Error running the /bin/bash command - %s\n", err)
      os.Exit(1)
    }
}

Здесь мы при помощи флагов клонирования добавили дополнительные пространства имён. Мы собираем и запускаем свою программу следующим образом:

root@osboxes:~/book_prep# ./myuts
nobody@osboxes:/root/book_prep$ ls -li /proc/self/ns/ total 0
63290 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 cgroup -> 'cgroup:[4026531835]'
63285 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 ipc -> 'ipc:[4026532508]'
63289 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 mnt -> 'mnt:[4026532506]'
63283 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 net -> 'net:[4026532511]'
63286 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 pid -> 'pid:[4026532509]'
63287 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 pid_for_children -> 'pid:[4026532509]'
63288 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 user -> 'user:[4026532505]'
63284 lrwxrwxrwx 1 nobody nogroup 0 Apr 13 10:14 uts -> 'uts:[4026532507]'

Мы получили те пространства имён, которые относятся к этому контейнеру. Теперь мы видим, что право владения не принадлежит никому (nobody). Это потому, что мы также воспользовались качестве флага клонирования и пространство имён пользователя. Теперь контейнер пребывает в новом пространстве имён пользователя. пространства имён пользователя требуют чтобы мы сопоставляли пользователя из пространства имён с его хостом. Поскольку мы ещё пока ничего не сделали, мы по- прежнему не видим никого в качестве пользователя.

Теперь мы добавим соответствие пользователя в свой код:

vfs.zfs.vdev.cache.size=<10M>package main
import (
        "fmt"
        "os"
        "os/exec"
        "syscall"
)
func main() {
        cmd := exec.Command("/bin/bash")
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
        cmd.Env = []string{"name=shashank"}
    //command below creates the UTS, PID and IPC , NETWORK and
    // USERNAMESPACES and adds the user and group mappings.
        cmd.SysProcAttr = &syscall.SysProcAttr{
                Cloneflags: syscall.CLONE_NEWNS |
                        syscall.CLONE_NEWUTS |
                        syscall.CLONE_NEWIPC |
                        syscall.CLONE_NEWPID |
                        syscall.CLONE_NEWNET |
                        syscall.CLONE_NEWUSER,
        UidMappings: []syscall.SysProcIDMap{
                        {
                                ContainerID: 0,
                                HostID: os.Getuid(),
                                Size: 1,
                        } ,
               },
               GidMappings: []syscall.SysProcIDMap{
                       {
                               ContainerID: 0,
                               HostID: os.Getgid(),
                               Size: 1,
                       },
               },
        }
        if err := cmd.Run(); err != nil {
        fmt.Printf("Error running the /bin/bash command - %s\n", err)
        os.Exit(1)
        }
}

Вы можете видеть, что у нас имеются UidMappings и GidMappings. У нас есть поле с названием ContainerID, которое мы установили в значение 0. Это означает, что устанавливаем внутри этого контейнера значения uid и gid равными 0, в те значения uid и gid пользователя, который запускает этот процесс.

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

Технически мы можем выступать не root в своём хосте и затем создавать пространство имён пользователя и прочие пространства имён внутри такого пространства имён. Имейте в виду, что для всех прочих пространств имён, когда они запускаются без пространства имён пользователя, потребуется доступ с правами root.

Если мы возьмём свой предыдущий пример, когда мы передаём все свои флаги совместно, наша система сначала создаст некое пространство имён пользователя и поместит все прочие пространства имён внутри этого пространства имён пользователя.

Я не могу здесь целиком осветить тему пространства имён пользователя, но это занятная область для любознательных читателей. Одной из областей, которую я могу сразу упомянуть, это область сборок Docker, внутри которой нам требуется доступ с правами root для построения некого образа внутри контейнера. Это требуется по целому ряду причин, ибо нам требуется несколько многоуровневых файловых систем, причём смонтированными внутри своего контейнера, а для создания нового монтирования требуются привилегии root.

То же самое относится и к установке виртуальных сетевых устройств, например, пары veth для подключения контейнеров к своему хосту. Сказав это, стоит отметить наличие продвижения в области контейнеров без root, которые делают возможным для разработчиков запуск контейнеров без root. Если вы желаете прочитать подробности об этом, вы можете изучить данную тему по следующим ссылкам:https://rootlesscontaine.rs/ и https://github.com/rootless-containers.

Чего мы достигли к данному моменту, так это возможности запускать некий процесс внутри набора пространств имён. Однако, несомненно, нам требуется больше. Нам необходим способ инициализации этих пространств имён до запуска их контейнера.

Вернёмся к созданной нами программе. Давайте соберём и запустим её:

root@osboxes:~/book_prep# ./myuts
root@osboxes:/root/book_prep# whoami
root

root@osboxes:/root/book_prep# id
uid=0(root) gid=0(root) groups=0(root)

Теперь мы видим, что пользователем внутри контейнера выступает root.

Наша программа проверяет самый первый аргумент. Когда самой первой командой выступает запуск, тогда эта программа выполняет /proc/self/exe, что является просто запуском самой себя (/proc/self/exe это копия того исполняемого образа, которым выступает вызывающая сторона сама по себе).

Модно спросить зачем требуется выполнять /proc/self/exe. Когда мы выполняем эту команду, она запускает тот же самый исполняемый файл с теми же самыми параметрами (в нашем случае мы передаём в неё свой аргумент fork). Раз мы пребываем в разных пространствах имён, нам необходимы некие настройки для своих пространств имён, например, значение имени хоста, прежде чем мы запустим этот процесс внутри его контейнера.

Исполнение /proc/self/exe предоставляет нам такую возможность настройки пространств имён подобно следующему:

Установит значение имени хоста.

Внутри пространства имён монтирования мы делаем некий основной корень, который позволит нам переключиться в эту корневую файловую систему. Это осуществляется путём копирования своего старого корня в некий иной каталог и превращая значения нового пути в новый корень. Этот опорный корень обязан изготавливаться в пределах пространства имён монтирования, ибо мы не желаем перемещать свою rootfs прочь из своего хоста. Мы также монтируем необходимую файловую систему proc. Это делается по причине того, что наше пространство имён монтирования наследует систему proc своего хоста, а мы желаем смонтировать proc в пределах своего пространства имён монтирования.

После того как наше пространство имён монтирования проинициализировано и настроено, мы осуществляем сам процесс контейнера (в нашем случае самого себя).

Исполнение этой программы запускает необходимую оболочку в некой песочнице, замкнутой в пространствах имён proc, mount и uts.

Теперь мы работаем в своём пространстве имён, проинициализированном до запуска самого процесса внутри его контейнера. В своём следующем примере у нас будет друго имя хоста в его пространстве имён uts. В свой приводимый дадее код мы внесём все необходимые изменения.

У нас имеется родительская функция, которая:

Клонирует необходимые пространства имён.

Запускает тот же самый процесс снова посредством /proc/self/exe и передаёт некого потомка в качестве своего параметра.

Теперь этот процесс вызывается снова. Проверки из функции main приводят к осуществлению своей дочерней функции. Теперь вы можете наблюдать, что мы клонировали свои пространства имён. Всё что мы делаем сейчас, так это меняем имя хоста на myhost внутри пространства имён uts. Выполнив это, мы запускаем необходимый исполняемый файл, переданный в качестве параметра командной строки (в нашем случае, /bin/bash).

Запуск программы оболочки внутри контейнера

В своих предыдущих разделах мы объяснили как создавать различные пространства имён. В этом разделе мы поясним как входить в эти пространства имён. Вход в рамки определённого пространства имён может выполняться через запуск программы/ процесса внутри этого пространства имён. Наша следующая программа запускает программу оболочки внутри таких пространств имён.

package main

import (
        "fmt"
        "os"
        "os/exec"
        "syscall"
)

func main() {

switch os.Args[1] {
        case "parent":
                parent()
        case "child":
                child()
        default:
               panic("help")
        }
}
// эта родительская функция осуществляется из главной программы, которая устанавливает все необходимые пространства имён
func parent() {

        cmd := exec.Command("/proc/self/exe",
append([]string{"child"}, os.Args[2:]...)...)

        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr

        cmd.Env = []string{"name=shashank"}

        cmd.SysProcAttr = &syscall.SysProcAttr{
                Cloneflags: syscall.CLONE_NEWNS |
                        syscall.CLONE_NEWUTS |
                        syscall.CLONE_NEWIPC |
                        syscall.CLONE_NEWPID |
                        syscall.CLONE_NEWNET |
                        syscall.CLONE_NEWUSER,
        UidMappings: []syscall.SysProcIDMap{
                        {
                                ContainerID: 0,
                                HostID: os.Getuid(),
                                Size: 1,
                        },
               },
               GidMappings: []syscall.SysProcIDMap{
                      {
                                ContainerID: 0,
                                HostID: os.Getgid(),
                                Size: 1,
                      },
               },
        }

        must(cmd.Run())
}
// это дочерний процесс, который является копией своей родительской программы сам по себе.
func child () {

cmd := exec.Command(os.Args[2], os.Args[3:]...)
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
//следующая команда устанавливает имя хоста в значение myhost. Основная идея состоит в демонстрации использования пространства имён UTS
must(syscall.Sethostname([]byte("myhost")))
// эта команда запускает оболочку, которая передаётся как аргумент программы
must(cmd.Run())
}

func must(err error) {
        if err != nil {
                 fmt.Printf("Error - %s\n", err)

        }
}

После выполнения этой программы мы можем запускать необходимый исполняемый файл внутри новых пространств имён. Кроме того, обратите внимание, что имя хоста установлено в значение myhost:

root@osboxes:~/book_prep# ./myuts parent /bin/bash
root@myhost:/root/book_prep# hostname
myhost
root@myhost:/root/book_prep#

После получения своего пространства имён uts пора пуститься во все тяжкие. Сейчас мы проинициализируем своё пространство имён монтирований.

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

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

Опять же, нам требуется пребывать внутри пространства имён монтирования прежде чем мы выполним pivot_root. Поскольку у нас уже имеется перехватчик для инициализации пространства имён (применяя приём /proc/self/exe), нам требуется ввести механизм опорного корня (pivot root).

Предоставление файловой системы Root

Мы воспользуемся необходимым нам rootfs из busybox, которую вы можете выгрузить с https://github.com/allthingssecurity/containerbook (busybox.tar).

После выгрузки busybox.tar раскройте его в /root/book_prep/rootfs в своей системе. На это местоположение ссылается данный код как на местоположение rootfs. Как это отображено на Рисунке 6-2, содержимое /root/book_prep/rootfs в вашей системе должно выглядеть таким же образом.

Рисунок 6-2

Содержимое пути /root/book_prep/rootfs

После раскрытия rootfs, мы можем наблюдать в rootfs следующий каталог:

Рисунок 6-3

Приводимая ниже программа делает опорным корнем эту rootfs внутри своего пространства имён монтирования.

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

В качестве примера предположим, что у нас имеются запущенными в одном и том же хосте две песочницы - sandboxA и sandboxB. Когда sandboxA получает свои собственные средства монтирования, её файловая система наблюдает другое монтирование, отличающееся от того, что видит sandboxB и ни одна из них не может отслеживать монтирования своего хоста. Это обеспечивает безопасность на уровне файловой системы, поскольку отдельные песочницы не могут получать доступ к файлам других песочниц или из самого хоста.

//providing rootfile system
package main

import (
        "fmt"
        "os"
        "os/exec"
        "path/filepath"
        "syscall"
)

func main() {

switch os.Args[1] {
        case "parent":
                parent()
        case "child":
                child()
        default:
               panic("help")
        }
}

func pivotRoot(newroot string) error {
        putold := filepath.Join(newroot, "/.pivot_root")

        //привязывает к самой себе монтирование newroot - именно это выступает небольшой уловкой, которая требуется для
        //удовлетворения требования pivot_root в том, что newroot и putold не должны быть той же самой
        //файловой системой что и текущий корень
        if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
                  return err
        }

        // создаём каталог putold
        if err := os.MkdirAll(putold, 0700); err != nil
                { return err
        }

        // вызываем pivot_root
        if err := syscall.PivotRoot(newroot, putold); err != nil {
           return err
        }

        // обеспечиваем установку нового текущего рабочего каталога
        root if err := os.Chdir("/"); err != nil {
                return err
        }

        //выполняем размонтирование putold, которая теперь пребывает в /.pivot_root putold = "/.pivot_root"
        if err := syscall.Unmount(putold, syscall.MNT_DETACH); err !=
        nil {
                return err
        }

        // удаляем putold
        if err := os.RemoveAll(putold); err != nil
                { return err
        }

        return nil
}

func parent() {
        cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)

        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr

        cmd.Env = []string{"name=shashank"}

        cmd.SysProcAttr = &syscall.SysProcAttr{
                 Cloneflags: syscall.CLONE_NEWNS |
                         syscall.CLONE_NEWUTS |
                         syscall.CLONE_NEWIPC |
                         syscall.CLONE_NEWPID |
        syscall.CLONE_NEWNET |
                      syscall.CLONE_NEWUSER,
         UidMappings: []syscall.SysProcIDMap{
                      {
                                 ContainerID: 0,
                                 HostID: os.Getuid(),
                                 Size: 1,
                      },
                },
                GidMappings: []syscall.SysProcIDMap{
                        {
                                ContainerID: 0,
                                HostID: os.Getgid(),
                                Size: 1,
                        },
                },
         }

         must(cmd.Run())
}

func child () {

cmd := exec.Command(os.Args[2], os.Args[3:]...)
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
must(syscall.Sethostname([]byte("myhost")))

        if err := pivotRoot("/root/book_prep/rootfs"); err != nil
                { fmt.Printf("Error running pivot_root - %s\n",
                err) os.Exit(1)
        }
must(cmd.Run())
}

func must(err error) {
        if err != nil {
                 fmt.Printf("Error - %s\n", err)
        }
}

После выполнения этой программы:

Рисунок 6-4

Мы наблюдаем каталоги rootfs и видим, что имя хоста изменилось. Также мы наблюдаем, что uid равен 0 (значение корня в контейнере).

У нас всё ещё присутствует проблема. Здесь отсутствует монтирование proc. Для предоставления сведений относительно различных запущенных внутри этого пространства имён и в качестве некого взаимодействия между различными процессами нам требуется монтирвоание proc, как это пояснялось ранее в этой главе про псевдофайловые системы. Внутри нашего пространства монтирвоания нам потребуется смонтировать файловую систему proc.

Монтирование файловой системы Proc

Мы добавляем в свою программу новую функцию mountProc:

package main

import (
        "fmt"
        "os"
        "os/exec"
        "path/filepath"
        "syscall"
)

func main() {

switch os.Args[1] {
        case "parent":
                parent()
        case "child":
                child()
        default:
               panic("help")
        }
}

func pivotRoot(newroot string) error {
        putold := filepath.Join(newroot, "/.pivot_root")
        //привязывает к самой себе монтирование newroot - именно это выступает небольшой уловкой, которая требуется для
        //удовлетворения требования pivot_root в том, что newroot и putold не должны быть той же самой
        //файловой системой что и текущий корень
        if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
                 return err
        }

        // создаём каталог putold
        if err := os.MkdirAll(putold, 0700); err != nil {
                return err
        }

        // вызываем pivot_root
        if err := syscall.PivotRoot(newroot, putold); err != nil {
                return err
        }

        // обеспечиваем установку нового текущего рабочего каталога
        if err := os.Chdir("/"); err != nil {
                return err
        }

        // выполняем размонтирование putold, которая теперь пребывает в /.pivot_root putold = "/.pivot_root"
        if err := syscall.Unmount(putold, syscall.MNT_DETACH); err !=
        nil {
                return err
        }

        // удаляем putold
        if err := os.RemoveAll(putold); err != nil
                { return err
        }

        return nil
}

func parent() {

        cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)

        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
        cmd.Env = []string{"name=shashank"}

        cmd.SysProcAttr = &syscall.SysProcAttr{
                 Cloneflags: syscall.CLONE_NEWNS |
                         syscall.CLONE_NEWUTS |
                         syscall.CLONE_NEWIPC |
                         syscall.CLONE_NEWPID |
        syscall.CLONE_NEWNET |
                         syscall.CLONE_NEWUSER,
         UidMappings: []syscall.SysProcIDMap{
                         {
                                 ContainerID: 0,
                                 HostID: os.Getuid(),
                                 Size: 1,
                         },
                },
                GidMappings: []syscall.SysProcIDMap{
                        {
                                ContainerID: 0,
                                HostID: os.Getgid(),
                                Size: 1,
                        },
                },
         }

         must(cmd.Run())
}

func child () {

cmd := exec.Command(os.Args[2], os.Args[3:]...)
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr

//выполняем вызов функции mountProc function которая смонтирует файловую систему proc в уже
//созданном пространстве имён монтирования
must(mountProc("/root/book_prep/rootfs"))
must(syscall.Sethostname([]byte("myhost")))

        if err := pivotRoot("/root/book_prep/rootfs"); err != nil
                { fmt.Printf("Error running pivot_root - %s\n",
                err) os.Exit(1)
        }
must(cmd.Run())
}

func must(err error) {
        if err != nil {
                 fmt.Printf("Error - %s\n", err)
        }
}

// эта функция монтирует файловую систему proc внутри
// нового пространства имён монтирований
func mountProc(newroot string) error {
        source := "proc"
        target := filepath.Join(newroot, "/proc")
        fstype := "proc"
        flags := 0
        data := ""
//выполняем системный вызов Mount для монтирования файловой системы proc внутри необходимого пространств имён монтирования
        os.MkdirAll(target, 0755)
        if err := syscall.Mount(
                source,
                target,
                fstype,
                uintptr(flags),
                data,
        ); err != nil {
                return err
        }
        return nil
}

Теперь когда мы выполняем ps внутри своего контейнера чтобы получить список взех запущенных в нашей песочнице процессов, мы получим показанный здесь ниже вывод. Основной причиной этого является то, что ps пользуется файловой системой /proc.

Рисунок 6-5

Мы можем воспользоваться другой командой nsenter для входа в созданное пространство имён контейнера. Чтобы испробывать это, давайте создадим контейнер пребывающим в запущенном состоянии и откроем другой терминал Linux. Затем выполните следующую команду:

ps -ef | grep /bin/sh

Вы должны обнаружить отражённый здесь вывод. В моём случае PID моего контейнера равен 5387. Пользователям следует применять PID их машин.

Рисунок 6-6

Выполнение nsenter -a -t 5387 /bin/sh делает возможным создание этой оболочки внутри соответствующих пространств имён с PID 5387, как это и отображено.

Включение сетевой среды для контейнера

В предыдущих разделах мы создали контейнер с uts, PID и пространствами имён монтирования. Мы не добавили необходимое сетевое пространство имён. В этом разделе мы обсудим как настраивать сетевые пространства имён для своих контейнеров.

Прежде чем мы окунёмся в тему сетевых ресурсов, я предоставлю небольшое пособие для начинающих по виртуальным устройствам в Linux, который существенен для понимания сетевых сред на основе контейнеров или, раз уж на то пошло, любых виртуальных сетей.

Небольшой пример построения виртуальной сети

В неком виртуальном мире имеется потребность отправки пакетов по виртуальным машинам в их реальные физические устройства, между виртуальными машинами, либо между различными контейнерами. Таким образом, нам требуется некий механизм применения виртуальных устройств. Linux предоставляет механизм создания виртуальных сетевых устройств, именуемых tun и tap. Устройства tun действуют на 3 уровне сетевого стека, что означает что они получают необходимые пакеты IP. Устройства tap действуют на уровне, где они получают сырые пакеты Ethernet.

Теперь может возникнуть вопрос: для чего используются эти устройства? Рассмотрим сценарий, при котором containerA требуется отправить исходящие пакеты в другой контейнер. Все пакеты из одного пакета передаются в машину своего хоста, который интеллектуально применяет устройство tap для передачи этих пакетов в некий программный мост. Этот мост затем может соединяться с другим контейнером.

Давайте на простом примере рассмотрим как работает это устройство tap. Здесь я создаю два устройства tap, с названиями mytap1 и mytap2:

Рисунок 6-7

Выдав перечень устройств tap, мы можем обнаружить что имеется два сетевых интерфейса:

Рисунок 6-8

Мы выделяем IP адреса этим устройствам:

Рисунок 6-9

Запуск простого ping из одного устройства имеет результатом следующее:

Рисунок 6-10

В этих примерах мы в явном виде создали два устройства tap и выполнили попытку ping между ними двумя.

Мы также можем пользоваться парами veth, которые могут выступать чем- то навроде виртуальных кабелей, которые соединяют виртуальные устройства. Они применяются в openstack для соединения программных мостов.

Прежде всего создадим пару veth следующим образом:

Рисунок 6-11

Это создаст пару устройств tap с названиями firsttap и secondtap.

Теперь мы добавим ip адреса в эти устройства tap и запустим ping:

Рисунок 6-12

Вооружившись базовым пониманием устройств tun и tap, давайте перейдём к тому как наша работать наша настройка сетевой среды между создаваемым для контейнера сетевым пространством имён и соответствующим пространством имён хоста. Для данного процесса мы следуем таким этапам:

Создаём в своём хосте мост.

Создаём пару veth.

Один конец veth обязан быть подключён к имеющемуся мосту.

Другой конец из этого моста должен соединяться с соответствующим сетевым интерфейсом в пространстве имён своего контейнера.

Эти этапы проиллюстрированы на Рисунке 6-13

Рисунок 6-13

Построение сетевой среды между пространством имён контейнера и пространством имён его хоста

Теперь мы изменим свой код чтобы включить необходимое нам сетевое пространство имён:

package main

import (
   "fmt"
   "os"
   "os/exec"
   "path/filepath"
   "syscall"
   "time"
   "net"
)

func main() {

    switch os.Args[1] {
        case "parent":
            parent()
        case "child":
            child()
        default:
            panic("help")
        }
}

func waitForNetwork() error {

    maxWait := time.Second * 3
     checkInterval := time.Second
     timeStarted := time.Now()
     for {
           interfaces, err := net.Interfaces()
           if err != nil {
              return err
         }
         // довольно простая проверка ...
         // > 1 в качестве уже имеющегося устройства lo
         exist if len(interfaces) > 1 {
              return nil
         }
         if time.Since(timeStarted) > maxWait {
         return fmt.Errorf("Timeout after %s waiting for network", maxWait)
         }
         time.Sleep(checkInterval)
         }
}

//Эта функция делает возможным монтирование файловой системы proc
func mountProc(newroot string) error {

    source := "proc"
    target := filepath.Join(newroot, "/proc")
    fstype := "proc"
    flags := 0
    data := ""
    os.MkdirAll(target, 0755)
    if err := syscall.Mount(
         source,
         target,
         fstype,
         uintptr(flags),
         data,
    ); err != nil {
         return err
    }
    return nil
}

// Эта функция позволяет сделать опорной данную корневую файловую систему. Это делает для нас возможным
// обладать корневой файловой системой, доступной в нашей песочнице
func pivotRoot(newroot string) error {
    putold := filepath.Join(newroot, "/.pivot_root")
    //привязывает к самой себе монтирование newroot - именно это выступает небольшой уловкой, которая требуется для
    //удовлетворения требования pivot_root в том, что newroot и putold не должны быть той же самой
    //файловой системой что и текущий корень
    if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
        return err
    }

     // создаём каталог putold
     if err := os.MkdirAll(putold, 0700); err != nil {
          return err
     }

     // вызывпем pivot_root
     if err := syscall.PivotRoot(newroot, putold); err != nil {
          return err
     }

     // обеспечиваем установку нового текущего рабочего каталога
     root if err := os.Chdir("/"); err != nil {
          return err
    }
    // выполняем размонтирование putold, которая теперь пребывает в /.pivot_root putold = "/.pivot_root"
    if err := syscall.Unmount(putold, syscall.MNT_DETACH); err != nil {
            return err
    }

    // удаляем putold
    if err := os.RemoveAll(putold); err != nil {
         return err
    }
    return nil
}

func parent() {

      cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)

      cmd.Stdin = os.Stdin
      cmd.Stdout = os.Stdout
      cmd.Stderr = os.Stderr
      cmd.Env = []string{"name=shashank"}

      cmd.SysProcAttr = &syscall.SysProcAttr{
           Cloneflags: syscall.CLONE_NEWNS |
               syscall.CLONE_NEWUTS |
               syscall.CLONE_NEWIPC |
               syscall.CLONE_NEWPID |
               syscall.CLONE_NEWNET |
               syscall.CLONE_NEWUSER,
           UidMappings: []syscall.SysProcIDMap{
             {
                 ContainerID: 0,
                 HostID: os.Getuid(),
                 Size: 1,
             },
           },
           GidMappings: []syscall.SysProcIDMap{
             {
                 ContainerID: 0,
                 HostID: os.Getgid(),
                 Size: 1,
             },
           },
      }

must(cmd.Start())

pid := fmt.Sprintf("%d", cmd.Process.Pid)

// Идущий далее код делает следующее:
// Создаёт в своём хосте необходимый мост
// Создаёт нужную нам пару veth
// Подключает один конец veth к мосту
// Другой конец подсоединяет к необходимому сетевому пространству имён. Именно это представляет интерес
// поскольку теперь мы обладаем доступом к стороне своего хостаи к необходимой стороне сетевой среды пока
// мы не блокированы.
netsetgoCmd := exec.Command("/usr/local/bin/netsetgo", "-pid", pid)
    if err := netsetgoCmd.Run(); err != nil {
          fmt.Printf("Error running netsetgo - %s\n", err)
          os.Exit(1)
    }
    if err := cmd.Wait(); err != nil {
         fmt.Printf("Error waiting for reexec.Command - %s\n", err)
         os.Exit(1)
    }
}

func child () {

cmd := exec.Command(os.Args[2], os.Args[3:]...)
     cmd.Stdin = os.Stdin
     cmd.Stdout = os.Stdout
     cmd.Stderr = os.Stderr
must(mountProc("/root/book_prep/rootfs"))
//must(syscall.Mount("proc", "proc", "proc", 0, ""))
must(syscall.Sethostname([]byte("myhost")))
    if err := pivotRoot("/root/book_prep/rootfs"); err != nil {
          fmt.Printf("Error running pivot_root - %s\n", err)
          os.Exit(1)
    }
//must(syscall.Mount("proc", "proc", "proc", 0, ""))
if err := waitForNetwork(); err != nil {
     fmt.Printf("Error waiting for network - %s\n", err)
     os.Exit(1)
}
if err := cmd.Run(); err != nil {
     fmt.Printf("Error starting the reexec.Command - %s\n", err)
     os.Exit(1)
}
//must(cmd.Run())
}

func must(err error) {
    if err != nil {
        fmt.Printf("Error - %s\n", err)
    }
}

Имеется пара моментов, которые будет не лишним рассмотреть здесь. В более раннем примере кода мы инициализировали пространства имён (как и изменение имени своего хоста и опорный корень) в своём дочернем методе. Затем мы запускали необходимую оболочку (/bin/sh) внутри этих пространств имён.

Этот механизм работал, потому как нам просто требовалось проинициализировть эти пространства имён и это осуществлялось внутри самих пространств имён. Когда дело доходит до нужных нам сетевых пространств имён, нам требуется позаботиться об определённых действиях, таких как:

Создании моста в своём хосте.

Создании пары veth и выполнения подключения одного конца к созданному мосту, и помещения другого конца вовнутрь необходимого пространства имён.

Основная задача в текущем подходе состоит в том, что когда мы запустим свою оболочку, мы останемся в её пространстве имён до тех пор, пока мы намеренно не покинем её. так, нам необходим способ вернуть свой код непосредственно в API с тем, чтобы мы были способны выполнить сетевую настройку в своём хосте и выполнить соединение своей пары veth.

К счастью, наша команда cmd.Run может быть разбита на две части.

Cmd.Start() выполняет непосредственный возврат.

Cmd.Wait() выполняет блокирование вплоть до выхода их оболочки.

Мы применяем это в своих целях в своём методе parent. Мы запускаем метод Cmd.Start(), который выполняет немедленный возврат.

После этого метода start мы пользуемся библиотекой netsetgo, созданной Эдом Кигом из Pivotal. Она выполняет следующее.

Создаёт в нашем хосте необходимый мост.

Создаёт нужную нам пару veth/

Подключает ожин конец созданного veth к имеющемуся мосту.

Другой её конец присоединяет к необходимому сетевому пространству имён. Это занимательно, ибо мы теперь обладаем доступом к стороне своего хоста и сетевой среде пока мы его не заблокируем.

Для выгрузки и установки netsetgo следуйте приводимым инструкциям:

wget "https://github.com/teddyking/netsetgo/releases/download/0.0.1/netsetgo"

sudo mv netsetgo /usr/local/bin/

sudo chown root:root /usr/local/bin/netsetgo

sudo chmod 4755 /usr/local/bin/netsetgo

На самом деле, многие из данных пояснений взяты из его примеров.

Здесь отображён соответствующий фрагмент кода:

must(cmd.Start())

pid := fmt.Sprintf("%d", cmd.Process.Pid)
netsetgoCmd := exec.Command("/usr/local/bin/netsetgo", "-pid", pid)
if err := netsetgoCmd.Run(); err != nil {
     fmt.Printf("Error running netsetgo - %s\n", err)
     os.Exit(1)
}
if err := cmd.Wait(); err != nil {
     fmt.Printf("Error waiting for reexec.Command - %s\n", err)
     os.Exit(1)
}

Когда это выполнено, мы применяем cmd.Wait(), который повторно запускает нашу программу (/proc/self/exe). Затем мы выполняем свой дочерний процесс и следуем далее со всеми прочими инициализациями. После этих инициализаций мы способны запустить необходимую оболочку внутри установленных пространств имён.

Затем нам необходимо проверить сетевое взаимодействие от своего хоста к созданному контейнеру и от этого контейнера к его хосту. Прежде всего запустим эту программу:

/myuts parent /bin/sh

Внутри запущенной оболочки выполните команду ifconfig. В должны обнаружить значение IP адреса контейнера, как это отражено далее:

Рисунок 6-14

Оставьте этот контейнер запущенным и откройте другой терминал (оболочку bash) в своём хосте. Выполните следующую команду, которая осуществляет ping к IP нашего контейнера:

ping 10.10.10.2

Рисунок 6-15

Заметьте, что теперь мы имеем возможность выполнения ping к IP адресу своего контейнера из его хоста.

Теперь мы попытаемся выполнить ping к IP адресу своего хоста из нашего контейнера. Прежде всего, заполучим IP адрес своего хоста выполнив командуifconfig. Как вы можете здесь обнаружить, IP адресом моего хоста является 10.0.2.15:

Рисунок 6-16

Теперь выполним ping этого IP хоста из своего контейнера:

Рисунок 6-17

Как вы можете наблюдать, у нас имеется возможность выполнения ping из своего контейнера к его хосту, а также от самого хоста к этому контейнеру, поэтому взамиодействие работает в обоих направлениях.

Давайте резюмируем чего мы достигли к данному моменту:

при помощи unshare мы создали контейнер и продемонстрировали его способность изменять имя своего хоста внутри пространства имён uts.

При помощи Golang мы создали некий контейнер с такими пространствами имён как UTS и пространство имён пользователя.UTS.

Мы добавили пространство имён монтирования и продемонстрировали как внутри этого пространства имён может быть смонтирована обособленная файловая система proc.

В соответствующее пространство имён мы добавили сетевые возможности, которые сделали для нас возможным взаимодействие между пространствами имён созданного контейнера и пространства имён его хоста.

Включение Cgroup для контейнера

Ранее мы смонтировали в /root/mygrp некую cgroup. Внутри неё мы создали дочерний каталог. Теперь мы поместим свой процесс вовнутрь этой cgroup и ограничим его максимальную память.

Вот образец нашего фрагмента кода:

func enableCgroup() {

    cgroups := "/root/mygrp"
    pids := filepath.Join(cgroups, "child")

    must(ioutil.WriteFile(filepath.Join(pids, "memory.max"), []byte("2M"), 0700))

    must(ioutil.WriteFile(filepath.Join(pids, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}

В этом фрагменте кода мы добавили значение PID того процесса, который мы создали внутри своего контейнера (/bin/sh) в файл cgroup.procs и нахлобучили максимальное значение памяти для него в 2 МБ.

Прежде чем мы выполним этот код, вам потребуется внести одно изменение в настройки своей ОС. Откройте файл /etc/default/grub при помощи Nano или предпочитаемого вами редактора:

nano /etc/default/grub

В этом файле вам придётся изменить значение ключа GRUB_CMDLINE_LINUX_DEFAULT, добавив systemd.unified_cgroup_hierarchy=1. Для ясности отсылаем к следующему изображению.

GRUB_CMDLINE_LINUX_DEFAULT="quiet splash systemd.unified_cgroup_hierarchy=1"

Рисунок 6-18

После этого обновления запустите следующую команду и перезагрузите свою систему:

sudo update-grub

После выполнения перезагрузки выполните такую команду:

cat /proc/cmdline

Рисунок 6-19

В /proc/cmdline вы должны наблюдать systemd.unified_cgroup_hierarchy=1 равным BOOT_IMAGE.

Для создания cgroup выполните в своём терминале следующую команду. Применяйте ту же папку, которую мы использовали в этой программе.

mkdir -p /root/mygrp

mount -t cgroup2 none /root/mygrp

mkdir -p /root/mygrp/child

Теперь вы можете выполнить эту программу:

package main

import (
        "fmt"
        "io/ioutil"
        "os"
        "os/exec"
        "path/filepath"
        "strconv"
        "syscall"
        "time"
        "net"
)

func main() {

switch os.Args[1] {
        case "parent":
                parent()
        case "child":
                child()
        default:
               panic("help")
        }
}

func enableCgroup() {

        cgroups := "/root/mygrp"
        pids := filepath.Join(cgroups, "child")
        must(ioutil.WriteFile(filepath.Join(pids, "memory.max"), []byte("2M"), 0700))
        must(ioutil.WriteFile(filepath.Join(pids, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}

func waitForNetwork() error {

        maxWait := time.Second * 3
        checkInterval := time.Second
        timeStarted := time.Now()
        for {
                interfaces, err := net.Interfaces()
                if err != nil {
                        return err
            }
            // довольно простая проверка ...
            // > 1 в качестве уже имеющегося устройства lo
            if len(interfaces) > 1 {
                    return nil
            }
            if time.Since(timeStarted) > maxWait {
                    return fmt.Errorf("Timeout after %s waiting
                    for network", maxWait)
            }
            time.Sleep(checkInterval)
        }
}

func mountProc(newroot string) error {

        source := "proc"
        target := filepath.Join(newroot, "/proc")
        fstype := "proc"
        flags := 0
        data := ""

       os.MkdirAll(target, 0755)
       if err := syscall.Mount(
               source,
               target,
               fstype,
               uintptr(flags),
               data,
       ); err != nil {
               return err
       }
       return nil
}

func pivotRoot(newroot string) error {

        putold := filepath.Join(newroot, "/.pivot_root")
        //привязывает к самой себе монтирование newroot - именно это выступает небольшой уловкой, которая требуется для
        //удовлетворения требования pivot_root в том, что newroot и putold не должны быть той же самой
        //файловой системой что и текущий корень         if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
        return err
       }

       // создаём каталог putold
       if err := os.MkdirAll(putold, 0700); err != nil
               { return err
       }

       // вызывпем pivot_root
       if err := syscall.PivotRoot(newroot, putold); err != nil
               { return err
       }

       // обеспечиваем установку нового текущего рабочего каталога
       root if err := os.Chdir("/"); err != nil {
                    return err
       }

       // выполняем размонтирование putold, которая теперь пребывает в /.pivot_root putold = "/.pivot_root"
       if err := syscall.Unmount(putold, syscall.MNT_DETACH); err != nil {
               return err
       }

       // удаляем putold
       if err := os.RemoveAll(putold); err != nil {
               return err
       }
       return nil
}

func parent() {

        cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)

        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr

        cmd.Env = []string{"name=shashank"}
        cmd.SysProcAttr = &syscall.SysProcAttr{
                Cloneflags: syscall.CLONE_NEWNS |
                        syscall.CLONE_NEWUTS |
                        syscall.CLONE_NEWIPC |
                        syscall.CLONE_NEWPID |
                        syscall.CLONE_NEWNET |
                        syscall.CLONE_NEWUSER,
        UidMappings: []syscall.SysProcIDMap{
              {
                        ContainerID: 0,
                        HostID: os.Getuid(),
                        Size: 1,
              },
        },
        GidMappings: []syscall.SysProcIDMap{
              {
                         ContainerID: 0,
                         HostID: os.Getgid(),
                         Size: 1,
              },
        },
}

    must(cmd.Start())

pid := fmt.Sprintf("%d", cmd.Process.Pid)
netsetgoCmd := exec.Command("/usr/local/bin/netsetgo", "-pid", pid) if err := netsetgoCmd.Run(); err != nil {
     fmt.Printf("Error running netsetgo - %s\n", err)
     os.Exit(1)
}
if err := cmd.Wait(); err != nil {
     fmt.Printf("Error waiting for reexec.Command - %s\n", err)
     os.Exit(1)
}
}

Func child () {
//включаем функциональность cgroup

enableCgroup()
cmd := exec.Command(os.Args[2], os.Args[3:]...)
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr

must(mountProc("/root/book_prep/rootfs"))
//must(syscall.Mount("proc", "proc", "proc", 0, ""))

must(syscall.Sethostname([]byte("myhost")))

        if err := pivotRoot("/root/book_prep/rootfs"); err != nil
             { fmt.Printf("Error running pivot_root - %s\n",
             err) os.Exit(1)
        }

//must(syscall.Mount("proc", "proc", "proc", 0, ""))

	if err := waitForNetwork(); err != nil {
             fmt.Printf("Error waiting for network - %s\n", err)
            os.Exit(1)
        }

	if err := cmd.Run(); err != nil {
           fmt.Printf("Error starting the reexec.Command - %s\n", err)
           os.Exit(1)
        }
//must(cmd.Run())
}

func must(err error) {
        if err != nil {
             fmt.Printf("Error - %s\n", err)
        }
}

Рисунок 6-20 показывает значение PID процесса, добавленного в нашу cgruop и то значение, которое сохранено в файле memory.max, которые мы и определили в своей программе.

Рисунок 6-20

Добавленное в соответствующую cgroup значение PID процесса и то значение, которое сохранено в файле memory.max

Выводы

В этой книге мы рассмотрели основы виртуализации. Мы погрузились в то как работает виртуализация и базовые методы связанные с ней. Мы рассмотрели различные сценарии протекания пакетов и как происходит взаимодействие от ВМ в сторону гипервизора.

Эта книга рассмотрела специфику контейнеров Linux (пространства имён, cgroup и единую файловую систему), а также как реализубтся контейнеры внутри ядра Linux. Мы предприняли попытку написать контейнер Linux и увидели как при помощи некого простого программирования мы способны создать простую среду исполнения контейнера подобную Docker.

Рекомендуется просмотреть все упражнения и попробовать разные сочетания кода. К примеру, вы можете предпринять следующее:

Попробовать новую rootfs вместо busybox.

Испытать взаимодействие контейнера с контейнером.

Поиграться с дополнительным контролем ресурсов.

Запустить HTTP сервер внутри одного контейнера и некого клиента HTTP внутри другого контейнера и установить взаимодействие поверх HTTP.

Теперь у вас должно иметься хорошее представление о том, что происходит под капотом внутри контейнера. Следовательно, когда вы применяете различные оркестраторы, такие как Kubernetes или Swarm, вам будет проще понимать что происходит на самом деле.

Указатель

Содержание

Указатель